// 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' or 'Apple Music' - 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,
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
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;
}
}
// ===============================
// 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('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);
// 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 handleWatchlistCountUpdate(data) {
if (data.success) {
const watchlistButton = document.getElementById('watchlist-button');
if (watchlistButton) {
const countdownText = data.next_run_in_seconds ? formatCountdownTime(data.next_run_in_seconds) : '';
watchlistButton.textContent = `\u{1F441}\uFE0F Watchlist (${data.count})`;
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) {
// Same logic as updateWishlistCount response handler
const count = data.count || 0;
const wishlistButton = document.getElementById('wishlist-button');
if (wishlistButton) {
wishlistButton.textContent = `\uD83C\uDFB5 Wishlist (${count})`;
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 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);
// Server save handled by the global debouncedAutoSaveSettings listener on all checkboxes
});
}
}
// 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';
}
})();
// โโ 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();
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);
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
}
}
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 = '
Loading...
';
try {
const res = await fetch('/api/profiles/me/listenbrainz');
const data = await res.json();
renderPersonalSettingsLB(data);
} catch (e) {
body.innerHTML = 'Failed to load settings
';
}
}
function closePersonalSettings() {
const overlay = document.getElementById('personal-settings-overlay');
if (overlay) overlay.style.display = 'none';
}
function renderPersonalSettingsLB(data) {
const body = document.getElementById('personal-settings-body');
const connected = data.connected;
const username = data.username || '';
const baseUrl = data.base_url || '';
const source = data.source || 'global';
const tokenFormHtml = `
User Token
Test
Connect
`;
let contentHtml;
if (connected && source === 'profile') {
// Personal token โ show connected state with Disconnect
const serverDisplay = baseUrl ? baseUrl.replace(/\/1$/, '').replace(/^https?:\/\//, '') : 'api.listenbrainz.org';
contentHtml = `
🧠
Connected as ${escapeHtml(username)}
${escapeHtml(serverDisplay)}
Personal token
Disconnect
`;
} else if (connected && source === 'global') {
// Using admin's shared token โ show status + option to set own token
const serverDisplay = baseUrl ? baseUrl.replace(/\/1$/, '').replace(/^https?:\/\//, '') : 'api.listenbrainz.org';
contentHtml = `
🧠
Connected as ${escapeHtml(username)}
${escapeHtml(serverDisplay)}
Using shared token from Settings
Set your own token to use a different ListenBrainz account:
${tokenFormHtml}
`;
} else {
// Not connected at all
contentHtml = tokenFormHtml;
}
body.innerHTML = `
${contentHtml}
`;
}
async function testPersonalListenBrainz() {
const token = document.getElementById('ps-lb-token')?.value?.trim();
const baseUrl = document.getElementById('ps-lb-base-url')?.value?.trim() || '';
const resultEl = document.getElementById('ps-lb-result');
if (!token) {
if (resultEl) resultEl.innerHTML = 'Please enter a token
';
return;
}
if (resultEl) resultEl.innerHTML = 'Testing...
';
try {
const res = await fetch('/api/profiles/me/listenbrainz/test', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({token, base_url: baseUrl})
});
const data = await res.json();
if (data.success) {
resultEl.innerHTML = `Valid token โ ${escapeHtml(data.username)}
`;
} else {
resultEl.innerHTML = `${escapeHtml(data.error || 'Invalid token')}
`;
}
} catch (e) {
resultEl.innerHTML = 'Connection failed
';
}
}
async function connectPersonalListenBrainz() {
const token = document.getElementById('ps-lb-token')?.value?.trim();
const baseUrl = document.getElementById('ps-lb-base-url')?.value?.trim() || '';
const resultEl = document.getElementById('ps-lb-result');
if (!token) {
if (resultEl) resultEl.innerHTML = 'Please enter a token
';
return;
}
// Disable buttons during connect
document.querySelectorAll('.ps-actions .ps-btn').forEach(b => b.disabled = true);
if (resultEl) resultEl.innerHTML = 'Connecting...
';
try {
const res = await fetch('/api/profiles/me/listenbrainz', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({token, base_url: baseUrl})
});
const data = await res.json();
if (data.success) {
showToast(`Connected to ListenBrainz as ${data.username}`, 'success');
// Re-render as connected
renderPersonalSettingsLB({connected: true, username: data.username, base_url: baseUrl, source: 'profile'});
// Refresh LB playlists on discover page
_invalidateListenBrainzCache();
if (typeof initializeListenBrainzTabs === 'function') {
initializeListenBrainzTabs();
}
} else {
resultEl.innerHTML = `${escapeHtml(data.error || 'Connection failed')}
`;
document.querySelectorAll('.ps-actions .ps-btn').forEach(b => b.disabled = false);
}
} catch (e) {
resultEl.innerHTML = 'Connection failed
';
document.querySelectorAll('.ps-actions .ps-btn').forEach(b => b.disabled = false);
}
}
async function disconnectPersonalListenBrainz() {
try {
await fetch('/api/profiles/me/listenbrainz', {method: 'DELETE'});
showToast('ListenBrainz disconnected', 'info');
// Re-render as disconnected โ re-fetch to check if global fallback exists
const res = await fetch('/api/profiles/me/listenbrainz');
const data = await res.json();
renderPersonalSettingsLB(data);
// Refresh LB playlists on discover page
_invalidateListenBrainzCache();
if (typeof initializeListenBrainzTabs === 'function') {
initializeListenBrainzTabs();
}
} catch (e) {
showToast('Failed to disconnect', 'error');
}
}
function _invalidateListenBrainzCache() {
if (typeof listenbrainzPlaylistsLoaded !== 'undefined') listenbrainzPlaylistsLoaded = false;
if (typeof listenbrainzPlaylistsCache !== 'undefined') {
try { Object.keys(listenbrainzPlaylistsCache).forEach(k => delete listenbrainzPlaylistsCache[k]); } catch(e) {}
}
if (typeof listenbrainzTracksCache !== 'undefined') {
try { Object.keys(listenbrainzTracksCache).forEach(k => delete listenbrainzTracksCache[k]); } catch(e) {}
}
}
function initProfileManagement() {
const manageBtn = document.getElementById('manage-profiles-btn');
const closeBtn = document.getElementById('profile-manage-close');
const createBtn = document.getElementById('create-profile-btn');
const adminPinBtn = document.getElementById('set-admin-pin-btn');
if (manageBtn) {
manageBtn.onclick = () => {
document.getElementById('profile-manage-panel').style.display = 'flex';
loadProfileManageList();
};
}
if (closeBtn) {
closeBtn.onclick = () => {
document.getElementById('profile-manage-panel').style.display = 'none';
// Refresh picker โ keep cancel button if user already has a profile selected
const hasCancel = !!currentProfile;
fetch('/api/profiles').then(r => r.json()).then(d => {
showProfilePicker(d.profiles || [], hasCancel);
});
};
}
// Color picker
let selectedColor = '#6366f1';
document.querySelectorAll('.profile-color-swatch').forEach(swatch => {
swatch.onclick = () => {
document.querySelectorAll('.profile-color-swatch').forEach(s => s.classList.remove('selected'));
swatch.classList.add('selected');
selectedColor = swatch.dataset.color;
};
});
// Select first by default
const firstSwatch = document.querySelector('.profile-color-swatch');
if (firstSwatch) firstSwatch.classList.add('selected');
if (createBtn) {
createBtn.onclick = async () => {
const name = document.getElementById('new-profile-name').value.trim();
const avatarUrl = document.getElementById('new-profile-avatar-url').value.trim();
const pin = document.getElementById('new-profile-pin').value;
if (!name) return;
// Collect profile settings
const homePage = document.getElementById('new-profile-home-page').value || null;
const pageCheckboxes = document.querySelectorAll('#new-profile-allowed-pages input[type="checkbox"]:not(:disabled)');
const allChecked = Array.from(pageCheckboxes).every(cb => cb.checked);
const allowedPages = allChecked ? null : Array.from(pageCheckboxes).filter(cb => cb.checked).map(cb => cb.value);
const canDl = document.getElementById('new-profile-can-download').checked;
const res = await fetch('/api/profiles', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name, avatar_color: selectedColor,
avatar_url: avatarUrl || undefined,
pin: pin || undefined,
home_page: homePage,
allowed_pages: allowedPages,
can_download: canDl
})
});
const data = await res.json();
if (data.success) {
document.getElementById('new-profile-name').value = '';
document.getElementById('new-profile-avatar-url').value = '';
document.getElementById('new-profile-pin').value = '';
document.getElementById('new-profile-home-page').value = '';
pageCheckboxes.forEach(cb => cb.checked = true);
document.getElementById('new-profile-can-download').checked = true;
loadProfileManageList();
// Show admin PIN section if >1 profiles and admin has no PIN
checkAdminPinRequired();
} else {
alert(data.error || 'Failed to create profile');
}
};
}
if (adminPinBtn) {
adminPinBtn.onclick = async () => {
const pin = document.getElementById('admin-pin-input').value;
if (!pin || pin.length < 1) return;
// Find admin profile
const res = await fetch('/api/profiles');
const data = await res.json();
const admin = (data.profiles || []).find(p => p.is_admin);
if (!admin) return;
try {
const pinRes = await fetch(`/api/profiles/${admin.id}/set-pin`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ pin })
});
const pinData = await pinRes.json();
if (!pinData.success) {
alert(pinData.error || 'Failed to set PIN');
return;
}
} catch (e) {
alert('Connection error');
return;
}
document.getElementById('admin-pin-input').value = '';
document.getElementById('admin-pin-section').style.display = 'none';
loadProfileManageList();
};
}
}
async function loadProfileManageList() {
const list = document.getElementById('profile-manage-list');
const res = await fetch('/api/profiles');
const data = await res.json();
const profiles = data.profiles || [];
list.innerHTML = '';
profiles.forEach(p => {
const item = document.createElement('div');
item.className = 'profile-manage-item';
const av = document.createElement('div');
renderProfileAvatar(av, p);
item.appendChild(av);
const info = document.createElement('div');
info.className = 'profile-info';
const nameDiv = document.createElement('div');
nameDiv.className = 'name';
nameDiv.textContent = p.name + (p.has_pin ? ' ๐' : '');
info.appendChild(nameDiv);
const roleTags = [];
if (p.is_admin) roleTags.push('Admin');
if (p.can_download === false) roleTags.push('No Downloads');
if (p.allowed_pages) roleTags.push(`${p.allowed_pages.length} pages`);
if (roleTags.length) {
const roleDiv = document.createElement('div');
roleDiv.className = 'role';
roleDiv.textContent = roleTags.join(' ยท ');
info.appendChild(roleDiv);
}
item.appendChild(info);
const actions = document.createElement('div');
actions.className = 'profile-manage-actions';
const editBtn = document.createElement('button');
editBtn.className = 'profile-edit-btn';
editBtn.dataset.id = p.id;
editBtn.dataset.name = p.name;
editBtn.dataset.color = p.avatar_color || '#6366f1';
editBtn.dataset.avatarUrl = p.avatar_url || '';
editBtn.dataset.homePage = p.home_page || '';
editBtn.dataset.allowedPages = p.allowed_pages ? JSON.stringify(p.allowed_pages) : '';
editBtn.dataset.canDownload = p.can_download !== false ? '1' : '0';
editBtn.dataset.isAdmin = p.is_admin ? '1' : '0';
editBtn.title = 'Edit profile';
editBtn.textContent = 'โ๏ธ';
actions.appendChild(editBtn);
if (!p.is_admin) {
const delBtn = document.createElement('button');
delBtn.className = 'profile-delete-btn';
delBtn.dataset.id = p.id;
delBtn.title = 'Delete profile';
delBtn.textContent = '๐๏ธ';
actions.appendChild(delBtn);
}
item.appendChild(actions);
list.appendChild(item);
});
// Bind edit buttons
list.querySelectorAll('.profile-edit-btn').forEach(btn => {
btn.onclick = () => {
showProfileEditForm(btn.dataset.id, btn.dataset.name, btn.dataset.color, btn.dataset.avatarUrl, {
home_page: btn.dataset.homePage || '',
allowed_pages: btn.dataset.allowedPages ? JSON.parse(btn.dataset.allowedPages) : null,
can_download: btn.dataset.canDownload !== '0',
is_admin: btn.dataset.isAdmin === '1'
});
};
});
// Bind delete buttons
list.querySelectorAll('.profile-delete-btn').forEach(btn => {
btn.onclick = async () => {
if (!await showConfirmDialog({ title: 'Delete Profile', message: 'Delete this profile and all its data?', confirmText: 'Delete', destructive: true })) return;
try {
const res = await fetch(`/api/profiles/${btn.dataset.id}`, { method: 'DELETE' });
const data = await res.json();
if (!data.success) {
alert(data.error || 'Failed to delete profile');
}
} catch (e) {
alert('Connection error');
}
loadProfileManageList();
};
});
checkAdminPinRequired();
}
function showProfileEditForm(profileId, currentName, currentColor, currentAvatarUrl, profileSettings = {}) {
const list = document.getElementById('profile-manage-list');
// Remove any existing edit form
const existing = document.getElementById('profile-edit-form');
if (existing) existing.remove();
const isAdmin = currentProfile && currentProfile.is_admin;
const isEditingAdmin = profileSettings.is_admin;
const editColors = ['#6366f1','#ec4899','#10b981','#f59e0b','#3b82f6','#ef4444','#8b5cf6','#14b8a6'];
const pageLabels = {
dashboard: 'Dashboard', sync: 'Sync', downloads: 'Search', discover: 'Discover',
artists: 'Artists', automations: 'Automations', library: 'Library', import: 'Import'
};
const form = document.createElement('div');
form.id = 'profile-edit-form';
form.className = 'profile-edit-form';
const nameInput = document.createElement('input');
nameInput.type = 'text';
nameInput.className = 'profile-input';
nameInput.value = currentName;
nameInput.maxLength = 20;
nameInput.placeholder = 'Profile name';
form.appendChild(nameInput);
const urlInput = document.createElement('input');
urlInput.type = 'url';
urlInput.className = 'profile-input';
urlInput.value = currentAvatarUrl || '';
urlInput.placeholder = 'Avatar image URL (optional)';
form.appendChild(urlInput);
const colorRow = document.createElement('div');
colorRow.className = 'profile-color-picker';
let editColor = currentColor;
editColors.forEach(c => {
const swatch = document.createElement('span');
swatch.className = 'profile-color-swatch' + (c === currentColor ? ' selected' : '');
swatch.style.background = c;
swatch.dataset.color = c;
swatch.onclick = () => {
colorRow.querySelectorAll('.profile-color-swatch').forEach(s => s.classList.remove('selected'));
swatch.classList.add('selected');
editColor = c;
};
colorRow.appendChild(swatch);
});
form.appendChild(colorRow);
// Home page selector โ visible to everyone (self-edit or admin editing others)
const homeLabel = document.createElement('label');
homeLabel.className = 'profile-settings-label';
homeLabel.textContent = 'Home Page';
form.appendChild(homeLabel);
const homeSelect = document.createElement('select');
homeSelect.className = 'profile-input';
const defaultOpt = document.createElement('option');
defaultOpt.value = '';
defaultOpt.textContent = isEditingAdmin ? 'Default (Dashboard)' : 'Default (Discover)';
homeSelect.appendChild(defaultOpt);
// Filter home page options to only allowed pages
const allowedSet = profileSettings.allowed_pages;
Object.entries(pageLabels).forEach(([id, label]) => {
if (allowedSet && !allowedSet.includes(id)) return; // Skip non-permitted
const opt = document.createElement('option');
opt.value = id;
opt.textContent = label;
if (id === profileSettings.home_page) opt.selected = true;
homeSelect.appendChild(opt);
});
form.appendChild(homeSelect);
// Admin-only settings: allowed pages & can_download
let pageCheckboxes = [];
let canDlCheckbox = null;
if (isAdmin && !isEditingAdmin) {
const apLabel = document.createElement('label');
apLabel.className = 'profile-settings-label';
apLabel.textContent = 'Page Access';
form.appendChild(apLabel);
const apContainer = document.createElement('div');
apContainer.className = 'profile-page-checkboxes';
Object.entries(pageLabels).forEach(([id, label]) => {
const lbl = document.createElement('label');
const cb = document.createElement('input');
cb.type = 'checkbox';
cb.value = id;
cb.checked = !allowedSet || allowedSet.includes(id);
lbl.appendChild(cb);
lbl.appendChild(document.createTextNode(' ' + label));
apContainer.appendChild(lbl);
pageCheckboxes.push(cb);
});
// Always-on help
const helpLbl = document.createElement('label');
const helpCb = document.createElement('input');
helpCb.type = 'checkbox';
helpCb.checked = true;
helpCb.disabled = true;
helpLbl.appendChild(helpCb);
helpLbl.appendChild(document.createTextNode(' Help & Docs'));
apContainer.appendChild(helpLbl);
form.appendChild(apContainer);
const dlLabel = document.createElement('label');
dlLabel.className = 'profile-checkbox-label';
canDlCheckbox = document.createElement('input');
canDlCheckbox.type = 'checkbox';
canDlCheckbox.checked = profileSettings.can_download !== false;
dlLabel.appendChild(canDlCheckbox);
dlLabel.appendChild(document.createTextNode(' Can download music'));
form.appendChild(dlLabel);
}
const btnRow = document.createElement('div');
btnRow.className = 'profile-edit-buttons';
const saveBtn = document.createElement('button');
saveBtn.className = 'profile-create-btn';
saveBtn.textContent = 'Save';
saveBtn.onclick = async () => {
const newName = nameInput.value.trim();
if (!newName) { alert('Name cannot be empty'); return; }
const newAvatarUrl = urlInput.value.trim() || null;
const payload = { name: newName, avatar_color: editColor, avatar_url: newAvatarUrl };
// Home page
payload.home_page = homeSelect.value || null;
// Admin-only fields
if (isAdmin && !isEditingAdmin && pageCheckboxes.length) {
const allChecked = pageCheckboxes.every(cb => cb.checked);
payload.allowed_pages = allChecked ? null : pageCheckboxes.filter(cb => cb.checked).map(cb => cb.value);
payload.can_download = canDlCheckbox ? canDlCheckbox.checked : true;
}
try {
const res = await fetch(`/api/profiles/${profileId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
const data = await res.json();
if (data.success) {
// Update sidebar indicator if editing current profile
if (currentProfile && currentProfile.id == profileId) {
currentProfile.name = newName;
currentProfile.avatar_color = editColor;
currentProfile.avatar_url = newAvatarUrl;
if (payload.home_page !== undefined) currentProfile.home_page = payload.home_page;
if (payload.allowed_pages !== undefined) currentProfile.allowed_pages = payload.allowed_pages;
if (payload.can_download !== undefined) currentProfile.can_download = payload.can_download;
updateProfileIndicator();
}
loadProfileManageList();
} else {
alert(data.error || 'Failed to update profile');
}
} catch (e) {
alert('Connection error');
}
};
btnRow.appendChild(saveBtn);
const cancelBtn = document.createElement('button');
cancelBtn.className = 'profile-picker-cancel';
cancelBtn.textContent = 'Cancel';
cancelBtn.onclick = () => form.remove();
btnRow.appendChild(cancelBtn);
form.appendChild(btnRow);
list.appendChild(form);
nameInput.focus();
nameInput.select();
}
function showSelfEditForm() {
if (!currentProfile) return;
const overlay = document.getElementById('profile-picker-overlay');
const container = overlay.querySelector('.profile-picker-container');
// Hide the picker grid and show self-edit form
const grid = document.getElementById('profile-picker-grid');
const actions = document.getElementById('profile-picker-actions');
grid.style.display = 'none';
actions.style.display = 'none';
// Remove any existing self-edit form
const existing = document.getElementById('self-edit-form');
if (existing) existing.remove();
const pageLabels = {
dashboard: 'Dashboard', sync: 'Sync', downloads: 'Search', discover: 'Discover',
artists: 'Artists', automations: 'Automations', library: 'Library', import: 'Import'
};
const form = document.createElement('div');
form.id = 'self-edit-form';
form.className = 'profile-edit-form';
form.style.marginTop = '16px';
const title = document.createElement('h3');
title.textContent = 'My Profile';
title.style.cssText = 'color: #fff; margin: 0 0 12px; font-size: 18px;';
form.appendChild(title);
// Name
const nameInput = document.createElement('input');
nameInput.type = 'text';
nameInput.className = 'profile-input';
nameInput.value = currentProfile.name;
nameInput.maxLength = 20;
nameInput.placeholder = 'Profile name';
form.appendChild(nameInput);
// Home page
const homeLabel = document.createElement('label');
homeLabel.className = 'profile-settings-label';
homeLabel.textContent = 'Home Page';
form.appendChild(homeLabel);
const homeSelect = document.createElement('select');
homeSelect.className = 'profile-input';
const defaultOpt = document.createElement('option');
defaultOpt.value = '';
defaultOpt.textContent = 'Default (Discover)';
homeSelect.appendChild(defaultOpt);
const ap = currentProfile.allowed_pages;
Object.entries(pageLabels).forEach(([id, label]) => {
if (ap && !ap.includes(id)) return;
const opt = document.createElement('option');
opt.value = id;
opt.textContent = label;
if (id === currentProfile.home_page) opt.selected = true;
homeSelect.appendChild(opt);
});
form.appendChild(homeSelect);
// Buttons
const btnRow = document.createElement('div');
btnRow.className = 'profile-edit-buttons';
btnRow.style.marginTop = '12px';
const saveBtn = document.createElement('button');
saveBtn.className = 'profile-create-btn';
saveBtn.textContent = 'Save';
saveBtn.onclick = async () => {
const newName = nameInput.value.trim();
if (!newName) { alert('Name cannot be empty'); return; }
try {
const res = await fetch(`/api/profiles/${currentProfile.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: newName, home_page: homeSelect.value || null })
});
const data = await res.json();
if (data.success) {
currentProfile.name = newName;
currentProfile.home_page = homeSelect.value || null;
updateProfileIndicator();
closeSelfEdit();
hideProfilePicker();
} else {
alert(data.error || 'Failed to update');
}
} catch (e) {
alert('Connection error');
}
};
btnRow.appendChild(saveBtn);
const cancelBtn = document.createElement('button');
cancelBtn.className = 'profile-picker-cancel';
cancelBtn.textContent = 'Cancel';
cancelBtn.onclick = () => closeSelfEdit();
btnRow.appendChild(cancelBtn);
form.appendChild(btnRow);
container.appendChild(form);
function closeSelfEdit() {
form.remove();
grid.style.display = '';
actions.style.display = '';
}
}
async function checkAdminPinRequired() {
const res = await fetch('/api/profiles');
const data = await res.json();
const profiles = data.profiles || [];
const admin = profiles.find(p => p.is_admin);
const section = document.getElementById('admin-pin-section');
if (profiles.length > 1 && admin && !admin.has_pin && section) {
section.style.display = '';
} else if (section) {
section.style.display = 'none';
}
}
document.addEventListener('DOMContentLoaded', async function () {
console.log('SoulSync WebUI initializing...');
// Initialize profile management UI handlers
initProfileManagement();
// Check profiles first โ may show picker instead of app
const profileReady = await initProfileSystem();
if (!profileReady) {
console.log('Waiting for profile selection...');
return; // App init deferred until profile is selected via picker
}
initApp();
});
function initApp() {
// Initialize components
initializeNavigation();
initializeMobileNavigation();
initializeMediaPlayer();
initExpandedPlayer();
initializeSyncPage();
initializeWatchlist();
initializeDownloadManagerToggle();
// Initialize Beatport rebuild slider if it's the active tab by default
const activeRebuildTab = document.querySelector('.beatport-tab-button.active[data-beatport-tab="rebuild"]');
if (activeRebuildTab) {
console.log('๐ Initializing default active rebuild tab...');
initializeBeatportRebuildSlider();
loadBeatportTop10Lists();
loadBeatportTop10Releases();
initializeBeatportReleasesSlider();
initializeBeatportHypePicksSlider();
initializeBeatportChartsSlider();
initializeBeatportDJSlider();
}
// Initialize WebSocket connection (falls back to HTTP polling if unavailable)
initializeWebSocket();
// Start global service status polling for sidebar (works on all pages)
// Initial fetch for immediate data, then setInterval as fallback when WebSocket is disconnected
fetchAndUpdateServiceStatus();
setInterval(fetchAndUpdateServiceStatus, 10000); // Every 10 seconds (no-op when WebSocket active)
// Check for updates on load and every hour
checkForUpdates();
setInterval(checkForUpdates, 3600000);
// Refresh key data immediately when user returns to this tab
document.addEventListener('visibilitychange', () => {
if (!document.hidden) {
fetchAndUpdateServiceStatus();
// Refresh dashboard-specific data if on dashboard
const dashboardPage = document.getElementById('dashboard-page');
if (dashboardPage && dashboardPage.classList.contains('active')) {
fetchAndUpdateSystemStats();
fetchAndUpdateActivityFeed();
}
}
});
// Start always-on download polling (batched, minimal overhead)
startGlobalDownloadPolling();
// Load issues badge count
loadIssuesBadge();
// Load initial data
loadInitialData();
// Handle window resize to re-check track title scrolling
window.addEventListener('resize', function () {
if (currentTrack) {
const trackTitleElement = document.getElementById('track-title');
const trackTitle = currentTrack.title || 'Unknown Track';
setTimeout(() => {
checkAndEnableScrolling(trackTitleElement, trackTitle);
}, 100); // Small delay to allow layout to settle
}
});
console.log('SoulSync WebUI initialized successfully!');
}
// ===============================
// NAVIGATION SYSTEM
// ===============================
function initializeNavigation() {
const navButtons = document.querySelectorAll('.nav-button');
navButtons.forEach(button => {
button.addEventListener('click', () => {
const page = button.getAttribute('data-page');
navigateToPage(page);
});
});
}
// ===============================
// MOBILE NAVIGATION
// ===============================
function initializeMobileNavigation() {
const hamburgerBtn = document.getElementById('hamburger-btn');
const sidebar = document.querySelector('.sidebar');
const overlay = document.getElementById('mobile-overlay');
if (!hamburgerBtn || !sidebar || !overlay) return;
function openMobileNav() {
sidebar.classList.add('mobile-open');
hamburgerBtn.classList.add('active');
overlay.classList.add('active');
document.body.classList.add('mobile-nav-open');
}
function closeMobileNav() {
sidebar.classList.remove('mobile-open');
hamburgerBtn.classList.remove('active');
overlay.classList.remove('active');
document.body.classList.remove('mobile-nav-open');
}
hamburgerBtn.addEventListener('click', () => {
if (sidebar.classList.contains('mobile-open')) {
closeMobileNav();
} else {
openMobileNav();
}
});
overlay.addEventListener('click', closeMobileNav);
// Close sidebar on nav button click (mobile only)
document.querySelectorAll('.nav-button').forEach(btn => {
btn.addEventListener('click', () => {
if (window.innerWidth <= 768) {
closeMobileNav();
}
});
});
}
function initializeWatchlist() {
// Add watchlist button click handler
const watchlistButton = document.getElementById('watchlist-button');
if (watchlistButton) {
watchlistButton.addEventListener('click', showWatchlistModal);
}
// Update watchlist count initially
updateWatchlistButtonCount();
// Update count every 30 seconds
setInterval(updateWatchlistButtonCount, 30000);
console.log('Watchlist system initialized');
}
function initializeDownloadManagerToggle() {
const toggleButton = document.getElementById('toggle-download-manager-btn');
const downloadsContent = document.querySelector('.downloads-content');
if (!toggleButton || !downloadsContent) {
console.log('Download manager toggle not found on this page');
return;
}
// Load saved state from localStorage
const isHidden = localStorage.getItem('downloadManagerHidden') === 'true';
if (isHidden) {
downloadsContent.classList.add('manager-hidden');
}
// Add click handler
toggleButton.addEventListener('click', () => {
const isCurrentlyHidden = downloadsContent.classList.contains('manager-hidden');
if (isCurrentlyHidden) {
downloadsContent.classList.remove('manager-hidden');
localStorage.setItem('downloadManagerHidden', 'false');
} else {
downloadsContent.classList.add('manager-hidden');
localStorage.setItem('downloadManagerHidden', 'true');
}
});
console.log('Download manager toggle initialized');
}
function navigateToPage(pageId) {
if (pageId === currentPage) return;
// Permission guard โ redirect to home page if not allowed
if (!isPageAllowed(pageId)) {
const home = getProfileHomePage();
if (home !== currentPage && isPageAllowed(home)) {
navigateToPage(home);
}
return;
}
// Update navigation buttons (only if there's a nav button for this page)
document.querySelectorAll('.nav-button').forEach(btn => {
btn.classList.remove('active');
});
// Handle artist-detail page specially - it should highlight the 'library' nav button
const navPageId = pageId === 'artist-detail' ? 'library' : pageId;
const navButton = document.querySelector(`[data-page="${navPageId}"]`);
if (navButton) {
navButton.classList.add('active');
}
// Update pages
document.querySelectorAll('.page').forEach(page => {
page.classList.remove('active');
});
document.getElementById(`${pageId}-page`).classList.add('active');
currentPage = pageId;
// Show/hide discover download sidebar based on page
const downloadSidebar = document.getElementById('discover-download-sidebar');
if (downloadSidebar) {
if (pageId === 'discover') {
// Show sidebar on discover page if there are active downloads
const activeDownloads = Object.keys(discoverDownloads || {}).length;
console.log(`๐ [NAVIGATE] Discover page - ${activeDownloads} active downloads`);
if (activeDownloads > 0) {
// Update the sidebar UI to render the bubbles
console.log(`๐ [NAVIGATE] Updating discover download bar UI`);
updateDiscoverDownloadBar();
}
} else {
// Always hide sidebar on other pages
downloadSidebar.classList.add('hidden');
}
}
// Load page-specific data
loadPageData(pageId);
// Update page background particles
if (window.pageParticles && window._particlesEnabled !== false) window.pageParticles.setPage(pageId);
}
// REPLACE your old loadPageData function with this one:
// REPLACE your old loadPageData function with this corrected one
async function loadPageData(pageId) {
try {
// Stop any active polling when navigating away
stopDbStatsPolling();
stopDbUpdatePolling();
stopWishlistCountPolling();
stopLogPolling();
switch (pageId) {
case 'dashboard':
await loadDashboardData();
break;
case 'sync':
initializeSyncPage();
await loadSyncData();
break;
case 'downloads':
initializeSearch();
initializeSearchModeToggle();
initializeFilters();
await loadDownloadsData();
break;
case 'artists':
// Only fully initialize if not already initialized
if (!artistsPageState.isInitialized) {
initializeArtistsPage();
} else {
// Just restore state if already initialized
restoreArtistsPageState();
}
break;
case 'library':
// Check if we should return to artist detail view instead of list
if (artistDetailPageState.currentArtistId && artistDetailPageState.currentArtistName) {
navigateToPage('artist-detail');
if (!artistDetailPageState.isInitialized) {
initializeArtistDetailPage();
loadArtistDetailData(artistDetailPageState.currentArtistId, artistDetailPageState.currentArtistName);
}
// Already initialized โ DOM content persists, no reload needed
} else {
if (!libraryPageState.isInitialized) {
initializeLibraryPage();
}
// Already initialized โ DOM content persists, no reload needed
}
break;
case 'artist-detail':
// Artist detail page is handled separately by navigateToArtistDetail()
break;
case 'discover':
if (!discoverPageInitialized) {
await loadDiscoverPage();
discoverPageInitialized = true;
}
// Already initialized โ DOM content persists, no reload needed
break;
case 'settings':
initializeSettings();
await loadSettingsData();
await loadQualityProfile();
loadApiKeys();
break;
case 'import':
initializeImportPage();
break;
case 'hydrabase':
// Check connection status and pre-fill saved credentials
try {
const hsResp = await fetch('/api/hydrabase/status');
const hsData = await hsResp.json();
_hydrabaseConnected = hsData.connected;
document.getElementById('hydra-connection-status').textContent = hsData.connected ? 'Connected' : 'Disconnected';
document.getElementById('hydra-connection-status').style.color = hsData.connected ? 'rgb(var(--accent-light-rgb))' : '#888';
document.getElementById('hydra-connect-btn').textContent = hsData.connected ? 'Disconnect' : 'Connect';
// Pre-fill saved credentials
if (hsData.saved_url) {
document.getElementById('hydra-ws-url').value = hsData.saved_url;
}
if (hsData.saved_api_key) {
document.getElementById('hydra-api-key').value = hsData.saved_api_key;
}
// Update peer count
if (hsData.peer_count !== null && hsData.peer_count !== undefined) {
document.getElementById('hydra-peer-count').textContent = `Peers: ${hsData.peer_count}`;
}
} catch (e) {}
// Load comparisons
loadHydrabaseComparisons();
break;
case 'automations':
await loadAutomations();
break;
case 'issues':
await loadIssuesPage();
break;
case 'help':
initializeDocsPage();
break;
}
} catch (error) {
console.error(`Error loading ${pageId} data:`, error);
showToast(`Failed to load ${pageId} data`, 'error');
}
}
// ===============================
// SERVICE STATUS MONITORING
// ===============================
// Legacy function - now handled by fetchAndUpdateServiceStatus
// Keeping this for compatibility but it's no longer actively used
// Old updateStatusIndicator function removed - replaced by updateSidebarServiceStatus
// ===============================
// MEDIA PLAYER FUNCTIONALITY
// ===============================
function initializeMediaPlayer() {
const trackTitle = document.getElementById('track-title');
const playButton = document.getElementById('play-button');
const stopButton = document.getElementById('stop-button');
const volumeSlider = document.getElementById('volume-slider');
// Initialize HTML5 audio player
audioPlayer = document.getElementById('audio-player');
if (audioPlayer) {
// Set up audio event listeners
audioPlayer.addEventListener('timeupdate', updateAudioProgress);
audioPlayer.addEventListener('ended', onAudioEnded);
audioPlayer.addEventListener('error', onAudioError);
audioPlayer.addEventListener('loadstart', onAudioLoadStart);
audioPlayer.addEventListener('canplay', onAudioCanPlay);
// Set initial volume
audioPlayer.volume = 0.7; // 70%
volumeSlider.value = 70;
}
// Track title click handled by initExpandedPlayer's media-player click handler
// Media controls
playButton.addEventListener('click', handlePlayPause);
stopButton.addEventListener('click', handleStop);
volumeSlider.addEventListener('input', handleVolumeChange);
// Progress bar controls
const progressBar = document.getElementById('progress-bar');
if (progressBar) {
// Handle seeking
progressBar.addEventListener('input', handleProgressBarChange);
progressBar.addEventListener('mousedown', () => {
progressBar.dataset.seeking = 'true';
});
progressBar.addEventListener('mouseup', () => {
delete progressBar.dataset.seeking;
});
}
// Update volume slider styling
volumeSlider.addEventListener('input', updateVolumeSliderAppearance);
}
function toggleMediaPlayerExpansion() {
// No-op: controls are always visible in the new layout.
// Kept for backward compatibility with any callers.
}
function extractTrackTitle(filename) {
if (!filename) return null;
// Remove file extension
let title = filename.replace(/\.[^/.]+$/, '');
// Remove path components, keep only the filename
title = title.split('/').pop().split('\\').pop();
// Clean up common filename patterns
title = title
.replace(/^\d+\.?\s*/, '') // Remove track numbers at start
.replace(/^\d+\s*-\s*/, '') // Remove "01 - " patterns
.replace(/\s*-\s*\d{4}\s*$/, '') // Remove years at end
.replace(/\s*\[\d+kbps\].*$/, '') // Remove bitrate info
.replace(/\s*\(.*?\)\s*$/, '') // Remove parenthetical info at end
.trim();
return title || null;
}
function setTrackInfo(track) {
currentTrack = track;
const trackTitleElement = document.getElementById('track-title');
const trackTitle = track.title || 'Unknown Track';
// Set up the HTML structure for scrolling
trackTitleElement.innerHTML = `${escapeHtml(trackTitle)} `;
document.getElementById('artist-name').textContent = track.artist || 'Unknown Artist';
document.getElementById('album-name').textContent = track.album || 'Unknown Album';
// Check if title needs scrolling (similar to GUI app)
setTimeout(() => {
checkAndEnableScrolling(trackTitleElement, trackTitle);
}, 100); // Allow DOM to settle
// Enable controls
document.getElementById('play-button').disabled = false;
document.getElementById('stop-button').disabled = false;
// Hide no track message
document.getElementById('no-track-message').classList.add('hidden');
// Sync expanded player and media session
updateNpTrackInfo();
updateMediaSessionMetadata();
updateMediaSessionPlaybackState();
}
function checkAndEnableScrolling(element, text) {
// Remove any existing scrolling class and reset styles
element.classList.remove('scrolling');
element.style.removeProperty('--scroll-distance');
// Force a layout to get accurate measurements
element.offsetWidth;
// Get the inner text element
const titleTextElement = element.querySelector('.title-text');
if (!titleTextElement) return;
// Check if text is wider than container
const containerWidth = element.offsetWidth;
const textWidth = titleTextElement.scrollWidth;
// Enable scrolling if text is significantly wider than container
if (textWidth > containerWidth + 15) {
const scrollDistance = containerWidth - textWidth;
element.style.setProperty('--scroll-distance', `${scrollDistance}px`);
element.classList.add('scrolling');
console.log(`๐ Enabled scrolling for title: "${text}"`);
console.log(`๐ Container: ${containerWidth}px, Text: ${textWidth}px, Scroll: ${scrollDistance}px`);
}
}
function clearTrack() {
// Clear track state
currentTrack = null;
isPlaying = false;
const trackTitleElement = document.getElementById('track-title');
trackTitleElement.innerHTML = 'No track ';
trackTitleElement.classList.remove('scrolling'); // Remove scrolling animation
trackTitleElement.style.removeProperty('--scroll-distance'); // Clear CSS variable
document.getElementById('artist-name').textContent = 'Unknown Artist';
document.getElementById('album-name').textContent = 'Unknown Album';
// Reset play button SVGs (don't use textContent โ it destroys SVG children)
const clearPlayBtn = document.getElementById('play-button');
const clearPlayIcon = clearPlayBtn.querySelector('.play-icon');
const clearPauseIcon = clearPlayBtn.querySelector('.pause-icon');
if (clearPlayIcon) clearPlayIcon.style.display = '';
if (clearPauseIcon) clearPauseIcon.style.display = 'none';
clearPlayBtn.disabled = true;
document.getElementById('stop-button').disabled = true;
// Reset progress bar and time displays
const progressBar = document.getElementById('progress-bar');
const progressFill = document.getElementById('progress-fill');
if (progressBar) {
progressBar.value = 0;
delete progressBar.dataset.seeking;
}
if (progressFill) {
progressFill.style.width = '0%';
}
const currentTimeElement = document.getElementById('current-time');
const totalTimeElement = document.getElementById('total-time');
if (currentTimeElement) currentTimeElement.textContent = '0:00';
if (totalTimeElement) totalTimeElement.textContent = '0:00';
// Hide loading animation
hideLoadingAnimation();
// Show no track message
document.getElementById('no-track-message').classList.remove('hidden');
// Reset queue state
npQueue = [];
npQueueIndex = -1;
// Sync expanded player and media session
updateNpTrackInfo();
updateNpPlayButton();
updateNpProgress();
renderNpQueue();
updateNpPrevNextButtons();
updateMediaSessionPlaybackState();
stopSidebarVisualizer();
if (npModalOpen) closeNowPlayingModal();
console.log('๐งน Track cleared and media player reset');
}
function setPlayingState(playing) {
isPlaying = playing;
const playButton = document.getElementById('play-button');
// Toggle SVG icons (don't use textContent โ it destroys SVG children)
const playIcon = playButton.querySelector('.play-icon');
const pauseIcon = playButton.querySelector('.pause-icon');
if (playIcon) playIcon.style.display = playing ? 'none' : '';
if (pauseIcon) pauseIcon.style.display = playing ? '' : 'none';
updateNpPlayButton();
updateMediaSessionPlaybackState();
// Sidebar audio visualizer
if (playing) {
npInitVisualizer();
startSidebarVisualizer();
} else {
stopSidebarVisualizer();
}
}
async function handlePlayPause() {
// Use new streaming system toggle function
togglePlayback();
}
async function handleStop() {
// Use new streaming system stop function
await stopStream();
clearTrack();
}
function handleVolumeChange(event) {
const volume = event.target.value;
updateVolumeSliderAppearance();
// Update HTML5 audio player volume
if (audioPlayer) {
audioPlayer.volume = volume / 100;
}
// Sync modal volume and clear mute state
npMuted = false;
const npVol = document.getElementById('np-volume-slider');
const npFill = document.getElementById('np-volume-fill');
if (npVol) npVol.value = volume;
if (npFill) npFill.style.width = volume + '%';
updateNpMuteIcon();
}
function handleProgressBarChange(event) {
// Handle seeking in the audio track
if (!audioPlayer || !audioPlayer.duration) return;
const progress = parseFloat(event.target.value);
const newTime = (progress / 100) * audioPlayer.duration;
console.log(`๐ฏ Seeking to ${formatTime(newTime)} (${progress.toFixed(1)}%)`);
try {
audioPlayer.currentTime = newTime;
// Update visual progress immediately
const progressFill = document.getElementById('progress-fill');
if (progressFill) {
progressFill.style.width = `${progress}%`;
}
// Update time displays immediately
const currentTimeElement = document.getElementById('current-time');
if (currentTimeElement) {
currentTimeElement.textContent = formatTime(newTime);
}
// Sync modal progress
const npBar = document.getElementById('np-progress-bar');
const npFill = document.getElementById('np-progress-fill');
const npTime = document.getElementById('np-current-time');
if (npBar) npBar.value = progress;
if (npFill) npFill.style.width = progress + '%';
if (npTime) npTime.textContent = formatTime(newTime);
} catch (error) {
console.warn('โ ๏ธ Seek failed:', error.message);
// Reset progress bar to current position
const actualProgress = (audioPlayer.currentTime / audioPlayer.duration) * 100;
event.target.value = actualProgress;
const progressFill = document.getElementById('progress-fill');
if (progressFill) {
progressFill.style.width = `${actualProgress}%`;
}
}
}
function updateVolumeSliderAppearance() {
const slider = document.getElementById('volume-slider');
const value = slider.value;
slider.style.setProperty('--volume-percent', `${value}%`);
}
function showLoadingAnimation() {
document.getElementById('loading-animation').classList.remove('hidden');
}
function hideLoadingAnimation() {
document.getElementById('loading-animation').classList.add('hidden');
}
function setLoadingProgress(percentage) {
const loadingAnimation = document.getElementById('loading-animation');
const progressBar = loadingAnimation.querySelector('.loading-progress');
const loadingText = loadingAnimation.querySelector('.loading-text');
loadingAnimation.classList.remove('hidden');
progressBar.style.width = `${percentage}%`;
loadingText.textContent = `${Math.round(percentage)}%`;
}
// ===============================
// STREAMING FUNCTIONALITY
// ===============================
async function startStream(searchResult) {
// Start streaming a track - handles same track toggle and new track streaming
try {
console.log(`๐ฎ startStream() called with data:`, searchResult);
// Check if this is the same track that's currently playing/loading
const currentTrackId = currentTrack ? `${currentTrack.username}:${currentTrack.filename}` : null;
const newTrackId = `${searchResult.username}:${searchResult.filename}`;
console.log(`๐ฎ startStream() called for: ${searchResult.filename}`);
console.log(`๐ฎ Current track ID: ${currentTrackId}`);
console.log(`๐ฎ New track ID: ${newTrackId}`);
if (currentTrackId === newTrackId && audioPlayer && !audioPlayer.paused) {
// Same track clicked while playing - toggle pause
console.log("๐ Toggling playback for same track");
togglePlayback();
return;
}
// Different track or no current track - start new stream
console.log("๐ต Starting new stream");
// Stop current streaming/playback if any
await stopStream();
// Set track info and show loading state
setTrackInfo({
title: extractTrackTitle(searchResult.filename) || searchResult.title || 'Unknown Track',
artist: searchResult.artist || searchResult.username || 'Unknown Artist',
album: searchResult.album || 'Unknown Album',
username: searchResult.username,
filename: searchResult.filename,
image_url: searchResult.image_url || searchResult.album_cover_url || null
});
showLoadingAnimation();
setLoadingProgress(0);
// Start streaming request
const response = await fetch(API.stream.start, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(searchResult)
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const data = await response.json();
if (!data.success) {
throw new Error(data.error || 'Failed to start streaming');
}
console.log("โ
Stream started successfully");
// Start status polling
startStreamStatusPolling();
} catch (error) {
console.error('Error starting stream:', error);
showToast(`Failed to start stream: ${error.message}`, 'error');
hideLoadingAnimation();
clearTrack();
}
}
function startStreamStatusPolling() {
// Start polling for stream status updates with retry logic
if (streamStatusPoller) {
clearInterval(streamStatusPoller);
}
// Reset polling state
streamPollingRetries = 0;
streamPollingInterval = 1000; // Reset to 1-second interval
console.log('๐ Starting enhanced stream status polling');
updateStreamStatus(); // Initial check
streamStatusPoller = setInterval(updateStreamStatus, streamPollingInterval);
}
function stopStreamStatusPolling() {
// Stop polling for stream status updates
if (streamStatusPoller) {
clearInterval(streamStatusPoller);
streamStatusPoller = null;
streamPollingRetries = 0;
streamPollingInterval = 1000; // Reset interval
console.log('โน๏ธ Stopped stream status polling');
}
}
// Phase 4: Track last known tool statuses to prevent repeated toasts on terminal states
let _lastToolStatus = {};
// Phase 5: Sync/Discovery/Scan WebSocket router functions
function updateSyncProgressFromData(data) {
const pid = data.playlist_id;
const callback = _syncProgressCallbacks[pid];
if (callback) callback(data);
}
function updateDiscoveryProgressFromData(data) {
const id = data.id;
const callback = _discoveryProgressCallbacks[id];
if (callback) callback(data);
}
function updateWatchlistScanFromData(data) {
if (!data.success) return;
if (_lastWatchlistScanStatus === data.status && data.status !== 'scanning') return;
_lastWatchlistScanStatus = data.status;
handleWatchlistScanData(data);
}
function updateMediaScanFromData(data) {
if (!data.success || !data.status) return;
const status = data.status;
const statusKey = status.is_scanning ? 'scanning' : (status.status || 'unknown');
if (_lastMediaScanStatus === statusKey && statusKey !== 'scanning') return;
_lastMediaScanStatus = statusKey;
const phaseLabel = document.getElementById('media-scan-phase-label');
const progressLabel = document.getElementById('media-scan-progress-label');
const button = document.getElementById('media-scan-btn');
const progressBar = document.getElementById('media-scan-progress-bar');
const statusValue = document.getElementById('media-scan-status');
if (status.is_scanning) {
if (phaseLabel) phaseLabel.textContent = 'Media server scanning...';
if (progressLabel) progressLabel.textContent = status.progress_message || 'Scan in progress';
} else if (status.status === 'idle') {
if (button) button.disabled = false;
if (phaseLabel) phaseLabel.textContent = 'Scan completed successfully';
if (progressBar) progressBar.style.width = '0%';
if (progressLabel) progressLabel.textContent = 'Ready for next scan';
if (statusValue) {
statusValue.textContent = 'Idle';
statusValue.style.color = '#b3b3b3';
}
showToast('โ
Media scan completed', 'success', 3000);
}
}
function updateWishlistStatsFromData(data) {
// Auto-processing detection: close modal and notify
if (data.is_auto_processing) {
if (typeof closeWishlistOverviewModal === 'function') {
closeWishlistOverviewModal();
showToast('Wishlist auto-processing started. View progress in Download Manager.', 'info');
}
return;
}
// Store latest stats for countdown timer refresh
_lastWishlistStats = data;
}
async function updateStreamStatus() {
if (socketConnected) return; // WebSocket handles this
// Poll server for streaming progress and handle state changes with enhanced error recovery
try {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 10000); // 10-second timeout
const response = await fetch(API.stream.status, {
signal: controller.signal
});
clearTimeout(timeoutId);
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const data = await response.json();
// Reset retry count on successful response
streamPollingRetries = 0;
streamPollingInterval = 1000; // Reset to normal interval
// Update current stream state
currentStream.status = data.status;
currentStream.progress = data.progress;
switch (data.status) {
case 'loading':
setLoadingProgress(data.progress);
// Update loading text with progress
const loadingText = document.querySelector('.loading-text');
if (loadingText && data.progress > 0) {
loadingText.textContent = `Downloading... ${Math.round(data.progress)}%`;
}
break;
case 'queued':
// Show queue status with better messaging
const queueText = document.querySelector('.loading-text');
if (queueText) {
queueText.textContent = 'Queuing with uploader...';
}
setLoadingProgress(0); // Reset progress for queue state
break;
case 'ready':
// Stream is ready - start audio playback
console.log('๐ต Stream ready, starting audio playback');
stopStreamStatusPolling();
await startAudioPlayback();
break;
case 'error':
console.error('โ Streaming error:', data.error_message);
stopStreamStatusPolling();
hideLoadingAnimation();
showToast(`Streaming error: ${data.error_message || 'Unknown error'}`, 'error');
clearTrack();
break;
case 'stopped':
// Handle stopped state
console.log('๐ Stream stopped');
stopStreamStatusPolling();
hideLoadingAnimation();
clearTrack();
break;
}
} catch (error) {
streamPollingRetries++;
console.warn(`Stream status polling error (attempt ${streamPollingRetries}):`, error.message);
if (streamPollingRetries >= maxStreamPollingRetries) {
// Too many consecutive failures - give up
console.error('โ Stream status polling failed after maximum retries');
stopStreamStatusPolling();
hideLoadingAnimation();
showToast('Lost connection to streaming server', 'error');
clearTrack();
} else {
// Implement exponential backoff for retries
const backoffMultiplier = Math.min(streamPollingRetries, 5); // Max 5x backoff
streamPollingInterval = 1000 * backoffMultiplier;
// Restart polling with new interval
if (streamStatusPoller) {
clearInterval(streamStatusPoller);
streamStatusPoller = setInterval(updateStreamStatus, streamPollingInterval);
console.log(`๐ Retrying stream status polling with ${streamPollingInterval}ms interval`);
}
}
}
}
function updateStreamStatusFromData(data) {
const prev = _lastToolStatus['stream'];
_lastToolStatus['stream'] = data.status;
// Skip repeated terminal states to avoid duplicate toasts/actions
if (prev !== undefined && data.status === prev && data.status !== 'loading' && data.status !== 'queued') return;
currentStream.status = data.status;
currentStream.progress = data.progress;
switch (data.status) {
case 'loading':
setLoadingProgress(data.progress);
const loadingText = document.querySelector('.loading-text');
if (loadingText && data.progress > 0) {
loadingText.textContent = `Downloading... ${Math.round(data.progress)}%`;
}
break;
case 'queued':
const queueText = document.querySelector('.loading-text');
if (queueText) {
queueText.textContent = 'Queuing with uploader...';
}
setLoadingProgress(0);
break;
case 'ready':
console.log('๐ต Stream ready, starting audio playback');
stopStreamStatusPolling();
startAudioPlayback();
break;
case 'error':
console.error('โ Streaming error:', data.error_message);
stopStreamStatusPolling();
hideLoadingAnimation();
showToast(`Streaming error: ${data.error_message || 'Unknown error'}`, 'error');
clearTrack();
break;
case 'stopped':
console.log('๐ Stream stopped');
stopStreamStatusPolling();
hideLoadingAnimation();
clearTrack();
break;
}
}
async function startAudioPlayback() {
// Start HTML5 audio playback of the streamed file with enhanced state management
try {
if (!audioPlayer) {
throw new Error('Audio player not initialized');
}
// Show loading state while preparing audio
const loadingText = document.querySelector('.loading-text');
if (loadingText) {
loadingText.textContent = 'Preparing playback...';
}
// Set audio source with cache-busting timestamp
const audioUrl = `/stream/audio?t=${new Date().getTime()}`;
console.log(`๐ต Loading audio from: ${audioUrl}`);
// Clear any existing source first
audioPlayer.pause();
audioPlayer.currentTime = 0;
audioPlayer.src = '';
// Set new source
audioPlayer.src = audioUrl;
audioPlayer.load(); // Force reload
// Wait for audio to be ready with promise-based approach
await new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
reject(new Error('Audio loading timeout'));
}, 15000); // 15-second timeout
const onCanPlay = () => {
clearTimeout(timeout);
audioPlayer.removeEventListener('canplay', onCanPlay);
audioPlayer.removeEventListener('error', onError);
resolve();
};
const onError = (event) => {
clearTimeout(timeout);
audioPlayer.removeEventListener('canplay', onCanPlay);
audioPlayer.removeEventListener('error', onError);
const error = event.target.error || new Error('Audio loading failed');
reject(error);
};
audioPlayer.addEventListener('canplay', onCanPlay);
audioPlayer.addEventListener('error', onError);
// If already ready, resolve immediately
if (audioPlayer.readyState >= 3) { // HAVE_FUTURE_DATA
onCanPlay();
}
});
console.log('โ
Audio loaded and ready for playback');
// Try to start playback with retry logic
let retryCount = 0;
const maxRetries = 3;
while (retryCount < maxRetries) {
try {
await audioPlayer.play();
console.log('โ
Audio playback started successfully');
// Update UI to playing state
hideLoadingAnimation();
setPlayingState(true);
// Show media player if hidden
const noTrackMessage = document.getElementById('no-track-message');
if (noTrackMessage) {
noTrackMessage.classList.add('hidden');
}
// Update volume to current slider value
const volumeSlider = document.getElementById('volume-slider');
if (volumeSlider) {
audioPlayer.volume = volumeSlider.value / 100;
}
// Enable play/stop buttons
const playButton = document.getElementById('play-button');
const stopButton = document.getElementById('stop-button');
if (playButton) playButton.disabled = false;
if (stopButton) stopButton.disabled = false;
return; // Success!
} catch (playError) {
retryCount++;
console.warn(`โ ๏ธ Audio play attempt ${retryCount} failed:`, playError.message);
if (retryCount >= maxRetries) {
throw playError; // Re-throw after max retries
}
// Wait before retry with exponential backoff
await new Promise(resolve => setTimeout(resolve, 1000 * retryCount));
}
}
} catch (error) {
console.error('โ Error starting audio playback:', error);
hideLoadingAnimation();
// Provide user-friendly error messages
let userMessage = 'Playback failed';
if (error.message.includes('no supported source') ||
error.message.includes('Not supported') ||
error.message.includes('MEDIA_ELEMENT_ERROR')) {
userMessage = 'Audio format not supported by your browser. Try downloading instead.';
} else if (error.message.includes('network') || error.message.includes('fetch')) {
userMessage = 'Network error - please check your connection';
} else if (error.message.includes('decode')) {
userMessage = 'Audio file is corrupted or incompatible';
} else if (error.message.includes('timeout')) {
userMessage = 'Audio loading timeout - file may be too large';
} else if (error.message.includes('AbortError')) {
userMessage = 'Playback was interrupted';
}
showToast(userMessage, 'error');
// Only clear track if not in queue playback mode โ queue handles its own error recovery
if (npQueue.length === 0) {
clearTrack();
}
}
}
async function stopStream() {
// Stop streaming and clean up all state
try {
// Stop status polling
stopStreamStatusPolling();
// Stop audio playback
if (audioPlayer) {
audioPlayer.pause();
audioPlayer.src = '';
}
// Call backend stop endpoint
const response = await fetch(API.stream.stop, { method: 'POST' });
if (response.ok) {
const data = await response.json();
console.log('๐ Stream stopped:', data.message);
}
// Reset UI state
hideLoadingAnimation();
setPlayingState(false);
// Reset stream state
currentStream = {
status: 'stopped',
progress: 0,
track: null
};
} catch (error) {
console.error('Error stopping stream:', error);
}
}
function togglePlayback() {
// Toggle play/pause for currently loaded audio
if (!audioPlayer || !currentTrack) {
console.log('โ ๏ธ No audio player or track to toggle');
return;
}
if (audioPlayer.paused) {
audioPlayer.play()
.then(() => {
setPlayingState(true);
console.log('โถ๏ธ Resumed playback');
})
.catch(error => {
console.error('Error resuming playback:', error);
showToast('Failed to resume playback', 'error');
});
} else {
audioPlayer.pause();
setPlayingState(false);
console.log('โธ๏ธ Paused playback');
}
}
// ===============================
// AUDIO EVENT HANDLERS
// ===============================
function updateAudioProgress() {
// Update progress bar based on audio playback time
if (!audioPlayer || !audioPlayer.duration) return;
const progress = (audioPlayer.currentTime / audioPlayer.duration) * 100;
// Update progress bar
const progressBar = document.getElementById('progress-bar');
const progressFill = document.getElementById('progress-fill');
if (progressBar && !progressBar.dataset.seeking) {
progressBar.value = progress;
// Update visual progress fill
if (progressFill) {
progressFill.style.width = `${progress}%`;
}
}
// Update time display
const currentTimeElement = document.getElementById('current-time');
const totalTimeElement = document.getElementById('total-time');
if (currentTimeElement) {
currentTimeElement.textContent = formatTime(audioPlayer.currentTime);
}
if (totalTimeElement) {
totalTimeElement.textContent = formatTime(audioPlayer.duration);
}
// Sync expanded player modal
if (npModalOpen) updateNpProgress();
}
function onAudioEnded() {
// Handle audio playback completion
console.log('๐ Audio playback ended');
setPlayingState(false);
// Reset progress to beginning
const progressBar = document.getElementById('progress-bar');
const progressFill = document.getElementById('progress-fill');
if (progressBar) {
progressBar.value = 0;
}
if (progressFill) {
progressFill.style.width = '0%';
}
const currentTimeElement = document.getElementById('current-time');
if (currentTimeElement) {
currentTimeElement.textContent = '0:00';
}
// Repeat-one is handled by audioPlayer.loop (set in handleNpRepeat)
// Auto-advance to next track if queue has a next item (guard against race conditions)
if (npQueue.length > 0 && !npLoadingQueueItem) {
const hasNext = npShuffleOn
? npQueue.length > 1
: (npQueueIndex < npQueue.length - 1 || npRepeatMode === 'all');
if (hasNext) { playNextInQueue(); return; }
}
// Radio mode: auto-fetch similar tracks when queue is exhausted
if (npRadioMode && currentTrack && currentTrack.id && !npLoadingQueueItem) {
npFetchRadioTracks();
}
}
function onAudioError(event) {
// Handle audio playback errors
const error = event.target.error;
console.error('โ Audio error:', error);
// Don't show error toast if it's just a format/codec issue and retrying
if (error && error.code) {
console.error(`Audio error code: ${error.code}, message: ${error.message || 'Unknown error'}`);
// Only show user-facing errors for serious issues
if (error.code === 4) { // MEDIA_ELEMENT_ERROR: Media not supported
console.warn('โ ๏ธ Media format not supported by browser, but streaming may still work');
// Don't clear track or show error - let retry logic handle it
return;
}
}
hideLoadingAnimation();
// Only clear track after a short delay to allow for recovery
setTimeout(() => {
if (audioPlayer && audioPlayer.error) {
let userMessage = 'Audio format not supported by your browser. Try downloading instead.';
if (error && error.code) {
switch (error.code) {
case 1: // MEDIA_ERR_ABORTED
userMessage = 'Playback was stopped';
break;
case 2: // MEDIA_ERR_NETWORK
userMessage = 'Network error - please try again';
break;
case 3: // MEDIA_ERR_DECODE
userMessage = 'Audio file is corrupted or incompatible';
break;
case 4: // MEDIA_ERR_SRC_NOT_SUPPORTED
userMessage = 'Audio format not supported by your browser. Try downloading instead.';
break;
}
}
showToast(userMessage, 'error');
// Only clear track if not in queue playback โ queue handles its own recovery
if (npQueue.length === 0) {
clearTrack();
}
}
}, 2000);
}
function onAudioLoadStart() {
// Handle audio load start
console.log('๐ Audio loading started');
}
function onAudioCanPlay() {
// Handle when audio can start playing
console.log('โ
Audio ready to play');
}
function formatTime(seconds) {
// Format seconds as MM:SS
if (!seconds || !isFinite(seconds)) return '0:00';
const minutes = Math.floor(seconds / 60);
const secs = Math.floor(seconds % 60);
return `${minutes}:${secs.toString().padStart(2, '0')}`;
}
function formatCountdownTime(seconds) {
// Format seconds as countdown timer (e.g., "24m 13s", "2h 15m", "23h 59m")
if (seconds === null || seconds === undefined || seconds < 0) return '';
if (seconds === 0) return '0s'; // Show "0s" instead of hiding timer
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
const secs = Math.floor(seconds % 60);
if (hours > 0) {
return `${hours}h ${minutes}m`;
} else if (minutes > 0) {
return `${minutes}m ${secs}s`;
} else {
return `${secs}s`;
}
}
// ===============================
// AUDIO FORMAT SUPPORT DETECTION
// ===============================
function getFileExtension(filename) {
if (!filename) return '';
const ext = filename.toLowerCase().match(/\.([^.]+)$/);
return ext ? ext[1] : '';
}
function isAudioFormatSupported(filename) {
const ext = getFileExtension(filename);
const supportedFormats = ['mp3', 'ogg', 'wav']; // Most reliable formats
const partialSupport = ['flac', 'aac', 'm4a', 'opus', 'webm']; // Test browser support
const unsupported = ['wma', 'ape', 'aiff']; // Generally problematic
if (supportedFormats.includes(ext)) {
return true;
}
if (partialSupport.includes(ext)) {
// Test if browser can actually play this format
return canPlayAudioFormat(ext);
}
return false; // Unsupported formats
}
function canPlayAudioFormat(extension) {
const audio = document.createElement('audio');
const mimeTypes = {
'mp3': 'audio/mpeg',
'ogg': 'audio/ogg; codecs="vorbis"',
'wav': 'audio/wav',
'flac': 'audio/flac',
'aac': 'audio/aac',
'm4a': 'audio/mp4; codecs="mp4a.40.2"', // More specific M4A MIME type
'opus': 'audio/ogg; codecs="opus"',
'webm': 'audio/webm; codecs="opus"',
'wma': 'audio/x-ms-wma'
};
const mimeType = mimeTypes[extension];
if (!mimeType) {
console.warn(`๐ต [FORMAT CHECK] No MIME type found for extension: ${extension}`);
return false;
}
const canPlay = audio.canPlayType(mimeType);
console.log(`๐ต [FORMAT CHECK] ${extension} (${mimeType}): ${canPlay}`);
let isSupported = canPlay === 'probably' || canPlay === 'maybe';
// Special handling for M4A - try fallback MIME types if first one fails
if (!isSupported && extension === 'm4a') {
const fallbackMimeTypes = ['audio/mp4', 'audio/x-m4a', 'audio/aac'];
console.log(`๐ต [FORMAT CHECK] M4A failed with primary MIME type, trying fallbacks...`);
for (const fallbackMime of fallbackMimeTypes) {
const fallbackResult = audio.canPlayType(fallbackMime);
console.log(`๐ต [FORMAT CHECK] M4A fallback (${fallbackMime}): ${fallbackResult}`);
if (fallbackResult === 'probably' || fallbackResult === 'maybe') {
isSupported = true;
console.log(`๐ต [FORMAT CHECK] M4A supported with fallback MIME type: ${fallbackMime}`);
break;
}
}
}
console.log(`๐ต [FORMAT CHECK] ${extension} final support result: ${isSupported}`);
return isSupported;
}
// ===============================
// EXPANDED NOW PLAYING MODAL
// ===============================
let npModalOpen = false;
let npRepeatMode = 'off'; // 'off' | 'all' | 'one'
let npShuffleOn = false;
let npQueue = [];
let npQueueIndex = -1;
let npMuted = false;
let npPreMuteVolume = 70;
let npMediaSessionThrottle = 0;
let npLoadingQueueItem = false;
let npRadioMode = false;
let npRecentlyPlayedIds = [];
let npAudioContext = null;
let npAnalyser = null;
let npMediaSource = null;
let npVizAnimFrame = null;
let npVizInitialized = false;
function initExpandedPlayer() {
const closeBtn = document.getElementById('np-close-btn');
const overlay = document.getElementById('np-modal-overlay');
const playBtn = document.getElementById('np-play-btn');
const stopBtn = document.getElementById('np-stop-btn');
const shuffleBtn = document.getElementById('np-shuffle-btn');
const repeatBtn = document.getElementById('np-repeat-btn');
const muteBtn = document.getElementById('np-mute-btn');
const npProgressBar = document.getElementById('np-progress-bar');
const npVolumeSlider = document.getElementById('np-volume-slider');
if (!overlay) return;
// Close handlers
closeBtn.addEventListener('click', closeNowPlayingModal);
overlay.addEventListener('click', (e) => { if (e.target === overlay) closeNowPlayingModal(); });
// Control handlers
playBtn.addEventListener('click', () => { togglePlayback(); });
stopBtn.addEventListener('click', async () => { await handleStop(); closeNowPlayingModal(); });
shuffleBtn.addEventListener('click', handleNpShuffle);
repeatBtn.addEventListener('click', handleNpRepeat);
muteBtn.addEventListener('click', handleNpMuteToggle);
// Progress bar (mouse)
npProgressBar.addEventListener('input', handleNpProgressBarChange);
npProgressBar.addEventListener('mousedown', () => { npProgressBar.dataset.seeking = 'true'; });
npProgressBar.addEventListener('mouseup', () => { delete npProgressBar.dataset.seeking; });
// Progress bar (touch)
npProgressBar.addEventListener('touchstart', () => { npProgressBar.dataset.seeking = 'true'; }, { passive: true });
npProgressBar.addEventListener('touchmove', (e) => {
const touch = e.touches[0];
const rect = npProgressBar.getBoundingClientRect();
const pct = Math.max(0, Math.min(100, ((touch.clientX - rect.left) / rect.width) * 100));
npProgressBar.value = pct;
npProgressBar.dispatchEvent(new Event('input'));
}, { passive: true });
npProgressBar.addEventListener('touchend', () => { delete npProgressBar.dataset.seeking; }, { passive: true });
// Volume slider
npVolumeSlider.addEventListener('input', handleNpVolumeChange);
// Keyboard shortcuts (global)
document.addEventListener('keydown', handlePlayerKeyboardShortcuts);
// Make sidebar media player clickable to open modal
const mediaPlayer = document.getElementById('media-player');
if (mediaPlayer) {
mediaPlayer.style.cursor = 'pointer';
mediaPlayer.addEventListener('click', (e) => {
// Don't open modal when clicking controls (let expand-hint through)
if (e.target.closest('.play-button, .stop-button, .volume-slider, .volume-control, .progress-bar, .volume-icon') && !e.target.closest('.expand-hint')) return;
if (currentTrack) openNowPlayingModal();
});
}
// Prev / Next buttons
const prevBtn = document.getElementById('np-prev-btn');
const nextBtn = document.getElementById('np-next-btn');
if (prevBtn) prevBtn.addEventListener('click', () => { playPreviousInQueue(); });
if (nextBtn) nextBtn.addEventListener('click', () => { playNextInQueue(); });
// Queue panel toggle + clear
const queueToggle = document.getElementById('np-queue-toggle');
if (queueToggle) {
queueToggle.addEventListener('click', () => {
const body = document.getElementById('np-queue-body');
if (body) body.classList.toggle('hidden');
queueToggle.classList.toggle('active');
});
}
const queueClearBtn = document.getElementById('np-queue-clear');
if (queueClearBtn) queueClearBtn.addEventListener('click', () => { clearQueue(); });
// Radio mode button
const radioBtn = document.getElementById('np-radio-btn');
if (radioBtn) {
radioBtn.addEventListener('click', () => {
npRadioMode = !npRadioMode;
radioBtn.classList.toggle('active', npRadioMode);
showToast(npRadioMode ? 'Radio mode on โ similar tracks will auto-queue' : 'Radio mode off', 'success');
// Immediately fetch radio tracks if turned on while playing with empty/exhausted queue
if (npRadioMode && currentTrack && currentTrack.id && !npLoadingQueueItem) {
const hasNext = npQueue.length > 0 && (npShuffleOn
? npQueue.length > 1
: (npQueueIndex < npQueue.length - 1 || npRepeatMode === 'all'));
if (!hasNext) {
// Add current track to queue first so it appears as "now playing" in context
if (npQueue.length === 0 && currentTrack.is_library) {
npQueue.push({
title: currentTrack.title,
artist: currentTrack.artist,
album: currentTrack.album,
file_path: currentTrack.filename || currentTrack.file_path,
filename: currentTrack.filename || currentTrack.file_path,
is_library: true,
image_url: currentTrack.image_url,
id: currentTrack.id,
artist_id: currentTrack.artist_id,
album_id: currentTrack.album_id,
bitrate: currentTrack.bitrate
});
npQueueIndex = 0;
renderNpQueue();
updateNpPrevNextButtons();
}
npFetchRadioTracks();
}
}
});
}
// Action button (Go to Artist)
const gotoArtistBtn = document.getElementById('np-goto-artist');
if (gotoArtistBtn) {
gotoArtistBtn.addEventListener('click', () => {
if (currentTrack && currentTrack.artist_id) {
closeNowPlayingModal();
navigateToArtistDetail(currentTrack.artist_id, currentTrack.artist || '');
}
});
}
// Buffering state listeners on audioPlayer
if (audioPlayer) {
audioPlayer.addEventListener('waiting', () => {
const ring = document.getElementById('np-buffering-ring');
if (ring) ring.classList.remove('hidden');
});
audioPlayer.addEventListener('canplay', () => {
const ring = document.getElementById('np-buffering-ring');
if (ring) ring.classList.add('hidden');
});
audioPlayer.addEventListener('playing', () => {
const ring = document.getElementById('np-buffering-ring');
if (ring) ring.classList.add('hidden');
});
}
// Init Media Session API
initMediaSession();
}
function openNowPlayingModal() {
const overlay = document.getElementById('np-modal-overlay');
if (!overlay) return;
npModalOpen = true;
overlay.classList.remove('hidden');
document.body.style.overflow = 'hidden';
syncExpandedPlayerUI();
// Start visualizer if already playing
if (isPlaying) { npInitVisualizer(); npStartVisualizerLoop(); }
}
function closeNowPlayingModal() {
const overlay = document.getElementById('np-modal-overlay');
if (!overlay) return;
npModalOpen = false;
overlay.classList.add('hidden');
document.body.style.overflow = '';
npStopVisualizerLoop();
}
function syncExpandedPlayerUI() {
if (!npModalOpen) return;
// Track info
updateNpTrackInfo();
// Play state
updateNpPlayButton();
// Progress
updateNpProgress();
// Volume
const sidebarVol = document.getElementById('volume-slider');
const npVol = document.getElementById('np-volume-slider');
const npVolFill = document.getElementById('np-volume-fill');
if (sidebarVol && npVol) {
npVol.value = sidebarVol.value;
if (npVolFill) npVolFill.style.width = sidebarVol.value + '%';
}
// Visualizer
const viz = document.getElementById('np-visualizer');
if (viz) viz.classList.toggle('playing', isPlaying);
// Queue
renderNpQueue();
updateNpPrevNextButtons();
}
function updateNpTrackInfo() {
const titleEl = document.getElementById('np-track-title');
const artistEl = document.getElementById('np-artist-name');
const albumEl = document.getElementById('np-album-name');
const artImg = document.getElementById('np-album-art');
const artPlaceholder = document.getElementById('np-album-art-placeholder');
const badgesEl = document.getElementById('np-format-badges');
const actionBtns = document.getElementById('np-action-buttons');
if (!titleEl) return;
// Sidebar album art
const sidebarArt = document.getElementById('sidebar-album-art');
if (currentTrack) {
// Track text transition animation
const textEls = [titleEl, artistEl, albumEl];
const oldTitle = titleEl.textContent;
const newTitle = currentTrack.title || 'Unknown Track';
const trackChanged = oldTitle !== newTitle && oldTitle !== 'No track';
titleEl.textContent = newTitle;
artistEl.textContent = currentTrack.artist || 'Unknown Artist';
albumEl.textContent = currentTrack.album || 'Unknown Album';
if (trackChanged) {
textEls.forEach(el => {
el.classList.remove('np-text-transition');
void el.offsetWidth; // force reflow
el.classList.add('np-text-transition');
});
}
// Album art (modal + sidebar) + ambient glow extraction
const artUrl = getNpAlbumArtUrl();
if (artUrl && artImg) {
// Only set crossOrigin for external URLs โ local paths break with CORS headers
if (artUrl.startsWith('http')) {
artImg.crossOrigin = 'anonymous';
} else {
artImg.removeAttribute('crossOrigin');
}
artImg.src = artUrl;
artImg.classList.remove('hidden');
artImg.onerror = () => { artImg.classList.add('hidden'); npResetAmbientGlow(); };
artImg.onload = () => { npExtractAmbientColor(artImg); };
} else if (artImg) {
artImg.classList.add('hidden');
npResetAmbientGlow();
}
if (sidebarArt) {
if (artUrl) {
sidebarArt.src = artUrl;
sidebarArt.style.display = '';
sidebarArt.onerror = () => { sidebarArt.src = ''; };
} else {
sidebarArt.src = '';
}
}
// Format badges (richer: include bitrate/sample_rate)
if (badgesEl) {
badgesEl.innerHTML = '';
const filename = currentTrack.filename || '';
if (filename) {
const ext = getFileExtension(filename);
if (ext) {
let label = ext.toUpperCase();
if (currentTrack.sample_rate) {
const khz = (currentTrack.sample_rate / 1000);
label += ' ' + (khz % 1 === 0 ? khz.toFixed(0) : khz.toFixed(1)) + 'kHz';
}
const badge = document.createElement('span');
badge.className = 'np-format-badge' + (ext === 'flac' ? ' flac' : '');
badge.textContent = label;
badgesEl.appendChild(badge);
}
if (currentTrack.bitrate) {
const brBadge = document.createElement('span');
brBadge.className = 'np-format-badge';
brBadge.textContent = currentTrack.bitrate + 'k';
badgesEl.appendChild(brBadge);
}
}
}
// Action buttons visibility
if (actionBtns) {
const hasArtist = currentTrack.artist_id;
actionBtns.classList.toggle('hidden', !hasArtist);
}
// Track recently played for radio mode
if (currentTrack.id && !npRecentlyPlayedIds.includes(currentTrack.id)) {
npRecentlyPlayedIds.push(currentTrack.id);
if (npRecentlyPlayedIds.length > 50) npRecentlyPlayedIds.shift();
}
} else {
titleEl.textContent = 'No track';
artistEl.textContent = 'Unknown Artist';
albumEl.textContent = 'Unknown Album';
if (artImg) artImg.classList.add('hidden');
if (sidebarArt) sidebarArt.src = '';
if (badgesEl) badgesEl.innerHTML = '';
if (actionBtns) actionBtns.classList.add('hidden');
npResetAmbientGlow();
}
}
function npExtractAmbientColor(imgEl) {
try {
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
canvas.width = 50;
canvas.height = 50;
ctx.drawImage(imgEl, 0, 0, 50, 50);
const data = ctx.getImageData(0, 0, 50, 50).data;
let rSum = 0, gSum = 0, bSum = 0, count = 0;
for (let i = 0; i < data.length; i += 16) { // sample every 4th pixel
const r = data[i], g = data[i + 1], b = data[i + 2];
const brightness = (r + g + b) / 3;
if (brightness > 20 && brightness < 230) {
rSum += r; gSum += g; bSum += b; count++;
}
}
if (count > 0) {
const modal = document.querySelector('.np-modal');
if (modal) {
modal.style.setProperty('--np-ambient-r', Math.round(rSum / count));
modal.style.setProperty('--np-ambient-g', Math.round(gSum / count));
modal.style.setProperty('--np-ambient-b', Math.round(bSum / count));
}
}
} catch (e) {
// Cross-origin or canvas error โ ignore silently
}
}
function npResetAmbientGlow() {
const modal = document.querySelector('.np-modal');
if (modal) {
modal.style.setProperty('--np-ambient-r', '29');
modal.style.setProperty('--np-ambient-g', '185');
modal.style.setProperty('--np-ambient-b', '84');
}
}
function updateNpPlayButton() {
const playIcon = document.querySelector('.np-icon-play');
const pauseIcon = document.querySelector('.np-icon-pause');
if (playIcon && pauseIcon) {
playIcon.classList.toggle('hidden', isPlaying);
pauseIcon.classList.toggle('hidden', !isPlaying);
}
const viz = document.getElementById('np-visualizer');
if (viz) viz.classList.toggle('playing', isPlaying);
// Drive Web Audio visualizer (only when modal is open to save CPU)
if (isPlaying && npModalOpen) {
npInitVisualizer();
npStartVisualizerLoop();
} else {
npStopVisualizerLoop();
}
}
function updateNpProgress() {
if (!npModalOpen || !audioPlayer) return;
const npProgressBar = document.getElementById('np-progress-bar');
const npProgressFill = document.getElementById('np-progress-fill');
const npCurrentTime = document.getElementById('np-current-time');
const npTotalTime = document.getElementById('np-total-time');
if (audioPlayer.duration) {
const progress = (audioPlayer.currentTime / audioPlayer.duration) * 100;
if (npProgressBar && !npProgressBar.dataset.seeking) {
npProgressBar.value = progress;
}
if (npProgressFill) npProgressFill.style.width = progress + '%';
if (npCurrentTime) npCurrentTime.textContent = formatTime(audioPlayer.currentTime);
if (npTotalTime) npTotalTime.textContent = formatTime(audioPlayer.duration);
} else {
if (npProgressBar) npProgressBar.value = 0;
if (npProgressFill) npProgressFill.style.width = '0%';
if (npCurrentTime) npCurrentTime.textContent = '0:00';
if (npTotalTime) npTotalTime.textContent = '0:00';
}
}
function handleNpProgressBarChange(event) {
if (!audioPlayer || !audioPlayer.duration) return;
const progress = parseFloat(event.target.value);
const newTime = (progress / 100) * audioPlayer.duration;
try {
audioPlayer.currentTime = newTime;
// Sync sidebar progress
const sidebarBar = document.getElementById('progress-bar');
const sidebarFill = document.getElementById('progress-fill');
if (sidebarBar) sidebarBar.value = progress;
if (sidebarFill) sidebarFill.style.width = progress + '%';
// Sync modal progress fill
const npFill = document.getElementById('np-progress-fill');
if (npFill) npFill.style.width = progress + '%';
// Update time displays
const sidebarTime = document.getElementById('current-time');
const npTime = document.getElementById('np-current-time');
if (sidebarTime) sidebarTime.textContent = formatTime(newTime);
if (npTime) npTime.textContent = formatTime(newTime);
} catch (error) {
console.warn('Seek failed:', error.message);
}
}
function handleNpVolumeChange(event) {
const volume = parseInt(event.target.value);
if (audioPlayer) audioPlayer.volume = volume / 100;
// Sync sidebar volume slider
const sidebarVol = document.getElementById('volume-slider');
if (sidebarVol) {
sidebarVol.value = volume;
sidebarVol.style.setProperty('--volume-percent', volume + '%');
}
// Update modal volume fill
const npFill = document.getElementById('np-volume-fill');
if (npFill) npFill.style.width = volume + '%';
// Update mute state
npMuted = volume === 0;
updateNpMuteIcon();
}
function handleNpMuteToggle() {
const npVol = document.getElementById('np-volume-slider');
if (!npVol) return;
if (npMuted) {
// Unmute โ restore previous volume
npVol.value = npPreMuteVolume;
npVol.dispatchEvent(new Event('input'));
npMuted = false;
} else {
// Mute โ save current volume, set to 0
npPreMuteVolume = parseInt(npVol.value) || 70;
npVol.value = 0;
npVol.dispatchEvent(new Event('input'));
npMuted = true;
}
updateNpMuteIcon();
}
function updateNpMuteIcon() {
const muteBtn = document.getElementById('np-mute-btn');
const volIcon = muteBtn ? muteBtn.querySelector('.np-icon-vol') : null;
const mutedIcon = muteBtn ? muteBtn.querySelector('.np-icon-muted') : null;
if (volIcon && mutedIcon) {
volIcon.classList.toggle('hidden', npMuted);
mutedIcon.classList.toggle('hidden', !npMuted);
}
if (muteBtn) muteBtn.classList.toggle('muted', npMuted);
}
function handleNpShuffle() {
npShuffleOn = !npShuffleOn;
const btn = document.getElementById('np-shuffle-btn');
if (btn) btn.classList.toggle('active', npShuffleOn);
updateNpPrevNextButtons();
}
function handleNpRepeat() {
const badge = document.getElementById('np-repeat-one-badge');
if (npRepeatMode === 'off') {
npRepeatMode = 'all';
if (audioPlayer) audioPlayer.loop = false;
} else if (npRepeatMode === 'all') {
npRepeatMode = 'one';
if (audioPlayer) audioPlayer.loop = true;
} else {
npRepeatMode = 'off';
if (audioPlayer) audioPlayer.loop = false;
}
const btn = document.getElementById('np-repeat-btn');
if (btn) btn.classList.toggle('active', npRepeatMode !== 'off');
if (badge) badge.classList.toggle('hidden', npRepeatMode !== 'one');
updateNpPrevNextButtons();
}
// ===============================
// QUEUE MANAGEMENT
// ===============================
function addToQueue(track) {
npQueue.push(track);
showToast('Added to queue', 'success');
renderNpQueue();
updateNpPrevNextButtons();
// If nothing is currently playing, auto-play the first queued track
if (!currentTrack) {
playQueueItem(npQueue.length - 1);
}
}
function removeFromQueue(index) {
if (index < 0 || index >= npQueue.length) return;
const wasCurrentTrack = (index === npQueueIndex);
npQueue.splice(index, 1);
// Adjust current index
if (npQueue.length === 0) {
npQueueIndex = -1;
// Current track keeps playing but queue is now empty โ that's OK
} else if (index < npQueueIndex) {
npQueueIndex--;
} else if (wasCurrentTrack) {
// Removed the currently playing item
if (npQueueIndex >= npQueue.length) {
npQueueIndex = npQueue.length - 1;
}
// Play the next track at the adjusted index
playQueueItem(npQueueIndex);
}
renderNpQueue();
updateNpPrevNextButtons();
}
function clearQueue() {
npQueue = [];
npQueueIndex = -1;
renderNpQueue();
updateNpPrevNextButtons();
}
function playNextInQueue() {
if (npQueue.length === 0) return;
if (npShuffleOn) {
// Pick a random index that is not the current one
const candidates = [];
for (let i = 0; i < npQueue.length; i++) {
if (i !== npQueueIndex) candidates.push(i);
}
if (candidates.length === 0) return;
const next = candidates[Math.floor(Math.random() * candidates.length)];
playQueueItem(next);
} else {
const next = npQueueIndex + 1;
if (next >= npQueue.length) {
// End of queue โ repeat-all wraps to start
if (npRepeatMode === 'all') {
playQueueItem(0);
}
return;
}
playQueueItem(next);
}
}
function playPreviousInQueue() {
// If more than 3 seconds in, restart current track
if (audioPlayer && audioPlayer.currentTime > 3) {
audioPlayer.currentTime = 0;
if (audioPlayer.paused) audioPlayer.play();
return;
}
if (npQueue.length === 0) return;
const prev = npQueueIndex - 1;
if (prev < 0) {
// At start โ restart current track
if (audioPlayer) {
audioPlayer.currentTime = 0;
if (audioPlayer.paused) audioPlayer.play();
}
return;
}
playQueueItem(prev);
}
async function playQueueItem(index) {
if (index < 0 || index >= npQueue.length) return;
if (npLoadingQueueItem) return; // Prevent race condition from double-advance
npLoadingQueueItem = true;
npQueueIndex = index;
const track = npQueue[index];
try {
if (track.is_library) {
// Library track playback flow
await stopStream();
setTrackInfo({
title: track.title,
artist: track.artist,
album: track.album,
filename: track.file_path,
is_library: true,
image_url: track.image_url,
id: track.id,
artist_id: track.artist_id,
album_id: track.album_id,
bitrate: track.bitrate,
sample_rate: track.sample_rate
});
showLoadingAnimation();
const response = await fetch('/api/library/play', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
file_path: track.file_path,
title: track.title || '',
artist: track.artist || '',
album: track.album || ''
})
});
const result = await response.json();
if (!result.success) throw new Error(result.error || 'Failed to start playback');
// Re-apply repeat-one loop property
if (audioPlayer) audioPlayer.loop = (npRepeatMode === 'one');
await startAudioPlayback();
} else {
// Non-library (stream) tracks cannot be queued for auto-advance
// Just show track info โ the stream flow handles its own playback
setTrackInfo({
title: track.title,
artist: track.artist,
album: track.album,
filename: track.filename || track.file_path,
is_library: false,
image_url: track.image_url,
id: track.id,
artist_id: track.artist_id,
album_id: track.album_id,
bitrate: track.bitrate,
sample_rate: track.sample_rate
});
}
} catch (error) {
console.error('Queue playback error:', error);
showToast(`Skipping track: ${error.message}`, 'error');
hideLoadingAnimation();
// Auto-skip to next track on failure instead of stopping the queue
npLoadingQueueItem = false;
const nextIdx = npQueueIndex + 1;
if (nextIdx < npQueue.length) {
setTimeout(() => playQueueItem(nextIdx), 500);
}
return;
} finally {
npLoadingQueueItem = false;
}
renderNpQueue();
updateNpPrevNextButtons();
}
function renderNpQueue() {
const listEl = document.getElementById('np-queue-list');
const emptyEl = document.getElementById('np-queue-empty');
const countEl = document.getElementById('np-queue-count');
if (!listEl) return;
if (countEl) countEl.textContent = npQueue.length > 0 ? `(${npQueue.length})` : '';
if (npQueue.length === 0) {
listEl.innerHTML = '';
if (emptyEl) emptyEl.classList.remove('hidden');
return;
}
if (emptyEl) emptyEl.classList.add('hidden');
listEl.innerHTML = '';
npQueue.forEach((track, i) => {
const item = document.createElement('div');
item.className = 'np-queue-item' + (i === npQueueIndex ? ' active' : '');
item.onclick = () => playQueueItem(i);
const info = document.createElement('div');
info.className = 'np-queue-item-info';
const title = document.createElement('div');
title.className = 'np-queue-item-title';
title.textContent = track.title || 'Unknown Track';
const artist = document.createElement('div');
artist.className = 'np-queue-item-artist';
artist.textContent = track.artist || 'Unknown Artist';
info.appendChild(title);
info.appendChild(artist);
item.appendChild(info);
const removeBtn = document.createElement('button');
removeBtn.className = 'np-queue-item-remove';
removeBtn.innerHTML = '✕';
removeBtn.title = 'Remove from queue';
removeBtn.onclick = (e) => {
e.stopPropagation();
removeFromQueue(i);
};
item.appendChild(removeBtn);
listEl.appendChild(item);
});
}
function updateNpPrevNextButtons() {
const prevBtn = document.getElementById('np-prev-btn');
const nextBtn = document.getElementById('np-next-btn');
if (prevBtn) {
const canPrev = npQueueIndex > 0 || (audioPlayer && audioPlayer.currentTime > 3);
prevBtn.disabled = !canPrev;
}
if (nextBtn) {
const canNext = npQueue.length > 0 && (npShuffleOn ? npQueue.length > 1 : (npQueueIndex < npQueue.length - 1 || npRepeatMode === 'all'));
nextBtn.disabled = !canNext;
}
}
function handlePlayerKeyboardShortcuts(event) {
// Don't intercept when typing in inputs or when non-player modals are open
const tag = document.activeElement.tagName.toLowerCase();
if (tag === 'input' || tag === 'textarea' || tag === 'select' || document.activeElement.isContentEditable) return;
// Only handle when player modal is open OR when no other modal is visible
const otherModals = document.querySelectorAll('.modal-overlay:not(.hidden):not(#np-modal-overlay)');
if (otherModals.length > 0 && !npModalOpen) return;
switch (event.key) {
case ' ':
if (!currentTrack) return;
event.preventDefault();
togglePlayback();
break;
case 'ArrowLeft':
if (!audioPlayer || !audioPlayer.duration) return;
event.preventDefault();
audioPlayer.currentTime = Math.max(0, audioPlayer.currentTime - 5);
break;
case 'ArrowRight':
if (!audioPlayer || !audioPlayer.duration) return;
event.preventDefault();
audioPlayer.currentTime = Math.min(audioPlayer.duration, audioPlayer.currentTime + 5);
break;
case 'ArrowUp':
event.preventDefault();
if (audioPlayer) {
const newVol = Math.min(1, audioPlayer.volume + 0.05);
audioPlayer.volume = newVol;
syncVolumeUI(Math.round(newVol * 100));
}
break;
case 'ArrowDown':
event.preventDefault();
if (audioPlayer) {
const newVol = Math.max(0, audioPlayer.volume - 0.05);
audioPlayer.volume = newVol;
syncVolumeUI(Math.round(newVol * 100));
}
break;
case 'm':
case 'M':
if (npModalOpen) handleNpMuteToggle();
break;
case 'Escape':
if (npModalOpen) closeNowPlayingModal();
break;
default:
return; // Don't prevent default for unhandled keys
}
}
function syncVolumeUI(volumePercent) {
// Sync both sidebar and modal volume UIs
const sidebarVol = document.getElementById('volume-slider');
const npVol = document.getElementById('np-volume-slider');
const npFill = document.getElementById('np-volume-fill');
if (sidebarVol) {
sidebarVol.value = volumePercent;
sidebarVol.style.setProperty('--volume-percent', volumePercent + '%');
}
if (npVol) npVol.value = volumePercent;
if (npFill) npFill.style.width = volumePercent + '%';
}
function getNpAlbumArtUrl() {
if (!currentTrack) return null;
return currentTrack.image_url || currentTrack.album_cover_url || currentTrack.thumb_url || null;
}
// ===============================
// WEB AUDIO VISUALIZER
// ===============================
function npInitVisualizer() {
if (npVizInitialized || !audioPlayer) return;
try {
if (!npAudioContext) {
npAudioContext = new (window.AudioContext || window.webkitAudioContext)();
}
if (!npMediaSource) {
npMediaSource = npAudioContext.createMediaElementSource(audioPlayer);
npAnalyser = npAudioContext.createAnalyser();
npAnalyser.fftSize = 64;
npAnalyser.smoothingTimeConstant = 0.8;
npMediaSource.connect(npAnalyser);
npAnalyser.connect(npAudioContext.destination);
}
npVizInitialized = true;
} catch (e) {
console.warn('Web Audio visualizer init failed, using CSS fallback:', e.message);
// Mark as CSS fallback
const viz = document.getElementById('np-visualizer');
if (viz) viz.classList.add('np-viz-css-fallback');
npVizInitialized = true; // don't retry
}
}
function npStartVisualizerLoop() {
if (npVizAnimFrame) return; // Already running
if (!npAnalyser) return; // No analyser โ CSS fallback handles it
if (npAudioContext && npAudioContext.state === 'suspended') {
npAudioContext.resume();
}
const bars = document.querySelectorAll('.np-viz-bar');
if (bars.length === 0) return;
const bufferLength = npAnalyser.frequencyBinCount;
const dataArray = new Uint8Array(bufferLength);
function draw() {
npVizAnimFrame = requestAnimationFrame(draw);
npAnalyser.getByteFrequencyData(dataArray);
// Map 7 bars to frequency bins (skip bin 0 which is DC offset)
const binCount = Math.min(bufferLength - 1, 7);
for (let i = 0; i < bars.length; i++) {
const binIndex = Math.min(i + 1, bufferLength - 1);
const value = dataArray[binIndex] / 255; // 0..1
const scale = Math.max(0.08, value); // minimum visible height
bars[i].style.transform = `scaleY(${scale})`;
}
}
draw();
}
function npStopVisualizerLoop() {
if (npVizAnimFrame) {
cancelAnimationFrame(npVizAnimFrame);
npVizAnimFrame = null;
}
// Reset bars to min
const bars = document.querySelectorAll('.np-viz-bar');
bars.forEach(bar => { bar.style.transform = 'scaleY(0.125)'; });
}
// ===============================
// SIDEBAR AUDIO VISUALIZER
// ===============================
let sidebarVizAnimFrame = null;
let sidebarVisualizerType = 'bars'; // bars | wave | spectrum | mirror | equalizer | none
const SIDEBAR_VIZ_BAR_COUNT = 32;
let _sidebarVizBuiltType = null;
function buildSidebarVizElements(type) {
const container = document.getElementById('sidebar-visualizer');
if (!container) return;
if (_sidebarVizBuiltType === type && container.children.length > 0) return;
_sidebarVizBuiltType = type;
container.innerHTML = '';
container.className = 'sidebar-visualizer';
if (type === 'bars') {
container.classList.add('viz-bars');
for (let i = 0; i < SIDEBAR_VIZ_BAR_COUNT; i++) {
const bar = document.createElement('div');
bar.className = 'sidebar-viz-bar';
container.appendChild(bar);
}
} else if (type === 'wave' || type === 'spectrum') {
container.classList.add('viz-canvas');
const canvas = document.createElement('canvas');
canvas.className = 'sidebar-viz-canvas';
canvas.width = 10;
canvas.height = 600;
container.appendChild(canvas);
} else if (type === 'mirror') {
container.classList.add('viz-mirror');
for (let i = 0; i < SIDEBAR_VIZ_BAR_COUNT; i++) {
const bar = document.createElement('div');
bar.className = 'sidebar-viz-mirror-bar';
container.appendChild(bar);
}
} else if (type === 'equalizer') {
container.classList.add('viz-equalizer');
for (let i = 0; i < SIDEBAR_VIZ_BAR_COUNT; i++) {
const wrap = document.createElement('div');
wrap.className = 'sidebar-viz-eq-wrap';
const bar = document.createElement('div');
bar.className = 'sidebar-viz-eq-bar';
const peak = document.createElement('div');
peak.className = 'sidebar-viz-eq-peak';
wrap.appendChild(bar);
wrap.appendChild(peak);
container.appendChild(wrap);
}
}
}
function startSidebarVisualizer() {
const type = sidebarVisualizerType;
if (type === 'none') return;
const container = document.getElementById('sidebar-visualizer');
if (!container) return;
buildSidebarVizElements(type);
container.classList.add('active');
if (sidebarVizAnimFrame) return;
if (!npAnalyser) return;
const bufferLength = npAnalyser.frequencyBinCount;
const dataArray = new Uint8Array(bufferLength);
const hueStart = 200, hueRange = 160;
// Helper: average frequency bins for a given segment index
function getBinValue(i, count) {
const binsPerSeg = Math.max(1, Math.floor((bufferLength - 1) / count));
let sum = 0;
const start = i * binsPerSeg + 1;
for (let b = 0; b < binsPerSeg; b++) sum += dataArray[Math.min(start + b, bufferLength - 1)];
return (sum / binsPerSeg) / 255;
}
// โโ Bars โโ
if (type === 'bars') {
const bars = container.querySelectorAll('.sidebar-viz-bar');
if (bars.length === 0) return;
function drawBars() {
sidebarVizAnimFrame = requestAnimationFrame(drawBars);
npAnalyser.getByteFrequencyData(dataArray);
for (let i = 0; i < bars.length; i++) {
const value = getBinValue(i, bars.length);
const scale = Math.max(0.08, value);
const hue = (hueStart + (i / bars.length) * hueRange + value * 30) % 360;
bars[i].style.transform = `scaleX(${scale})`;
bars[i].style.backgroundColor = `hsla(${hue}, 80%, ${50 + value * 15}%, ${0.5 + value * 0.5})`;
}
}
drawBars();
// โโ Wave โโ
} else if (type === 'wave') {
const canvas = container.querySelector('.sidebar-viz-canvas');
if (!canvas) return;
const ctx = canvas.getContext('2d');
let hueOffset = 0;
function drawWave() {
sidebarVizAnimFrame = requestAnimationFrame(drawWave);
const ch = container.clientHeight;
if (ch > 0 && canvas.height !== ch) canvas.height = ch;
npAnalyser.getByteFrequencyData(dataArray);
const w = canvas.width, h = canvas.height;
if (h === 0) return;
ctx.clearRect(0, 0, w, h);
let totalEnergy = 0;
for (let i = 1; i < bufferLength; i++) totalEnergy += dataArray[i];
const avgEnergy = totalEnergy / (bufferLength - 1) / 255;
hueOffset = (hueOffset + 0.5) % 360;
const segments = 64;
ctx.lineWidth = 3;
ctx.lineCap = 'round';
ctx.beginPath();
for (let i = 0; i <= segments; i++) {
const y = (i / segments) * h;
const binIdx = Math.min(Math.floor((i / segments) * (bufferLength - 1)) + 1, bufferLength - 1);
const value = dataArray[binIdx] / 255;
const x = (w / 2) + Math.sin((i / segments) * Math.PI * 4 + Date.now() * 0.003) * value * (w - 2) * 0.4;
if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y);
}
const grad = ctx.createLinearGradient(0, 0, 0, h);
grad.addColorStop(0, `hsla(${hueOffset + 200}, 80%, 60%, ${0.3 + avgEnergy * 0.7})`);
grad.addColorStop(0.5, `hsla(${hueOffset + 280}, 80%, 55%, ${0.3 + avgEnergy * 0.7})`);
grad.addColorStop(1, `hsla(${hueOffset + 360}, 80%, 60%, ${0.3 + avgEnergy * 0.7})`);
ctx.strokeStyle = grad;
ctx.stroke();
ctx.lineWidth = 6;
ctx.globalAlpha = 0.15 + avgEnergy * 0.2;
ctx.stroke();
ctx.globalAlpha = 1;
}
drawWave();
// โโ Spectrum (mountain/terrain fill) โโ
} else if (type === 'spectrum') {
const canvas = container.querySelector('.sidebar-viz-canvas');
if (!canvas) return;
const ctx = canvas.getContext('2d');
let hueOffset = 0;
// Smoothed values for fluid motion
const smoothed = new Float32Array(64);
function drawSpectrum() {
sidebarVizAnimFrame = requestAnimationFrame(drawSpectrum);
const ch = container.clientHeight;
if (ch > 0 && canvas.height !== ch) canvas.height = ch;
npAnalyser.getByteFrequencyData(dataArray);
const w = canvas.width, h = canvas.height;
if (h === 0) return;
ctx.clearRect(0, 0, w, h);
hueOffset = (hueOffset + 0.3) % 360;
const segments = smoothed.length;
// Smooth the frequency data
for (let i = 0; i < segments; i++) {
const binIdx = Math.min(Math.floor((i / segments) * (bufferLength - 1)) + 1, bufferLength - 1);
const target = dataArray[binIdx] / 255;
smoothed[i] += (target - smoothed[i]) * 0.25;
}
// Draw filled mountain shape from left edge
ctx.beginPath();
ctx.moveTo(0, 0);
for (let i = 0; i <= segments; i++) {
const y = (i / segments) * h;
const value = i < segments ? smoothed[i] : smoothed[segments - 1];
const x = value * w * 0.95;
ctx.lineTo(x, y);
}
ctx.lineTo(0, h);
ctx.closePath();
// Gradient fill
const fillGrad = ctx.createLinearGradient(0, 0, 0, h);
fillGrad.addColorStop(0, `hsla(${hueOffset + 200}, 85%, 55%, 0.7)`);
fillGrad.addColorStop(0.25, `hsla(${hueOffset + 240}, 80%, 50%, 0.6)`);
fillGrad.addColorStop(0.5, `hsla(${hueOffset + 290}, 85%, 50%, 0.65)`);
fillGrad.addColorStop(0.75, `hsla(${hueOffset + 330}, 80%, 50%, 0.6)`);
fillGrad.addColorStop(1, `hsla(${hueOffset + 360}, 85%, 55%, 0.7)`);
ctx.fillStyle = fillGrad;
ctx.fill();
// Bright edge line
ctx.beginPath();
ctx.moveTo(0, 0);
for (let i = 0; i <= segments; i++) {
const y = (i / segments) * h;
const value = i < segments ? smoothed[i] : smoothed[segments - 1];
ctx.lineTo(value * w * 0.95, y);
}
const lineGrad = ctx.createLinearGradient(0, 0, 0, h);
lineGrad.addColorStop(0, `hsla(${hueOffset + 200}, 90%, 70%, 0.9)`);
lineGrad.addColorStop(0.5, `hsla(${hueOffset + 290}, 90%, 65%, 0.9)`);
lineGrad.addColorStop(1, `hsla(${hueOffset + 360}, 90%, 70%, 0.9)`);
ctx.strokeStyle = lineGrad;
ctx.lineWidth = 1.5;
ctx.stroke();
// Outer glow
ctx.lineWidth = 4;
ctx.globalAlpha = 0.2;
ctx.stroke();
ctx.globalAlpha = 1;
}
drawSpectrum();
// โโ Mirror (bars from center outward) โโ
} else if (type === 'mirror') {
const bars = container.querySelectorAll('.sidebar-viz-mirror-bar');
if (bars.length === 0) return;
function drawMirror() {
sidebarVizAnimFrame = requestAnimationFrame(drawMirror);
npAnalyser.getByteFrequencyData(dataArray);
const half = Math.floor(bars.length / 2);
for (let i = 0; i < half; i++) {
const value = getBinValue(i, half);
const scale = Math.max(0.06, value);
const hue = (hueStart + (i / half) * hueRange + value * 30) % 360;
const color = `hsla(${hue}, 80%, ${50 + value * 15}%, ${0.5 + value * 0.5})`;
// Top half โ mirror index from center
const topIdx = half - 1 - i;
const bottomIdx = half + i;
bars[topIdx].style.transform = `scaleX(${scale})`;
bars[topIdx].style.backgroundColor = color;
if (bottomIdx < bars.length) {
bars[bottomIdx].style.transform = `scaleX(${scale})`;
bars[bottomIdx].style.backgroundColor = color;
}
}
}
drawMirror();
// โโ Equalizer (bars with falling peak indicators) โโ
} else if (type === 'equalizer') {
const wraps = container.querySelectorAll('.sidebar-viz-eq-wrap');
if (wraps.length === 0) return;
const peaks = new Float32Array(wraps.length);
const peakVelocity = new Float32Array(wraps.length);
function drawEqualizer() {
sidebarVizAnimFrame = requestAnimationFrame(drawEqualizer);
npAnalyser.getByteFrequencyData(dataArray);
for (let i = 0; i < wraps.length; i++) {
const value = getBinValue(i, wraps.length);
const scale = Math.max(0.06, value);
const hue = (hueStart + (i / wraps.length) * hueRange + value * 30) % 360;
const barEl = wraps[i].querySelector('.sidebar-viz-eq-bar');
const peakEl = wraps[i].querySelector('.sidebar-viz-eq-peak');
barEl.style.transform = `scaleX(${scale})`;
barEl.style.backgroundColor = `hsla(${hue}, 80%, ${50 + value * 15}%, ${0.5 + value * 0.5})`;
// Peak hold with gravity
if (value > peaks[i]) {
peaks[i] = value;
peakVelocity[i] = 0;
} else {
peakVelocity[i] += 0.002; // gravity
peaks[i] = Math.max(0, peaks[i] - peakVelocity[i]);
}
const peakPos = Math.max(0.06, peaks[i]);
peakEl.style.left = `${peakPos * 100}%`;
peakEl.style.backgroundColor = `hsla(${hue}, 90%, 75%, ${0.6 + peaks[i] * 0.4})`;
peakEl.style.boxShadow = `0 0 4px hsla(${hue}, 90%, 70%, ${peaks[i] * 0.5})`;
}
}
drawEqualizer();
}
}
function stopSidebarVisualizer() {
if (sidebarVizAnimFrame) {
cancelAnimationFrame(sidebarVizAnimFrame);
sidebarVizAnimFrame = null;
}
const container = document.getElementById('sidebar-visualizer');
if (container) {
container.classList.remove('active');
}
}
// Listen for visualizer type changes in settings โ use isPlaying (not wasRunning)
// so switching from 'none' to a real type while music plays starts the visualizer
document.addEventListener('change', (e) => {
if (e.target.id === 'sidebar-visualizer-type') {
const newType = e.target.value;
stopSidebarVisualizer();
_sidebarVizBuiltType = null; // force rebuild for new type
sidebarVisualizerType = newType;
if (isPlaying && newType !== 'none') {
npInitVisualizer();
startSidebarVisualizer();
}
}
});
// ===============================
// RADIO MODE
// ===============================
async function npFetchRadioTracks() {
if (!currentTrack || !currentTrack.id) return;
try {
npLoadingQueueItem = true;
const excludeIds = npRecentlyPlayedIds.join(',');
const resp = await fetch(`/api/library/radio?track_id=${currentTrack.id}&limit=50&exclude=${encodeURIComponent(excludeIds)}`);
if (!resp.ok) {
console.warn('Radio endpoint returned', resp.status);
npLoadingQueueItem = false;
return;
}
const data = await resp.json();
// Bail if radio was toggled off during the fetch
if (!npRadioMode) { npLoadingQueueItem = false; return; }
if (data.tracks && data.tracks.length > 0) {
data.tracks.forEach(t => {
npQueue.push({
title: t.title || 'Unknown Track',
artist: t.artist || 'Unknown Artist',
album: t.album || 'Unknown Album',
file_path: t.file_path,
filename: t.file_path,
is_library: true,
image_url: t.image_url || null,
id: t.id,
artist_id: t.artist_id,
album_id: t.album_id,
bitrate: t.bitrate,
sample_rate: t.sample_rate
});
});
showToast(`Radio: Added ${data.tracks.length} similar tracks`, 'success');
renderNpQueue();
updateNpPrevNextButtons();
npLoadingQueueItem = false;
// Only auto-advance if nothing is currently playing (triggered by onAudioEnded)
if (!isPlaying) {
playNextInQueue();
}
} else {
showToast('Radio: No similar tracks found', 'info');
npLoadingQueueItem = false;
}
} catch (e) {
console.warn('Radio fetch error:', e);
npLoadingQueueItem = false;
}
}
// Media Session API
function initMediaSession() {
if (!('mediaSession' in navigator)) return;
navigator.mediaSession.setActionHandler('play', () => {
if (audioPlayer && currentTrack) {
audioPlayer.play().then(() => setPlayingState(true));
}
});
navigator.mediaSession.setActionHandler('pause', () => {
if (audioPlayer) {
audioPlayer.pause();
setPlayingState(false);
}
});
navigator.mediaSession.setActionHandler('stop', () => {
handleStop();
});
navigator.mediaSession.setActionHandler('seekbackward', () => {
if (audioPlayer && audioPlayer.duration) {
audioPlayer.currentTime = Math.max(0, audioPlayer.currentTime - 10);
}
});
navigator.mediaSession.setActionHandler('seekforward', () => {
if (audioPlayer && audioPlayer.duration) {
audioPlayer.currentTime = Math.min(audioPlayer.duration, audioPlayer.currentTime + 10);
}
});
navigator.mediaSession.setActionHandler('previoustrack', () => {
if (npQueue.length > 0) playPreviousInQueue();
});
navigator.mediaSession.setActionHandler('nexttrack', () => {
if (npQueue.length > 0) playNextInQueue();
});
}
function updateMediaSessionMetadata() {
if (!('mediaSession' in navigator) || !currentTrack) return;
const artwork = [];
const artUrl = getNpAlbumArtUrl();
if (artUrl) artwork.push({ src: artUrl, sizes: '512x512', type: 'image/jpeg' });
navigator.mediaSession.metadata = new MediaMetadata({
title: currentTrack.title || 'Unknown Track',
artist: currentTrack.artist || 'Unknown Artist',
album: currentTrack.album || 'Unknown Album',
artwork: artwork
});
}
function updateMediaSessionPlaybackState() {
if (!('mediaSession' in navigator)) return;
if (!currentTrack) {
navigator.mediaSession.playbackState = 'none';
} else {
navigator.mediaSession.playbackState = isPlaying ? 'playing' : 'paused';
}
}
// ===============================
// SUPPORT MODAL
// ===============================
function showSupportModal() {
const overlay = document.getElementById('support-modal-overlay');
if (overlay) overlay.classList.remove('hidden');
}
function closeSupportModal() {
const overlay = document.getElementById('support-modal-overlay');
if (overlay) overlay.classList.add('hidden');
}
async function copyAddress(address, cryptoName) {
try {
await navigator.clipboard.writeText(address);
showToast(`${cryptoName} address copied to clipboard`, 'success');
console.log(`Copied ${cryptoName} address: ${address}`);
} catch (error) {
console.error('Failed to copy address:', error);
showToast(`Failed to copy ${cryptoName} address`, 'error');
}
}
// ===============================
// SETTINGS FUNCTIONALITY
// ===============================
let settingsAutoSaveTimer = null;
function debouncedAutoSaveSettings() {
if (settingsAutoSaveTimer) clearTimeout(settingsAutoSaveTimer);
settingsAutoSaveTimer = setTimeout(() => saveSettings(true), 2000);
}
function handleManualSaveClick() {
if (settingsAutoSaveTimer) clearTimeout(settingsAutoSaveTimer);
saveSettings(false);
}
function initializeSettings() {
// This function is called when the settings page is loaded.
// It attaches event listeners to all interactive elements on the page.
// Accent color listeners (live preview + custom picker toggle)
initAccentColorListeners();
// Main save button (manual save, non-quiet)
// Uses named function reference so addEventListener deduplicates across repeated calls
const saveButton = document.getElementById('save-settings');
if (saveButton) {
saveButton.addEventListener('click', handleManualSaveClick);
}
// Debounced auto-save on all settings inputs
// Uses named function reference (debouncedAutoSaveSettings) so addEventListener deduplicates
const settingsPage = document.getElementById('settings-page');
if (settingsPage) {
settingsPage.querySelectorAll('input[type="text"], input[type="url"], input[type="password"], input[type="number"], input[type="range"]').forEach(input => {
input.addEventListener('input', debouncedAutoSaveSettings);
});
settingsPage.querySelectorAll('input[type="checkbox"], select').forEach(input => {
input.addEventListener('change', debouncedAutoSaveSettings);
});
}
// Server toggle buttons
const plexToggle = document.getElementById('plex-toggle');
if (plexToggle) {
plexToggle.addEventListener('click', () => toggleServer('plex'));
}
const jellyfinToggle = document.getElementById('jellyfin-toggle');
if (jellyfinToggle) {
jellyfinToggle.addEventListener('click', () => toggleServer('jellyfin'));
}
// Auto-detect buttons
const detectSlskdBtn = document.querySelector('#soulseek-url + .detect-button');
if (detectSlskdBtn) {
detectSlskdBtn.addEventListener('click', autoDetectSlskd);
}
const detectPlexBtn = document.querySelector('#plex-container .detect-button');
if (detectPlexBtn) {
detectPlexBtn.addEventListener('click', autoDetectPlex);
}
const detectJellyfinBtn = document.querySelector('#jellyfin-container .detect-button');
if (detectJellyfinBtn) {
detectJellyfinBtn.addEventListener('click', autoDetectJellyfin);
}
// Test connection buttons
// Test button event listeners removed - they use onclick attributes in HTML to avoid double firing
}
function resetFileOrganizationTemplates() {
// Reset templates to defaults
const defaults = {
album: '$albumartist/$albumartist - $album/$track - $title',
single: '$artist/$artist - $title/$title',
playlist: '$playlist/$artist - $title'
};
document.getElementById('template-album-path').value = defaults.album;
document.getElementById('template-single-path').value = defaults.single;
document.getElementById('template-playlist-path').value = defaults.playlist;
debouncedAutoSaveSettings();
}
function validateFileOrganizationTemplates() {
const errors = [];
// Valid variables for each template type
const validVars = {
album: ['$artist', '$albumartist', '$artistletter', '$album', '$albumtype', '$title', '$track', '$disc', '$year', '$quality'],
single: ['$artist', '$albumartist', '$artistletter', '$album', '$albumtype', '$title', '$year', '$quality'],
playlist: ['$artist', '$artistletter', '$playlist', '$title', '$year', '$quality']
};
// Get template values
const albumPath = document.getElementById('template-album-path').value.trim();
const singlePath = document.getElementById('template-single-path').value.trim();
const playlistPath = document.getElementById('template-playlist-path').value.trim();
// Validate album template
if (albumPath) {
if (albumPath.endsWith('/')) {
errors.push('Album template cannot end with /');
}
if (albumPath.startsWith('/')) {
errors.push('Album template cannot start with /');
}
if (!albumPath.includes('/')) {
errors.push('Album template must include at least one folder (use / separator)');
}
if (albumPath.includes('//')) {
errors.push('Album template cannot have consecutive slashes //');
}
// Check for likely typos of valid variables (case-insensitive to catch $Album, $ARTIST, etc.)
const albumVarPattern = /\$[a-zA-Z]+/g;
const foundVars = albumPath.match(albumVarPattern) || [];
foundVars.forEach(v => {
const lowerVar = v.toLowerCase();
// Check if lowercase version exists in valid vars
const isValid = validVars.album.some(validVar => validVar.toLowerCase() === lowerVar);
if (!isValid) {
errors.push(`Invalid variable "${v}" in album template. Valid: ${validVars.album.join(', ')}`);
} else if (v !== lowerVar && validVars.album.includes(lowerVar)) {
// Variable is valid but has wrong case
errors.push(`Variable "${v}" should be lowercase: "${lowerVar}"`);
}
});
}
// Validate single template
if (singlePath) {
if (singlePath.endsWith('/')) {
errors.push('Single template cannot end with /');
}
if (singlePath.startsWith('/')) {
errors.push('Single template cannot start with /');
}
if (!singlePath.includes('/')) {
errors.push('Single template must include at least one folder (use / separator)');
}
if (singlePath.includes('//')) {
errors.push('Single template cannot have consecutive slashes //');
}
const singleVarPattern = /\$[a-zA-Z]+/g;
const foundVars = singlePath.match(singleVarPattern) || [];
foundVars.forEach(v => {
const lowerVar = v.toLowerCase();
const isValid = validVars.single.some(validVar => validVar.toLowerCase() === lowerVar);
if (!isValid) {
errors.push(`Invalid variable "${v}" in single template. Valid: ${validVars.single.join(', ')}`);
} else if (v !== lowerVar && validVars.single.includes(lowerVar)) {
errors.push(`Variable "${v}" should be lowercase: "${lowerVar}"`);
}
});
}
// Validate playlist template
if (playlistPath) {
if (playlistPath.endsWith('/')) {
errors.push('Playlist template cannot end with /');
}
if (playlistPath.startsWith('/')) {
errors.push('Playlist template cannot start with /');
}
if (!playlistPath.includes('/')) {
errors.push('Playlist template must include at least one folder (use / separator)');
}
if (playlistPath.includes('//')) {
errors.push('Playlist template cannot have consecutive slashes //');
}
const playlistVarPattern = /\$[a-zA-Z]+/g;
const foundVars = playlistPath.match(playlistVarPattern) || [];
foundVars.forEach(v => {
const lowerVar = v.toLowerCase();
const isValid = validVars.playlist.some(validVar => validVar.toLowerCase() === lowerVar);
if (!isValid) {
errors.push(`Invalid variable "${v}" in playlist template. Valid: ${validVars.playlist.join(', ')}`);
} else if (v !== lowerVar && validVars.playlist.includes(lowerVar)) {
errors.push(`Variable "${v}" should be lowercase: "${lowerVar}"`);
}
});
}
return errors;
}
async function loadSettingsData() {
try {
const response = await fetch(API.settings);
const settings = await response.json();
// Populate Spotify settings
document.getElementById('spotify-client-id').value = settings.spotify?.client_id || '';
document.getElementById('spotify-client-secret').value = settings.spotify?.client_secret || '';
document.getElementById('spotify-redirect-uri').value = settings.spotify?.redirect_uri || 'http://127.0.0.1:8888/callback';
document.getElementById('spotify-callback-display').textContent = settings.spotify?.redirect_uri || 'http://127.0.0.1:8888/callback';
// Populate Tidal settings
document.getElementById('tidal-client-id').value = settings.tidal?.client_id || '';
document.getElementById('tidal-client-secret').value = settings.tidal?.client_secret || '';
document.getElementById('tidal-redirect-uri').value = settings.tidal?.redirect_uri || 'http://127.0.0.1:8889/tidal/callback';
document.getElementById('tidal-callback-display').textContent = settings.tidal?.redirect_uri || 'http://127.0.0.1:8889/tidal/callback';
// Add event listeners to update display URLs when input changes
document.getElementById('spotify-redirect-uri').addEventListener('input', function () {
document.getElementById('spotify-callback-display').textContent = this.value || 'http://127.0.0.1:8888/callback';
});
document.getElementById('tidal-redirect-uri').addEventListener('input', function () {
document.getElementById('tidal-callback-display').textContent = this.value || 'http://127.0.0.1:8889/tidal/callback';
});
// Populate Plex settings
document.getElementById('plex-url').value = settings.plex?.base_url || '';
document.getElementById('plex-token').value = settings.plex?.token || '';
// Populate Jellyfin settings
document.getElementById('jellyfin-url').value = settings.jellyfin?.base_url || '';
document.getElementById('jellyfin-api-key').value = settings.jellyfin?.api_key || '';
document.getElementById('jellyfin-timeout').value = settings.jellyfin?.api_timeout || 120;
// Populate Navidrome settings
document.getElementById('navidrome-url').value = settings.navidrome?.base_url || '';
document.getElementById('navidrome-username').value = settings.navidrome?.username || '';
document.getElementById('navidrome-password').value = settings.navidrome?.password || '';
// Set active server and toggle visibility
const activeServer = settings.active_media_server || 'plex';
toggleServer(activeServer);
// Load Plex music libraries if Plex is the active server
if (activeServer === 'plex') {
loadPlexMusicLibraries();
}
// Load Jellyfin users and music libraries if Jellyfin is the active server
if (activeServer === 'jellyfin') {
loadJellyfinUsers().then(() => loadJellyfinMusicLibraries());
}
// Load Navidrome music folders if Navidrome is the active server
if (activeServer === 'navidrome') {
loadNavidromeMusicFolders();
}
// Populate Soulseek settings
document.getElementById('soulseek-url').value = settings.soulseek?.slskd_url || '';
document.getElementById('soulseek-api-key').value = settings.soulseek?.api_key || '';
document.getElementById('soulseek-search-timeout').value = settings.soulseek?.search_timeout || 60;
document.getElementById('soulseek-search-timeout-buffer').value = settings.soulseek?.search_timeout_buffer || 15;
// Populate ListenBrainz settings
document.getElementById('listenbrainz-base-url').value = settings.listenbrainz?.base_url || '';
document.getElementById('listenbrainz-token').value = settings.listenbrainz?.token || '';
// Populate AcoustID settings
document.getElementById('acoustid-api-key').value = settings.acoustid?.api_key || '';
document.getElementById('acoustid-enabled').checked = settings.acoustid?.enabled || false;
// Populate Last.fm settings
document.getElementById('lastfm-api-key').value = settings.lastfm?.api_key || '';
// Populate Genius settings
document.getElementById('genius-access-token').value = settings.genius?.access_token || '';
// Populate iTunes settings
document.getElementById('itunes-country').value = settings.itunes?.country || 'US';
// Populate Download settings (right column)
document.getElementById('download-path').value = settings.soulseek?.download_path || './downloads';
document.getElementById('transfer-path').value = settings.soulseek?.transfer_path || './Transfer';
document.getElementById('staging-path').value = settings.import?.staging_path || './Staging';
// Populate Download Source settings
document.getElementById('download-source-mode').value = settings.download_source?.mode || 'soulseek';
document.getElementById('hybrid-primary-source').value = settings.download_source?.hybrid_primary || 'soulseek';
document.getElementById('hybrid-secondary-source').value = settings.download_source?.hybrid_secondary || 'youtube';
updateHybridSecondaryOptions();
document.getElementById('tidal-download-quality').value = settings.tidal_download?.quality || 'lossless';
document.getElementById('qobuz-quality').value = settings.qobuz?.quality || 'lossless';
// Populate YouTube settings
document.getElementById('youtube-cookies-browser').value = settings.youtube?.cookies_browser || '';
document.getElementById('youtube-download-delay').value = settings.youtube?.download_delay ?? 3;
// Update UI based on download source mode
updateDownloadSourceUI();
// Populate Database settings
document.getElementById('max-workers').value = settings.database?.max_workers || '5';
// Populate Post-Processing settings
document.getElementById('metadata-enabled').checked = settings.metadata_enhancement?.enabled !== false;
document.getElementById('embed-album-art').checked = settings.metadata_enhancement?.embed_album_art !== false;
document.getElementById('cover-art-download').checked = settings.metadata_enhancement?.cover_art_download !== false;
document.getElementById('lrclib-enabled').checked = settings.metadata_enhancement?.lrclib_enabled !== false;
document.getElementById('embed-musicbrainz').checked = settings.musicbrainz?.embed_tags !== false;
document.getElementById('embed-deezer').checked = settings.deezer?.embed_tags !== false;
document.getElementById('embed-audiodb').checked = settings.audiodb?.embed_tags !== false;
document.getElementById('embed-tidal').checked = settings.tidal?.embed_tags !== false;
document.getElementById('embed-qobuz').checked = settings.qobuz?.embed_tags !== false;
document.getElementById('embed-lastfm').checked = settings.lastfm?.embed_tags !== false;
document.getElementById('embed-genius').checked = settings.genius?.embed_tags !== false;
document.getElementById('post-processing-options').style.display = settings.metadata_enhancement?.enabled !== false ? 'block' : 'none';
// Populate File Organization settings
document.getElementById('file-organization-enabled').checked = settings.file_organization?.enabled !== false;
document.getElementById('template-album-path').value = settings.file_organization?.templates?.album_path || '$albumartist/$albumartist - $album/$track - $title';
document.getElementById('template-single-path').value = settings.file_organization?.templates?.single_path || '$artist/$artist - $title/$title';
document.getElementById('template-playlist-path').value = settings.file_organization?.templates?.playlist_path || '$playlist/$artist - $title';
document.getElementById('disc-label').value = settings.file_organization?.disc_label || 'Disc';
// Populate Playlist Sync settings
document.getElementById('create-backup').checked = settings.playlist_sync?.create_backup !== false;
// Populate Lossy Copy settings
document.getElementById('lossy-copy-enabled').checked = settings.lossy_copy?.enabled === true;
document.getElementById('lossy-copy-bitrate').value = settings.lossy_copy?.bitrate || '320';
document.getElementById('lossy-copy-delete-original').checked = settings.lossy_copy?.delete_original === true;
document.getElementById('lossy-copy-options').style.display =
settings.lossy_copy?.enabled ? 'block' : 'none';
// Populate Content Filter settings
document.getElementById('allow-explicit').checked = settings.content_filter?.allow_explicit !== false;
// Populate M3U Export settings
document.getElementById('m3u-export-enabled').checked = settings.m3u_export?.enabled === true;
// Populate UI Appearance settings
const accentPreset = settings.ui_appearance?.accent_preset || '#1db954';
const accentCustom = settings.ui_appearance?.accent_color || '#1db954';
const presetSelect = document.getElementById('accent-preset');
const customPicker = document.getElementById('accent-custom-color');
const customGroup = document.getElementById('custom-color-group');
if (presetSelect) {
// Check if the saved preset matches a dropdown option
const presetOptions = Array.from(presetSelect.options).map(o => o.value);
if (presetOptions.includes(accentPreset)) {
presetSelect.value = accentPreset;
} else {
presetSelect.value = 'custom';
}
if (presetSelect.value === 'custom') {
if (customGroup) customGroup.style.display = '';
if (customPicker) customPicker.value = accentCustom;
applyAccentColor(accentCustom);
} else {
if (customGroup) customGroup.style.display = 'none';
applyAccentColor(accentPreset);
}
}
// Sidebar visualizer type
const vizType = settings.ui_appearance?.sidebar_visualizer || 'bars';
const vizSelect = document.getElementById('sidebar-visualizer-type');
if (vizSelect) vizSelect.value = vizType;
sidebarVisualizerType = vizType;
// Background particles toggle
const particlesEnabled = settings.ui_appearance?.particles_enabled !== false; // default true
const particlesCheckbox = document.getElementById('particles-enabled');
if (particlesCheckbox) particlesCheckbox.checked = particlesEnabled;
applyParticlesSetting(particlesEnabled);
// Populate Logging information (read-only)
document.getElementById('log-level-display').textContent = settings.logging?.level || 'INFO';
document.getElementById('log-path-display').textContent = settings.logging?.path || 'logs/app.log';
// Load Discovery Lookback Period setting
try {
const lookbackResponse = await fetch('/api/discovery/lookback-period');
const lookbackData = await lookbackResponse.json();
if (lookbackData.period) {
document.getElementById('discovery-lookback-period').value = lookbackData.period;
}
} catch (error) {
console.error('Error loading discovery lookback period:', error);
}
// Load current log level
try {
const logLevelResponse = await fetch('/api/settings/log-level');
const logLevelData = await logLevelResponse.json();
if (logLevelData.success && logLevelData.level) {
document.getElementById('log-level-select').value = logLevelData.level;
}
} catch (error) {
console.error('Error loading log level:', error);
}
// Check dev mode status
try {
const devResponse = await fetch('/api/dev-mode');
const devData = await devResponse.json();
if (devData.enabled) {
document.getElementById('dev-mode-status').textContent = 'Active';
document.getElementById('dev-mode-status').style.color = 'rgb(var(--accent-light-rgb))';
document.getElementById('hydrabase-nav').style.display = '';
document.getElementById('hydrabase-button-container').style.display = '';
}
} catch (error) {
console.error('Error checking dev mode:', error);
}
} catch (error) {
console.error('Error loading settings:', error);
showToast('Failed to load settings', 'error');
}
}
async function changeLogLevel() {
const selector = document.getElementById('log-level-select');
const level = selector.value;
try {
const response = await fetch('/api/settings/log-level', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ level: level })
});
const data = await response.json();
if (data.success) {
showToast(`Log level changed to ${level}`, 'success');
console.log(`Log level changed to: ${level}`);
} else {
showToast(`Failed to change log level: ${data.error}`, 'error');
}
} catch (error) {
console.error('Error changing log level:', error);
showToast('Failed to change log level', 'error');
}
}
function updateMediaServerFields() {
const serverType = document.getElementById('media-server-type').value;
const urlInput = document.getElementById('media-server-url');
const tokenInput = document.getElementById('media-server-token');
if (serverType === 'plex') {
urlInput.placeholder = 'http://localhost:32400';
tokenInput.placeholder = 'Plex Token';
} else {
urlInput.placeholder = 'http://localhost:8096';
tokenInput.placeholder = 'Jellyfin API Key';
}
}
function toggleServer(serverType) {
// Update toggle buttons
document.getElementById('plex-toggle').classList.remove('active');
document.getElementById('jellyfin-toggle').classList.remove('active');
document.getElementById('navidrome-toggle').classList.remove('active');
document.getElementById(`${serverType}-toggle`).classList.add('active');
// Show/hide server containers
document.getElementById('plex-container').classList.toggle('hidden', serverType !== 'plex');
document.getElementById('jellyfin-container').classList.toggle('hidden', serverType !== 'jellyfin');
document.getElementById('navidrome-container').classList.toggle('hidden', serverType !== 'navidrome');
// Load Plex music libraries when switching to Plex
if (serverType === 'plex') {
loadPlexMusicLibraries();
}
// Load Jellyfin users and music libraries when switching to Jellyfin
if (serverType === 'jellyfin') {
loadJellyfinUsers().then(() => loadJellyfinMusicLibraries());
}
// Load Navidrome music folders when switching to Navidrome
if (serverType === 'navidrome') {
loadNavidromeMusicFolders();
}
// Auto-save after server toggle change
debouncedAutoSaveSettings();
}
function updateDownloadSourceUI() {
const mode = document.getElementById('download-source-mode').value;
const hybridContainer = document.getElementById('hybrid-settings-container');
const soulseekContainer = document.getElementById('soulseek-settings-container');
const tidalContainer = document.getElementById('tidal-download-settings-container');
const qobuzContainer = document.getElementById('qobuz-settings-container');
const youtubeContainer = document.getElementById('youtube-settings-container');
hybridContainer.style.display = mode === 'hybrid' ? 'block' : 'none';
// Determine which sources are active
let activeSources = new Set();
if (mode === 'hybrid') {
const primary = document.getElementById('hybrid-primary-source').value;
const secondary = document.getElementById('hybrid-secondary-source').value;
activeSources.add(primary);
activeSources.add(secondary);
} else {
activeSources.add(mode);
}
soulseekContainer.style.display = activeSources.has('soulseek') ? 'block' : 'none';
tidalContainer.style.display = activeSources.has('tidal') ? 'block' : 'none';
qobuzContainer.style.display = activeSources.has('qobuz') ? 'block' : 'none';
youtubeContainer.style.display = activeSources.has('youtube') ? 'block' : 'none';
// Quality profile is Soulseek-only (streaming sources handle quality via their own settings)
const qualityProfileSection = document.getElementById('quality-profile-section');
if (qualityProfileSection) {
qualityProfileSection.style.display = activeSources.has('soulseek') ? '' : 'none';
}
if (activeSources.has('tidal')) {
checkTidalDownloadAuthStatus();
}
if (activeSources.has('qobuz')) {
checkQobuzAuthStatus();
}
}
function updateHybridSecondaryOptions() {
const primary = document.getElementById('hybrid-primary-source').value;
const secondary = document.getElementById('hybrid-secondary-source');
const currentValue = secondary.value;
const allSources = [
{ value: 'soulseek', label: 'Soulseek' },
{ value: 'youtube', label: 'YouTube' },
{ value: 'tidal', label: 'Tidal' },
{ value: 'qobuz', label: 'Qobuz' },
];
secondary.innerHTML = '';
for (const source of allSources) {
if (source.value === primary) continue;
const opt = document.createElement('option');
opt.value = source.value;
opt.textContent = source.label;
secondary.appendChild(opt);
}
// Restore previous selection if still valid, otherwise pick first available
if (currentValue !== primary) {
secondary.value = currentValue;
}
// Refresh source-specific settings visibility based on new primary/secondary
updateDownloadSourceUI();
}
// ===============================
// QUALITY PROFILE FUNCTIONS
// ===============================
let currentQualityProfile = null;
async function loadQualityProfile() {
try {
const response = await fetch('/api/quality-profile');
const data = await response.json();
if (data.success) {
currentQualityProfile = data.profile;
populateQualityProfileUI(currentQualityProfile);
}
} catch (error) {
console.error('Error loading quality profile:', error);
}
}
function populateQualityProfileUI(profile) {
// Update preset buttons
document.querySelectorAll('.preset-button').forEach(btn => {
btn.classList.remove('active');
});
const activePresetBtn = document.querySelector(`.preset-button[onclick*="${profile.preset}"]`);
if (activePresetBtn) {
activePresetBtn.classList.add('active');
}
// Populate each quality tier
const qualities = ['flac', 'mp3_320', 'mp3_256', 'mp3_192'];
qualities.forEach(quality => {
const config = profile.qualities[quality];
if (config) {
// Set enabled checkbox
const enabledCheckbox = document.getElementById(`quality-${quality}-enabled`);
if (enabledCheckbox) {
enabledCheckbox.checked = config.enabled;
}
// Set min/max sliders
const minSlider = document.getElementById(`${quality}-min`);
const maxSlider = document.getElementById(`${quality}-max`);
if (minSlider && maxSlider) {
minSlider.value = config.min_kbps;
maxSlider.value = config.max_kbps;
updateQualityRange(quality);
}
// Set priority display
const prioritySpan = document.getElementById(`priority-${quality}`);
if (prioritySpan) {
prioritySpan.textContent = `Priority: ${config.priority}`;
}
// Toggle sliders visibility
const sliders = document.getElementById(`sliders-${quality}`);
if (sliders) {
if (config.enabled) {
sliders.classList.remove('disabled');
} else {
sliders.classList.add('disabled');
}
}
// FLAC-specific: restore bit depth selector and fallback toggle
if (quality === 'flac') {
const bitDepthValue = config.bit_depth || 'any';
document.querySelectorAll('.bit-depth-btn').forEach(btn => {
btn.classList.toggle('active', btn.getAttribute('data-value') === bitDepthValue);
});
const bitDepthSelector = document.getElementById('flac-bit-depth-selector');
if (bitDepthSelector) {
if (config.enabled) {
bitDepthSelector.classList.remove('disabled');
} else {
bitDepthSelector.classList.add('disabled');
}
}
// Show/hide and restore fallback toggle
const fallbackToggle = document.getElementById('flac-fallback-toggle');
if (fallbackToggle) {
fallbackToggle.style.display = bitDepthValue === 'any' ? 'none' : 'block';
}
const fallbackCb = document.getElementById('flac-bit-depth-fallback');
if (fallbackCb) {
fallbackCb.checked = config.bit_depth_fallback !== false;
}
}
}
});
// Set fallback checkbox
const fallbackCheckbox = document.getElementById('quality-fallback-enabled');
if (fallbackCheckbox) {
fallbackCheckbox.checked = profile.fallback_enabled;
}
}
function updateQualityRange(quality) {
const minSlider = document.getElementById(`${quality}-min`);
const maxSlider = document.getElementById(`${quality}-max`);
const minValue = document.getElementById(`${quality}-min-value`);
const maxValue = document.getElementById(`${quality}-max-value`);
if (!minSlider || !maxSlider || !minValue || !maxValue) return;
let min = parseInt(minSlider.value);
let max = parseInt(maxSlider.value);
// Ensure min doesn't exceed max
if (min > max) {
min = max;
minSlider.value = min;
}
// Ensure max doesn't go below min
if (max < min) {
max = min;
maxSlider.value = max;
}
minValue.textContent = `${min} kbps`;
maxValue.textContent = `${max} kbps`;
}
function toggleQuality(quality) {
const checkbox = document.getElementById(`quality-${quality}-enabled`);
const sliders = document.getElementById(`sliders-${quality}`);
if (checkbox && sliders) {
if (checkbox.checked) {
sliders.classList.remove('disabled');
} else {
sliders.classList.add('disabled');
}
}
// Also toggle FLAC bit depth selector
if (quality === 'flac') {
const bitDepthSelector = document.getElementById('flac-bit-depth-selector');
if (bitDepthSelector && checkbox) {
if (checkbox.checked) {
bitDepthSelector.classList.remove('disabled');
} else {
bitDepthSelector.classList.add('disabled');
}
}
}
// Mark preset as custom when manually changing
if (currentQualityProfile) {
currentQualityProfile.preset = 'custom';
document.querySelectorAll('.preset-button').forEach(btn => {
btn.classList.remove('active');
});
}
}
function setFlacBitDepth(value) {
document.querySelectorAll('.bit-depth-btn').forEach(btn => {
btn.classList.toggle('active', btn.getAttribute('data-value') === value);
});
// Show/hide fallback toggle โ only relevant when a specific bit depth is selected
const fallbackToggle = document.getElementById('flac-fallback-toggle');
if (fallbackToggle) {
fallbackToggle.style.display = value === 'any' ? 'none' : 'block';
}
// Mark preset as custom when manually changing
if (currentQualityProfile) {
currentQualityProfile.preset = 'custom';
document.querySelectorAll('.preset-button').forEach(btn => {
btn.classList.remove('active');
});
}
debouncedAutoSaveSettings();
}
function setFlacBitDepthFallback(enabled) {
if (currentQualityProfile) {
currentQualityProfile.preset = 'custom';
document.querySelectorAll('.preset-button').forEach(btn => {
btn.classList.remove('active');
});
}
debouncedAutoSaveSettings();
}
async function applyQualityPreset(presetName) {
try {
showLoadingOverlay(`Applying ${presetName} preset...`);
const response = await fetch(`/api/quality-profile/preset/${presetName}`, {
method: 'POST'
});
const data = await response.json();
if (data.success) {
currentQualityProfile = data.profile;
populateQualityProfileUI(currentQualityProfile);
showToast(`Applied '${presetName}' preset`, 'success');
} else {
showToast(`Failed to apply preset: ${data.error}`, 'error');
}
} catch (error) {
console.error('Error applying quality preset:', error);
showToast('Failed to apply preset', 'error');
} finally {
hideLoadingOverlay();
}
}
function collectQualityProfileFromUI() {
const profile = {
version: 2,
preset: 'custom', // Will be overridden if a preset is active
qualities: {},
fallback_enabled: document.getElementById('quality-fallback-enabled')?.checked ?? true
};
const qualities = ['flac', 'mp3_320', 'mp3_256', 'mp3_192'];
qualities.forEach((quality, index) => {
const enabled = document.getElementById(`quality-${quality}-enabled`)?.checked || false;
const minSlider = document.getElementById(`${quality}-min`);
const maxSlider = document.getElementById(`${quality}-max`);
// Preserve priority from the currently loaded profile instead of using array order
const existingPriority = currentQualityProfile?.qualities?.[quality]?.priority ?? (index + 1);
profile.qualities[quality] = {
enabled: enabled,
min_kbps: parseInt(minSlider?.value || 0),
max_kbps: parseInt(maxSlider?.value || 99999),
priority: existingPriority
};
// Add FLAC-specific bit_depth and fallback settings
if (quality === 'flac') {
const activeBtn = document.querySelector('.bit-depth-btn.active');
profile.qualities[quality].bit_depth = activeBtn ? activeBtn.getAttribute('data-value') : 'any';
const fallbackCb = document.getElementById('flac-bit-depth-fallback');
profile.qualities[quality].bit_depth_fallback = fallbackCb ? fallbackCb.checked : true;
}
});
// Check if current profile matches a preset
if (currentQualityProfile && currentQualityProfile.preset !== 'custom') {
profile.preset = currentQualityProfile.preset;
}
return profile;
}
async function saveQualityProfile() {
try {
const profile = collectQualityProfileFromUI();
const response = await fetch('/api/quality-profile', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(profile)
});
const data = await response.json();
if (data.success) {
currentQualityProfile = profile;
console.log('Quality profile saved successfully');
return true;
} else {
console.error('Failed to save quality profile:', data.error);
return false;
}
} catch (error) {
console.error('Error saving quality profile:', error);
return false;
}
}
// ===============================
// END QUALITY PROFILE FUNCTIONS
// ===============================
async function activateDevMode() {
const password = document.getElementById('dev-mode-password').value;
try {
const response = await fetch('/api/dev-mode', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ password })
});
const data = await response.json();
if (data.success) {
document.getElementById('dev-mode-status').textContent = 'Active';
document.getElementById('dev-mode-status').style.color = 'rgb(var(--accent-light-rgb))';
document.getElementById('hydrabase-nav').style.display = '';
document.getElementById('hydrabase-button-container').style.display = '';
document.getElementById('dev-mode-password').value = '';
showToast('Dev mode activated', 'success');
} else {
showToast('Invalid password', 'error');
}
} catch (e) {
showToast('Failed to activate dev mode', 'error');
}
}
// โโ Hydrabase Functions โโ
let _hydrabaseConnected = false;
async function hydrabaseToggleConnection() {
if (_hydrabaseConnected) {
await hydrabaseDisconnect();
} else {
await hydrabaseConnect();
}
}
async function hydrabaseConnect() {
const url = document.getElementById('hydra-ws-url').value.trim();
const apiKey = document.getElementById('hydra-api-key').value.trim();
if (!url || !apiKey) {
showToast('URL and API key required', 'error');
return;
}
const statusEl = document.getElementById('hydra-connection-status');
const btn = document.getElementById('hydra-connect-btn');
statusEl.textContent = 'Connecting...';
statusEl.style.color = '#f0ad4e';
try {
const response = await fetch('/api/hydrabase/connect', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url, api_key: apiKey })
});
const data = await response.json();
if (data.success) {
_hydrabaseConnected = true;
statusEl.textContent = 'Connected';
statusEl.style.color = 'rgb(var(--accent-light-rgb))';
btn.textContent = 'Disconnect';
showToast('Connected to Hydrabase', 'success');
} else {
statusEl.textContent = 'Failed';
statusEl.style.color = '#f44336';
showToast(data.error || 'Connection failed', 'error');
}
} catch (e) {
statusEl.textContent = 'Error';
statusEl.style.color = '#f44336';
showToast('Connection error', 'error');
}
}
async function hydrabaseDisconnect() {
try {
await fetch('/api/hydrabase/disconnect', { method: 'POST' });
} catch (e) {}
_hydrabaseConnected = false;
document.getElementById('hydra-connection-status').textContent = 'Disconnected';
document.getElementById('hydra-connection-status').style.color = '#888';
document.getElementById('hydra-connect-btn').textContent = 'Connect';
// Dev mode is disabled on disconnect โ hide Hydrabase nav and update settings status
document.getElementById('hydrabase-nav').style.display = 'none';
document.getElementById('hydrabase-button-container').style.display = 'none';
const devStatus = document.getElementById('dev-mode-status');
if (devStatus) {
devStatus.textContent = 'Inactive';
devStatus.style.color = '#888';
}
showToast('Disconnected โ dev mode disabled', 'success');
navigateToPage('settings');
}
async function loadHydrabaseComparisons() {
const container = document.getElementById('hydra-comparisons-container');
if (!container) return;
try {
const response = await fetch('/api/hydrabase/comparisons');
const data = await response.json();
if (!data.success || !data.comparisons?.length) {
container.innerHTML = 'No comparisons yet. Search with Hydrabase active to generate comparisons.
';
return;
}
let html = '';
for (const comp of data.comparisons) {
const time = new Date(comp.timestamp * 1000).toLocaleTimeString();
html += `
"${comp.query}"
${time}
Hydrabase
${comp.hydrabase?.tracks || 0}T / ${comp.hydrabase?.artists || 0}A / ${comp.hydrabase?.albums || 0}Al
Spotify
${comp.spotify?.tracks || 0}T / ${comp.spotify?.artists || 0}A / ${comp.spotify?.albums || 0}Al
iTunes
${comp.itunes?.tracks || 0}T / ${comp.itunes?.artists || 0}A / ${comp.itunes?.albums || 0}Al
`;
}
container.innerHTML = html;
} catch (e) {
container.innerHTML = 'Failed to load comparisons.
';
}
}
async function hydrabaseSendRaw(textareaId) {
const textarea = document.getElementById(textareaId);
const raw = textarea.value.trim();
if (!raw) {
showToast('Payload is empty', 'error');
return;
}
if (!_hydrabaseConnected) {
showToast('Not connected to Hydrabase', 'error');
return;
}
let payload;
try {
payload = JSON.parse(raw);
} catch (e) {
showToast('Invalid JSON payload', 'error');
return;
}
// Auto-inject a fresh nonce if not set or zero
if (!payload.nonce) {
payload.nonce = Date.now();
}
const responseArea = document.getElementById('hydra-response');
responseArea.textContent = 'Sending...';
try {
const response = await fetch('/api/hydrabase/send', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ payload })
});
const data = await response.json();
if (data.success) {
responseArea.textContent = JSON.stringify(data.data, null, 2);
} else {
responseArea.textContent = 'Error: ' + (data.error || 'Unknown error');
if (data.error && data.error.includes('Not connected')) {
_hydrabaseConnected = false;
document.getElementById('hydra-connection-status').textContent = 'Disconnected';
document.getElementById('hydra-connection-status').style.color = '#888';
document.getElementById('hydra-connect-btn').textContent = 'Connect';
}
}
} catch (e) {
responseArea.textContent = 'Error: ' + e.message;
}
}
async function saveSettings(quiet = false) {
// Validate file organization templates before saving
const validationErrors = validateFileOrganizationTemplates();
if (validationErrors.length > 0) {
if (!quiet) showToast('Template validation failed: ' + validationErrors.join(', '), 'error');
return;
}
// Determine active server from toggle buttons
let activeServer = 'plex';
if (document.getElementById('jellyfin-toggle').classList.contains('active')) {
activeServer = 'jellyfin';
} else if (document.getElementById('navidrome-toggle').classList.contains('active')) {
activeServer = 'navidrome';
}
const settings = {
active_media_server: activeServer,
spotify: {
client_id: document.getElementById('spotify-client-id').value,
client_secret: document.getElementById('spotify-client-secret').value,
redirect_uri: document.getElementById('spotify-redirect-uri').value
},
tidal: {
client_id: document.getElementById('tidal-client-id').value,
client_secret: document.getElementById('tidal-client-secret').value,
redirect_uri: document.getElementById('tidal-redirect-uri').value,
embed_tags: document.getElementById('embed-tidal').checked
},
plex: {
base_url: document.getElementById('plex-url').value,
token: document.getElementById('plex-token').value
},
jellyfin: {
base_url: document.getElementById('jellyfin-url').value,
api_key: document.getElementById('jellyfin-api-key').value,
api_timeout: parseInt(document.getElementById('jellyfin-timeout').value) || 30
},
navidrome: {
base_url: document.getElementById('navidrome-url').value,
username: document.getElementById('navidrome-username').value,
password: document.getElementById('navidrome-password').value
},
soulseek: {
slskd_url: document.getElementById('soulseek-url').value,
api_key: document.getElementById('soulseek-api-key').value,
download_path: document.getElementById('download-path').value,
transfer_path: document.getElementById('transfer-path').value,
search_timeout: parseInt(document.getElementById('soulseek-search-timeout').value) || 60,
search_timeout_buffer: parseInt(document.getElementById('soulseek-search-timeout-buffer').value) || 15
},
listenbrainz: {
base_url: document.getElementById('listenbrainz-base-url').value,
token: document.getElementById('listenbrainz-token').value
},
acoustid: {
api_key: document.getElementById('acoustid-api-key').value,
enabled: document.getElementById('acoustid-enabled').checked
},
lastfm: {
api_key: document.getElementById('lastfm-api-key').value,
embed_tags: document.getElementById('embed-lastfm').checked
},
genius: {
access_token: document.getElementById('genius-access-token').value,
embed_tags: document.getElementById('embed-genius').checked
},
itunes: {
country: document.getElementById('itunes-country').value || 'US'
},
download_source: {
mode: document.getElementById('download-source-mode').value,
hybrid_primary: document.getElementById('hybrid-primary-source').value,
hybrid_secondary: document.getElementById('hybrid-secondary-source').value,
},
tidal_download: {
quality: document.getElementById('tidal-download-quality').value || 'lossless'
},
qobuz: {
quality: document.getElementById('qobuz-quality').value || 'lossless',
embed_tags: document.getElementById('embed-qobuz').checked
},
database: {
max_workers: parseInt(document.getElementById('max-workers').value)
},
metadata_enhancement: {
enabled: document.getElementById('metadata-enabled').checked,
embed_album_art: document.getElementById('embed-album-art').checked,
cover_art_download: document.getElementById('cover-art-download').checked,
lrclib_enabled: document.getElementById('lrclib-enabled').checked
},
musicbrainz: {
embed_tags: document.getElementById('embed-musicbrainz').checked
},
deezer: {
embed_tags: document.getElementById('embed-deezer').checked
},
audiodb: {
embed_tags: document.getElementById('embed-audiodb').checked
},
file_organization: {
enabled: document.getElementById('file-organization-enabled').checked,
disc_label: document.getElementById('disc-label').value,
templates: {
album_path: document.getElementById('template-album-path').value,
single_path: document.getElementById('template-single-path').value,
playlist_path: document.getElementById('template-playlist-path').value
}
},
playlist_sync: {
create_backup: document.getElementById('create-backup').checked
},
content_filter: {
allow_explicit: document.getElementById('allow-explicit').checked
},
lossy_copy: {
enabled: document.getElementById('lossy-copy-enabled').checked,
bitrate: document.getElementById('lossy-copy-bitrate').value,
delete_original: document.getElementById('lossy-copy-delete-original').checked
},
import: {
staging_path: document.getElementById('staging-path').value || './Staging'
},
m3u_export: {
enabled: document.getElementById('m3u-export-enabled').checked
},
ui_appearance: {
accent_preset: document.getElementById('accent-preset')?.value || '#1db954',
accent_color: document.getElementById('accent-custom-color')?.value || '#1db954',
sidebar_visualizer: document.getElementById('sidebar-visualizer-type')?.value || 'bars',
particles_enabled: document.getElementById('particles-enabled')?.checked !== false
},
youtube: {
cookies_browser: document.getElementById('youtube-cookies-browser').value,
download_delay: parseInt(document.getElementById('youtube-download-delay').value) || 3,
}
};
try {
if (!quiet) showLoadingOverlay('Saving settings...');
// Save main settings
const response = await fetch(API.settings, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(settings)
});
const result = await response.json();
// Save quality profile
const qualityProfileSaved = await saveQualityProfile();
// Save discovery lookback period
let lookbackSaved = true;
try {
const lookbackPeriod = document.getElementById('discovery-lookback-period').value;
const lookbackResponse = await fetch('/api/discovery/lookback-period', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ period: lookbackPeriod })
});
const lookbackResult = await lookbackResponse.json();
lookbackSaved = lookbackResult.success === true;
} catch (error) {
console.error('Error saving discovery lookback period:', error);
lookbackSaved = false;
}
if (result.success && qualityProfileSaved && lookbackSaved) {
showToast(quiet ? 'Settings auto-saved' : 'Settings saved successfully', 'success');
setTimeout(updateServiceStatus, 1000);
} else if (result.success && qualityProfileSaved && !lookbackSaved) {
showToast('Settings saved, but discovery lookback period failed to save', 'warning');
setTimeout(updateServiceStatus, 1000);
} else if (result.success && !qualityProfileSaved) {
showToast('Settings saved, but quality profile failed to save', 'warning');
setTimeout(updateServiceStatus, 1000);
} else {
showToast(`Failed to save settings: ${result.error}`, 'error', 'set-services');
}
} catch (error) {
console.error('Error saving settings:', error);
showToast('Failed to save settings', 'error', 'set-services');
} finally {
if (!quiet) hideLoadingOverlay();
}
}
async function testConnection(service) {
try {
showLoadingOverlay(`Testing ${service} connection...`);
const response = await fetch(API.testConnection, {
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 (Spotify or Apple Music)
showToast(result.message || `${service} connection successful`, 'success');
// Load music libraries after successful connection
if (service === 'plex') {
loadPlexMusicLibraries();
} else if (service === 'jellyfin') {
loadJellyfinUsers().then(() => loadJellyfinMusicLibraries());
} else if (service === 'navidrome') {
loadNavidromeMusicFolders();
}
} else {
showToast(`${service} connection failed: ${result.error}`, 'error', 'gs-connecting');
}
} catch (error) {
console.error(`Error testing ${service} connection:`, error);
showToast(`Failed to test ${service} connection`, 'error', 'gs-connecting');
} finally {
hideLoadingOverlay();
}
}
async function clearQuarantine() {
if (!await showConfirmDialog({ title: 'Clear Quarantine', message: 'Delete all files in the quarantine folder? This cannot be undone.', confirmText: 'Delete', destructive: true })) return;
try {
showLoadingOverlay('Clearing quarantine folder...');
const response = await fetch('/api/quarantine/clear', { method: 'POST' });
const result = await response.json();
if (result.success) {
showToast(result.message || 'Quarantine cleared', 'success');
} else {
showToast(`Failed to clear quarantine: ${result.error}`, 'error');
}
} catch (error) {
console.error('Error clearing quarantine:', error);
showToast('Failed to clear quarantine', 'error');
} finally {
hideLoadingOverlay();
}
}
// ======================== API Key Management ========================
async function loadApiKeys() {
const container = document.getElementById('api-keys-list');
if (!container) return;
try {
const response = await fetch('/api/v1/api-keys-internal');
if (response.ok) {
const data = await response.json();
renderApiKeys(data.data?.keys || []);
} else {
container.innerHTML = 'No API keys configured.
';
}
} catch (e) {
container.innerHTML = 'No API keys configured.
';
}
}
function renderApiKeys(keys) {
const container = document.getElementById('api-keys-list');
if (!container) return;
if (!keys || keys.length === 0) {
container.innerHTML = 'No API keys yet. Generate one below.
';
return;
}
container.innerHTML = keys.map(k => `
${k.label || 'Unnamed'}
${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() : ''}
Revoke
`).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 (Spotify or Apple Music)
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('Starting Spotify authentication...');
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() {
if (!await showConfirmDialog({ title: 'Disconnect Spotify', message: 'Disconnect Spotify? The app will switch to Apple Music/iTunes 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 Apple Music/iTunes.', '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 Apple Music/iTunes.', '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('Starting Tidal authentication...');
// This would trigger the OAuth flow
showToast('Tidal authentication started', 'success');
// In a real implementation, this would open the OAuth URL
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 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();
const formEl = document.getElementById('qobuz-auth-form');
const loggedInEl = document.getElementById('qobuz-auth-logged-in');
const userInfoEl = document.getElementById('qobuz-auth-user-info');
if (data.authenticated) {
const user = data.user || {};
userInfoEl.textContent = `Connected: ${user.display_name || 'Qobuz User'} (${user.subscription || 'Active'})`;
loggedInEl.style.display = 'flex';
formEl.style.display = 'none';
} else {
loggedInEl.style.display = 'none';
formEl.style.display = 'block';
}
} catch (e) {
console.error('Qobuz auth status check failed:', e);
}
}
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;
// 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 request
if (abortController) {
abortController.abort();
}
abortController = new AbortController();
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);
// Calculate total
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 {
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
const metadataSource = data.metadata_source || 'spotify';
const sourceBadge = metadataSource === 'hydrabase'
? { text: 'Hydrabase', class: 'enh-badge-hydrabase' }
: { text: 'Spotify', class: 'enh-badge-spotify' };
// 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 () => {
console.log(`๐ต Opening Spotify artist detail: ${artist.name} (ID: ${artist.id})`);
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 (same pattern as discover page)
await selectArtistForDetail(artist);
}
})
);
// 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();
}
// 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 response = await fetch(`/api/artist/${artistId}/image`);
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('artists-grid');
} else if (isAlbum) {
list.classList.add('albums-grid');
} else if (isTrack) {
list.classList.add('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
? ` `
: `${config.placeholder}
`;
const badgeHtml = config.badge
? `${config.badge.text}
`
: '';
const durationHtml = config.duration && isTrack
? `
${escapeHtml(config.duration)}
โถ
`
: '';
elem.innerHTML = `
${imageHtml}
${escapeHtml(config.name)}
${escapeHtml(config.meta)}
${durationHtml}
${badgeHtml}
`;
elem.addEventListener('click', config.onClick);
// Add play button handler for tracks
if (isTrack && config.onPlay) {
const playBtn = elem.querySelector('.enh-item-play-btn');
if (playBtn) {
playBtn.addEventListener('click', (e) => {
e.stopPropagation(); // Don't trigger main onClick
config.onPlay();
});
}
}
list.appendChild(elem);
// Extract colors from image for dynamic glow effect
if (config.image) {
extractImageColors(config.image, (colors) => {
applyDynamicGlow(elem, colors);
});
}
});
}
async function handleEnhancedSearchAlbumClick(album) {
console.log(`๐ฟ Enhanced search album clicked: ${album.name} by ${album.artist}`);
hideDropdown();
showLoadingOverlay('Loading album...');
try {
// Fetch full album data with tracks (Hydrabase or Spotify)
const albumParams = new URLSearchParams({ name: album.name || '', artist: album.artist || '' });
const response = await fetch(`/api/spotify/album/${album.id}?${albumParams}`);
if (!response.ok) {
if (response.status === 401) {
throw new Error('Spotify not authenticated. Please check your API settings.');
}
throw new Error(`Failed to load album: ${response.status}`);
}
const albumData = await response.json();
if (!albumData || !albumData.tracks || albumData.tracks.length === 0) {
hideLoadingOverlay();
showToast(`No tracks available for "${album.name}". This release may have been delisted or is not available in your region.`, 'warning');
return;
}
console.log(`โ
Loaded ${albumData.tracks.length} tracks for ${albumData.name}`);
// Create virtual playlist ID for enhanced search albums
const virtualPlaylistId = `enhanced_search_album_${album.id}`;
// Check if modal already exists and show it
if (activeDownloadProcesses[virtualPlaylistId]) {
console.log(`๐ฑ Reopening existing modal for ${album.name}`);
const process = activeDownloadProcesses[virtualPlaylistId];
if (process.modalElement) {
if (process.status === 'complete') {
showToast('Showing previous results. Close this modal to start a new analysis.', 'info');
}
process.modalElement.style.display = 'flex';
hideLoadingOverlay();
return;
}
}
// Enrich each track with full album object (needed for wishlist functionality)
const enrichedTracks = albumData.tracks.map(track => ({
...track,
album: {
name: albumData.name,
id: albumData.id,
album_type: albumData.album_type || 'album',
images: albumData.images || [],
release_date: albumData.release_date,
total_tracks: albumData.total_tracks
}
}));
console.log(`๐ฆ Enriched ${enrichedTracks.length} tracks with album metadata`);
// Format playlist name
const playlistName = `[${album.artist}] ${albumData.name}`;
// Create minimal artist object for the modal
const artistObject = {
id: null, // No artist ID from enhanced search
name: album.artist
};
// Prepare full album object for modal
const fullAlbumObject = {
name: albumData.name,
id: albumData.id,
album_type: albumData.album_type || 'album',
images: albumData.images || [],
release_date: albumData.release_date,
total_tracks: albumData.total_tracks,
artists: albumData.artists || [{ name: album.artist }]
};
// Open download missing tracks modal
await openDownloadMissingModalForArtistAlbum(
virtualPlaylistId,
playlistName,
enrichedTracks,
fullAlbumObject,
artistObject,
false // Don't show loading overlay, we already have one
);
// Register this download in search bubbles
registerSearchDownload(
{
id: album.id,
name: albumData.name,
artist: album.artist,
image_url: albumData.images?.[0]?.url || null,
images: albumData.images || []
},
'album',
virtualPlaylistId,
album.artist // artistName for grouping
);
hideLoadingOverlay();
} catch (error) {
hideLoadingOverlay();
console.error('โ Error handling enhanced search album click:', error);
showToast(`Error opening album: ${error.message}`, 'error');
}
}
async function streamEnhancedSearchTrack(track) {
console.log(`โถ๏ธ Stream enhanced search track: ${track.name} by ${track.artist}`);
hideDropdown();
showLoadingOverlay(`Searching for ${track.name}...`);
try {
// Send track metadata to backend for quick slskd search
const response = await fetch('/api/enhanced-search/stream-track', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
track_name: track.name,
artist_name: track.artist,
album_name: track.album,
duration_ms: track.duration_ms
})
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error || 'Failed to search for track');
}
const data = await response.json();
if (!data.success || !data.result) {
throw new Error('No suitable track found');
}
const slskdResult = data.result;
// Check if audio format is supported (YouTube/Tidal use encoded filenames, skip check)
const isStreamingSource = slskdResult.username === 'youtube' || slskdResult.username === 'tidal';
if (!isStreamingSource && slskdResult.filename && !isAudioFormatSupported(slskdResult.filename)) {
const format = getFileExtension(slskdResult.filename);
hideLoadingOverlay();
showToast(`Sorry, ${format.toUpperCase()} format is not supported in your browser. Try downloading instead.`, 'error');
return;
}
console.log(`โ
Found track to stream:`, slskdResult);
console.log(`๐ต Track details - Username: ${slskdResult.username}, Filename: ${slskdResult.filename}`);
hideLoadingOverlay();
// Use existing startStream function to play the track
console.log(`๐ก Calling startStream() with result...`);
await startStream(slskdResult);
console.log(`โ
startStream() completed`);
} catch (error) {
hideLoadingOverlay();
console.error('โ Error streaming enhanced search track:', error);
showToast(`Failed to stream track: ${error.message}`, 'error');
}
}
async function handleEnhancedSearchTrackClick(track) {
console.log(`๐ต Enhanced search track clicked: ${track.name} by ${track.artist}`);
hideDropdown();
showLoadingOverlay('Loading track...');
try {
// Create virtual playlist ID for enhanced search tracks
const virtualPlaylistId = `enhanced_search_track_${track.id}`;
// Check if modal already exists and show it
if (activeDownloadProcesses[virtualPlaylistId]) {
console.log(`๐ฑ Reopening existing modal for ${track.name}`);
const process = activeDownloadProcesses[virtualPlaylistId];
if (process.modalElement) {
if (process.status === 'complete') {
showToast('Showing previous results. Close this modal to start a new analysis.', 'info');
}
process.modalElement.style.display = 'flex';
hideLoadingOverlay();
return;
}
}
// Enrich track with album object (needed for wishlist functionality)
const enrichedTrack = {
id: track.id,
name: track.name,
artists: [track.artist], // Convert string to array for modal compatibility
album: {
name: track.album,
id: null,
album_type: 'single',
images: track.image_url ? [{ url: track.image_url }] : [],
release_date: track.release_date || null,
total_tracks: 1
},
duration_ms: track.duration_ms,
popularity: track.popularity || 0,
preview_url: track.preview_url || null,
external_urls: track.external_urls || null,
image_url: track.image_url
};
console.log(`๐ฆ Enriched track with album metadata`);
// Format playlist name
const playlistName = `${track.artist} - ${track.name}`;
// Create minimal artist object for the modal
const artistObject = {
id: null,
name: track.artist
};
// Prepare album object for modal (single track)
const albumObject = {
name: track.album,
id: null,
album_type: 'single',
images: track.image_url ? [{ url: track.image_url }] : [],
release_date: track.release_date || null,
total_tracks: 1,
artists: [{ name: track.artist }]
};
// Open download missing tracks modal with single track
await openDownloadMissingModalForArtistAlbum(
virtualPlaylistId,
playlistName,
[enrichedTrack], // Array with single track
albumObject,
artistObject,
false
);
// Register this download in search bubbles
registerSearchDownload(
{
id: track.id,
name: track.name,
artist: track.artist,
image_url: track.image_url,
images: track.image_url ? [{ url: track.image_url }] : []
},
'track',
virtualPlaylistId,
track.artist // artistName for grouping
);
hideLoadingOverlay();
} catch (error) {
hideLoadingOverlay();
console.error('โ Error handling enhanced search track click:', error);
showToast(`Error opening track: ${error.message}`, 'error');
}
}
async function searchSlskdFor(type, item) {
const mainResultsArea = document.getElementById('enhanced-main-results-area');
if (!mainResultsArea) return;
// Show loading in main results area
mainResultsArea.innerHTML = `
Searching for ${type === 'album' ? 'album' : 'track'}...
`;
const query = `${item.artist} ${item.name}`;
try {
const response = await fetch('/api/search', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ query })
});
const data = await response.json();
if (data.error) {
showToast(`Search error: ${data.error}`, 'error');
return;
}
// Filter results
const filtered = data.results.filter(r => r.result_type === type);
// Render slskd results in main area
renderSlskdInMainArea(filtered, type, item);
} catch (error) {
console.error('Slskd search error:', error);
showToast('Search failed', 'error');
mainResultsArea.innerHTML = 'Search failed. Please try again.
';
}
}
function renderSlskdInMainArea(results, type, originalItem) {
const mainResultsArea = document.getElementById('enhanced-main-results-area');
if (!mainResultsArea) return;
if (!results || results.length === 0) {
mainResultsArea.innerHTML = 'No matches found for this ' + type + '.
';
return;
}
// Render results using same style as basic search
mainResultsArea.innerHTML = results.map(result => {
const title = type === 'album'
? `${result.album_title} (${result.tracks ? result.tracks.length : 0} tracks)`
: result.title;
return `
${result.bitrate ? `${result.bitrate} kbps ` : ''}
${result.format ? `${result.format.toUpperCase()} ` : ''}
${result.size ? `${(result.size / 1024 / 1024).toFixed(1)} MB ` : ''}
${result.username ? `๐ค ${escapeHtml(result.username)} ` : ''}
`;
}).join('');
// Attach download handlers
mainResultsArea.querySelectorAll('.download-result-btn').forEach(btn => {
btn.addEventListener('click', async function () {
const result = JSON.parse(this.dataset.result);
const type = this.dataset.type;
this.disabled = true;
this.textContent = 'Downloading...';
try {
const downloadData = type === 'album'
? { result_type: 'album', tracks: result.tracks || [] }
: { result_type: 'track', username: result.username, filename: result.filename, size: result.size };
const response = await fetch('/api/download', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(downloadData)
});
const data = await response.json();
if (data.error) {
showToast(`Download error: ${data.error}`, 'error');
this.disabled = false;
this.innerHTML = '๐พ Download';
} else {
showToast('Download started!', 'success');
this.innerHTML = 'โ
Added';
}
} catch (error) {
console.error('Download error:', error);
showToast('Download failed', 'error');
this.disabled = false;
this.innerHTML = '๐พ Download';
}
});
});
}
function showDropdown() {
const dropdown = document.getElementById('enhanced-dropdown');
if (dropdown) {
dropdown.classList.remove('hidden');
updateToggleButtonState();
}
}
function hideDropdown() {
const dropdown = document.getElementById('enhanced-dropdown');
if (dropdown) {
dropdown.classList.add('hidden');
updateToggleButtonState();
}
}
function updateToggleButtonState() {
// Get fresh references
const btn = document.getElementById('enhanced-search-btn');
const dropdown = document.getElementById('enhanced-dropdown');
if (!btn || !dropdown) return;
const btnIcon = btn.querySelector('.btn-icon');
const btnText = btn.querySelector('.btn-text');
if (dropdown.classList.contains('hidden')) {
// Dropdown is hidden - button should say "Show Results"
if (btnIcon) btnIcon.textContent = '๐๏ธ';
if (btnText) btnText.textContent = 'Show Results';
} else {
// Dropdown is visible - button should say "Hide Results"
if (btnIcon) btnIcon.textContent = '๐';
if (btnText) btnText.textContent = 'Hide Results';
}
}
}
async function performSearch() {
const query = document.getElementById('search-input').value.trim();
if (!query) {
showToast('Please enter a search term', 'error');
return;
}
try {
showLoadingOverlay('Searching...');
displaySearchResults([]); // Clear previous results
const response = await fetch(API.search, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ query })
});
const data = await response.json();
if (data.error) {
showToast(`Search error: ${data.error}`, 'error');
return;
}
searchResults = data.results || [];
displaySearchResults(searchResults);
if (searchResults.length === 0) {
showToast('No results found', 'error');
} else {
showToast(`Found ${searchResults.length} results`, 'success');
}
} catch (error) {
console.error('Error performing search:', error);
showToast('Search failed', 'error');
} finally {
hideLoadingOverlay();
}
}
function displaySearchResults(results) {
const resultsContainer = document.getElementById('search-results');
if (!results.length) {
resultsContainer.innerHTML = 'No search results
';
return;
}
resultsContainer.innerHTML = results.map((result, index) => {
const isAlbum = result.type === 'album';
const sizeText = isAlbum ?
`${result.track_count || 0} tracks, ${(result.size_mb || 0).toFixed(1)} MB` :
`${(result.file_size / 1024 / 1024).toFixed(1)} MB, ${result.bitrate || 0}kbps`;
return `
${sizeText}
by ${escapeHtml(result.username)}
${result.quality ? `${escapeHtml(result.quality)} ` : ''}
`;
}).join('');
}
function selectResult(index) {
const result = searchResults[index];
if (!result) return;
console.log('Selected result:', result);
// Could show detailed view or additional actions here
}
async function startDownload(index) {
const result = searchResults[index];
if (!result) return;
try {
const response = await fetch('/api/downloads/start', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(result)
});
const data = await response.json();
if (data.success) {
showToast('Download started', 'success');
} else {
showToast(`Download failed: ${data.error}`, 'error');
}
} catch (error) {
console.error('Error starting download:', error);
showToast('Failed to start download', 'error');
}
}
// ===============================
// PAGE DATA LOADING
// ===============================
async function loadInitialData() {
try {
// Load artist bubble state first
await hydrateArtistBubblesFromSnapshot();
// Load search bubble state
await hydrateSearchBubblesFromSnapshot();
// Load discover download state
await hydrateDiscoverDownloadsFromSnapshot();
// Navigate to user's home page (or dashboard for admin)
const homePage = getProfileHomePage();
if (homePage !== 'dashboard') {
navigateToPage(homePage);
} else {
await loadDashboardData();
}
} catch (error) {
console.error('Error loading initial data:', error);
}
}
async function loadDashboardData() {
try {
const response = await fetch(API.activity);
const data = await response.json();
const activityFeed = document.getElementById('activity-feed');
if (data.activities && data.activities.length) {
activityFeed.innerHTML = data.activities.map(activity => `
${activity.time}
${escapeHtml(activity.text)}
`).join('');
}
// Initialize wishlist count when dashboard loads
await updateWishlistCount();
// Start periodic refresh of wishlist count (every 30 seconds, matching GUI behavior)
stopWishlistCountPolling(); // Ensure no duplicates
wishlistCountInterval = setInterval(updateWishlistCount, 30000);
} catch (error) {
console.error('Error loading dashboard data:', error);
}
}
// ===========================================
// == SYNC PAGE SPOTIFY FUNCTIONALITY ==
// ===========================================
async function loadSyncData() {
// This is called when the sync page is navigated to.
if (!spotifyPlaylistsLoaded) {
await loadSpotifyPlaylists();
}
// Load YouTube playlists from backend (always refresh to get latest state)
await loadYouTubePlaylistsFromBackend();
// Load Beatport charts from backend (always refresh to get latest state)
await loadBeatportChartsFromBackend();
}
async function checkForActiveProcesses() {
try {
const response = await fetch('/api/active-processes');
if (!response.ok) return;
const data = await response.json();
const processes = data.active_processes || [];
if (processes.length > 0) {
console.log(`๐ Found ${processes.length} active process(es) from backend. Rehydrating UI...`);
// Separate download batch processes from YouTube playlist processes
const downloadProcesses = processes.filter(p => p.type === 'batch');
const youtubeProcesses = processes.filter(p => p.type === 'youtube_playlist');
console.log(`๐ Process breakdown: ${downloadProcesses.length} download batches, ${youtubeProcesses.length} YouTube playlists`);
// Rehydrate download modal processes (existing Spotify system)
for (const processInfo of downloadProcesses) {
if (!activeDownloadProcesses[processInfo.playlist_id]) {
rehydrateModal(processInfo);
}
}
// Note: YouTube playlists are handled by loadYouTubePlaylistsFromBackend() and rehydrateYouTubePlaylist()
// in loadSyncData(), which provides more complete data than active processes and handles download modal rehydration.
console.log(`โน๏ธ Skipping ${youtubeProcesses.length} YouTube playlists - handled by full backend loading`);
}
} catch (error) {
console.error('Failed to check for active processes:', error);
}
}
async function rehydrateArtistAlbumModal(virtualPlaylistId, playlistName, batchId) {
/**
* Rehydrates an artist album download modal from backend process data.
* Extracts artist/album info from virtual playlist ID and recreates the modal.
*/
try {
console.log(`๐ง Rehydrating artist album modal: ${virtualPlaylistId} (${playlistName})`);
// Extract artist_id and album_id from virtualPlaylistId format: artist_album_[artist_id]_[album_id]
const parts = virtualPlaylistId.split('_');
if (parts.length < 4 || parts[0] !== 'artist' || parts[1] !== 'album') {
console.error(`โ Invalid virtual playlist ID format: ${virtualPlaylistId}`);
return;
}
const artistId = parts[2];
const albumId = parts.slice(3).join('_'); // Handle album IDs that might contain underscores
console.log(`๐ Extracted from virtual playlist: artistId=${artistId}, albumId=${albumId}`);
// Fetch the album tracks to get proper artist and album data
try {
const response = await fetch(`/api/artist/${artistId}/album/${albumId}/tracks`);
const data = await response.json();
if (!data.success || !data.album || !data.tracks) {
console.error('โ Failed to fetch album data for rehydration:', data.error);
return;
}
const album = data.album;
const tracks = data.tracks;
// Extract artist info from the first track (all tracks should have same artist)
const artist = {
id: artistId,
name: tracks[0].artists[0] // Use first artist name from first track
};
console.log(`โ
Retrieved album data: "${album.name}" by ${artist.name} (${tracks.length} tracks)`);
// Create the modal using the same function as normal artist album downloads
await openDownloadMissingModalForArtistAlbum(virtualPlaylistId, playlistName, tracks, album, artist);
// Update the rehydrated process with batch info and hide modal for background rehydration
const process = activeDownloadProcesses[virtualPlaylistId];
if (process) {
process.status = 'running';
process.batchId = batchId;
subscribeToDownloadBatch(batchId);
// Update button states to reflect running status
const beginBtn = document.getElementById(`begin-analysis-btn-${virtualPlaylistId}`);
const cancelBtn = document.getElementById(`cancel-all-btn-${virtualPlaylistId}`);
const wishlistBtn = document.getElementById(`add-to-wishlist-btn-${virtualPlaylistId}`);
if (beginBtn) beginBtn.style.display = 'none';
if (cancelBtn) cancelBtn.style.display = 'inline-block';
if (wishlistBtn) wishlistBtn.style.display = 'none';
// Hide the modal - this is background rehydration, not user-requested
if (process.modalElement) {
process.modalElement.style.display = 'none';
console.log(`๐ Hiding rehydrated modal for background processing: ${album.name}`);
}
console.log(`โ
Rehydrated artist album modal: ${artist.name} - ${album.name}`);
} else {
console.error(`โ Failed to find rehydrated process for ${virtualPlaylistId}`);
}
} catch (error) {
console.error(`โ Error fetching album data for rehydration:`, error);
}
} catch (error) {
console.error(`โ Error rehydrating artist album modal:`, error);
}
}
async function rehydrateDiscoverPlaylistModal(virtualPlaylistId, playlistName, batchId) {
/**
* Rehydrates a discover playlist download modal from backend process data.
* Fetches tracks from the appropriate discover API endpoint and recreates the modal.
*/
try {
console.log(`๐ง Rehydrating discover playlist modal: ${virtualPlaylistId} (${playlistName})`);
// Handle album downloads from Recent Releases
if (virtualPlaylistId.startsWith('discover_album_')) {
const albumId = virtualPlaylistId.replace('discover_album_', '');
console.log(`๐ง Album download - fetching album ${albumId}...`);
try {
const albumResponse = await fetch(`/api/spotify/album/${albumId}`);
if (!albumResponse.ok) {
console.error(`โ Failed to fetch album: ${albumResponse.status}`);
return;
}
const albumData = await albumResponse.json();
if (!albumData.tracks || albumData.tracks.length === 0) {
console.error(`โ No tracks in album`);
return;
}
// Convert tracks to expected format
const spotifyTracks = albumData.tracks.map(track => {
let artists = track.artists || [];
if (Array.isArray(artists)) {
artists = artists.map(a => a.name || a);
}
return {
id: track.id,
name: track.name,
artists: artists,
album: {
name: albumData.name || playlistName.split(' - ')[0],
images: albumData.images || []
},
duration_ms: track.duration_ms || 0
};
});
console.log(`โ
Retrieved ${spotifyTracks.length} tracks for album`);
// Create modal
await openDownloadMissingModalForYouTube(virtualPlaylistId, playlistName, spotifyTracks);
// Update process
const process = activeDownloadProcesses[virtualPlaylistId];
if (process) {
process.status = 'running';
process.batchId = batchId;
subscribeToDownloadBatch(batchId);
const beginBtn = document.getElementById(`begin-analysis-btn-${virtualPlaylistId}`);
const cancelBtn = document.getElementById(`cancel-all-btn-${virtualPlaylistId}`);
if (beginBtn) beginBtn.style.display = 'none';
if (cancelBtn) cancelBtn.style.display = 'inline-block';
// Hide modal for background rehydration
if (process.modalElement) {
process.modalElement.style.display = 'none';
console.log(`๐ Hiding rehydrated modal for background processing: ${playlistName}`);
}
console.log(`โ
Rehydrated album modal: ${playlistName}`);
}
return;
} catch (error) {
console.error(`โ Error fetching album:`, error);
return;
}
}
// Determine API endpoint based on playlist ID
let apiEndpoint;
if (virtualPlaylistId === 'discover_release_radar') {
apiEndpoint = '/api/discover/release-radar';
} else if (virtualPlaylistId === 'discover_discovery_weekly') {
apiEndpoint = '/api/discover/discovery-weekly';
} else if (virtualPlaylistId === 'discover_seasonal_playlist') {
apiEndpoint = '/api/discover/seasonal-playlist';
} else if (virtualPlaylistId === 'discover_popular_picks') {
apiEndpoint = '/api/discover/popular-picks';
} else if (virtualPlaylistId === 'discover_hidden_gems') {
apiEndpoint = '/api/discover/hidden-gems';
} else if (virtualPlaylistId === 'discover_discovery_shuffle') {
apiEndpoint = '/api/discover/discovery-shuffle';
} else if (virtualPlaylistId === 'discover_familiar_favorites') {
apiEndpoint = '/api/discover/familiar-favorites';
} else if (virtualPlaylistId === 'build_playlist_custom') {
apiEndpoint = '/api/discover/build-playlist';
} else if (virtualPlaylistId.startsWith('discover_lb_')) {
console.log(`๐ง ListenBrainz playlist - skipping (no automatic rehydration for ListenBrainz)`);
return;
} else {
console.error(`โ Unknown discover playlist type: ${virtualPlaylistId}`);
return;
}
// Fetch tracks from API
console.log(`๐ก Fetching tracks from ${apiEndpoint}...`);
const response = await fetch(apiEndpoint);
if (!response.ok) {
console.error(`โ Failed to fetch discover playlist data: ${response.status}`);
return;
}
const data = await response.json();
if (!data.success || !data.tracks) {
console.error(`โ Invalid discover playlist data:`, data);
return;
}
const tracks = data.tracks;
console.log(`โ
Retrieved ${tracks.length} tracks for ${playlistName}`);
// Transform tracks to format expected by download modal (same as openDownloadModalForDiscoverPlaylist)
const spotifyTracks = tracks.map(track => {
let spotifyTrack;
// Use track_data_json if available, otherwise construct from track data
if (track.track_data_json) {
spotifyTrack = track.track_data_json;
} else {
// Fallback: construct track object from available data
spotifyTrack = {
id: track.spotify_track_id,
name: track.track_name,
artists: [{ name: track.artist_name }],
album: {
name: track.album_name,
images: track.album_cover_url ? [{ url: track.album_cover_url }] : []
},
duration_ms: track.duration_ms || 0
};
}
// Normalize artists to array of strings for modal compatibility
if (spotifyTrack.artists && Array.isArray(spotifyTrack.artists)) {
spotifyTrack.artists = spotifyTrack.artists.map(a => a.name || a);
}
return spotifyTrack;
});
// Create the modal using the same function as normal discover downloads
await openDownloadMissingModalForYouTube(virtualPlaylistId, playlistName, spotifyTracks);
// Update the rehydrated process with batch info and hide modal for background rehydration
const process = activeDownloadProcesses[virtualPlaylistId];
if (process) {
process.status = 'running';
process.batchId = batchId;
subscribeToDownloadBatch(batchId);
// Update button states to reflect running status
const beginBtn = document.getElementById(`begin-analysis-btn-${virtualPlaylistId}`);
const cancelBtn = document.getElementById(`cancel-all-btn-${virtualPlaylistId}`);
if (beginBtn) beginBtn.style.display = 'none';
if (cancelBtn) cancelBtn.style.display = 'inline-block';
// Hide the modal - this is background rehydration, not user-requested
if (process.modalElement) {
process.modalElement.style.display = 'none';
console.log(`๐ Hiding rehydrated modal for background processing: ${playlistName}`);
}
console.log(`โ
Rehydrated discover playlist modal: ${playlistName}`);
} else {
console.error(`โ Failed to find rehydrated process for ${virtualPlaylistId}`);
}
} catch (error) {
console.error(`โ Error rehydrating discover playlist modal:`, error);
}
}
async function rehydrateEnhancedSearchModal(virtualPlaylistId, playlistName, batchId) {
/**
* Rehydrates an enhanced search download modal from backend process data.
* Fetches item data from searchDownloadBubbles and recreates the modal.
*/
try {
console.log(`๐ง Rehydrating enhanced search modal: ${virtualPlaylistId} (${playlistName})`);
// Find the download in searchDownloadBubbles
let downloadData = null;
for (const artistName in searchDownloadBubbles) {
const bubble = searchDownloadBubbles[artistName];
const download = bubble.downloads.find(d => d.virtualPlaylistId === virtualPlaylistId);
if (download) {
downloadData = download;
break;
}
}
if (!downloadData) {
console.warn(`โ ๏ธ No download data found in searchDownloadBubbles for ${virtualPlaylistId}`);
return;
}
const { item, type } = downloadData;
if (type === 'album') {
// For albums, fetch tracks (pass name/artist for Hydrabase support)
console.log(`๐ง Album download - fetching album ${item.id}...`);
try {
const _sap1 = new URLSearchParams({ name: item.name || '', artist: item.artist || '' });
const response = await fetch(`/api/spotify/album/${item.id}?${_sap1}`);
if (!response.ok) {
console.error(`โ Failed to fetch album: ${response.status}`);
return;
}
const albumData = await response.json();
if (!albumData.tracks || albumData.tracks.length === 0) {
console.error(`โ No tracks in album`);
return;
}
const spotifyTracks = albumData.tracks.map(track => ({
id: track.id,
name: track.name,
artists: track.artists || [{ name: item.artists?.[0]?.name || item.artist || 'Unknown Artist' }],
album: {
name: item.name,
images: item.image_url ? [{ url: item.image_url }] : []
},
duration_ms: track.duration_ms || 0
}));
console.log(`โ
Retrieved ${spotifyTracks.length} tracks for album`);
// Create modal
await openDownloadMissingModalForArtistAlbum(
virtualPlaylistId,
item.name,
spotifyTracks,
item,
{ name: item.artists?.[0]?.name || item.artist || 'Unknown Artist' },
false // Don't show loading overlay
);
// Update process
const process = activeDownloadProcesses[virtualPlaylistId];
if (process) {
process.status = 'running';
process.batchId = batchId;
subscribeToDownloadBatch(batchId);
const beginBtn = document.getElementById(`begin-analysis-btn-${virtualPlaylistId}`);
const cancelBtn = document.getElementById(`cancel-all-btn-${virtualPlaylistId}`);
if (beginBtn) beginBtn.style.display = 'none';
if (cancelBtn) cancelBtn.style.display = 'inline-block';
// Hide modal for background rehydration
if (process.modalElement) {
process.modalElement.style.display = 'none';
console.log(`๐ Hiding rehydrated modal for background processing: ${playlistName}`);
}
// Start polling for live updates
startModalDownloadPolling(virtualPlaylistId);
console.log(`โ
Rehydrated enhanced search album modal: ${playlistName}`);
} else {
console.error(`โ Failed to find rehydrated process for ${virtualPlaylistId}`);
}
} catch (error) {
console.error(`โ Error fetching album:`, error);
}
} else {
// For tracks, create enriched track and open modal
console.log(`๐ง Track download - creating modal for ${item.name}...`);
const enrichedTrack = {
id: item.id,
name: item.name,
artists: item.artists || [{ name: item.artist || 'Unknown Artist' }],
album: item.album || {
name: item.album?.name || 'Unknown Album',
images: item.image_url ? [{ url: item.image_url }] : []
},
duration_ms: item.duration_ms || 0
};
// Create modal
await openDownloadMissingModalForYouTube(
virtualPlaylistId,
`${enrichedTrack.name} - ${enrichedTrack.artists[0].name || enrichedTrack.artists[0]}`,
[enrichedTrack]
);
// Update process
const process = activeDownloadProcesses[virtualPlaylistId];
if (process) {
process.status = 'running';
process.batchId = batchId;
subscribeToDownloadBatch(batchId);
const beginBtn = document.getElementById(`begin-analysis-btn-${virtualPlaylistId}`);
const cancelBtn = document.getElementById(`cancel-all-btn-${virtualPlaylistId}`);
if (beginBtn) beginBtn.style.display = 'none';
if (cancelBtn) cancelBtn.style.display = 'inline-block';
// Hide modal for background rehydration
if (process.modalElement) {
process.modalElement.style.display = 'none';
console.log(`๐ Hiding rehydrated modal for background processing: ${playlistName}`);
}
// Start polling for live updates
startModalDownloadPolling(virtualPlaylistId);
console.log(`โ
Rehydrated enhanced search track modal: ${playlistName}`);
} else {
console.error(`โ Failed to find rehydrated process for ${virtualPlaylistId}`);
}
}
} catch (error) {
console.error(`โ Error rehydrating enhanced search modal:`, error);
}
}
async function rehydrateModal(processInfo, userRequested = false) {
const { playlist_id, playlist_name, batch_id, current_cycle } = processInfo;
console.log(`๐ง Rehydrating modal for "${playlist_name}" (batch: ${batch_id}) - User requested: ${userRequested}`);
// Handle YouTube virtual playlists - skip rehydration here, handled by YouTube system
if (playlist_id.startsWith('youtube_')) {
console.log(`โญ๏ธ Skipping YouTube virtual playlist rehydration - handled by YouTube system`);
return;
}
// Handle Beatport virtual playlists - skip rehydration here, handled by Beatport system
if (playlist_id.startsWith('beatport_')) {
console.log(`โญ๏ธ Skipping Beatport virtual playlist rehydration - handled by Beatport system`);
return;
}
// Handle artist album virtual playlists
if (playlist_id.startsWith('artist_album_')) {
console.log(`๐ง Rehydrating artist album virtual playlist: ${playlist_id}`);
await rehydrateArtistAlbumModal(playlist_id, playlist_name, batch_id);
return;
}
// Handle discover virtual playlists (Fresh Tape, The Archives)
if (playlist_id.startsWith('discover_')) {
console.log(`๐ง Rehydrating discover playlist: ${playlist_id}`);
await rehydrateDiscoverPlaylistModal(playlist_id, playlist_name, batch_id);
return;
}
// Handle enhanced search virtual playlists (albums and tracks)
if (playlist_id.startsWith('enhanced_search_album_') || playlist_id.startsWith('enhanced_search_track_')) {
console.log(`๐ง Rehydrating enhanced search virtual playlist: ${playlist_id}`);
await rehydrateEnhancedSearchModal(playlist_id, playlist_name, batch_id);
return;
}
// Handle wishlist processes specially
if (playlist_id === "wishlist") {
console.log(`๐ง [Rehydrate] Handling wishlist modal for active process: ${batch_id}`);
// Check if modal already exists and is visible
const existingProcess = activeDownloadProcesses[playlist_id];
const modalAlreadyOpen = existingProcess && existingProcess.modalElement &&
existingProcess.modalElement.style.display === 'flex';
if (modalAlreadyOpen) {
console.log(`๐ง [Rehydrate] Wishlist modal already open - updating existing modal with auto-process state`);
// Update existing process with new batch info
existingProcess.status = 'running';
existingProcess.batchId = batch_id;
// Update UI to reflect running state
const beginBtn = document.getElementById(`begin-analysis-btn-${playlist_id}`);
const cancelBtn = document.getElementById(`cancel-all-btn-${playlist_id}`);
if (beginBtn) beginBtn.style.display = 'none';
if (cancelBtn) cancelBtn.style.display = 'inline-block';
// Ensure polling is active for live updates
if (!existingProcess.intervalId) {
console.log(`๐ง [Rehydrate] Starting polling for existing modal`);
startModalDownloadPolling(playlist_id);
}
console.log(`โ
[Rehydrate] Successfully updated existing wishlist modal for auto-process`);
} else {
// Only create modal if user requested it - don't create for background auto-processing
if (userRequested) {
console.log(`๐ง [Rehydrate] User requested - creating wishlist modal for active process: ${batch_id}`);
// Create the modal with current server state (pass category filter for auto-processing)
await openDownloadMissingWishlistModal(current_cycle);
const process = activeDownloadProcesses[playlist_id];
if (!process) {
console.error('โ [Rehydrate] Failed to create wishlist process in activeDownloadProcesses');
return;
}
// Sync process state with server
console.log(`โ
[Rehydrate] Syncing wishlist process state - batchId: ${batch_id}, status: running`);
process.status = 'running';
process.batchId = batch_id;
// Update UI to reflect running state
const beginBtn = document.getElementById(`begin-analysis-btn-${playlist_id}`);
const cancelBtn = document.getElementById(`cancel-all-btn-${playlist_id}`);
if (beginBtn) beginBtn.style.display = 'none';
if (cancelBtn) cancelBtn.style.display = 'inline-block';
// Start polling for live updates
startModalDownloadPolling(playlist_id);
// Show modal
console.log('๐ค [Rehydrate] User requested - showing wishlist modal');
process.modalElement.style.display = 'flex';
WishlistModalState.setVisible();
WishlistModalState.clearUserClosed();
} else {
console.log('๐ [Rehydrate] Background auto-processing detected - NOT creating modal (user must click wishlist button to see progress)');
// Don't create modal for background auto-processing
// User must click the wishlist button to see the modal
}
}
return;
}
// Handle regular Spotify playlist processes
let playlistData = spotifyPlaylists.find(p => p.id === playlist_id);
if (!playlistData) {
console.warn(`Cannot rehydrate modal: Playlist data for ${playlist_id} not loaded.`);
return;
}
await openDownloadMissingModal(playlist_id);
const process = activeDownloadProcesses[playlist_id];
if (!process) return;
process.status = 'running';
process.batchId = batch_id;
updatePlaylistCardUI(playlist_id);
updateRefreshButtonState();
document.getElementById(`begin-analysis-btn-${playlist_id}`).style.display = 'none';
document.getElementById(`cancel-all-btn-${playlist_id}`).style.display = 'inline-block';
// Hide wishlist button if it exists
const wishlistBtn = document.getElementById(`add-to-wishlist-btn-${playlist_id}`);
if (wishlistBtn) wishlistBtn.style.display = 'none';
startModalDownloadPolling(playlist_id);
process.modalElement.style.display = 'none';
}
// ===================================================================
// YOUTUBE PLAYLIST BACKEND HYDRATION FUNCTIONS
// ===================================================================
async function loadYouTubePlaylistsFromBackend() {
// Load all stored YouTube playlists from backend and recreate cards (similar to Spotify hydration)
try {
console.log('๐ Loading YouTube playlists from backend...');
const response = await fetch('/api/youtube/playlists');
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || 'Failed to fetch YouTube playlists');
}
const data = await response.json();
const playlists = data.playlists || [];
console.log(`๐ฌ Found ${playlists.length} stored YouTube playlists in backend`);
if (playlists.length === 0) {
console.log('๐ No YouTube playlists to hydrate');
return;
}
const container = document.getElementById('youtube-playlist-container');
// Create cards for playlists that don't already exist (avoid duplicates)
for (const playlistInfo of playlists) {
const urlHash = playlistInfo.url_hash;
// Check if card already exists (from rehydration or previous loading)
if (youtubePlaylistStates[urlHash] && youtubePlaylistStates[urlHash].cardElement &&
document.body.contains(youtubePlaylistStates[urlHash].cardElement)) {
console.log(`โญ๏ธ Skipping existing YouTube playlist card: ${playlistInfo.playlist.name}`);
// Update existing state with backend data
const state = youtubePlaylistStates[urlHash];
state.phase = playlistInfo.phase;
state.discoveryProgress = playlistInfo.discovery_progress;
state.spotifyMatches = playlistInfo.spotify_matches;
state.convertedSpotifyPlaylistId = playlistInfo.converted_spotify_playlist_id;
// Fetch discovery results for existing cards too if they don't have them
if (playlistInfo.phase !== 'fresh' && playlistInfo.phase !== 'discovering' &&
(!state.discoveryResults || state.discoveryResults.length === 0)) {
try {
console.log(`๐ Fetching missing discovery results for existing card: ${playlistInfo.playlist.name}`);
const stateResponse = await fetch(`/api/youtube/state/${urlHash}`);
if (stateResponse.ok) {
const fullState = await stateResponse.json();
if (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 for existing card`);
}
}
} catch (error) {
console.warn(`โ ๏ธ Error fetching discovery results for existing card:`, error.message);
}
}
continue;
}
console.log(`๐ฌ Creating YouTube playlist card: ${playlistInfo.playlist.name} (Phase: ${playlistInfo.phase})`);
createYouTubeCardFromBackendState(playlistInfo);
// Fetch discovery results for non-fresh playlists (same logic as rehydrateYouTubePlaylist)
if (playlistInfo.phase !== 'fresh' && playlistInfo.phase !== 'discovering') {
try {
console.log(`๐ Fetching discovery results for: ${playlistInfo.playlist.name}`);
const stateResponse = await fetch(`/api/youtube/state/${urlHash}`);
if (stateResponse.ok) {
const fullState = await stateResponse.json();
console.log(`๐ Retrieved full state with ${fullState.discovery_results?.length || 0} discovery results`);
// Store discovery results in local state
const state = youtubePlaylistStates[urlHash];
if (fullState.discovery_results && state) {
state.discoveryResults = fullState.discovery_results;
state.syncPlaylistId = fullState.sync_playlist_id;
state.syncProgress = fullState.sync_progress || {};
console.log(`โ
Restored ${state.discoveryResults.length} discovery results for: ${playlistInfo.playlist.name}`);
}
} else {
console.warn(`โ ๏ธ Could not fetch discovery results for: ${playlistInfo.playlist.name}`);
}
} catch (error) {
console.warn(`โ ๏ธ Error fetching discovery results for ${playlistInfo.playlist.name}:`, error.message);
}
}
}
// Rehydrate download modals for YouTube playlists in downloading/download_complete phases
for (const playlistInfo of playlists) {
if ((playlistInfo.phase === 'downloading' || playlistInfo.phase === 'download_complete') &&
playlistInfo.converted_spotify_playlist_id && playlistInfo.download_process_id) {
const convertedPlaylistId = playlistInfo.converted_spotify_playlist_id;
if (!activeDownloadProcesses[convertedPlaylistId]) {
console.log(`๐ง Rehydrating download modal for YouTube playlist: ${playlistInfo.playlist.name}`);
try {
// Create the download modal using the YouTube-specific function
const spotifyTracks = youtubePlaylistStates[playlistInfo.url_hash]?.discoveryResults
?.filter(result => result.spotify_data)
?.map(result => result.spotify_data) || [];
if (spotifyTracks.length > 0) {
await openDownloadMissingModalForYouTube(
convertedPlaylistId,
playlistInfo.playlist.name,
spotifyTracks
);
// Set the modal to running state with the correct batch ID
const process = activeDownloadProcesses[convertedPlaylistId];
if (process) {
process.status = 'running';
process.batchId = playlistInfo.download_process_id;
// Update UI to running state
const beginBtn = document.getElementById(`begin-analysis-btn-${convertedPlaylistId}`);
const cancelBtn = document.getElementById(`cancel-all-btn-${convertedPlaylistId}`);
if (beginBtn) beginBtn.style.display = 'none';
if (cancelBtn) cancelBtn.style.display = 'inline-block';
// Start polling for this process
startModalDownloadPolling(convertedPlaylistId);
// Hide modal since this is background rehydration
process.modalElement.style.display = 'none';
console.log(`โ
Rehydrated download modal for YouTube playlist: ${playlistInfo.playlist.name}`);
}
} else {
console.warn(`โ ๏ธ No Spotify tracks found for YouTube download modal: ${playlistInfo.playlist.name}`);
}
} catch (error) {
console.error(`โ Error rehydrating download modal for ${playlistInfo.playlist.name}:`, error);
}
}
}
}
console.log(`โ
Successfully hydrated ${playlists.length} YouTube playlists from backend`);
} catch (error) {
console.error('โ Error loading YouTube playlists from backend:', error);
showToast(`Error loading YouTube playlists: ${error.message}`, 'error');
}
}
async function loadBeatportChartsFromBackend() {
// Load all stored Beatport charts from backend and recreate cards (similar to YouTube hydration)
try {
console.log('๐ Loading Beatport charts from backend...');
const response = await fetch('/api/beatport/charts');
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || 'Failed to fetch Beatport charts');
}
const charts = await response.json();
console.log(`๐ง Found ${charts.length} stored Beatport charts in backend`);
if (charts.length === 0) {
console.log('๐ No Beatport charts to hydrate');
return;
}
const container = document.getElementById('beatport-playlist-container');
// Create cards for charts that don't already exist (avoid duplicates)
for (const chartInfo of charts) {
const chartHash = chartInfo.hash;
// Check if card already exists (from previous loading)
if (beatportChartStates[chartHash] && beatportChartStates[chartHash].cardElement &&
document.body.contains(beatportChartStates[chartHash].cardElement)) {
console.log(`โญ๏ธ Skipping existing Beatport chart card: ${chartInfo.name}`);
// Update existing state with backend data
const state = beatportChartStates[chartHash];
state.phase = chartInfo.phase;
continue;
}
console.log(`๐ง Creating Beatport chart card: ${chartInfo.name} (Phase: ${chartInfo.phase})`);
createBeatportCardFromBackendState(chartInfo);
// Fetch full state for non-fresh charts to restore discovery results
if (chartInfo.phase !== 'fresh') {
try {
console.log(`๐ Fetching full state for: ${chartInfo.name}`);
const stateResponse = await fetch(`/api/beatport/charts/status/${chartHash}`);
if (stateResponse.ok) {
const fullState = await stateResponse.json();
console.log(`๐ Retrieved full state with ${fullState.discovery_results?.length || 0} discovery results`);
// Store in YouTube state system (since Beatport reuses it)
if (fullState.discovery_results && fullState.discovery_results.length > 0) {
// Transform backend results to frontend format (like Tidal does)
const transformedResults = fullState.discovery_results.map((result, index) => ({
index: result.index !== undefined ? result.index : index,
yt_track: result.beatport_track ? result.beatport_track.title : 'Unknown',
yt_artist: result.beatport_track ? result.beatport_track.artist : 'Unknown',
status: result.status === 'found' ? 'โ
Found' : (result.status === 'error' ? 'โ Error' : 'โ Not Found'),
status_class: result.status_class || (result.status === 'found' ? 'found' : (result.status === 'error' ? 'error' : 'not-found')),
spotify_track: result.spotify_data ? result.spotify_data.name : '-',
spotify_artist: result.spotify_data && result.spotify_data.artists ?
result.spotify_data.artists.map(a => a.name || a).join(', ') : '-',
spotify_album: result.spotify_data ? (typeof result.spotify_data.album === 'object' ? result.spotify_data.album.name : result.spotify_data.album) : '-'
}));
// Create Beatport state in YouTube system for modal functionality
youtubePlaylistStates[chartHash] = {
phase: fullState.phase,
playlist: {
name: chartInfo.name,
tracks: chartInfo.chart_data.tracks,
description: `${chartInfo.track_count} tracks from ${chartInfo.name}`,
source: 'beatport'
},
is_beatport_playlist: true,
beatport_chart_type: chartInfo.chart_data.chart_type,
beatport_chart_hash: chartHash,
discovery_progress: fullState.discovery_progress,
discoveryProgress: fullState.discovery_progress,
spotify_matches: fullState.spotify_matches,
spotifyMatches: fullState.spotify_matches,
discovery_results: fullState.discovery_results,
discoveryResults: transformedResults,
convertedSpotifyPlaylistId: fullState.converted_spotify_playlist_id,
download_process_id: fullState.download_process_id,
syncPlaylistId: fullState.sync_playlist_id,
syncProgress: fullState.sync_progress || {}
};
console.log(`โ
Restored ${transformedResults.length} discovery results for: ${chartInfo.name}`);
}
} else {
console.warn(`โ ๏ธ Could not fetch full state for: ${chartInfo.name}`);
}
} catch (error) {
console.warn(`โ ๏ธ Error fetching full state for ${chartInfo.name}:`, error.message);
}
}
}
// Rehydrate download modals for Beatport charts in downloading/download_complete phases
for (const chartInfo of charts) {
if ((chartInfo.phase === 'downloading' || chartInfo.phase === 'download_complete') &&
chartInfo.converted_spotify_playlist_id && chartInfo.download_process_id) {
const convertedPlaylistId = chartInfo.converted_spotify_playlist_id;
console.log(`๐ฅ Rehydrating download modal for Beatport chart: ${chartInfo.name} (Playlist: ${convertedPlaylistId})`);
// Set up active download process for Beatport chart (like YouTube/Tidal)
try {
// Rehydrate the chart state first to get discovery results
await rehydrateBeatportChart(chartInfo, false);
// Create the download modal using the Beatport-specific function (like YouTube)
if (!activeDownloadProcesses[convertedPlaylistId]) {
// Get tracks from the rehydrated state
const ytState = youtubePlaylistStates[chartInfo.hash];
let spotifyTracks = [];
if (ytState && ytState.discovery_results) {
spotifyTracks = ytState.discovery_results
.filter(result => result.spotify_data)
.map(result => {
const track = result.spotify_data;
// Ensure artists is an array of strings
if (track.artists && Array.isArray(track.artists)) {
track.artists = track.artists.map(artist =>
typeof artist === 'string' ? artist : (artist.name || artist)
);
} else if (track.artists && typeof track.artists === 'string') {
track.artists = [track.artists];
} else {
track.artists = ['Unknown Artist'];
}
return {
id: track.id,
name: track.name,
artists: track.artists,
album: track.album || 'Unknown Album',
duration_ms: track.duration_ms || 0,
external_urls: track.external_urls || {}
};
});
}
if (spotifyTracks.length > 0) {
await openDownloadMissingModalForYouTube(
convertedPlaylistId,
chartInfo.name,
spotifyTracks
);
// Set the modal to running state with the correct batch ID
const process = activeDownloadProcesses[convertedPlaylistId];
if (process) {
process.status = chartInfo.phase === 'download_complete' ? 'complete' : 'running';
process.batchId = chartInfo.download_process_id;
// Update UI to running state
const beginBtn = document.getElementById(`begin-analysis-btn-${convertedPlaylistId}`);
const cancelBtn = document.getElementById(`cancel-all-btn-${convertedPlaylistId}`);
if (beginBtn) beginBtn.style.display = 'none';
if (cancelBtn) cancelBtn.style.display = 'inline-block';
// Start polling for this process
startModalDownloadPolling(convertedPlaylistId);
// Hide modal since this is background rehydration
process.modalElement.style.display = 'none';
console.log(`โ
Rehydrated download modal for Beatport chart: ${chartInfo.name}`);
}
} else {
console.warn(`โ ๏ธ No Spotify tracks found for Beatport download modal: ${chartInfo.name}`);
}
}
} catch (error) {
console.warn(`โ ๏ธ Error setting up download process for Beatport chart "${chartInfo.name}":`, error.message);
}
}
}
console.log(`โ
Successfully loaded and rehydrated ${charts.length} Beatport charts`);
// Start polling for any charts that are still in discovering phase
for (const chartInfo of charts) {
if (chartInfo.phase === 'discovering') {
console.log(`๐ [Backend Loading] Auto-starting polling for discovering chart: ${chartInfo.name}`);
startBeatportDiscoveryPolling(chartInfo.hash);
}
}
// Update clear button state after loading charts
updateBeatportClearButtonState();
} catch (error) {
console.error('โ Error loading Beatport charts from backend:', error);
showToast(`Error loading Beatport charts: ${error.message}`, 'error');
}
}
async function loadListenBrainzPlaylistsFromBackend() {
// Load all stored ListenBrainz playlist states from backend for persistence (similar to Beatport hydration)
try {
console.log('๐ Loading ListenBrainz playlists from backend...');
const response = await fetch('/api/listenbrainz/playlists');
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || 'Failed to fetch ListenBrainz playlists');
}
const data = await response.json();
const playlists = data.playlists || [];
console.log(`๐ต Found ${playlists.length} stored ListenBrainz playlists in backend`);
if (playlists.length === 0) {
console.log('๐ No ListenBrainz playlists to hydrate');
listenbrainzPlaylistsLoaded = true;
return;
}
// Restore state for each playlist
for (const playlistInfo of playlists) {
const playlistMbid = playlistInfo.playlist_mbid;
console.log(`๐ต Hydrating ListenBrainz playlist: ${playlistInfo.playlist.name} (Phase: ${playlistInfo.phase}, MBID: ${playlistMbid})`);
// Fetch full state for non-fresh playlists to restore discovery results
if (playlistInfo.phase !== 'fresh') {
try {
console.log(`๐ Fetching full state for: ${playlistInfo.playlist.name}`);
const stateResponse = await fetch(`/api/listenbrainz/state/${playlistMbid}`);
if (stateResponse.ok) {
const fullState = await stateResponse.json();
console.log(`๐ Retrieved full state with ${fullState.discovery_results?.length || 0} discovery results`);
// Transform backend results to frontend format (like Beatport does)
const transformedResults = (fullState.discovery_results || []).map((result, index) => ({
index: result.index !== undefined ? result.index : index,
yt_track: result.lb_track || result.track_name || 'Unknown',
yt_artist: result.lb_artist || result.artist_name || 'Unknown',
status: result.status === 'found' || result.status === 'โ
Found' || result.status_class === 'found' ? 'โ
Found' : (result.status === 'error' ? 'โ Error' : 'โ Not Found'),
status_class: result.status_class || (result.status === 'found' || result.status === 'โ
Found' ? 'found' : (result.status === 'error' ? 'error' : 'not-found')),
spotify_track: result.spotify_data ? result.spotify_data.name : (result.spotify_track || '-'),
spotify_artist: result.spotify_data && result.spotify_data.artists ?
(Array.isArray(result.spotify_data.artists) ? (typeof result.spotify_data.artists[0] === 'object' ? result.spotify_data.artists[0].name : result.spotify_data.artists[0]) : result.spotify_data.artists) : (result.spotify_artist || '-'),
spotify_album: result.spotify_data ? (typeof result.spotify_data.album === 'object' ? result.spotify_data.album.name : result.spotify_data.album) : (result.spotify_album || '-'),
spotify_data: result.spotify_data,
duration: result.duration || '0:00'
}));
// Create ListenBrainz state with both naming conventions
listenbrainzPlaylistStates[playlistMbid] = {
phase: fullState.phase,
playlist: fullState.playlist,
is_listenbrainz_playlist: true,
playlist_mbid: playlistMbid,
// Store with both naming conventions
discovery_results: fullState.discovery_results || [],
discoveryResults: transformedResults,
discovery_progress: fullState.discovery_progress || 0,
discoveryProgress: fullState.discovery_progress || 0,
spotify_matches: fullState.spotify_matches || 0,
spotifyMatches: fullState.spotify_matches || 0,
spotify_total: fullState.spotify_total || 0,
spotifyTotal: fullState.spotify_total || 0,
convertedSpotifyPlaylistId: fullState.converted_spotify_playlist_id,
download_process_id: fullState.download_process_id
};
console.log(`โ
Restored ${transformedResults.length} discovery results for: ${playlistInfo.playlist.name}`);
} else {
console.warn(`โ ๏ธ Could not fetch full state for: ${playlistInfo.playlist.name}`);
}
} catch (error) {
console.warn(`โ ๏ธ Error fetching full state for ${playlistInfo.playlist.name}:`, error.message);
}
}
}
// Start polling for any playlists that are still in discovering phase
for (const playlistInfo of playlists) {
if (playlistInfo.phase === 'discovering') {
console.log(`๐ [Backend Loading] Auto-starting polling for discovering playlist: ${playlistInfo.playlist.name}`);
startListenBrainzDiscoveryPolling(playlistInfo.playlist_mbid);
}
// Show sync button for discovered playlists (hidden by default)
else if (playlistInfo.phase === 'discovered' || playlistInfo.phase === 'syncing' || playlistInfo.phase === 'sync_complete') {
const playlistId = `discover-lb-playlist-${playlistInfo.playlist_mbid}`;
const syncBtn = document.getElementById(`${playlistId}-sync-btn`);
if (syncBtn) {
syncBtn.style.display = 'inline-block';
console.log(`โ
Showing sync button for discovered playlist: ${playlistInfo.playlist.name}`);
}
}
}
listenbrainzPlaylistsLoaded = true;
console.log(`โ
Successfully loaded and rehydrated ${playlists.length} ListenBrainz playlists`);
} catch (error) {
console.error('โ Error loading ListenBrainz playlists from backend:', error);
listenbrainzPlaylistsLoaded = true; // Mark as loaded even on error to prevent retries
}
}
function createBeatportCardFromBackendState(chartInfo) {
// Create Beatport chart card from backend state data
const chartHash = chartInfo.hash;
const chartData = chartInfo.chart_data;
const phase = chartInfo.phase;
const container = document.getElementById('beatport-playlist-container');
// Remove placeholder if it exists
const placeholder = container.querySelector('.playlist-placeholder');
if (placeholder) {
placeholder.remove();
}
// Create card HTML using same structure as createBeatportCard
const cardHtml = `
๐ง
${escapeHtml(chartInfo.name)}
${chartInfo.track_count} tracks
${getPhaseText(phase)}
โช ${chartInfo.spotify_total} / โ ${chartInfo.spotify_matches} / โ ${chartInfo.spotify_total - chartInfo.spotify_matches} (${Math.round((chartInfo.spotify_matches / chartInfo.spotify_total) * 100) || 0}%)
${getActionButtonText(phase)}
`;
container.insertAdjacentHTML('beforeend', cardHtml);
// Initialize state
beatportChartStates[chartHash] = {
phase: phase,
chart: chartData,
cardElement: document.getElementById(`beatport-card-${chartHash}`)
};
// Add click handler
const card = document.getElementById(`beatport-card-${chartHash}`);
if (card) {
card.addEventListener('click', async () => await handleBeatportCardClick(chartHash));
}
console.log(`๐ Created Beatport card from backend state: ${chartInfo.name} (${phase})`);
}
async function rehydrateBeatportChart(chartInfo, userRequested = false) {
// Rehydrate Beatport chart state and optionally open modal (similar to rehydrateYouTubePlaylist)
const chartHash = chartInfo.hash;
const chartName = chartInfo.name;
try {
console.log(`๐ [Rehydration] Starting rehydration for Beatport chart: ${chartName}`);
// Get full state from backend including discovery results
let fullState;
try {
const stateResponse = await fetch(`/api/beatport/charts/status/${chartHash}`);
if (stateResponse.ok) {
fullState = await stateResponse.json();
console.log(`๐ [Rehydration] Retrieved full backend state with ${fullState.discovery_results?.length || 0} discovery results`);
} else {
console.warn(`โ ๏ธ [Rehydration] Could not fetch full state, using basic info`);
}
} catch (error) {
console.warn(`โ ๏ธ [Rehydration] Error fetching full state:`, error.message);
}
const phase = chartInfo.phase;
// Create or update Beatport chart state
if (!beatportChartStates[chartHash]) {
beatportChartStates[chartHash] = {
phase: 'fresh',
chart: chartInfo.chart_data,
cardElement: null
};
}
const state = beatportChartStates[chartHash];
state.phase = phase;
// Transform discovery results if available (like Tidal does)
let transformedResults = [];
if (fullState && fullState.discovery_results) {
transformedResults = fullState.discovery_results.map((result, index) => ({
index: result.index !== undefined ? result.index : index,
yt_track: result.beatport_track ? result.beatport_track.title : 'Unknown',
yt_artist: result.beatport_track ? result.beatport_track.artist : 'Unknown',
status: result.status === 'found' ? 'โ
Found' : (result.status === 'error' ? 'โ Error' : 'โ Not Found'),
status_class: result.status_class || (result.status === 'found' ? 'found' : (result.status === 'error' ? 'error' : 'not-found')),
spotify_track: result.spotify_data ? result.spotify_data.name : '-',
spotify_artist: result.spotify_data && result.spotify_data.artists ?
result.spotify_data.artists.map(a => a.name || a).join(', ') : '-',
spotify_album: result.spotify_data ? (typeof result.spotify_data.album === 'object' ? result.spotify_data.album.name : result.spotify_data.album) : '-'
}));
}
// Store in YouTube state system (since Beatport reuses it)
youtubePlaylistStates[chartHash] = {
phase: phase,
playlist: {
name: chartName,
tracks: chartInfo.chart_data.tracks,
description: `${chartInfo.track_count} tracks from ${chartName}`,
source: 'beatport'
},
is_beatport_playlist: true,
beatport_chart_type: chartInfo.chart_data.chart_type,
beatport_chart_hash: chartHash,
discovery_progress: fullState?.discovery_progress || chartInfo.discovery_progress,
discoveryProgress: fullState?.discovery_progress || chartInfo.discovery_progress,
spotify_matches: fullState?.spotify_matches || chartInfo.spotify_matches,
spotifyMatches: fullState?.spotify_matches || chartInfo.spotify_matches,
discovery_results: fullState?.discovery_results || [],
discoveryResults: transformedResults,
convertedSpotifyPlaylistId: fullState?.converted_spotify_playlist_id || chartInfo.converted_spotify_playlist_id,
download_process_id: fullState?.download_process_id || chartInfo.download_process_id,
syncPlaylistId: fullState?.sync_playlist_id,
syncProgress: fullState?.sync_progress || {}
};
// Restore discovery results if we have them
if (fullState && fullState.discovery_results) {
console.log(`โ
Restored ${fullState.discovery_results.length} discovery results from backend`);
// Update modal if it already exists
const existingModal = document.getElementById(`youtube-discovery-modal-${chartHash}`);
if (existingModal && !existingModal.classList.contains('hidden')) {
console.log(`๐ Refreshing existing modal with restored discovery results`);
refreshYouTubeDiscoveryModalTable(chartHash);
}
}
// Update card display
updateBeatportCardPhase(chartHash, phase);
updateBeatportCardProgress(chartHash, {
spotify_total: chartInfo.spotify_total,
spotify_matches: chartInfo.spotify_matches,
failed: chartInfo.spotify_total - chartInfo.spotify_matches
});
// Handle active polling resumption
if (phase === 'discovering') {
console.log(`๐ Resuming discovery polling for: ${chartName}`);
startBeatportDiscoveryPolling(chartHash);
} else if (phase === 'syncing') {
console.log(`๐ Resuming sync polling for: ${chartName}`);
startBeatportSyncPolling(chartHash);
}
// Open modal if user requested
if (userRequested) {
switch (phase) {
case 'discovering':
case 'discovered':
case 'syncing':
case 'sync_complete':
openYouTubeDiscoveryModal(chartHash);
break;
case 'downloading':
case 'download_complete':
// Open download modal if we have the converted playlist ID
if (chartInfo.converted_spotify_playlist_id) {
await openDownloadMissingModal(chartInfo.converted_spotify_playlist_id);
}
break;
}
}
console.log(`โ
Successfully rehydrated Beatport chart: ${chartName}`);
} catch (error) {
console.error(`โ Error rehydrating Beatport chart "${chartName}":`, error);
}
}
function createYouTubeCardFromBackendState(playlistInfo) {
// Create YouTube playlist card from backend state data
const urlHash = playlistInfo.url_hash;
const playlist = playlistInfo.playlist;
const phase = playlistInfo.phase;
const container = document.getElementById('youtube-playlist-container');
// Remove placeholder if it exists
const placeholder = container.querySelector('.youtube-playlist-placeholder');
if (placeholder) {
placeholder.remove();
}
// Create card HTML (using EXACT same structure as createYouTubeCard)
const cardHtml = `
โถ
${escapeHtml(playlist.name)}
${playlist.tracks.length} tracks
${getPhaseText(phase)}
โช ${playlistInfo.spotify_total} / โ ${playlistInfo.spotify_matches} / โ ${playlistInfo.spotify_total - playlistInfo.spotify_matches} / ${Math.round(getProgressWidth(playlistInfo))}%
${getActionButtonText(phase)}
`;
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!
';
}
showToast(`Removed "${playlistName}" from backend storage`, 'success');
console.log(`โ
Successfully removed YouTube playlist: ${playlistName}`);
} catch (error) {
console.error(`โ Error removing YouTube playlist "${playlistName}":`, error);
showToast(`Error removing playlist: ${error.message}`, 'error');
}
}
async function loadSpotifyPlaylists() {
const container = document.getElementById('spotify-playlist-container');
const refreshBtn = document.getElementById('spotify-refresh-btn');
container.innerHTML = `๐ Loading playlists...
`;
refreshBtn.disabled = true;
refreshBtn.textContent = '๐ Loading...';
try {
const response = await fetch('/api/spotify/playlists');
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || 'Failed to fetch playlists');
}
spotifyPlaylists = await response.json();
renderSpotifyPlaylists();
spotifyPlaylistsLoaded = true;
await checkForActiveProcesses();
} catch (error) {
container.innerHTML = `โ Error: ${error.message}
`;
showToast(`Error loading playlists: ${error.message}`, 'error');
} finally {
refreshBtn.disabled = false;
refreshBtn.textContent = '๐ Refresh';
}
}
function renderSpotifyPlaylists() {
const container = document.getElementById('spotify-playlist-container');
if (spotifyPlaylists.length === 0) {
container.innerHTML = `No Spotify playlists found.
`;
return;
}
container.innerHTML = spotifyPlaylists.map(p => {
let statusClass = 'status-never-synced';
if (p.sync_status.startsWith('Synced')) statusClass = 'status-synced';
if (p.sync_status === 'Needs Sync') statusClass = 'status-needs-sync';
// This HTML structure creates the interactive playlist cards
return `
${escapeHtml(p.name)}
${p.track_count} tracks โข
${p.sync_status}
Sync / Download
View Progress
`;
}).join('');
}
function handleViewProgressClick(event, playlistId) {
event.stopPropagation(); // Prevent the card selection from toggling
const process = activeDownloadProcesses[playlistId];
if (process && process.modalElement) {
// If a process is active, just show its modal
console.log(`Re-opening active download modal for playlist ${playlistId}`);
process.modalElement.style.display = 'flex';
}
}
function updatePlaylistCardUI(playlistId) {
const process = activeDownloadProcesses[playlistId];
const progressBtn = document.getElementById(`progress-btn-${playlistId}`);
const actionBtn = document.getElementById(`action-btn-${playlistId}`);
const card = document.querySelector(`.playlist-card[data-playlist-id="${playlistId}"]`);
if (!progressBtn || !actionBtn) return;
if (process && process.status === 'running') {
// A process is running: show the progress button
progressBtn.classList.remove('hidden');
progressBtn.textContent = 'View Progress';
progressBtn.style.backgroundColor = ''; // Reset any custom styling
actionBtn.textContent = '๐ฅ Downloading...';
actionBtn.disabled = true;
// Remove completion styling from card
if (card) card.classList.remove('download-complete');
} else if (process && process.status === 'complete') {
// Process completed: show "ready for review" indicator
progressBtn.classList.remove('hidden');
progressBtn.textContent = '๐ View Results';
progressBtn.style.backgroundColor = '#28a745'; // Green success color
progressBtn.style.color = 'white';
actionBtn.textContent = 'โ
Ready for Review';
actionBtn.disabled = false; // Allow clicking to see results
// Add completion styling to card
if (card) card.classList.add('download-complete');
} else {
// No process or it's been cleaned up: normal state
progressBtn.classList.add('hidden');
progressBtn.style.backgroundColor = ''; // Reset styling
progressBtn.style.color = ''; // Reset styling
actionBtn.textContent = 'Sync / Download';
actionBtn.disabled = false;
// Remove completion styling from card
if (card) card.classList.remove('download-complete');
}
}
async function cleanupDownloadProcess(playlistId) {
const process = activeDownloadProcesses[playlistId];
if (!process) return;
console.log(`๐งน Cleaning up download process for playlist ${playlistId}`);
// Stop any active polling first
if (process.poller) {
console.log(`๐ Stopping individual polling for ${playlistId}`);
clearInterval(process.poller);
process.poller = null;
}
// Mark process as no longer running
if (process.status === 'running') {
process.status = 'complete';
}
// If the process has a batchId, tell the server to clean it up.
if (process.batchId) {
try {
console.log(`๐ Sending cleanup request to server for batch: ${process.batchId}`);
const response = await fetch('/api/playlists/cleanup_batch', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ batch_id: process.batchId })
});
// Handle deferred cleanup (202 = wishlist processing in progress)
if (response.status === 202) {
console.log(`โณ Wishlist processing in progress for batch ${process.batchId}, will retry cleanup in 2s...`);
// Retry cleanup after delay to allow wishlist processing to complete
setTimeout(async () => {
try {
await fetch('/api/playlists/cleanup_batch', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ batch_id: process.batchId })
});
console.log(`โ
Delayed cleanup completed for batch: ${process.batchId}`);
} catch (error) {
console.warn(`โ ๏ธ Delayed cleanup failed:`, error);
}
}, 2000); // 2 second delay
} else {
console.log(`โ
Server cleanup completed for batch: ${process.batchId}`);
}
} catch (error) {
console.warn(`โ ๏ธ Failed to send cleanup request to server:`, error);
// Don't show toast for cleanup failures - they're not user-facing
}
}
// Remove modal from DOM
if (process.modalElement && process.modalElement.parentElement) {
process.modalElement.parentElement.removeChild(process.modalElement);
}
// Remove from client-side global state
delete activeDownloadProcesses[playlistId];
// Check if global polling should be stopped
checkAndCleanupGlobalPolling();
// Restore card UI (only for non-wishlist playlists)
if (playlistId !== 'wishlist') {
updatePlaylistCardUI(playlistId);
}
updateRefreshButtonState(); // Now safe since hasActiveOperations() excludes wishlist
}
function togglePlaylistSelection(event) {
const card = event.currentTarget;
const playlistId = card.dataset.playlistId;
// Don't toggle if clicking the button
if (event.target.tagName === 'BUTTON') return;
const isSelected = !card.classList.contains('selected');
card.classList.toggle('selected', isSelected);
if (isSelected) {
selectedPlaylists.add(playlistId);
} else {
selectedPlaylists.delete(playlistId);
}
updateSyncActionsUI();
}
function updateSyncActionsUI() {
// If sequential sync is running, let the manager handle UI updates
if (sequentialSyncManager && sequentialSyncManager.isRunning) {
sequentialSyncManager.updateUI();
return;
}
const selectionInfo = document.getElementById('selection-info');
const startSyncBtn = document.getElementById('start-sync-btn');
const count = selectedPlaylists.size;
if (count === 0) {
if (selectionInfo) selectionInfo.textContent = 'Select playlists to sync';
if (startSyncBtn) startSyncBtn.disabled = true;
} else {
if (selectionInfo) selectionInfo.textContent = `${count} playlist${count > 1 ? 's' : ''} selected`;
if (startSyncBtn) startSyncBtn.disabled = false;
}
}
async function openPlaylistDetailsModal(event, playlistId) {
event.stopPropagation();
const playlist = spotifyPlaylists.find(p => p.id === playlistId);
if (!playlist) return;
showLoadingOverlay(`Loading playlist: ${playlist.name}...`);
try {
// --- CACHING LOGIC START ---
if (playlistTrackCache[playlistId]) {
console.log(`Cache HIT for playlist ${playlistId}. Using cached tracks.`);
// Use the cached tracks instead of fetching
const fullPlaylist = { ...playlist, tracks: playlistTrackCache[playlistId] };
showPlaylistDetailsModal(fullPlaylist);
} else {
console.log(`Cache MISS for playlist ${playlistId}. Fetching from server...`);
// Fetch from the server if not in cache
const response = await fetch(`/api/spotify/playlist/${playlistId}`);
const fullPlaylist = await response.json();
if (fullPlaylist.error) throw new Error(fullPlaylist.error);
// Store the fetched tracks in the cache
playlistTrackCache[playlistId] = fullPlaylist.tracks;
console.log(`Cached ${fullPlaylist.tracks.length} tracks for playlist ${playlistId}.`);
// Auto-mirror this Spotify playlist
mirrorPlaylist('spotify', playlistId, fullPlaylist.name, fullPlaylist.tracks.map(t => ({
track_name: t.name, artist_name: (t.artists && t.artists[0]) ? (typeof t.artists[0] === 'object' ? t.artists[0].name : t.artists[0]) : '',
album_name: t.album ? (typeof t.album === 'object' ? t.album.name : t.album) : '',
duration_ms: t.duration_ms || 0,
image_url: t.album && typeof t.album === 'object' && t.album.images && t.album.images[0] ? t.album.images[0].url : null,
source_track_id: t.id || t.spotify_track_id || ''
})), { description: fullPlaylist.description, owner: fullPlaylist.owner, image_url: fullPlaylist.image_url });
showPlaylistDetailsModal(fullPlaylist);
}
// --- CACHING LOGIC END ---
} catch (error) {
showToast(`Error: ${error.message}`, 'error');
} finally {
hideLoadingOverlay();
}
}
function showPlaylistDetailsModal(playlist) {
// Create modal if it doesn't exist
let modal = document.getElementById('playlist-details-modal');
if (!modal) {
modal = document.createElement('div');
modal.id = 'playlist-details-modal';
modal.className = 'modal-overlay';
document.body.appendChild(modal);
}
// Check if there's a completed download missing tracks process for this playlist
const activeProcess = activeDownloadProcesses[playlist.id];
const hasCompletedProcess = activeProcess && activeProcess.status === 'complete';
// Check if sync is currently running for this playlist
const isSyncing = !!activeSyncPollers[playlist.id];
modal.innerHTML = `
${playlist.description ? `
${escapeHtml(playlist.description)}
` : ''}
${playlist.tracks.map((track, index) => `
${index + 1}
${escapeHtml(track.name)}
${formatArtists(track.artists)}
${formatDuration(track.duration_ms)}
`).join('')}
`;
modal.style.display = 'flex';
}
function closePlaylistDetailsModal() {
const modal = document.getElementById('playlist-details-modal');
if (modal) {
modal.style.display = 'none';
}
}
function formatDuration(ms) {
const minutes = Math.floor(ms / 60000);
const seconds = Math.floor((ms % 60000) / 1000);
return `${minutes}:${seconds.toString().padStart(2, '0')}`;
}
// ===============================
// DOWNLOAD MISSING TRACKS MODAL
// ===============================
let activeAnalysisTaskId = null;
let currentPlaylistTracks = [];
let analysisResults = [];
let missingTracks = [];
// New variables for enhanced modal functionality
let currentDownloadBatchId = null;
// ===============================
// HERO SECTION HELPER FUNCTIONS
// ===============================
/**
* Generate hero section HTML for download missing tracks modal
* Context-aware display based on available data
*/
function generateDownloadModalHeroSection(context) {
const { type, playlist, artist, album, trackCount } = context;
let heroContent = '';
let heroBackgroundImage = '';
switch (type) {
case 'album':
case 'artist_album':
// Artist/album context - show artist + album images
const artistImage = artist?.image_url || artist?.images?.[0]?.url;
const albumImage = album?.image_url || album?.images?.[0]?.url;
// Use album image as background if available
if (albumImage) {
heroBackgroundImage = `
`;
}
heroContent = `
${artistImage ? `
` : ''}
${albumImage ? `
` : ''}
`;
break;
case 'playlist':
// Playlist context - show playlist info
heroContent = `
`;
break;
case 'wishlist':
// Wishlist context - show wishlist icon
heroContent = `
`;
break;
default:
// Fallback - basic display
heroContent = `
`;
break;
}
return `
${heroBackgroundImage}
${heroContent}
${context.trackCount}
Total
`;
}
let modalDownloadPoller = null;
let currentModalPlaylistId = null;
// PHASE 2: Local cancelled track management (GUI PARITY)
let cancelledTracks = new Set(); // Track cancelled track indices like GUI's cancelled_tracks
const TRACK_RENDER_BATCH_SIZE = 100;
function applyProgressiveTrackRendering(playlistId, totalTrackCount) {
if (totalTrackCount <= TRACK_RENDER_BATCH_SIZE) return;
const modal = document.getElementById(`download-missing-modal-${playlistId}`);
if (!modal) return;
const tbody = document.getElementById(`download-tracks-tbody-${playlistId}`);
if (!tbody) return;
const rows = tbody.querySelectorAll('tr[data-track-index]');
if (rows.length <= TRACK_RENDER_BATCH_SIZE) return;
// Hide rows beyond first batch
for (let i = TRACK_RENDER_BATCH_SIZE; i < rows.length; i++) {
rows[i].classList.add('hidden');
}
let revealedCount = TRACK_RENDER_BATCH_SIZE;
// Append indicator into .download-tracks-title
const titleEl = modal.querySelector('.download-tracks-title');
if (titleEl) {
const indicator = document.createElement('span');
indicator.className = 'track-render-indicator';
indicator.id = `track-render-indicator-${playlistId}`;
indicator.textContent = `Showing ${revealedCount} of ${totalTrackCount} tracks`;
titleEl.appendChild(indicator);
}
// Scroll listener on table container
const container = modal.querySelector('.download-tracks-table-container');
if (!container) return;
container.addEventListener('scroll', function onScroll() {
const scrollBottom = container.scrollHeight - container.scrollTop - container.clientHeight;
if (scrollBottom > 200) return;
if (revealedCount >= rows.length) return;
const nextEnd = Math.min(revealedCount + TRACK_RENDER_BATCH_SIZE, rows.length);
for (let i = revealedCount; i < nextEnd; i++) {
rows[i].classList.remove('hidden');
}
revealedCount = nextEnd;
const indicator = document.getElementById(`track-render-indicator-${playlistId}`);
if (indicator) {
indicator.textContent = revealedCount >= rows.length
? `Showing all ${totalTrackCount} tracks`
: `Showing ${revealedCount} of ${totalTrackCount} tracks`;
}
if (revealedCount >= rows.length) {
container.removeEventListener('scroll', onScroll);
}
});
}
async function openDownloadMissingModal(playlistId) {
showLoadingOverlay('Loading playlist...');
// **NEW**: Check if a process is already active for this playlist
if (activeDownloadProcesses[playlistId]) {
console.log(`Modal for ${playlistId} already exists. Showing it.`);
closePlaylistDetailsModal(); // Close playlist details modal even when reusing existing modal
const process = activeDownloadProcesses[playlistId];
if (process.modalElement) {
// Show helpful message if it's a completed process
if (process.status === 'complete') {
showToast('Showing previous results. Close this modal to start a new analysis.', 'info');
}
process.modalElement.style.display = 'flex';
}
hideLoadingOverlay();
return; // Don't create a new one
}
console.log(`๐ฅ Opening Download Missing Tracks modal for playlist: ${playlistId}`);
closePlaylistDetailsModal();
const playlist = spotifyPlaylists.find(p => p.id === playlistId);
if (!playlist) {
showToast('Could not find playlist data.', 'error');
return;
}
let tracks = playlistTrackCache[playlistId];
if (!tracks) {
try {
const response = await fetch(`/api/spotify/playlist/${playlistId}`);
const fullPlaylist = await response.json();
if (fullPlaylist.error) throw new Error(fullPlaylist.error);
tracks = fullPlaylist.tracks;
playlistTrackCache[playlistId] = tracks;
} catch (error) {
showToast(`Failed to fetch tracks: ${error.message}`, 'error');
return;
}
}
currentPlaylistTracks = tracks;
currentModalPlaylistId = playlistId;
let modal = document.createElement('div');
modal.id = `download-missing-modal-${playlistId}`; // **NEW**: Unique ID
modal.className = 'download-missing-modal'; // **NEW**: Use class for styling
modal.style.display = 'none'; // Start hidden
document.body.appendChild(modal);
// **NEW**: Register the new process in our global state tracker
activeDownloadProcesses[playlistId] = {
status: 'idle', // idle, running, complete, cancelled
modalElement: modal,
poller: null,
batchId: null,
playlist: playlist,
tracks: tracks
};
// Generate hero section for playlist context
const heroContext = {
type: 'playlist',
playlist: playlist,
trackCount: tracks.length,
playlistId: playlistId
};
modal.innerHTML = `
๐ Library Analysis
Ready to start
โฌ Downloads
Waiting for analysis
`;
applyProgressiveTrackRendering(playlistId, tracks.length);
modal.style.display = 'flex';
hideLoadingOverlay();
}
async function autoSavePlaylistM3U(playlistId) {
/**
* Automatically save M3U file server-side for playlist and album modals.
* The server checks the m3u_export.enabled setting before writing.
*/
const process = activeDownloadProcesses[playlistId];
if (!process || !process.tracks || process.tracks.length === 0) {
return;
}
const modal = document.getElementById(`download-missing-modal-${playlistId}`);
if (!modal) return;
const m3uContent = generateM3UContent(playlistId);
if (!m3uContent) return;
// Determine context type and gather metadata
const dataContext = modal.querySelector('.download-missing-modal-content')?.getAttribute('data-context');
const isAlbum = dataContext === 'artist_album';
const playlistName = process.playlist?.name || process.playlistName || 'Playlist';
const artistName = process.artist?.name || '';
const albumName = process.album?.name || '';
const releaseDate = process.album?.release_date || '';
const year = releaseDate ? releaseDate.substring(0, 4) : '';
try {
const response = await fetch('/api/save-playlist-m3u', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
playlist_name: playlistName,
m3u_content: m3uContent,
context_type: isAlbum ? 'album' : 'playlist',
artist_name: artistName,
album_name: albumName,
year: year
})
});
if (response.ok) {
console.log(`โ
Auto-saved M3U for ${isAlbum ? 'album' : 'playlist'}: ${playlistName}`);
} else {
console.warn(`โ ๏ธ Failed to auto-save M3U for ${playlistName}`);
}
} catch (error) {
console.debug('Auto-save M3U error (non-critical):', error);
}
}
function generateM3UContent(playlistId) {
/**
* Generate M3U file content from modal data
* Shared between manual export and auto-save
*/
const process = activeDownloadProcesses[playlistId];
if (!process || !process.tracks || process.tracks.length === 0) {
return null;
}
const tracks = process.tracks;
const playlistName = process.playlist?.name || process.playlistName || 'Playlist';
// Generate M3U8 content with status information
let m3uContent = '#EXTM3U\n';
m3uContent += `#PLAYLIST:${playlistName}\n`;
m3uContent += `#GENERATED:${new Date().toISOString()}\n\n`;
let foundCount = 0;
let downloadedCount = 0;
let missingCount = 0;
tracks.forEach((track, index) => {
const durationSeconds = track.duration_ms ? Math.floor(track.duration_ms / 1000) : -1;
const artists = Array.isArray(track.artists) ? track.artists.join(', ') : (track.artists || 'Unknown Artist');
// Check library match status from the modal UI
const matchEl = document.getElementById(`match-${playlistId}-${index}`);
const downloadEl = document.getElementById(`download-${playlistId}-${index}`);
const isFoundInLibrary = matchEl && matchEl.textContent.includes('Found');
const isDownloaded = downloadEl && downloadEl.textContent.includes('Completed');
const isMissing = matchEl && matchEl.textContent.includes('Missing');
// Track status
let status = 'UNKNOWN';
if (isDownloaded) {
status = 'DOWNLOADED';
downloadedCount++;
} else if (isFoundInLibrary) {
status = 'FOUND_IN_LIBRARY';
foundCount++;
} else if (isMissing) {
status = 'MISSING';
missingCount++;
}
// Add track info
m3uContent += `#EXTINF:${durationSeconds},${artists} - ${track.name}\n`;
m3uContent += `#STATUS:${status}\n`;
// Generate file path
const sanitizedArtist = artists.replace(/[/\\?%*:|"<>]/g, '-');
const sanitizedTrack = track.name.replace(/[/\\?%*:|"<>]/g, '-');
if (isDownloaded || isFoundInLibrary) {
m3uContent += `${sanitizedArtist} - ${sanitizedTrack}.mp3\n\n`;
} else {
m3uContent += `# NOT AVAILABLE: ${sanitizedArtist} - ${sanitizedTrack}.mp3\n\n`;
}
});
// Add summary
m3uContent += `#SUMMARY\n`;
m3uContent += `#TOTAL_TRACKS:${tracks.length}\n`;
m3uContent += `#FOUND_IN_LIBRARY:${foundCount}\n`;
m3uContent += `#DOWNLOADED:${downloadedCount}\n`;
m3uContent += `#MISSING:${missingCount}\n`;
return m3uContent;
}
async function exportPlaylistAsM3U(playlistId) {
/**
* Export the tracks from the download missing tracks modal as an M3U playlist file.
* Downloads via browser AND saves server-side to the relevant folder (force=true).
*/
console.log(`๐ Exporting playlist ${playlistId} as M3U`);
const process = activeDownloadProcesses[playlistId];
if (!process || !process.tracks || process.tracks.length === 0) {
showToast('No tracks available to export', 'warning');
return;
}
// Generate M3U content using shared function
const m3uContent = generateM3UContent(playlistId);
if (!m3uContent) {
showToast('Failed to generate M3U content', 'error');
return;
}
const playlistName = process.playlist?.name || process.playlistName || 'Playlist';
// Parse summary from content for toast message
const summaryMatch = m3uContent.match(/#FOUND_IN_LIBRARY:(\d+)\n#DOWNLOADED:(\d+)\n#MISSING:(\d+)/);
const foundCount = summaryMatch ? parseInt(summaryMatch[1]) : 0;
const downloadedCount = summaryMatch ? parseInt(summaryMatch[2]) : 0;
const missingCount = summaryMatch ? parseInt(summaryMatch[3]) : 0;
// Create a Blob and download it via browser
const blob = new Blob([m3uContent], { type: 'audio/x-mpegurl;charset=utf-8' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = `${playlistName.replace(/[/\\?%*:|"<>]/g, '-')}.m3u`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
// Also save server-side to the relevant folder (force=true bypasses setting check)
const modal = document.getElementById(`download-missing-modal-${playlistId}`);
const dataContext = modal?.querySelector('.download-missing-modal-content')?.getAttribute('data-context');
const isAlbum = dataContext === 'artist_album';
try {
const releaseDate = process.album?.release_date || '';
const year = releaseDate ? releaseDate.substring(0, 4) : '';
await fetch('/api/save-playlist-m3u', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
playlist_name: playlistName,
m3u_content: m3uContent,
context_type: isAlbum ? 'album' : 'playlist',
artist_name: process.artist?.name || '',
album_name: process.album?.name || '',
year: year,
force: true
})
});
} catch (error) {
console.debug('Server-side M3U save error (non-critical):', error);
}
const availableCount = foundCount + downloadedCount;
showToast(`Exported M3U: ${availableCount} available, ${missingCount} missing`, 'success');
console.log(`โ
Exported M3U - Total: ${process.tracks.length}, Available: ${availableCount}, Missing: ${missingCount}`);
}
async function openDownloadMissingModalForYouTube(virtualPlaylistId, playlistName, spotifyTracks, artist = null, album = null) {
showLoadingOverlay('Loading YouTube playlist...');
// Check if a process is already active for this virtual playlist
if (activeDownloadProcesses[virtualPlaylistId]) {
console.log(`Modal for ${virtualPlaylistId} already exists. Showing it.`);
const process = activeDownloadProcesses[virtualPlaylistId];
if (process.modalElement) {
if (process.status === 'complete') {
showToast('Showing previous results. Close this modal to start a new analysis.', 'info');
}
process.modalElement.style.display = 'flex';
}
hideLoadingOverlay(); // Hide overlay when reopening existing modal
return;
}
console.log(`๐ฅ Opening Download Missing Tracks modal for YouTube playlist: ${virtualPlaylistId}`);
// Create virtual playlist object for compatibility with existing modal logic
const virtualPlaylist = {
id: virtualPlaylistId,
name: playlistName,
track_count: spotifyTracks.length
};
// Store the tracks in the cache for the modal to use
playlistTrackCache[virtualPlaylistId] = spotifyTracks;
currentPlaylistTracks = spotifyTracks;
currentModalPlaylistId = virtualPlaylistId;
let modal = document.createElement('div');
modal.id = `download-missing-modal-${virtualPlaylistId}`;
modal.className = 'download-missing-modal';
modal.style.display = 'none';
document.body.appendChild(modal);
// Register the new process in our global state tracker using the same structure as Spotify
activeDownloadProcesses[virtualPlaylistId] = {
status: 'idle',
modalElement: modal,
poller: null,
batchId: null,
playlist: virtualPlaylist,
tracks: spotifyTracks,
artist: artist, // โ
Store artist context
album: album // โ
Store album context
};
// Generate hero section with dynamic source detection
const source = virtualPlaylistId.startsWith('beatport_') ? 'Beatport' :
virtualPlaylistId.startsWith('tidal_') ? 'Tidal' :
virtualPlaylistId.startsWith('listenbrainz_') ? 'ListenBrainz' :
virtualPlaylistId.startsWith('discover_') ? 'SoulSync' :
virtualPlaylistId.startsWith('seasonal_') ? 'SoulSync' :
virtualPlaylistId.startsWith('spotify_library_') ? 'SoulSync' :
virtualPlaylistId.startsWith('build_playlist_') ? 'SoulSync' :
virtualPlaylistId.startsWith('decade_') ? 'SoulSync' :
virtualPlaylistId === 'build_playlist_custom' ? 'SoulSync' :
'YouTube';
// Store metadata for discover download sidebar (will be added when Begin Analysis is clicked)
if (source === 'SoulSync' || virtualPlaylistId.startsWith('discover_lb_') || virtualPlaylistId.startsWith('listenbrainz_')) {
// Extract image URL from album context or first track's album cover
let imageUrl = null;
if (album && album.images && album.images.length > 0) {
imageUrl = album.images[0].url;
} else if (spotifyTracks && spotifyTracks.length > 0) {
const firstTrack = spotifyTracks[0];
if (firstTrack.album && firstTrack.album.images && firstTrack.album.images.length > 0) {
imageUrl = firstTrack.album.images[0].url;
}
}
// Store in process for later use when Begin Analysis is clicked
activeDownloadProcesses[virtualPlaylistId].discoverMetadata = {
imageUrl: imageUrl,
type: album ? 'album' : 'playlist' // โ
Use 'album' if album context provided
};
}
// CRITICAL FIX: Use album context for discover_album playlists
const isDiscoverAlbum = virtualPlaylistId.startsWith('discover_album_') || virtualPlaylistId.startsWith('seasonal_album_') || virtualPlaylistId.startsWith('spotify_library_');
const heroContext = isDiscoverAlbum && album && artist ? {
type: 'album',
artist: {
name: artist.name,
image_url: artist.image_url || null
},
album: {
name: album.name,
album_type: album.album_type || 'album',
images: album.images || []
},
trackCount: spotifyTracks.length,
playlistId: virtualPlaylistId
} : {
type: 'playlist',
playlist: { name: playlistName, owner: source },
trackCount: spotifyTracks.length,
playlistId: virtualPlaylistId
};
// Use the exact same modal HTML structure as the existing Spotify modal
modal.innerHTML = `
๐ Library Analysis
Ready to start
โฌ Downloads
Waiting for analysis
`;
applyProgressiveTrackRendering(virtualPlaylistId, spotifyTracks.length);
modal.style.display = 'flex';
hideLoadingOverlay();
}
async function closeDownloadMissingModal(playlistId) {
const process = activeDownloadProcesses[playlistId];
if (!process) {
// If somehow called without a process, try to find and remove the element
const modal = document.getElementById(`download-missing-modal-${playlistId}`);
if (modal && modal.parentElement) {
modal.parentElement.removeChild(modal);
}
return;
}
// If the process is running, just hide the modal.
// If it's idle, complete, or cancelled, perform a full cleanup.
if (process.status === 'running') {
console.log(`Hiding active download modal for playlist ${playlistId}.`);
process.modalElement.style.display = 'none';
// Track wishlist modal state changes
if (playlistId === 'wishlist') {
WishlistModalState.setUserClosed(); // User manually closed during processing
console.log('๐ฑ [Modal State] User manually closed wishlist modal during processing');
}
} else {
console.log(`Closing and cleaning up download modal for playlist ${playlistId}.`);
// Reset YouTube playlist phase to 'discovered' when modal is closed after completion
if (playlistId.startsWith('youtube_')) {
const urlHash = playlistId.replace('youtube_', '');
updateYouTubeCardPhase(urlHash, 'discovered');
// Also update mirrored playlist card if applicable
if (urlHash.startsWith('mirrored_')) {
updateMirroredCardPhase(urlHash, 'discovered');
}
// Update backend state to prevent rehydration issues on page refresh (similar to Tidal fix)
try {
const response = await fetch(`/api/youtube/update_phase/${urlHash}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
phase: 'discovered'
})
});
if (response.ok) {
console.log(`โ
[Modal Close] Updated backend phase for YouTube playlist ${urlHash} to 'discovered'`);
} else {
console.warn(`โ ๏ธ [Modal Close] Failed to update backend phase for YouTube playlist ${urlHash}`);
}
} catch (error) {
console.error(`โ [Modal Close] Error updating backend phase for YouTube playlist ${urlHash}:`, error);
}
}
// Reset Beatport chart phase to 'discovered' when modal is closed
if (playlistId.startsWith('beatport_')) {
const urlHash = playlistId.replace('beatport_', '');
const state = youtubePlaylistStates[urlHash];
if (state && state.is_beatport_playlist) {
console.log(`๐งน [Modal Close] Processing Beatport chart close: playlistId="${playlistId}", urlHash="${urlHash}"`);
const chartHash = state.beatport_chart_hash || urlHash;
// Reset to discovered phase (unless download actually started and completed)
if (state.phase !== 'download_complete') {
updateBeatportCardPhase(chartHash, 'discovered');
state.phase = 'discovered';
// Update Beatport chart state
if (beatportChartStates[chartHash]) {
beatportChartStates[chartHash].phase = 'discovered';
}
// Update backend state
try {
await fetch(`/api/beatport/charts/update-phase/${chartHash}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ phase: 'discovered' })
});
console.log(`โ
[Modal Close] Updated backend phase for Beatport chart ${chartHash} to 'discovered'`);
} catch (error) {
console.error(`โ [Modal Close] Error updating backend phase for Beatport chart ${chartHash}:`, error);
}
}
}
}
// Enhanced Tidal playlist state management (based on GUI sync.py patterns)
if (playlistId.startsWith('tidal_')) {
const tidalPlaylistId = playlistId.replace('tidal_', '');
console.log(`๐งน [Modal Close] Processing Tidal playlist close: playlistId="${playlistId}", tidalPlaylistId="${tidalPlaylistId}"`);
console.log(`๐งน [Modal Close] Current Tidal state:`, tidalPlaylistStates[tidalPlaylistId]);
// Clear download-specific state but preserve discovery results (like GUI closeEvent)
if (tidalPlaylistStates[tidalPlaylistId]) {
const currentPhase = tidalPlaylistStates[tidalPlaylistId].phase;
console.log(`๐งน [Modal Close] Current phase before reset: ${currentPhase}`);
// Preserve discovery data for future use (like GUI modal behavior)
const preservedData = {
playlist: tidalPlaylistStates[tidalPlaylistId].playlist,
discovery_results: tidalPlaylistStates[tidalPlaylistId].discovery_results,
spotify_matches: tidalPlaylistStates[tidalPlaylistId].spotify_matches,
discovery_progress: tidalPlaylistStates[tidalPlaylistId].discovery_progress,
convertedSpotifyPlaylistId: tidalPlaylistStates[tidalPlaylistId].convertedSpotifyPlaylistId
};
// Clear download-specific state
delete tidalPlaylistStates[tidalPlaylistId].download_process_id;
delete tidalPlaylistStates[tidalPlaylistId].phase;
// Restore preserved data and set to discovered phase
Object.assign(tidalPlaylistStates[tidalPlaylistId], preservedData);
tidalPlaylistStates[tidalPlaylistId].phase = 'discovered';
console.log(`๐งน [Modal Close] Reset Tidal playlist ${tidalPlaylistId} - cleared download state, preserved discovery data`);
console.log(`๐งน [Modal Close] New phase after reset: ${tidalPlaylistStates[tidalPlaylistId].phase}`);
} else {
console.error(`โ [Modal Close] No Tidal state found for playlistId: ${tidalPlaylistId}`);
}
updateTidalCardPhase(tidalPlaylistId, 'discovered');
console.log(`๐ [Modal Close] Reset Tidal playlist ${tidalPlaylistId} to discovered phase`);
console.log(`๐ [Modal Close] Expected button text for discovered phase: "${getActionButtonText('discovered')}"`);
// Update backend state to prevent rehydration issues on page refresh
try {
const response = await fetch(`/api/tidal/update_phase/${tidalPlaylistId}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
phase: 'discovered'
})
});
if (response.ok) {
console.log(`โ
[Modal Close] Updated backend phase for Tidal playlist ${tidalPlaylistId} to 'discovered'`);
} else {
console.warn(`โ ๏ธ [Modal Close] Failed to update backend phase for Tidal playlist ${tidalPlaylistId}`);
}
} catch (error) {
console.error(`โ [Modal Close] Error updating backend phase for Tidal playlist ${tidalPlaylistId}:`, error);
}
}
// Reset ListenBrainz playlist phase to 'discovered' when modal is closed
if (playlistId.startsWith('listenbrainz_')) {
const playlistMbid = playlistId.replace('listenbrainz_', '');
console.log(`๐งน [Modal Close] Processing ListenBrainz playlist close: playlistId="${playlistId}", mbid="${playlistMbid}"`);
// Clear download-specific state but preserve discovery results
if (listenbrainzPlaylistStates[playlistMbid]) {
const currentPhase = listenbrainzPlaylistStates[playlistMbid].phase;
console.log(`๐งน [Modal Close] Current phase before reset: ${currentPhase}`);
// Reset to discovered phase (unless download actually completed successfully)
if (currentPhase !== 'download_complete') {
// Clear download-specific fields
delete listenbrainzPlaylistStates[playlistMbid].download_process_id;
delete listenbrainzPlaylistStates[playlistMbid].convertedSpotifyPlaylistId;
// Set back to discovered
listenbrainzPlaylistStates[playlistMbid].phase = 'discovered';
// Update backend state
try {
await fetch(`/api/listenbrainz/update-phase/${playlistMbid}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ phase: 'discovered' })
});
console.log(`โ
[Modal Close] Updated backend phase for ListenBrainz playlist ${playlistMbid} to 'discovered'`);
} catch (error) {
console.error(`โ [Modal Close] Error updating backend phase for ListenBrainz playlist ${playlistMbid}:`, error);
}
console.log(`๐ [Modal Close] Reset ListenBrainz playlist ${playlistMbid} to discovered phase`);
}
} else {
console.error(`โ [Modal Close] No ListenBrainz state found for mbid: ${playlistMbid}`);
}
}
// Clear wishlist modal state when modal is fully closed
if (playlistId === 'wishlist') {
WishlistModalState.clear(); // Clear all tracking since modal is fully closed
console.log('๐ฑ [Modal State] Cleared wishlist modal state on full close');
}
// Clean up artist download if this is an artist album playlist
if (playlistId.startsWith('artist_album_')) {
console.log(`๐งน [MODAL CLOSE] Cleaning up artist download for completed modal: ${playlistId}`);
cleanupArtistDownload(playlistId);
console.log(`โ
[MODAL CLOSE] Artist download cleanup completed for: ${playlistId}`);
}
// Clean up search download if this is an enhanced search playlist
if (playlistId.startsWith('enhanced_search_')) {
console.log(`๐งน [MODAL CLOSE] Cleaning up search download for completed modal: ${playlistId}`);
cleanupSearchDownload(playlistId);
console.log(`โ
[MODAL CLOSE] Search download cleanup completed for: ${playlistId}`);
}
// Remove from discover download sidebar if this is a discover page download
if (discoverDownloads && discoverDownloads[playlistId]) {
console.log(`๐งน [MODAL CLOSE] Removing discover download bubble: ${playlistId}`);
removeDiscoverDownload(playlistId);
console.log(`โ
[MODAL CLOSE] Discover download bubble removed for: ${playlistId}`);
}
// Automatic cleanup and server operations after successful downloads
await handlePostDownloadAutomation(playlistId, process);
cleanupDownloadProcess(playlistId);
}
}
/**
* Extract unique album cover images from tracks
*/
function extractUniqueCoverImages(tracks, maxCovers = 20) {
const uniqueCovers = new Set();
const covers = [];
for (const track of tracks) {
if (covers.length >= maxCovers) break;
let coverUrl = null;
let spotifyData = track.spotify_data;
// Parse spotify_data if it's a string
if (typeof spotifyData === 'string') {
try {
spotifyData = JSON.parse(spotifyData);
} catch (e) {
continue;
}
}
// Extract cover URL
coverUrl = spotifyData?.album?.images?.[0]?.url;
// Add to list if unique and valid
if (coverUrl && !uniqueCovers.has(coverUrl)) {
uniqueCovers.add(coverUrl);
covers.push(coverUrl);
}
}
return covers;
}
/**
* Shuffle array using Fisher-Yates algorithm
*/
function shuffleArray(array) {
const shuffled = [...array];
for (let i = shuffled.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
}
return shuffled;
}
/**
* Generate mosaic grid background HTML with continuous scrolling rows
*/
function generateMosaicBackground(coverUrls) {
// If less than 3 covers, use gradient fallback
if (!coverUrls || coverUrls.length < 3) {
return `
`;
}
// Cap covers per row to 15 for GPU performance (avoids hundreds of tiles)
if (coverUrls.length > 15) {
coverUrls = coverUrls.slice(0, 15);
}
const rows = 4;
let mosaicHTML = '';
// Calculate scroll speed based on number of images
// More images = longer duration to maintain consistent visual speed
// Minimum 40s to prevent scrolling too fast
const scrollSpeed = Math.max(40, coverUrls.length * 2);
for (let row = 0; row < rows; row++) {
const isEvenRow = row % 2 === 0;
const direction = isEvenRow ? 'left' : 'right';
// Randomize order for each row
const shuffledCovers = shuffleArray(coverUrls);
// Create row wrapper
mosaicHTML += `
`;
mosaicHTML += `
'; // Close row
mosaicHTML += '
'; // Close wrapper
}
mosaicHTML += '
';
mosaicHTML += '
'; // Dark overlay for readability
return mosaicHTML;
}
/**
* Open wishlist overview modal showing category breakdown
* This is the NEW entry point for wishlist from dashboard
*/
async function openWishlistOverviewModal() {
try {
showLoadingOverlay('Loading wishlist...');
// Fetch wishlist stats
const statsResponse = await fetch('/api/wishlist/stats');
const statsData = await statsResponse.json();
if (!statsResponse.ok) {
throw new Error(statsData.error || 'Failed to fetch wishlist stats');
}
const { singles, albums, total } = statsData;
if (total === 0) {
hideLoadingOverlay();
showToast('Wishlist is empty. No tracks to process.', 'info');
return;
}
// Fetch album covers for mosaic backgrounds
// Limit to 50 tracks per category (enough to get 20 unique covers while being efficient)
const albumCoversPromise = fetch('/api/wishlist/tracks?category=albums&limit=50').then(r => r.json());
const singleCoversPromise = fetch('/api/wishlist/tracks?category=singles&limit=50').then(r => r.json());
const [albumTracksData, singleTracksData] = await Promise.all([albumCoversPromise, singleCoversPromise]);
// Extract unique album covers (max 20 per category)
const albumCovers = extractUniqueCoverImages(albumTracksData.tracks || [], 20);
const singleCovers = extractUniqueCoverImages(singleTracksData.tracks || [], 20);
// Create modal if it doesn't exist
let modal = document.getElementById('wishlist-overview-modal');
if (!modal) {
modal = document.createElement('div');
modal.id = 'wishlist-overview-modal';
modal.className = 'modal-overlay';
document.body.appendChild(modal);
}
// Fetch current cycle
const cycleResponse = await fetch('/api/wishlist/cycle');
const cycleData = await cycleResponse.json();
const currentCycle = cycleData.cycle || 'albums';
// Format countdown timer
const nextRunSeconds = statsData.next_run_in_seconds || 0;
const countdownText = formatCountdownTime(nextRunSeconds);
const nextCycleText = currentCycle === 'albums' ? 'Albums/EPs' : 'Singles';
modal.innerHTML = `
${generateMosaicBackground(albumCovers)}
๐ฟ
Albums / EPs
${albums} tracks
${currentCycle === 'albums' ? '
Next in Queue
' : ''}
${generateMosaicBackground(singleCovers)}
๐ต
Singles
${singles} tracks
${currentCycle === 'singles' ? '
Next in Queue
' : ''}
0 selected
Remove Selected
`;
modal.style.display = 'flex';
hideLoadingOverlay();
// Start countdown timer update interval
startWishlistCountdownTimer(currentCycle, nextRunSeconds);
} catch (error) {
console.error('Error opening wishlist overview:', error);
showToast(`Failed to load wishlist: ${error.message}`, 'error');
hideLoadingOverlay();
}
}
function startWishlistCountdownTimer(currentCycle, initialSeconds) {
// Clear any existing interval
if (wishlistCountdownInterval) {
clearInterval(wishlistCountdownInterval);
}
let remainingSeconds = initialSeconds;
const nextCycleText = currentCycle === 'albums' ? 'Albums/EPs' : 'Singles';
wishlistCountdownInterval = setInterval(async () => {
remainingSeconds--;
// Check if auto-processing has started (every 2 seconds to avoid overwhelming backend)
if (remainingSeconds % 2 === 0 || remainingSeconds <= 0) {
// Use WebSocket data if available, otherwise fall back to HTTP
if (socketConnected && _lastWishlistStats) {
const data = _lastWishlistStats;
if (data.is_auto_processing) {
closeWishlistOverviewModal();
showToast('Wishlist auto-processing started. View progress in Download Manager.', 'info');
return;
}
if (remainingSeconds <= 0) {
remainingSeconds = data.next_run_in_seconds || 0;
const timerElement = document.getElementById('wishlist-next-auto-timer');
if (timerElement) {
const countdownText = formatCountdownTime(remainingSeconds);
timerElement.textContent = `Next Auto: ${nextCycleText}${countdownText ? ' in ' + countdownText : ''}`;
}
}
} else {
try {
const response = await fetch('/api/wishlist/stats');
const data = await response.json();
// AUTO-CLOSE DETECTION: If auto-processing started, close modal and notify user
if (data.is_auto_processing) {
console.log('๐ค [Wishlist] Auto-processing detected, closing overview modal');
closeWishlistOverviewModal();
showToast('Wishlist auto-processing started. View progress in Download Manager.', 'info');
return; // Exit interval
}
// Update remaining seconds if timer expired
if (remainingSeconds <= 0) {
remainingSeconds = data.next_run_in_seconds || 0;
// Also update cycle in case it changed
const newCycle = data.current_cycle || 'albums';
const newCycleText = newCycle === 'albums' ? 'Albums/EPs' : 'Singles';
const timerElement = document.getElementById('wishlist-next-auto-timer');
if (timerElement) {
const countdownText = formatCountdownTime(remainingSeconds);
timerElement.textContent = `Next Auto: ${newCycleText}${countdownText ? ' in ' + countdownText : ''}`;
}
}
} catch (error) {
console.debug('Error updating wishlist countdown:', error);
}
} // end else (HTTP fallback)
}
// Always update the display countdown
const timerElement = document.getElementById('wishlist-next-auto-timer');
if (timerElement) {
const countdownText = formatCountdownTime(remainingSeconds);
timerElement.textContent = `Next Auto: ${nextCycleText}${countdownText ? ' in ' + countdownText : ''}`;
}
}, 1000); // Update every second
}
function closeWishlistOverviewModal() {
console.log('๐ช closeWishlistOverviewModal() called');
// Stop countdown timer
if (wishlistCountdownInterval) {
clearInterval(wishlistCountdownInterval);
wishlistCountdownInterval = null;
}
const modal = document.getElementById('wishlist-overview-modal');
console.log('Modal element:', modal);
if (modal) {
modal.style.display = 'none';
console.log('Modal display set to none');
// Also remove from DOM to ensure clean state
modal.remove();
console.log('Modal removed from DOM');
} else {
console.warn('Modal element not found');
}
window.selectedWishlistCategory = null;
console.log('โ
Modal closed');
}
async function cleanupWishlistOverview() {
console.log('๐งน cleanupWishlistOverview() called');
if (!await showConfirmDialog({ title: 'Cleanup Wishlist', message: 'This will remove all tracks from the wishlist that already exist in your library. Continue?' })) {
return;
}
try {
showLoadingOverlay('Cleaning up wishlist...');
const response = await fetch('/api/wishlist/cleanup', {
method: 'POST'
});
const result = await response.json();
if (result.success) {
const removedCount = result.removed_count || 0;
if (removedCount > 0) {
showToast(`Cleanup complete! Removed ${removedCount} tracks that already exist in your library`, 'success');
} else {
showToast('No tracks needed to be removed', 'info');
}
// Check if wishlist is now empty
const statsResponse = await fetch('/api/wishlist/stats');
const statsData = await statsResponse.json();
if (statsData.total === 0) {
// Wishlist is empty, just close the modal
closeWishlistOverviewModal();
await updateWishlistCount();
} else {
// Wishlist still has items, refresh the modal to show updated counts
closeWishlistOverviewModal();
await openWishlistOverviewModal();
}
} else {
showToast(`Failed to cleanup wishlist: ${result.error || 'Unknown error'}`, 'error');
}
hideLoadingOverlay();
} catch (error) {
console.error('Error cleaning up wishlist:', error);
showToast(`Failed to cleanup wishlist: ${error.message}`, 'error');
hideLoadingOverlay();
}
}
async function clearEntireWishlist() {
console.log('๐๏ธ clearEntireWishlist() called');
if (!await showConfirmDialog({ title: 'Clear Wishlist', message: 'WARNING: This will permanently delete ALL tracks from your wishlist.\n\nThis action cannot be undone.\n\nAre you sure you want to continue?', confirmText: 'Clear All', destructive: true })) {
console.log('User cancelled confirmation');
return;
}
console.log('User confirmed, proceeding with clear...');
try {
showLoadingOverlay('Clearing wishlist...');
console.log('Loading overlay shown');
const response = await fetch('/api/wishlist/clear', {
method: 'POST'
});
console.log('API response received:', response.status);
const result = await response.json();
console.log('Clear wishlist response:', result);
hideLoadingOverlay();
console.log('Loading overlay hidden');
if (result.success) {
console.log('Clear was successful, showing toast...');
showToast('Wishlist cleared successfully', 'success');
console.log('Updating wishlist button count...');
await updateWishlistCount();
console.log('Closing modal...');
closeWishlistOverviewModal();
console.log('Modal should be closed now');
} else {
console.error('Clear failed:', result.error);
showToast(`Failed to clear wishlist: ${result.error || 'Unknown error'}`, 'error');
}
} catch (error) {
console.error('Error clearing wishlist:', error);
hideLoadingOverlay();
showToast(`Failed to clear wishlist: ${error.message}`, 'error');
}
}
async function selectWishlistCategory(category) {
try {
window.selectedWishlistCategory = category;
const tracksList = document.getElementById('wishlist-tracks-list');
const categoryTracksSection = document.getElementById('wishlist-category-tracks');
const categoryGrid = document.querySelector('.wishlist-category-grid');
const downloadBtn = document.getElementById('wishlist-download-btn');
const categoryName = document.getElementById('wishlist-category-name');
categoryGrid.style.display = 'none';
categoryTracksSection.style.display = 'block';
downloadBtn.style.display = 'inline-block';
categoryName.textContent = category === 'albums' ? 'Albums / EPs' : 'Singles';
tracksList.innerHTML = 'Loading tracks...
';
const response = await fetch(`/api/wishlist/tracks?category=${category}`);
const data = await response.json();
if (!response.ok) throw new Error(data.error || 'Failed to fetch tracks');
const tracks = data.tracks || [];
if (tracks.length === 0) {
tracksList.innerHTML = 'No tracks in this category
';
return;
}
// For Albums/EPs, group by album
if (category === 'albums') {
const albumGroups = {};
tracks.forEach(track => {
let spotifyData = track.spotify_data;
if (typeof spotifyData === 'string') {
try {
spotifyData = JSON.parse(spotifyData);
} catch (e) {
spotifyData = null;
}
}
const albumName = spotifyData?.album?.name || 'Unknown Album';
// Handle both object format {name: '...'} and sanitized string format
let artistName = 'Unknown Artist';
let artistId = null;
if (spotifyData?.artists?.[0]?.name) {
// Object format from Spotify API
artistName = spotifyData.artists[0].name;
artistId = spotifyData.artists[0].id;
} else if (spotifyData?.artists?.[0] && typeof spotifyData.artists[0] === 'string') {
// Sanitized string format
artistName = spotifyData.artists[0];
} else if (Array.isArray(track.artists) && track.artists.length > 0) {
// Fallback to track.artists
if (typeof track.artists[0] === 'string') {
artistName = track.artists[0];
} else if (track.artists[0]?.name) {
artistName = track.artists[0].name;
artistId = track.artists[0].id;
}
}
const albumImage = spotifyData?.album?.images?.[0]?.url || '';
// Use album ID if available, otherwise create unique key from album + artist
// Sanitize the ID to remove all special characters that could break DOM IDs or CSS selectors
const albumId = spotifyData?.album?.id || `${albumName}_${artistName}`
.replace(/[^a-zA-Z0-9\s_-]/g, '') // Remove all special chars except spaces, underscores, hyphens
.replace(/\s+/g, '_') // Replace spaces with underscores
.toLowerCase();
if (!albumGroups[albumId]) {
albumGroups[albumId] = {
albumName,
artistName,
artistId,
albumImage,
tracks: []
};
}
const spotifyTrackId = track.spotify_track_id || track.id || '';
albumGroups[albumId].tracks.push({
name: track.name || 'Unknown Track',
artistName,
trackNumber: spotifyData?.track_number || 0,
spotifyTrackId
});
});
// Render album cards
let albumsHTML = '';
Object.entries(albumGroups).forEach(([albumId, albumData]) => {
// Sort tracks by track number
albumData.tracks.sort((a, b) => a.trackNumber - b.trackNumber);
const tracksListHTML = albumData.tracks.map(track => `
${track.name}
๐๏ธ
`).join('');
// Handle missing album images with a placeholder
const albumImageStyle = albumData.albumImage
? `background-image: url('${albumData.albumImage}')`
: `background: linear-gradient(135deg, rgba(30, 30, 30, 0.9) 0%, rgba(50, 50, 50, 0.9) 100%); display: flex; align-items: center; justify-content: center; font-size: 40px;`;
const albumImageContent = albumData.albumImage ? '' : '
๐ฟ ';
albumsHTML += `
`;
});
albumsHTML += '
';
tracksList.innerHTML = albumsHTML;
_attachWishlistDelegation(tracksList);
} else {
// For Singles, show list with album images
let tracksHTML = '';
tracks.forEach((track, index) => {
const trackName = track.name || 'Unknown Track';
let spotifyData = track.spotify_data;
if (typeof spotifyData === 'string') {
try {
spotifyData = JSON.parse(spotifyData);
} catch (e) {
spotifyData = null;
}
}
let artistName = 'Unknown Artist';
if (spotifyData?.artists?.[0]?.name) {
artistName = spotifyData.artists[0].name;
} else if (Array.isArray(track.artists) && track.artists.length > 0) {
if (typeof track.artists[0] === 'string') {
artistName = track.artists[0];
} else if (track.artists[0]?.name) {
artistName = track.artists[0].name;
}
}
let albumName = 'Unknown Album';
if (spotifyData?.album?.name) {
albumName = spotifyData.album.name;
} else if (typeof track.album === 'string') {
albumName = track.album;
} else if (track.album?.name) {
albumName = track.album.name;
}
const albumImage = spotifyData?.album?.images?.[0]?.url || '';
const spotifyTrackId = track.spotify_track_id || track.id || '';
tracksHTML += `
${trackName}
${artistName} โข ${albumName}
๐๏ธ
`;
});
tracksList.innerHTML = tracksHTML;
_attachWishlistDelegation(tracksList);
}
} catch (error) {
console.error('Error loading category tracks:', error);
showToast(`Failed to load tracks: ${error.message}`, 'error');
}
}
function _attachWishlistDelegation(container) {
// Single click handler for all wishlist album/track interactions
container.addEventListener('click', (e) => {
const target = e.target;
// Skip checkbox wrapper clicks โ handled by change listener
if (target.closest('.wishlist-checkbox-wrapper')) return;
// Album header click (expand/collapse)
const header = target.closest('.wishlist-album-header');
if (header && !target.closest('.wishlist-delete-album-btn')) {
toggleAlbumTracks(header.dataset.albumId);
return;
}
// Album delete button
const albumDelBtn = target.closest('.wishlist-delete-album-btn');
if (albumDelBtn) {
e.stopPropagation();
removeAlbumFromWishlist(albumDelBtn.dataset.albumId, e);
return;
}
// Track delete button
const trackDelBtn = target.closest('.wishlist-delete-btn');
if (trackDelBtn && trackDelBtn.dataset.trackId) {
e.stopPropagation();
removeTrackFromWishlist(trackDelBtn.dataset.trackId, e);
return;
}
});
// Separate change handler for checkboxes (more reliable than click for inputs)
container.addEventListener('change', (e) => {
const target = e.target;
if (target.classList.contains('wishlist-album-select-all-cb')) {
toggleWishlistAlbumSelection(target.dataset.albumId, target.checked);
} else if (target.classList.contains('wishlist-select-cb')) {
updateWishlistBatchBar();
}
});
}
function backToCategories() {
const categoryTracksSection = document.getElementById('wishlist-category-tracks');
const categoryGrid = document.querySelector('.wishlist-category-grid');
const downloadBtn = document.getElementById('wishlist-download-btn');
const batchBar = document.getElementById('wishlist-batch-bar');
categoryTracksSection.style.display = 'none';
categoryGrid.style.display = 'grid';
downloadBtn.style.display = 'none';
if (batchBar) batchBar.style.display = 'none';
window.selectedWishlistCategory = null;
}
function toggleAlbumTracks(albumId) {
const tracksElement = document.getElementById(`tracks-${albumId}`);
const expandIcon = document.getElementById(`expand-icon-${albumId}`);
if (tracksElement.style.display === 'none') {
tracksElement.style.display = 'block';
expandIcon.textContent = 'โฒ';
} else {
tracksElement.style.display = 'none';
expandIcon.textContent = 'โผ';
}
}
/**
* Get all checked wishlist track checkboxes
*/
function getCheckedWishlistTracks() {
return Array.from(document.querySelectorAll('.wishlist-select-cb:checked'));
}
/**
* Toggle select all / deselect all tracks in the current wishlist category
*/
function toggleWishlistSelectAll() {
const allCheckboxes = document.querySelectorAll('.wishlist-select-cb');
const albumCheckboxes = document.querySelectorAll('.wishlist-album-select-all-cb');
const btn = document.getElementById('wishlist-select-all-btn');
const allChecked = allCheckboxes.length > 0 && Array.from(allCheckboxes).every(cb => cb.checked);
const newState = !allChecked;
allCheckboxes.forEach(cb => { cb.checked = newState; });
albumCheckboxes.forEach(cb => { cb.checked = newState; });
// Expand all albums when selecting all
if (newState) {
document.querySelectorAll('.wishlist-album-tracks').forEach(el => {
el.style.display = 'block';
});
document.querySelectorAll('[id^="expand-icon-"]').forEach(icon => {
icon.textContent = 'โฒ';
});
}
if (btn) btn.textContent = newState ? 'Deselect All' : 'Select All';
updateWishlistBatchBar();
}
/**
* Update the wishlist batch action bar based on checkbox selection
*/
function updateWishlistBatchBar() {
const checked = getCheckedWishlistTracks();
const bar = document.getElementById('wishlist-batch-bar');
const countEl = document.getElementById('wishlist-batch-count');
if (!bar || !countEl) return;
if (checked.length > 0) {
bar.style.display = 'flex';
countEl.textContent = `${checked.length} selected`;
} else {
bar.style.display = 'none';
}
// Sync the Select All button text
const btn = document.getElementById('wishlist-select-all-btn');
if (btn) {
const allCheckboxes = document.querySelectorAll('.wishlist-select-cb');
const allChecked = allCheckboxes.length > 0 && Array.from(allCheckboxes).every(cb => cb.checked);
btn.textContent = allChecked ? 'Deselect All' : 'Select All';
}
}
/**
* Toggle all track checkboxes within an album when album header checkbox is clicked
*/
function toggleWishlistAlbumSelection(albumId, checked) {
const tracksContainer = document.getElementById(`tracks-${albumId}`);
if (tracksContainer) {
// Expand the album tracks if selecting
if (checked) {
tracksContainer.style.display = 'block';
const expandIcon = document.getElementById(`expand-icon-${albumId}`);
if (expandIcon) expandIcon.textContent = 'โฒ';
}
tracksContainer.querySelectorAll('.wishlist-select-cb').forEach(cb => {
cb.checked = checked;
});
}
updateWishlistBatchBar();
}
/**
* Batch remove selected tracks from wishlist
*/
async function batchRemoveFromWishlist() {
const checked = getCheckedWishlistTracks();
if (checked.length === 0) return;
const count = checked.length;
const confirmed = await showConfirmationModal(
'Remove Tracks',
`Remove ${count} track${count !== 1 ? 's' : ''} from your wishlist?`,
'๐๏ธ'
);
if (!confirmed) return;
const trackIds = checked.map(cb => cb.getAttribute('data-track-id'));
try {
const response = await fetch('/api/wishlist/remove-batch', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ spotify_track_ids: trackIds })
});
const data = await response.json();
if (data.success) {
showToast(`Removed ${data.removed} track(s) from wishlist`, 'success');
// Reload the current category to refresh the list
if (window.selectedWishlistCategory) {
await selectWishlistCategory(window.selectedWishlistCategory);
}
// Update wishlist count in sidebar
await updateWishlistCount();
} else {
showToast(`Failed to remove tracks: ${data.error}`, 'error');
}
} catch (error) {
console.error('Error batch removing from wishlist:', error);
showToast('Failed to remove tracks from wishlist', 'error');
}
}
function showConfirmationModal(title, message, icon = 'โ ๏ธ') {
return new Promise((resolve) => {
// Create modal if it doesn't exist
let modal = document.getElementById('confirmation-modal-overlay');
if (!modal) {
modal = document.createElement('div');
modal.id = 'confirmation-modal-overlay';
modal.className = 'confirmation-modal-overlay';
document.body.appendChild(modal);
}
// Set modal content
modal.innerHTML = `
${icon}
${title}
${message}
Cancel
Yes, Remove
`;
// Show modal with animation
setTimeout(() => {
modal.classList.add('show');
}, 10);
// Escape key handler - defined outside so we can remove it
const handleEscape = (e) => {
if (e.key === 'Escape') {
handleCancel();
}
};
// Handle button clicks
const handleCancel = () => {
document.removeEventListener('keydown', handleEscape);
modal.classList.remove('show');
setTimeout(() => {
modal.remove();
}, 200);
resolve(false);
};
const handleConfirm = () => {
document.removeEventListener('keydown', handleEscape);
modal.classList.remove('show');
setTimeout(() => {
modal.remove();
}, 200);
resolve(true);
};
document.getElementById('confirm-cancel').addEventListener('click', handleCancel);
document.getElementById('confirm-yes').addEventListener('click', handleConfirm);
// Close on overlay click
modal.addEventListener('click', (e) => {
if (e.target === modal) {
handleCancel();
}
});
// Add Escape key listener
document.addEventListener('keydown', handleEscape);
});
}
async function removeTrackFromWishlist(spotifyTrackId, event) {
// Stop event propagation to prevent triggering parent click handlers
if (event) {
event.stopPropagation();
}
const confirmed = await showConfirmationModal(
'Remove Track',
'Are you sure you want to remove this track from your wishlist?',
'๐๏ธ'
);
if (!confirmed) {
return;
}
try {
const response = await fetch('/api/wishlist/remove-track', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ spotify_track_id: spotifyTrackId })
});
const data = await response.json();
if (data.success) {
showToast('Track removed from wishlist', 'success');
// Reload the current category to refresh the list
if (window.selectedWishlistCategory) {
await selectWishlistCategory(window.selectedWishlistCategory);
}
// Update wishlist count in sidebar
await updateWishlistCount();
} else {
showToast(`Failed to remove track: ${data.error}`, 'error');
}
} catch (error) {
console.error('Error removing track from wishlist:', error);
showToast('Failed to remove track from wishlist', 'error');
}
}
async function removeAlbumFromWishlist(albumId, event) {
// Stop event propagation to prevent triggering parent click handlers
if (event) {
event.stopPropagation();
}
const confirmed = await showConfirmationModal(
'Remove Album',
'Are you sure you want to remove all tracks from this album from your wishlist?',
'๐ฟ'
);
if (!confirmed) {
return;
}
try {
const response = await fetch('/api/wishlist/remove-album', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ album_id: albumId })
});
const data = await response.json();
if (data.success) {
showToast(`Removed ${data.removed_count} track(s) from wishlist`, 'success');
// Reload the current category to refresh the list
if (window.selectedWishlistCategory) {
await selectWishlistCategory(window.selectedWishlistCategory);
}
// Update wishlist count in sidebar
await updateWishlistCount();
} else {
showToast(`Failed to remove album: ${data.error}`, 'error');
}
} catch (error) {
console.error('Error removing album from wishlist:', error);
showToast('Failed to remove album from wishlist', 'error');
}
}
async function downloadSelectedCategory() {
const category = window.selectedWishlistCategory;
if (!category) {
showToast('No category selected', 'error');
return;
}
closeWishlistOverviewModal();
await openDownloadMissingWishlistModal(category);
}
async function openDownloadMissingWishlistModal(category = null) {
showLoadingOverlay('Loading wishlist...');
const playlistId = "wishlist"; // Use a consistent ID for wishlist
// Check if a process is already active for the wishlist
if (activeDownloadProcesses[playlistId]) {
console.log(`Modal for wishlist already exists. Showing it.`);
const process = activeDownloadProcesses[playlistId];
if (process.modalElement) {
// Show helpful message if it's a completed process
if (process.status === 'complete') {
showToast('Showing previous results. Close this modal to start a new analysis.', 'info');
}
process.modalElement.style.display = 'flex';
WishlistModalState.setVisible(); // Track that modal is now visible
}
hideLoadingOverlay(); // Always hide overlay before returning
return; // Don't create a new one
}
console.log(`๐ฅ Opening Download Missing Tracks modal for wishlist${category ? ' (' + category + ')' : ''}`);
// Store category in global state for when process starts
window.currentWishlistCategory = category;
// Fetch actual wishlist tracks from the server
let tracks;
try {
// Build API URL with optional category filter
const apiUrl = category ? `/api/wishlist/tracks?category=${category}` : '/api/wishlist/tracks';
const response = await fetch('/api/wishlist/count');
const countData = await response.json();
if (countData.count === 0) {
showToast('Wishlist is empty. No tracks to download.', 'info');
hideLoadingOverlay();
return;
}
// Fetch the actual wishlist tracks for display (filtered by category if specified)
const tracksResponse = await fetch(apiUrl);
if (!tracksResponse.ok) {
throw new Error('Failed to fetch wishlist tracks');
}
const tracksData = await tracksResponse.json();
tracks = tracksData.tracks || [];
} catch (error) {
showToast(`Failed to fetch wishlist data: ${error.message}`, 'error');
hideLoadingOverlay();
return;
}
currentPlaylistTracks = tracks;
currentModalPlaylistId = playlistId;
let modal = document.createElement('div');
modal.id = `download-missing-modal-${playlistId}`; // Unique ID
modal.className = 'download-missing-modal'; // Use class for styling
modal.style.display = 'none'; // Start hidden
document.body.appendChild(modal);
// Register the new process in our global state tracker
activeDownloadProcesses[playlistId] = {
status: 'idle', // idle, running, complete, cancelled
modalElement: modal,
poller: null,
batchId: null,
playlist: { id: playlistId, name: "Wishlist" }, // Create a pseudo-playlist object
tracks: tracks
};
// Generate hero section for wishlist context
const heroContext = {
type: 'wishlist',
trackCount: tracks.length,
playlistId: playlistId
};
modal.innerHTML = `
๐ Library Analysis
Ready to start
โฌ Downloads
Waiting for analysis
#
Track
Artist
Library Match
Download Status
Actions
${tracks.map((track, index) => `
${index + 1}
${escapeHtml(track.name)}
${escapeHtml(formatArtists(track.artists))}
๐ Pending
-
-
`).join('')}
`;
applyProgressiveTrackRendering(playlistId, tracks.length);
modal.style.display = 'flex';
hideLoadingOverlay();
WishlistModalState.setVisible(); // Track that new wishlist modal is now visible
}
async function startWishlistMissingTracksProcess(playlistId) {
const process = activeDownloadProcesses[playlistId];
if (!process) return;
console.log(`๐ Kicking off wishlist missing tracks process`);
try {
process.status = 'running';
// Note: Wishlist processes don't affect sync page refresh button state
document.getElementById(`begin-analysis-btn-${playlistId}`).style.display = 'none';
document.getElementById(`cancel-all-btn-${playlistId}`).style.display = 'inline-block';
// Check if force download toggle is enabled
const forceDownloadCheckbox = document.getElementById(`force-download-all-${playlistId}`);
const forceDownloadAll = forceDownloadCheckbox ? forceDownloadCheckbox.checked : false;
// Hide the force download toggle during processing
const forceToggleContainer = forceDownloadCheckbox ? forceDownloadCheckbox.closest('.force-download-toggle-container') : null;
if (forceToggleContainer) {
forceToggleContainer.style.display = 'none';
}
// Extract track IDs from what the user is currently seeing in the modal
// This prevents race conditions where wishlist changes between modal open and analysis start
const trackIds = process.tracks ? process.tracks.map(t => t.spotify_track_id || t.id).filter(id => id) : null;
console.log(`๐ฏ [Wishlist] Sending ${trackIds ? trackIds.length : 'all'} specific track IDs to prevent race condition`);
const response = await fetch('/api/wishlist/download_missing', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
force_download_all: forceDownloadAll,
category: window.currentWishlistCategory, // Keep for backward compat
track_ids: trackIds // NEW: Send exact tracks to process
})
});
const data = await response.json();
if (!data.success) {
// Special handling for auto-processing conflict
if (response.status === 409) {
console.log('๐ค [Wishlist] Auto-processing is running, redirecting to download manager');
showToast('Wishlist auto-processing is already running. Opening Download Manager...', 'info');
// Close wishlist modal and show download manager
const wishlistModal = document.getElementById('download-modal-wishlist');
if (wishlistModal) {
wishlistModal.remove();
}
delete activeDownloadProcesses[playlistId];
// Open download manager to show active batch
setTimeout(() => {
const downloadManager = document.getElementById('download-manager-modal');
if (downloadManager) {
downloadManager.style.display = 'flex';
} else {
openDownloadManagerModal();
}
}, 300);
return;
}
// Special handling for rate limit
if (response.status === 429) {
throw new Error(`${data.error} Try closing some other download processes first.`);
}
throw new Error(data.error);
}
process.batchId = data.batch_id;
console.log(`โ
Wishlist process started successfully. Batch ID: ${data.batch_id}`);
// Start polling for updates
startModalDownloadPolling(playlistId);
} catch (error) {
console.error('Error starting wishlist missing tracks process:', error);
showToast(`Error: ${error.message}`, 'error');
// Reset UI state on error
process.status = 'idle';
// Note: Wishlist processes don't affect sync page refresh button state
document.getElementById(`begin-analysis-btn-${playlistId}`).style.display = 'inline-block';
document.getElementById(`cancel-all-btn-${playlistId}`).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';
}
}
}
async function startMissingTracksProcess(playlistId) {
const process = activeDownloadProcesses[playlistId];
if (!process) return;
console.log(`๐ Kicking off unified missing tracks process for playlist: ${playlistId}`);
try {
process.status = 'running';
updatePlaylistCardUI(playlistId);
updateRefreshButtonState();
// Set album to downloading status if this is an artist album
if (playlistId.startsWith('artist_album_')) {
// Format: artist_album_{artist.id}_{album.id}
const parts = playlistId.split('_');
if (parts.length >= 4) {
const albumId = parts.slice(3).join('_'); // In case album ID has underscores
const totalTracks = process.tracks ? process.tracks.length : 0;
setAlbumDownloadingStatus(albumId, 0, totalTracks);
console.log(`๐ Set album ${albumId} to downloading status (0/${totalTracks} tracks)`);
console.log(`๐ Virtual playlist ID: ${playlistId} โ Album ID: ${albumId}`);
}
}
// Update YouTube playlist phase to 'downloading' if this is a YouTube playlist
if (playlistId.startsWith('youtube_')) {
const urlHash = playlistId.replace('youtube_', '');
updateYouTubeCardPhase(urlHash, 'downloading');
// Also update mirrored playlist card if applicable
if (urlHash.startsWith('mirrored_')) {
updateMirroredCardPhase(urlHash, 'downloading');
}
}
// Update Tidal playlist phase to 'downloading' if this is a Tidal playlist
if (playlistId.startsWith('tidal_')) {
const tidalPlaylistId = playlistId.replace('tidal_', '');
if (tidalPlaylistStates[tidalPlaylistId]) {
tidalPlaylistStates[tidalPlaylistId].phase = 'downloading';
updateTidalCardPhase(tidalPlaylistId, 'downloading');
console.log(`๐ Updated Tidal playlist ${tidalPlaylistId} to downloading phase`);
}
}
// Update Beatport chart phase to 'downloading' if this is a Beatport chart
if (playlistId.startsWith('beatport_')) {
const urlHash = playlistId.replace('beatport_', '');
const state = youtubePlaylistStates[urlHash];
if (state && state.is_beatport_playlist) {
const chartHash = state.beatport_chart_hash || urlHash;
// Update frontend states
state.phase = 'downloading';
if (beatportChartStates[chartHash]) {
beatportChartStates[chartHash].phase = 'downloading';
}
// Update card UI
updateBeatportCardPhase(chartHash, 'downloading');
// Update backend state
try {
fetch(`/api/beatport/charts/update-phase/${chartHash}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ phase: 'downloading' })
});
} catch (error) {
console.warn('โ ๏ธ Error updating backend Beatport phase to downloading:', error);
}
console.log(`๐ Updated Beatport chart ${chartHash} to downloading phase`);
}
}
// Update ListenBrainz playlist phase to 'downloading' if this is a ListenBrainz playlist
if (playlistId.startsWith('listenbrainz_')) {
const playlistMbid = playlistId.replace('listenbrainz_', '');
const state = listenbrainzPlaylistStates[playlistMbid];
if (state) {
// Update frontend state
state.phase = 'downloading';
// Update backend state
try {
fetch(`/api/listenbrainz/update-phase/${playlistMbid}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ phase: 'downloading' })
});
} catch (error) {
console.warn('โ ๏ธ Error updating backend ListenBrainz phase to downloading:', error);
}
console.log(`๐ Updated ListenBrainz playlist ${playlistMbid} to downloading phase`);
}
}
document.getElementById(`begin-analysis-btn-${playlistId}`).style.display = 'none';
document.getElementById(`cancel-all-btn-${playlistId}`).style.display = 'inline-block';
// Hide wishlist button if it exists (only for non-wishlist modals)
const wishlistBtn = document.getElementById(`add-to-wishlist-btn-${playlistId}`);
if (wishlistBtn) {
wishlistBtn.style.display = 'none';
}
// Add to discover download sidebar if this is a discover page download
if (process.discoverMetadata) {
const playlistName = process.playlist.name;
const imageUrl = process.discoverMetadata.imageUrl;
const type = process.discoverMetadata.type;
addDiscoverDownload(playlistId, playlistName, type, imageUrl);
console.log(`๐ฅ [BEGIN ANALYSIS] Added discover download: ${playlistName}`);
}
// Check if force download toggle is enabled
const forceDownloadCheckbox = document.getElementById(`force-download-all-${playlistId}`);
const forceDownloadAll = forceDownloadCheckbox ? forceDownloadCheckbox.checked : false;
// Check if playlist folder mode toggle is enabled (only for sync page playlists)
const playlistFolderModeCheckbox = document.getElementById(`playlist-folder-mode-${playlistId}`);
const playlistFolderMode = playlistFolderModeCheckbox ? playlistFolderModeCheckbox.checked : false;
// Hide the force download toggle during processing
const forceToggleContainer = forceDownloadCheckbox ? forceDownloadCheckbox.closest('.force-download-toggle-container') : null;
if (forceToggleContainer) {
forceToggleContainer.style.display = 'none';
}
// Filter tracks based on checkbox selection (if checkboxes exist in this modal)
const tbody = document.getElementById(`download-tracks-tbody-${playlistId}`);
let selectedTracks = process.tracks;
if (tbody) {
const allCbs = tbody.querySelectorAll('.track-select-cb');
if (allCbs.length > 0) {
// Checkboxes exist โ filter to only checked tracks
const checkedCbs = tbody.querySelectorAll('.track-select-cb:checked');
const selectedIndices = new Set([...checkedCbs].map(cb => parseInt(cb.dataset.trackIndex)));
console.log(`๐ฒ [Track Selection] Total checkboxes: ${allCbs.length}, Checked: ${checkedCbs.length}`);
console.log(`๐ฒ [Track Selection] Checked indices:`, [...selectedIndices]);
console.log(`๐ฒ [Track Selection] process.tracks has ${process.tracks.length} items, first: "${process.tracks[0]?.name}", last: "${process.tracks[process.tracks.length-1]?.name}"`);
// Stamp each selected track with its original table index so the backend
// maps status updates back to the correct modal row
selectedTracks = process.tracks
.map((track, i) => ({ ...track, _original_index: i }))
.filter(track => selectedIndices.has(track._original_index));
console.log(`๐ฒ [Track Selection] Filtered to ${selectedTracks.length} tracks:`, selectedTracks.map(t => `[${t._original_index}] ${t.name}`));
// Disable checkboxes once analysis starts
allCbs.forEach(cb => { cb.disabled = true; });
}
}
const selectAllCb = document.getElementById(`select-all-${playlistId}`);
if (selectAllCb) selectAllCb.disabled = true;
// Prepare request body - add album/artist context for artist album downloads
const requestBody = {
tracks: selectedTracks,
force_download_all: forceDownloadAll
};
// If this is an artist album download, use album name and include full context
// Match 'artist_album_', 'enhanced_search_album_', 'enhanced_search_track_', 'discover_album_', and 'seasonal_album_' prefixes
if (playlistId.startsWith('artist_album_') || playlistId.startsWith('enhanced_search_album_') || playlistId.startsWith('enhanced_search_track_') || playlistId.startsWith('discover_album_') || playlistId.startsWith('seasonal_album_') || playlistId.startsWith('spotify_library_') || playlistId.startsWith('issue_download_')) {
requestBody.playlist_name = process.album?.name || process.playlist.name;
requestBody.is_album_download = true;
requestBody.album_context = process.album; // Full Spotify album object
requestBody.artist_context = process.artist; // Full Spotify artist object
console.log(`๐ต [Artist Album] Sending album context: ${process.album?.name} by ${process.artist?.name}`);
} else {
// For playlists/wishlists, use the virtual playlist name
requestBody.playlist_name = process.playlist.name;
// Add playlist folder mode flag for sync page playlists
requestBody.playlist_folder_mode = playlistFolderMode;
if (playlistFolderMode) {
console.log(`๐ [Playlist Folder] Enabled for playlist: ${process.playlist.name}`);
}
}
const response = await fetch(`/api/playlists/${playlistId}/start-missing-process`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(requestBody)
});
const data = await response.json();
if (!data.success) {
// Special handling for rate limit
if (response.status === 429) {
throw new Error(`${data.error} Try closing some other download processes first.`);
}
throw new Error(data.error);
}
process.batchId = data.batch_id;
// Update Beatport backend state with download_process_id now that we have the batchId
if (playlistId.startsWith('beatport_')) {
const urlHash = playlistId.replace('beatport_', '');
const state = youtubePlaylistStates[urlHash];
if (state && state.is_beatport_playlist) {
const chartHash = state.beatport_chart_hash || urlHash;
try {
fetch(`/api/beatport/charts/update-phase/${chartHash}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
phase: 'downloading',
download_process_id: data.batch_id
})
});
console.log(`๐ Updated Beatport backend with download_process_id: ${data.batch_id}`);
} catch (error) {
console.warn('โ ๏ธ Error updating Beatport backend with download_process_id:', error);
}
}
}
// Update ListenBrainz backend state with download_process_id and convertedSpotifyPlaylistId
if (playlistId.startsWith('listenbrainz_')) {
const playlistMbid = playlistId.replace('listenbrainz_', '');
const state = listenbrainzPlaylistStates[playlistMbid];
if (state) {
// Store in frontend state
state.download_process_id = data.batch_id;
state.convertedSpotifyPlaylistId = playlistId;
// Update backend state
try {
fetch(`/api/listenbrainz/update-phase/${playlistMbid}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
phase: 'downloading',
download_process_id: data.batch_id,
converted_spotify_playlist_id: playlistId
})
});
console.log(`๐ Updated ListenBrainz backend with download_process_id: ${data.batch_id}`);
} catch (error) {
console.warn('โ ๏ธ Error updating ListenBrainz backend with download_process_id:', error);
}
}
}
startModalDownloadPolling(playlistId);
} catch (error) {
showToast(`Failed to start process: ${error.message}`, 'error');
process.status = 'cancelled';
// Reset button states on error
const beginBtn = document.getElementById(`begin-analysis-btn-${playlistId}`);
const cancelBtn = document.getElementById(`cancel-all-btn-${playlistId}`);
const wishlistBtn = document.getElementById(`add-to-wishlist-btn-${playlistId}`);
if (beginBtn) beginBtn.style.display = 'inline-block';
if (cancelBtn) cancelBtn.style.display = 'none';
if (wishlistBtn) wishlistBtn.style.display = 'inline-block';
// 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';
}
cleanupDownloadProcess(playlistId);
}
}
function updateTrackAnalysisResults(playlistId, results) {
// Update match results for all rows (tracks are now pre-populated)
for (const result of results) {
const matchElement = document.getElementById(`match-${playlistId}-${result.track_index}`);
if (matchElement) {
matchElement.textContent = result.found ? 'โ
Found' : 'โ Missing';
matchElement.className = `track-match-status ${result.found ? 'match-found' : 'match-missing'}`;
}
}
}
// ============================================================================
// GLOBAL BATCHED POLLING SYSTEM - Optimized for multiple concurrent modals
// ============================================================================
let globalDownloadStatusPoller = null;
let globalPollingFailureCount = 0; // Track consecutive failures for exponential backoff
let globalPollingBaseInterval = 2000; // Base polling interval in ms - MATCHES sync.py exactly
function startGlobalDownloadPolling() {
if (socketConnected) {
console.debug('๐ [Global Polling] WebSocket active, skipping HTTP polling');
return; // WebSocket handles download updates via room subscriptions
}
if (globalDownloadStatusPoller) {
console.debug('๐ [Global Polling] Already running, skipping start');
return; // Prevent duplicate pollers
}
console.log('๐ [Global Polling] Starting batched download status polling');
globalDownloadStatusPoller = setInterval(async () => {
if (document.hidden) return; // Skip polling when tab is not visible
// Get all active processes that need polling
const activeBatchIds = [];
const batchToPlaylistMap = {};
let hasOpenWishlistModal = false;
Object.entries(activeDownloadProcesses).forEach(([playlistId, process]) => {
if (process.batchId && process.status === 'running') {
activeBatchIds.push(process.batchId);
batchToPlaylistMap[process.batchId] = playlistId;
}
// Check if there's an open wishlist modal (visible and idle/waiting)
if (playlistId === 'wishlist' && process.modalElement &&
process.modalElement.style.display === 'flex' &&
(!process.batchId || process.status !== 'running')) {
hasOpenWishlistModal = true;
}
});
// Special handling for open wishlist modal - check for new auto-processing
if (hasOpenWishlistModal) {
try {
const response = await fetch('/api/active-processes');
if (response.ok) {
const data = await response.json();
const processes = data.active_processes || [];
const serverWishlistProcess = processes.find(p => p.playlist_id === 'wishlist');
if (serverWishlistProcess) {
console.log('๐ [Global Polling] Detected auto-processing for open wishlist modal - rehydrating');
await rehydrateModal(serverWishlistProcess, false); // false = not user-requested
}
}
} catch (error) {
console.debug('โ ๏ธ [Global Polling] Failed to check for wishlist auto-processing:', error);
}
}
if (activeBatchIds.length === 0) {
console.debug('๐ [Global Polling] No active processes, continuing polling');
return;
}
try {
// Single batched API call for all active processes
const queryParams = activeBatchIds.map(id => `batch_ids=${id}`).join('&');
const response = await fetch(`/api/download_status/batch?${queryParams}`);
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const data = await response.json();
console.debug(`๐ [Global Polling] Received batched update for ${Object.keys(data.batches).length} processes`);
// Process each batch's status data using existing logic
Object.entries(data.batches).forEach(([batchId, statusData]) => {
const playlistId = batchToPlaylistMap[batchId];
if (!playlistId || statusData.error) {
if (statusData.error) {
console.error(`โ [Global Polling] Error for batch ${batchId}:`, statusData.error);
}
return;
}
// Use existing modal update logic - zero changes needed!
processModalStatusUpdate(playlistId, statusData);
});
// ENHANCED: Reset failure count on successful polling
globalPollingFailureCount = 0;
} catch (error) {
console.error('โ [Global Polling] Batched request failed:', error);
// ENHANCED: Implement exponential backoff on failure
globalPollingFailureCount++;
if (globalPollingFailureCount >= 5) {
console.error(`๐จ [Global Polling] ${globalPollingFailureCount} consecutive failures, continuing with backoff`);
// Don't stop polling - just continue with exponential backoff
}
// Exponential backoff: increase interval temporarily
const backoffInterval = Math.min(globalPollingBaseInterval * Math.pow(2, globalPollingFailureCount - 1), 8000);
console.warn(`โ ๏ธ [Global Polling] Failure ${globalPollingFailureCount}/5, backing off to ${backoffInterval}ms`);
// Temporarily adjust the polling interval
if (globalDownloadStatusPoller) {
clearInterval(globalDownloadStatusPoller);
globalDownloadStatusPoller = null;
// Restart with backoff interval
setTimeout(() => {
if (Object.keys(activeDownloadProcesses).length > 0) {
startGlobalDownloadPollingWithInterval(backoffInterval);
}
}, backoffInterval);
}
}
}, globalPollingBaseInterval); // Use base interval initially
}
function startGlobalDownloadPollingWithInterval(interval) {
if (globalDownloadStatusPoller) {
console.debug('๐ [Global Polling] Already running, skipping start with interval');
return;
}
console.log(`๐ [Global Polling] Starting with interval ${interval}ms`);
// Use the exact same logic as startGlobalDownloadPolling but with custom interval
globalDownloadStatusPoller = setInterval(async () => {
const activeBatchIds = [];
const batchToPlaylistMap = {};
let hasOpenWishlistModal = false;
Object.entries(activeDownloadProcesses).forEach(([playlistId, process]) => {
if (process.batchId && process.status === 'running') {
activeBatchIds.push(process.batchId);
batchToPlaylistMap[process.batchId] = playlistId;
}
// Check if there's an open wishlist modal (visible and idle/waiting)
if (playlistId === 'wishlist' && process.modalElement &&
process.modalElement.style.display === 'flex' &&
(!process.batchId || process.status !== 'running')) {
hasOpenWishlistModal = true;
}
});
// Special handling for open wishlist modal - check for new auto-processing
if (hasOpenWishlistModal) {
try {
const response = await fetch('/api/active-processes');
if (response.ok) {
const data = await response.json();
const processes = data.active_processes || [];
const serverWishlistProcess = processes.find(p => p.playlist_id === 'wishlist');
if (serverWishlistProcess) {
console.log('๐ [Global Polling] Detected auto-processing for open wishlist modal - rehydrating');
await rehydrateModal(serverWishlistProcess, false); // false = not user-requested
}
}
} catch (error) {
console.debug('โ ๏ธ [Global Polling] Failed to check for wishlist auto-processing:', error);
}
}
if (activeBatchIds.length === 0) {
console.debug('๐ [Global Polling] No active processes, continuing polling');
return;
}
try {
const queryParams = activeBatchIds.map(id => `batch_ids=${id}`).join('&');
const response = await fetch(`/api/download_status/batch?${queryParams}`);
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const data = await response.json();
console.debug(`๐ [Global Polling] Received batched update for ${Object.keys(data.batches).length} processes`);
Object.entries(data.batches).forEach(([batchId, statusData]) => {
const playlistId = batchToPlaylistMap[batchId];
if (!playlistId || statusData.error) {
if (statusData.error) {
console.error(`โ [Global Polling] Error for batch ${batchId}:`, statusData.error);
}
return;
}
processModalStatusUpdate(playlistId, statusData);
});
// Success - reset to normal interval if we were backing off
globalPollingFailureCount = 0;
if (interval !== globalPollingBaseInterval) {
console.log('โ
[Global Polling] Recovered from backoff, returning to normal interval');
clearInterval(globalDownloadStatusPoller);
globalDownloadStatusPoller = null;
startGlobalDownloadPolling(); // Restart with normal interval
}
} catch (error) {
console.error('โ [Global Polling] Request failed:', error);
globalPollingFailureCount++;
if (globalPollingFailureCount >= 5) {
console.error(`๐จ [Global Polling] Too many failures, continuing with backoff`);
// Don't stop polling - just continue with exponential backoff
}
}
}, interval);
}
function stopGlobalDownloadPolling() {
if (globalDownloadStatusPoller) {
console.log('๐ [Global Polling] Stopping batched download status polling');
clearInterval(globalDownloadStatusPoller);
globalDownloadStatusPoller = null;
}
}
// --- Error tooltip for failed/cancelled downloads (fixed-position, escapes overflow) ---
function _getErrorTooltipPopup() {
let el = document.getElementById('error-tooltip-popup');
if (!el) {
el = document.createElement('div');
el.id = 'error-tooltip-popup';
document.body.appendChild(el);
}
return el;
}
function _hideErrorTooltip() {
const popup = document.getElementById('error-tooltip-popup');
if (popup) popup.classList.remove('visible');
}
function _ensureErrorTooltipListeners(statusEl) {
if (statusEl._errorTooltipBound) return;
statusEl._errorTooltipBound = true;
statusEl.addEventListener('mouseenter', function () {
const msg = this.dataset.errorMsg;
if (!msg || !this.offsetParent) return; // skip if element is hidden
const popup = _getErrorTooltipPopup();
popup.textContent = msg;
popup.classList.add('visible');
const rect = this.getBoundingClientRect();
const popupRect = popup.getBoundingClientRect();
let left = rect.left + rect.width / 2 - popupRect.width / 2;
let top = rect.top - popupRect.height - 10;
// Keep within viewport
if (left < 8) left = 8;
if (left + popupRect.width > window.innerWidth - 8) left = window.innerWidth - 8 - popupRect.width;
if (top < 8) { top = rect.bottom + 10; } // flip below if no room above
popup.style.left = left + 'px';
popup.style.top = top + 'px';
});
statusEl.addEventListener('mouseleave', _hideErrorTooltip);
// Dismiss tooltip when the scrollable modal body scrolls
const scrollParent = statusEl.closest('.download-missing-modal-body');
if (scrollParent && !scrollParent._errorTooltipScrollBound) {
scrollParent._errorTooltipScrollBound = true;
scrollParent.addEventListener('scroll', _hideErrorTooltip, { passive: true });
}
}
function _ensureCandidatesClickListener(statusEl) {
if (statusEl._candidatesClickBound) return;
statusEl._candidatesClickBound = true;
statusEl.addEventListener('click', function (e) {
e.stopPropagation();
_hideErrorTooltip();
const taskId = this.dataset.taskId;
if (taskId) showCandidatesModal(taskId);
});
}
async function showCandidatesModal(taskId) {
try {
const resp = await fetch(`/api/downloads/task/${encodeURIComponent(taskId)}/candidates`);
if (!resp.ok) { console.error('Failed to fetch candidates:', resp.status); return; }
const data = await resp.json();
_renderCandidatesModal(data);
} catch (err) {
console.error('Error fetching candidates:', err);
}
}
function _renderCandidatesModal(data) {
let overlay = document.getElementById('candidates-modal-overlay');
if (overlay) overlay.remove();
const trackName = data.track_info?.name || 'Unknown Track';
const trackArtist = data.track_info?.artist || 'Unknown Artist';
const candidates = data.candidates || [];
const errorMsg = data.error_message || '';
const fmtSize = (bytes) => {
if (!bytes) return '-';
const units = ['B', 'KB', 'MB', 'GB'];
let s = bytes, u = 0;
while (s >= 1024 && u < units.length - 1) { s /= 1024; u++; }
return `${s.toFixed(1)} ${units[u]}`;
};
const fmtDur = (ms) => {
if (!ms) return '-';
const sec = Math.floor(ms / 1000);
return `${Math.floor(sec / 60)}:${(sec % 60).toString().padStart(2, '0')}`;
};
let tableRows = '';
if (candidates.length === 0) {
tableRows = `
No candidates were found during search. `;
} else {
candidates.forEach((c, i) => {
const shortFile = c.filename ? c.filename.split(/[/\\]/).pop() : '-';
const qBadge = c.quality
? `${c.quality.toUpperCase()} `
: '';
tableRows += `
${i + 1}
${escapeHtml(shortFile)}
${qBadge}${c.bitrate ? ` ${c.bitrate}kbps` : ''}
${fmtSize(c.size)}
${fmtDur(c.duration)}
${escapeHtml(c.username || '-')}
โฌ
`;
});
}
overlay = document.createElement('div');
overlay.id = 'candidates-modal-overlay';
overlay.className = 'candidates-modal-overlay';
overlay.onclick = (e) => { if (e.target === overlay) closeCandidatesModal(); };
overlay.innerHTML = `
Search Results
${escapeHtml(trackName)} โ ${escapeHtml(trackArtist)}
✕
${errorMsg ? `
${escapeHtml(errorMsg)}
` : ''}
${candidates.length} candidate${candidates.length !== 1 ? 's' : ''} found${candidates.length > 0 ? ' but none passed filters' : ''}
# File Quality Size Duration User
${tableRows}
`;
document.body.appendChild(overlay);
requestAnimationFrame(() => overlay.classList.add('visible'));
// Bind download buttons
overlay.querySelectorAll('.candidates-download-btn').forEach(btn => {
btn.addEventListener('click', () => {
const idx = parseInt(btn.dataset.index);
const c = candidates[idx];
if (c) downloadCandidate(data.task_id, c, trackName);
});
});
}
async function downloadCandidate(taskId, candidate, trackName) {
if (!await showConfirmDialog({ title: 'Download File', message: `Download this file as "${trackName}"?\n\n${candidate.filename?.split(/[/\\]/).pop() || 'Unknown file'}\nfrom ${candidate.username || 'Unknown user'}`, confirmText: 'Download' })) return;
try {
const resp = await fetch(`/api/downloads/task/${encodeURIComponent(taskId)}/download-candidate`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(candidate)
});
const result = await resp.json();
if (result.success) {
closeCandidatesModal();
showToast(result.message || 'Download initiated', 'success');
} else {
showToast(`Failed: ${result.error}`, 'error');
}
} catch (err) {
console.error('Error initiating manual download:', err);
showToast('Failed to initiate download', 'error');
}
}
function closeCandidatesModal() {
const overlay = document.getElementById('candidates-modal-overlay');
if (overlay) {
overlay.classList.remove('visible');
setTimeout(() => overlay.remove(), 300);
}
}
function processModalStatusUpdate(playlistId, data) {
// This function contains ALL the existing polling logic from startModalDownloadPolling
// Extracted so it can be called from both individual and batched polling
const process = activeDownloadProcesses[playlistId];
if (!process) {
console.debug(`โ ๏ธ [Status Update] No process found for ${playlistId}, skipping update`);
return;
}
if (data.error) {
console.error(`โ [Status Update] Error for ${playlistId}: ${data.error}`);
return;
}
// ENHANCED: Validate response data to prevent UI corruption
if (!data || typeof data !== 'object') {
console.error(`โ [Status Update] Invalid data for ${playlistId}:`, data);
return;
}
// ENHANCED: Validate task data structure
if (data.tasks && !Array.isArray(data.tasks)) {
console.error(`โ [Status Update] Invalid tasks data for ${playlistId} - not an array:`, data.tasks);
return;
}
console.debug(`๐ [Status Update] Processing update for ${playlistId}: phase=${data.phase}, tasks=${(data.tasks || []).length}`);
// Note: Wishlist modal visibility is now managed by handleWishlistButtonClick() only
// Auto-show logic has been simplified to prevent conflicts
if (data.phase === 'analysis') {
const progress = data.analysis_progress;
const percent = progress.total > 0 ? (progress.processed / progress.total) * 100 : 0;
document.getElementById(`analysis-progress-fill-${playlistId}`).style.width = `${percent}%`;
document.getElementById(`analysis-progress-text-${playlistId}`).textContent =
`${progress.processed}/${progress.total} tracks analyzed`;
if (data.analysis_results) {
updateTrackAnalysisResults(playlistId, data.analysis_results);
// Update stats when we first get analysis results
const foundCount = data.analysis_results.filter(r => r.found).length;
const missingCount = data.analysis_results.filter(r => !r.found).length;
document.getElementById(`stat-found-${playlistId}`).textContent = foundCount;
document.getElementById(`stat-missing-${playlistId}`).textContent = missingCount;
// Auto-save M3U file for playlists after analysis
autoSavePlaylistM3U(playlistId);
}
} else if (data.phase === 'downloading' || data.phase === 'complete' || data.phase === 'error') {
console.debug(`๐ [Status Update] Processing ${data.phase} phase for playlistId: ${playlistId}, tasks: ${(data.tasks || []).length}`);
if (document.getElementById(`analysis-progress-fill-${playlistId}`).style.width !== '100%') {
document.getElementById(`analysis-progress-fill-${playlistId}`).style.width = '100%';
document.getElementById(`analysis-progress-text-${playlistId}`).textContent = 'Analysis complete!';
if (data.analysis_results) {
updateTrackAnalysisResults(playlistId, data.analysis_results);
const foundCount = data.analysis_results.filter(r => r.found).length;
const missingCount = data.analysis_results.filter(r => !r.found).length;
document.getElementById(`stat-found-${playlistId}`).textContent = foundCount;
document.getElementById(`stat-missing-${playlistId}`).textContent = missingCount;
}
}
const missingTracks = (data.analysis_results || []).filter(r => !r.found);
const missingCount = missingTracks.length;
let completedCount = 0;
let failedOrCancelledCount = 0;
let notFoundCount = 0;
// Verify modal exists before processing tasks
const modal = document.getElementById(`download-missing-modal-${playlistId}`);
if (!modal) {
console.error(`โ [Status Update] Modal not found: download-missing-modal-${playlistId}`);
return;
}
// Update download progress text immediately when entering downloading phase
// This handles the case where tasks array is empty or still being populated
const downloadProgressText = document.getElementById(`download-progress-text-${playlistId}`);
if (data.phase === 'downloading' && missingCount > 0 && (!data.tasks || data.tasks.length === 0)) {
// No tasks yet, but we're in downloading phase with missing tracks
if (downloadProgressText) {
downloadProgressText.textContent = 'Preparing downloads...';
console.log(`๐ฅ [Download Phase] Preparing ${missingCount} downloads...`);
}
}
(data.tasks || []).forEach(task => {
const row = document.querySelector(`#download-missing-modal-${CSS.escape(playlistId)} tr[data-track-index="${task.track_index}"]`);
if (!row) {
console.debug(`โ [Status Update] Row not found for playlistId: ${playlistId}, track_index: ${task.track_index}`);
return;
}
// V2 SYSTEM: Check for persistent cancel state from backend
const isV2Task = task.playlist_id !== undefined; // V2 tasks have playlist_id
const cancelRequested = task.cancel_requested || false;
const uiState = task.ui_state || 'normal';
// Legacy protection for old system compatibility
if (row.dataset.locallyCancelled === 'true' && !isV2Task) {
failedOrCancelledCount++;
return; // Only skip for legacy system tasks
}
// Mark row with V2 system info
if (isV2Task) {
row.dataset.useV2System = 'true';
row.dataset.cancelRequested = cancelRequested.toString();
row.dataset.uiState = uiState;
}
row.dataset.taskId = task.task_id;
const statusEl = document.getElementById(`download-${playlistId}-${task.track_index}`);
const actionsEl = document.getElementById(`actions-${playlistId}-${task.track_index}`);
let statusText = '';
// V2 SYSTEM: Handle UI state override for cancelling tasks
if (isV2Task && uiState === 'cancelling' && task.status !== 'cancelled') {
statusText = '๐ Cancelling...';
} else {
switch (task.status) {
case 'pending': statusText = 'โธ๏ธ Pending'; break;
case 'searching': statusText = '๐ Searching...'; break;
case 'downloading': statusText = `โฌ Downloading... ${Math.round(task.progress || 0)}%`; break;
case 'post_processing': statusText = 'โ Processing...'; break;
case 'completed': statusText = 'โ
Completed'; completedCount++; break;
case 'not_found': statusText = '๐ Not Found'; notFoundCount++; break;
case 'failed': statusText = 'โ Failed'; failedOrCancelledCount++; break;
case 'cancelled': statusText = '๐ซ Cancelled'; failedOrCancelledCount++; break;
default: statusText = `โช ${task.status}`; break;
}
}
if (statusEl) {
statusEl.classList.remove('has-error-tooltip');
statusEl.removeAttribute('title');
statusEl.removeAttribute('data-error-msg');
statusEl.textContent = statusText;
if ((task.status === 'failed' || task.status === 'cancelled' || task.status === 'not_found') && task.error_message) {
statusEl.classList.add('has-error-tooltip');
statusEl.dataset.errorMsg = task.error_message;
_ensureErrorTooltipListeners(statusEl);
}
// Make not_found cells clickable to review search candidates
if (task.status === 'not_found' && task.has_candidates) {
statusEl.classList.add('has-candidates');
statusEl.dataset.taskId = task.task_id;
_ensureCandidatesClickListener(statusEl);
}
console.debug(`โ
[Status Update] Updated track ${task.track_index} to: ${statusText}${isV2Task ? ' (V2)' : ''}`);
} else {
console.warn(`โ [Status Update] Status element not found: download-${playlistId}-${task.track_index}`);
}
// V2 SYSTEM: Smart button management with persistent state awareness
if (actionsEl && !['completed', 'failed', 'cancelled', 'not_found', 'post_processing'].includes(task.status)) {
// Check if we're in a cancelling state
if (isV2Task && uiState === 'cancelling') {
actionsEl.innerHTML = 'Cancelling... ';
} else {
// Create V2 cancel button for all active tasks
const onclickHandler = isV2Task ? 'cancelTrackDownloadV2' : 'cancelTrackDownload';
actionsEl.innerHTML = `ร `;
}
} else if (actionsEl && ['completed', 'failed', 'cancelled', 'not_found', 'post_processing'].includes(task.status)) {
actionsEl.innerHTML = '-'; // No actions available for terminal or processing states
}
});
// ENHANCED: Validate worker counts from server data
const serverActiveWorkers = data.active_count || 0;
const maxWorkers = data.max_concurrent || 3;
// V2 SYSTEM: Simplified worker counting - backend is authoritative
// Count active tasks, excluding locally cancelled legacy tasks only
const clientActiveWorkers = (data.tasks || []).filter(task => {
const row = document.querySelector(`tr[data-track-index="${task.track_index}"]`);
const isLegacyCancelled = row && row.dataset.locallyCancelled === 'true' && !row.dataset.useV2System;
return ['searching', 'downloading', 'queued'].includes(task.status) && !isLegacyCancelled;
}).length;
// Log discrepancies for debugging
if (serverActiveWorkers !== clientActiveWorkers) {
console.warn(`๐ [Worker Validation] ${playlistId}: server reports ${serverActiveWorkers} active, client sees ${clientActiveWorkers} active tasks`);
// If server reports 0 but client sees active tasks, this might indicate ghost workers were fixed
if (serverActiveWorkers === 0 && clientActiveWorkers > 0) {
console.warn(`๐จ [Worker Validation] Server reports 0 workers but client sees ${clientActiveWorkers} active tasks - potential UI desync`);
}
}
console.debug(`๐ [Worker Status] ${playlistId}: ${serverActiveWorkers}/${maxWorkers} active workers, ${clientActiveWorkers} client-side active tasks`);
const totalFinished = completedCount + failedOrCancelledCount + notFoundCount;
const progressPercent = missingCount > 0 ? (totalFinished / missingCount) * 100 : 0;
document.getElementById(`download-progress-fill-${playlistId}`).style.width = `${progressPercent}%`;
document.getElementById(`download-progress-text-${playlistId}`).textContent = `${completedCount}/${missingCount} completed (${progressPercent.toFixed(0)}%)`;
document.getElementById(`stat-downloaded-${playlistId}`).textContent = completedCount;
// Auto-save M3U file for playlists as downloads progress
if (completedCount > 0) {
autoSavePlaylistM3U(playlistId);
}
// CLIENT-SIDE COMPLETION: If all tracks are finished (completed or failed), complete the modal
const allTracksFinished = totalFinished >= missingCount && missingCount > 0;
if (allTracksFinished && process.status !== 'complete') {
console.log(`๐ฏ [Client Completion] All ${totalFinished}/${missingCount} tracks finished - completing modal locally`);
// Hide cancel button and mark as complete
document.getElementById(`cancel-all-btn-${playlistId}`).style.display = 'none';
process.status = 'complete';
updatePlaylistCardUI(playlistId);
// 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';
}
// Set album to downloaded status if this is an artist album
if (playlistId.startsWith('artist_album_')) {
const parts = playlistId.split('_');
if (parts.length >= 4) {
const albumId = parts.slice(3).join('_');
setTimeout(() => setAlbumDownloadedStatus(albumId), 500); // Small delay to ensure UI updates
}
}
// Update mirrored playlist card phase on client-side completion
if (playlistId.startsWith('youtube_')) {
const urlHash = playlistId.replace('youtube_', '');
if (urlHash.startsWith('mirrored_')) {
updateMirroredCardPhase(urlHash, 'download_complete');
}
}
// Auto-save final M3U file for playlists
autoSavePlaylistM3U(playlistId);
// Show completion message
let completionParts = [`${completedCount} downloaded`];
if (notFoundCount > 0) completionParts.push(`${notFoundCount} not found`);
if (failedOrCancelledCount > 0) completionParts.push(`${failedOrCancelledCount} failed`);
const completionMessage = `Download complete! ${completionParts.join(', ')}.`;
showToast(completionMessage, 'success');
// Auto-close wishlist modal when completed (for auto-processing)
if (playlistId === 'wishlist') {
console.log('๐ [Auto-Wishlist] Auto-closing completed wishlist modal to enable next cycle');
setTimeout(() => {
closeDownloadMissingModal(playlistId);
}, 3000); // 3-second delay to show completion message
}
// Check if any other processes still need polling
checkAndCleanupGlobalPolling();
return; // Skip waiting for backend signal
}
// FIXED: Only trigger completion logic when backend actually reports batch as complete
// Don't assume completion based on task counts - let backend determine when truly complete
if (data.phase === 'complete' || data.phase === 'error') {
// Enhanced check for background auto-processing for wishlist
const isWishlist = (playlistId === 'wishlist');
const isModalHidden = (process.modalElement && process.modalElement.style.display === 'none');
const isAutoInitiated = data.auto_initiated || false; // Server indicates if batch was auto-started
const isBackgroundWishlist = isWishlist && (isModalHidden || isAutoInitiated);
// Note: Auto-show logic removed - wishlist modal visibility managed by user interaction only
if (data.phase === 'cancelled') {
process.status = 'cancelled';
// Reset YouTube playlist phase to 'discovered' if this is a YouTube playlist on cancel
if (playlistId.startsWith('youtube_')) {
const urlHash = playlistId.replace('youtube_', '');
updateYouTubeCardPhase(urlHash, 'discovered');
if (urlHash.startsWith('mirrored_')) {
updateMirroredCardPhase(urlHash, 'discovered');
}
}
showToast(`Process cancelled for ${process.playlist.name}.`, 'info');
} else if (data.phase === 'error') {
process.status = 'complete'; // Treat as complete to allow cleanup
updatePlaylistCardUI(playlistId); // Update card to show ready for review
// Reset YouTube playlist phase to 'discovered' if this is a YouTube playlist on error
if (playlistId.startsWith('youtube_')) {
const urlHash = playlistId.replace('youtube_', '');
updateYouTubeCardPhase(urlHash, 'discovered');
if (urlHash.startsWith('mirrored_')) {
updateMirroredCardPhase(urlHash, 'discovered');
}
}
showToast(`Process for ${process.playlist.name} failed!`, 'error');
} else {
process.status = 'complete';
updatePlaylistCardUI(playlistId); // Update card to show ready for review
// Update YouTube playlist phase to 'download_complete' if this is a YouTube playlist
if (playlistId.startsWith('youtube_')) {
const urlHash = playlistId.replace('youtube_', '');
updateYouTubeCardPhase(urlHash, 'download_complete');
if (urlHash.startsWith('mirrored_')) {
updateMirroredCardPhase(urlHash, 'download_complete');
}
}
// Update Tidal playlist phase to 'download_complete' if this is a Tidal playlist
if (playlistId.startsWith('tidal_')) {
const tidalPlaylistId = playlistId.replace('tidal_', '');
if (tidalPlaylistStates[tidalPlaylistId]) {
tidalPlaylistStates[tidalPlaylistId].phase = 'download_complete';
// Store the download process ID for potential modal rehydration
tidalPlaylistStates[tidalPlaylistId].download_process_id = process.batchId;
updateTidalCardPhase(tidalPlaylistId, 'download_complete');
console.log(`โ
[Status Complete] Updated Tidal playlist ${tidalPlaylistId} to download_complete phase`);
}
}
// Update Beatport chart phase to 'download_complete' if this is a Beatport chart
if (playlistId.startsWith('beatport_')) {
const urlHash = playlistId.replace('beatport_', '');
const state = youtubePlaylistStates[urlHash];
if (state && state.is_beatport_playlist) {
const chartHash = state.beatport_chart_hash || urlHash;
// Update frontend states
state.phase = 'download_complete';
state.download_process_id = process.batchId;
if (beatportChartStates[chartHash]) {
beatportChartStates[chartHash].phase = 'download_complete';
}
// Update card UI
updateBeatportCardPhase(chartHash, 'download_complete');
// Update backend state
try {
fetch(`/api/beatport/charts/update-phase/${chartHash}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
phase: 'download_complete',
download_process_id: process.batchId
})
});
} catch (error) {
console.warn('โ ๏ธ Error updating backend Beatport phase to download_complete:', error);
}
console.log(`โ
[Status Complete] Updated Beatport chart ${chartHash} to download_complete phase`);
}
}
// Handle background wishlist processing completion specially
if (isBackgroundWishlist) {
console.log(`๐ Background wishlist processing complete: ${completedCount} downloaded, ${notFoundCount} not found, ${failedOrCancelledCount} failed`);
// Reset modal to idle state to prevent "complete" phase disruption
setTimeout(() => {
resetWishlistModalToIdleState();
// Server-side auto-processing will handle next cycle automatically
}, 500);
return; // Skip normal completion handling
}
// Show completion summary with wishlist stats (matching sync.py behavior)
let completionMessage = `Process complete for ${process.playlist.name}!`;
let messageType = 'success';
// Check for wishlist summary from backend (added when failed/cancelled tracks are processed)
if (data.wishlist_summary) {
const summary = data.wishlist_summary;
let summaryParts = [`Downloaded: ${completedCount}`];
if (notFoundCount > 0) summaryParts.push(`Not Found: ${notFoundCount}`);
if (failedOrCancelledCount > 0) summaryParts.push(`Failed: ${failedOrCancelledCount}`);
completionMessage = `Download process complete! ${summaryParts.join(', ')}.`;
if (summary.tracks_added > 0) {
completionMessage += ` Added ${summary.tracks_added} failed track${summary.tracks_added !== 1 ? 's' : ''} to wishlist for automatic retry.`;
} else if (summary.total_failed > 0) {
completionMessage += ` ${summary.total_failed} track${summary.total_failed !== 1 ? 's' : ''} could not be added to wishlist.`;
messageType = 'warning';
}
}
showToast(completionMessage, messageType);
}
document.getElementById(`cancel-all-btn-${playlistId}`).style.display = 'none';
// Mark process as complete and trigger cleanup check
process.status = 'complete';
updatePlaylistCardUI(playlistId);
// Check if any other processes still need polling
checkAndCleanupGlobalPolling();
}
}
}
function checkAndCleanupGlobalPolling() {
// Check if any processes still need polling
const hasActivePolling = Object.values(activeDownloadProcesses)
.some(p => p.batchId && p.status === 'running');
if (!hasActivePolling) {
console.debug('๐งน [Cleanup] No more active processes, continuing polling');
// Keep polling active - no need to stop
}
}
// LEGACY FUNCTION: Keep for backward compatibility, but now uses global polling
function startModalDownloadPolling(playlistId) {
const process = activeDownloadProcesses[playlistId];
if (!process || !process.batchId) return;
console.log(`๐ [Legacy Polling] Starting polling for ${playlistId}, delegating to global poller`);
// Clear any existing individual poller (cleanup)
if (process.poller) {
clearInterval(process.poller);
process.poller = null;
}
// Mark process as running to be picked up by global poller
process.status = 'running';
// Start global polling if not already running
startGlobalDownloadPolling();
// Create dummy poller for backward compatibility with cleanup functions
ensureLegacyCompatibility(playlistId);
}
// For backward compatibility with cleanup functions that expect process.poller
// Creates a dummy poller that will be cleaned up by the existing cleanup logic
function createLegacyPoller(playlistId) {
const process = activeDownloadProcesses[playlistId];
if (!process) return;
// Create a dummy interval that just checks if the process is still active
// This ensures existing cleanup logic that calls clearInterval(process.poller) works
process.poller = setInterval(() => {
// This dummy poller doesn't do anything - global poller handles updates
if (!activeDownloadProcesses[playlistId] || process.status === 'complete') {
clearInterval(process.poller);
process.poller = null;
return;
}
}, 5000); // Very infrequent check, just for cleanup compatibility
}
// Call this to create the legacy poller after starting global polling
function ensureLegacyCompatibility(playlistId) {
const process = activeDownloadProcesses[playlistId];
if (process && !process.poller) {
createLegacyPoller(playlistId);
}
}
async function updateModalWithLiveDownloadProgress() {
try {
if (!currentDownloadBatchId) return;
// Fetch live download data from the downloads API
const response = await fetch('/api/downloads/status');
const downloadData = await response.json();
if (downloadData.error) return;
// Get all active and finished downloads
const allDownloads = { ...(downloadData.active || {}), ...(downloadData.finished || {}) };
// Update modal tracks that have active downloads
const modalRows = document.querySelectorAll('.download-missing-modal tr[data-track-index]');
for (const row of modalRows) {
const taskId = row.dataset.taskId;
if (!taskId) continue;
// Find corresponding download by checking if filename/title matches
const trackName = row.querySelector('.track-name')?.textContent?.trim();
if (!trackName) continue;
// Search for matching download
for (const [downloadId, downloadInfo] of Object.entries(allDownloads)) {
// Extract display title from filename (handle YouTube encoding)
let downloadTitle = '';
if (downloadInfo.filename) {
if ((downloadInfo.username === 'youtube' || downloadInfo.username === 'tidal') && downloadInfo.filename.includes('||')) {
const parts = downloadInfo.filename.split('||');
downloadTitle = parts[1] || parts[0];
} else {
downloadTitle = downloadInfo.filename.split(/[\\/]/).pop();
}
}
// Simple matching - could be improved with better logic
if (downloadTitle && trackName && (
downloadTitle.toLowerCase().includes(trackName.toLowerCase()) ||
trackName.toLowerCase().includes(downloadTitle.toLowerCase())
)) {
// Update the track with live download progress
const statusElement = row.querySelector('.track-download-status');
const progress = downloadInfo.percentComplete || 0;
const state = downloadInfo.state || '';
if (statusElement && state.includes('InProgress') && progress > 0) {
statusElement.textContent = `โฌ Downloading... ${Math.round(progress)}%`;
statusElement.className = 'track-download-status download-downloading';
} else if (statusElement && (state.includes('Completed') || state.includes('Succeeded'))) {
statusElement.textContent = 'โ
Completed';
statusElement.className = 'track-download-status download-complete';
}
break; // Found a match, stop searching
}
}
}
} catch (error) {
// Silent fail - don't spam console during normal operation
}
}
function toggleAllTrackSelections(playlistId, checked) {
const tbody = document.getElementById(`download-tracks-tbody-${playlistId}`);
if (!tbody) return;
const checkboxes = tbody.querySelectorAll('.track-select-cb');
checkboxes.forEach(cb => { cb.checked = checked; });
updateTrackSelectionCount(playlistId);
}
function updateTrackSelectionCount(playlistId) {
const tbody = document.getElementById(`download-tracks-tbody-${playlistId}`);
if (!tbody) return;
const allCbs = tbody.querySelectorAll('.track-select-cb');
const checkedCbs = tbody.querySelectorAll('.track-select-cb:checked');
const total = allCbs.length;
const selected = checkedCbs.length;
// Update selection count label
const countLabel = document.getElementById(`track-selection-count-${playlistId}`);
if (countLabel) {
countLabel.textContent = `${selected} / ${total} tracks selected`;
}
// Update select-all checkbox state
const selectAll = document.getElementById(`select-all-${playlistId}`);
if (selectAll) {
selectAll.checked = selected === total;
selectAll.indeterminate = selected > 0 && selected < total;
}
// Update row dimming
allCbs.forEach(cb => {
const row = cb.closest('tr');
if (row) row.classList.toggle('track-deselected', !cb.checked);
});
// Disable Begin Analysis and Add to Wishlist buttons when 0 selected
const beginBtn = document.getElementById(`begin-analysis-btn-${playlistId}`);
if (beginBtn) {
beginBtn.disabled = selected === 0;
}
const wishlistBtn = document.getElementById(`add-to-wishlist-btn-${playlistId}`);
if (wishlistBtn) {
wishlistBtn.disabled = selected === 0;
}
}
async function cancelAllOperations(playlistId) {
const process = activeDownloadProcesses[playlistId];
if (!process) return;
// Prevent multiple cancel all operations
if (process.cancellingAll) {
console.log(`โ ๏ธ Cancel All already in progress for ${playlistId}`);
return;
}
process.cancellingAll = true;
console.log(`๐ซ Cancel All clicked for playlist ${playlistId} - closing modal and cleaning up server`);
showToast('Cancelling all operations and closing modal...', 'info');
// Mark process as complete immediately so polling stops
process.status = 'complete';
// Stop any active polling
if (process.poller) {
clearInterval(process.poller);
process.poller = null;
}
// Tell server to stop starting new downloads and clean up the batch
if (process.batchId) {
try {
// Cancel the batch (stops new downloads from starting)
const cancelResponse = await fetch(`/api/playlists/${process.batchId}/cancel_batch`, {
method: 'POST'
});
if (cancelResponse.ok) {
const cancelData = await cancelResponse.json();
console.log(`โ
Server stopped new downloads for batch ${process.batchId}`);
}
} catch (error) {
console.warn('Error during server batch cancel:', error);
}
}
// Close the modal immediately - this will handle cleanup
closeDownloadMissingModal(playlistId);
showToast('Modal closed. Active downloads will finish in background.', 'success');
}
function resetToInitialState() {
// Reset UI
document.getElementById('begin-analysis-btn').style.display = 'inline-block';
document.getElementById('start-downloads-btn').style.display = 'none';
document.getElementById('cancel-all-btn').style.display = 'none';
// Reset progress bars
document.getElementById('analysis-progress-fill').style.width = '0%';
document.getElementById('download-progress-fill').style.width = '0%';
document.getElementById('analysis-progress-text').textContent = 'Ready to start';
document.getElementById('download-progress-text').textContent = 'Waiting for analysis';
// Reset stats
document.getElementById('stat-found').textContent = '-';
document.getElementById('stat-missing').textContent = '-';
document.getElementById('stat-downloaded').textContent = '0';
// Reset track table
const tbody = document.getElementById('download-tracks-tbody');
if (tbody) {
const rows = tbody.querySelectorAll('tr');
rows.forEach((row, index) => {
const matchElement = row.querySelector('.track-match-status');
const downloadElement = row.querySelector('.track-download-status');
const actionsElement = row.querySelector('.track-actions');
if (matchElement) {
matchElement.textContent = '๐ Pending';
matchElement.className = 'track-match-status match-checking';
}
if (downloadElement) {
downloadElement.textContent = '-';
downloadElement.className = 'track-download-status';
}
if (actionsElement) {
actionsElement.textContent = '-';
}
});
}
// Reset state
activeAnalysisTaskId = null;
analysisResults = [];
missingTracks = [];
}
// ===============================
// NEW ATOMIC CANCEL SYSTEM V2
// ===============================
async function cancelTrackDownloadV2(playlistId, trackIndex) {
/**
* NEW ATOMIC CANCEL SYSTEM V2
*
* - No optimistic UI updates
* - Single API call handles everything atomically
* - Backend is single source of truth for all state
* - No race conditions or dual state management
*/
const process = activeDownloadProcesses[playlistId];
if (!process) {
console.warn(`โ [Cancel V2] No process found for playlist: ${playlistId}`);
return;
}
const row = document.querySelector(`#download-missing-modal-${CSS.escape(playlistId)} tr[data-track-index="${trackIndex}"]`);
if (!row) {
console.warn(`โ [Cancel V2] No row found for track index: ${trackIndex}`);
return;
}
// Check if already in cancelling state
const statusEl = document.getElementById(`download-${playlistId}-${trackIndex}`);
const currentStatus = statusEl ? statusEl.textContent : '';
if (currentStatus.includes('Cancelling') || currentStatus.includes('Cancelled')) {
console.log(`โ ๏ธ [Cancel V2] Task already being cancelled or cancelled: ${currentStatus}`);
return;
}
console.log(`๐ฏ [Cancel V2] Starting atomic cancel: playlist=${playlistId}, track=${trackIndex}`);
// V2 SYSTEM: Set temporary UI state - will be confirmed by server
row.dataset.uiState = 'cancelling';
// Show loading state only - no optimistic "cancelled" state
if (statusEl) {
statusEl.textContent = '๐ Cancelling...';
}
// Disable the cancel button to prevent double-clicks
const actionsEl = document.getElementById(`actions-${playlistId}-${trackIndex}`);
if (actionsEl) {
actionsEl.innerHTML = 'Cancelling... ';
}
try {
const response = await fetch('/api/downloads/cancel_task_v2', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
playlist_id: playlistId,
track_index: trackIndex
})
});
const data = await response.json();
if (data.success) {
console.log(`โ
[Cancel V2] Successfully cancelled: ${data.task_info.track_name}`);
showToast(`Cancelled "${data.task_info.track_name}" and added to wishlist.`, 'success');
// Let the status polling system update the UI with server truth
// No manual UI updates - backend is authoritative
} else {
console.error(`โ [Cancel V2] Cancel failed: ${data.error}`);
showToast(`Cancel failed: ${data.error}`, 'error');
// Reset UI to previous state on failure
row.dataset.uiState = 'normal'; // Reset UI state
if (statusEl) {
statusEl.textContent = 'โ Cancel Failed';
}
if (actionsEl) {
actionsEl.innerHTML = `ร `;
}
}
} catch (error) {
console.error(`โ [Cancel V2] Network/API error:`, error);
showToast(`Cancel request failed: ${error.message}`, 'error');
// Reset UI on network error
row.dataset.uiState = 'normal'; // Reset UI state
if (statusEl) {
statusEl.textContent = 'โ Cancel Failed';
}
if (actionsEl) {
actionsEl.innerHTML = `ร `;
}
}
}
// ===============================
// LEGACY CANCEL SYSTEM (OLD)
// ===============================
async function cancelTrackDownload(playlistId, trackIndex) {
const process = activeDownloadProcesses[playlistId];
if (!process) return;
const row = document.querySelector(`#download-missing-modal-${CSS.escape(playlistId)} tr[data-track-index="${trackIndex}"]`);
if (!row) return;
// Prevent double cancellation
if (row.dataset.locallyCancelled === 'true') {
return; // Already cancelled locally
}
const taskId = row.dataset.taskId;
if (!taskId) {
showToast('Task not started yet, cannot cancel.', 'warning');
return;
}
// UI update for immediate feedback - mark as cancelled FIRST to prevent race conditions
row.dataset.locallyCancelled = 'true';
document.getElementById(`download-${playlistId}-${trackIndex}`).textContent = '๐ซ Cancelling...';
document.getElementById(`actions-${playlistId}-${trackIndex}`).innerHTML = '-';
try {
const response = await fetch('/api/downloads/cancel_task', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ task_id: taskId })
});
const data = await response.json();
if (data.success) {
// Update final UI state after successful cancellation
document.getElementById(`download-${playlistId}-${trackIndex}`).textContent = '๐ซ Cancelled';
showToast('Download cancelled and added to wishlist.', 'info');
} else {
throw new Error(data.error);
}
} catch (error) {
// Reset UI state if cancellation failed
row.dataset.locallyCancelled = 'false';
document.getElementById(`download-${playlistId}-${trackIndex}`).textContent = 'โ Cancel Failed';
showToast(`Could not cancel task: ${error.message}`, 'error');
}
}
// Find and REPLACE the old startPlaylistSyncFromModal function
async function startPlaylistSync(playlistId) {
const startTime = Date.now();
console.log(`๐ [${new Date().toTimeString().split(' ')[0]}] Starting sync for playlist: ${playlistId}`);
const playlist = spotifyPlaylists.find(p => p.id === playlistId);
if (!playlist) {
console.error(`โ Could not find playlist data for ID: ${playlistId}`);
showToast('Could not find playlist data.', 'error');
return;
}
console.log(`โ
Found playlist: ${playlist.name} with ${playlist.track_count || 'unknown'} tracks`);
// Check if already syncing to prevent duplicate syncs
if (activeSyncPollers[playlistId]) {
showToast('Sync already in progress for this playlist', 'warning');
return;
}
// Update button state immediately for user feedback
const syncBtn = document.getElementById(`sync-btn-${playlistId}`);
if (syncBtn) {
syncBtn.disabled = true;
syncBtn.textContent = 'โณ Syncing...';
}
// Ensure we have the full track list before starting
let tracks = playlistTrackCache[playlistId];
if (!tracks) {
const trackFetchStart = Date.now();
console.log(`๐ [${new Date().toTimeString().split(' ')[0]}] Cache miss - fetching tracks for playlist ${playlistId}`);
try {
const response = await fetch(`/api/spotify/playlist/${playlistId}`);
const fullPlaylist = await response.json();
if (fullPlaylist.error) throw new Error(fullPlaylist.error);
tracks = fullPlaylist.tracks;
playlistTrackCache[playlistId] = tracks; // Cache it
const trackFetchTime = Date.now() - trackFetchStart;
console.log(`โ
[${new Date().toTimeString().split(' ')[0]}] Fetched and cached ${tracks.length} tracks (took ${trackFetchTime}ms)`);
} catch (error) {
console.error(`โ Failed to fetch tracks:`, error);
showToast(`Failed to fetch tracks for sync: ${error.message}`, 'error');
return;
}
} else {
console.log(`โ
[${new Date().toTimeString().split(' ')[0]}] Using cached tracks: ${tracks.length} tracks`);
}
// DON'T close the modal - let it show live progress like the GUI
try {
const syncStartTime = Date.now();
console.log(`๐ [${new Date().toTimeString().split(' ')[0]}] Making API call to /api/sync/start with ${tracks.length} tracks`);
const response = await fetch('/api/sync/start', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
playlist_id: playlist.id,
playlist_name: playlist.name,
tracks: tracks // Send the full track list
})
});
const syncRequestTime = Date.now() - syncStartTime;
console.log(`๐ก [${new Date().toTimeString().split(' ')[0]}] API response status: ${response.status} (took ${syncRequestTime}ms)`);
const data = await response.json();
console.log(`๐ก [${new Date().toTimeString().split(' ')[0]}] API response data:`, data);
if (!data.success) throw new Error(data.error);
const totalTime = Date.now() - startTime;
console.log(`โ
[${new Date().toTimeString().split(' ')[0]}] Sync started successfully for "${playlist.name}" (total time: ${totalTime}ms)`);
showToast(`Sync started for "${playlist.name}"`, 'success');
// Show initial sync state in modal if open
const modal = document.getElementById('playlist-details-modal');
if (modal && modal.style.display !== 'none') {
const statusDisplay = document.getElementById(`modal-sync-status-${playlist.id}`);
if (statusDisplay) {
statusDisplay.style.display = 'flex';
console.log(`๐ [${new Date().toTimeString().split(' ')[0]}] Showing modal sync status for ${playlist.id}`);
}
}
updateCardToSyncing(playlist.id, 0); // Initial state
startSyncPolling(playlist.id);
} catch (error) {
console.error(`โ Failed to start sync:`, error);
showToast(`Failed to start sync: ${error.message}`, 'error');
updateCardToDefault(playlist.id);
}
}
// Add these new helper functions to script.js
function startSyncPolling(playlistId) {
// Clear any existing poller for this playlist
if (activeSyncPollers[playlistId]) {
clearInterval(activeSyncPollers[playlistId]);
}
// Phase 5: Subscribe via WebSocket
if (socketConnected) {
socket.emit('sync:subscribe', { playlist_ids: [playlistId] });
_syncProgressCallbacks[playlistId] = (data) => {
if (data.status === 'syncing') {
const progress = data.progress;
updateCardToSyncing(playlistId, progress.progress, progress);
updateModalSyncProgress(playlistId, progress);
} else if (data.status === 'finished' || data.status === 'error' || data.status === 'cancelled') {
stopSyncPolling(playlistId);
updateCardToDefault(playlistId, data);
closePlaylistDetailsModal();
}
};
}
// Start a new poller that checks every 2 seconds
console.log(`๐ Starting sync polling for playlist: ${playlistId}`);
activeSyncPollers[playlistId] = setInterval(async () => {
if (socketConnected) return; // Phase 5: WS handles updates
try {
console.log(`๐ Polling sync status for: ${playlistId}`);
const response = await fetch(`/api/sync/status/${playlistId}`);
const state = await response.json();
console.log(`๐ Poll response:`, state);
if (state.status === 'syncing') {
const progress = state.progress;
console.log(`๐ Sync progress:`, progress);
console.log(` ๐ Progress values: ${progress.progress}% | Total: ${progress.total_tracks} | Matched: ${progress.matched_tracks} | Failed: ${progress.failed_tracks}`);
console.log(` ๐ Current step: "${progress.current_step}" | Current track: "${progress.current_track}"`);
// Use the actual progress percentage from the sync service
updateCardToSyncing(playlistId, progress.progress, progress);
// Also update the modal if it's open
updateModalSyncProgress(playlistId, progress);
} else if (state.status === 'finished' || state.status === 'error' || state.status === 'cancelled') {
console.log(`๐ Sync completed with status: ${state.status}`);
stopSyncPolling(playlistId);
updateCardToDefault(playlistId, state);
// Also update the modal if it's open
closePlaylistDetailsModal(); // Close modal on completion/error
}
} catch (error) {
console.error(`โ Error polling sync status for ${playlistId}:`, error);
stopSyncPolling(playlistId);
updateCardToDefault(playlistId, { status: 'error', error: 'Polling failed' });
}
}, 2000); // Poll every 2 seconds
updateRefreshButtonState();
}
function stopSyncPolling(playlistId) {
if (activeSyncPollers[playlistId]) {
clearInterval(activeSyncPollers[playlistId]);
delete activeSyncPollers[playlistId];
}
// Phase 5: Unsubscribe and clean up callback
if (_syncProgressCallbacks[playlistId]) {
if (socketConnected) socket.emit('sync:unsubscribe', { playlist_ids: [playlistId] });
delete _syncProgressCallbacks[playlistId];
}
updateRefreshButtonState();
}
// Sync sidebar visibility helpers
function showSyncSidebar() {
const sidebar = document.querySelector('.sync-sidebar');
const contentArea = document.querySelector('.sync-content-area');
if (sidebar && contentArea && window.innerWidth > 1300) {
sidebar.style.display = '';
contentArea.style.gridTemplateColumns = '2.5fr 0.75fr';
}
}
function hideSyncSidebar() {
const sidebar = document.querySelector('.sync-sidebar');
const contentArea = document.querySelector('.sync-content-area');
if (sidebar && contentArea) {
sidebar.style.display = 'none';
contentArea.style.gridTemplateColumns = '1fr';
}
}
// Sequential Sync Functions
function startSequentialSync() {
// Initialize manager if needed
if (!sequentialSyncManager) {
sequentialSyncManager = new SequentialSyncManager();
}
// Check if already running - if so, cancel
if (sequentialSyncManager.isRunning) {
sequentialSyncManager.cancel();
return;
}
// Validate selection
if (selectedPlaylists.size === 0) {
showToast('No playlists selected for sync', 'error');
return;
}
// Get playlist order from DOM to maintain display order
const playlistCards = document.querySelectorAll('.playlist-card');
const orderedPlaylistIds = [];
playlistCards.forEach(card => {
const playlistId = card.dataset.playlistId;
if (selectedPlaylists.has(playlistId)) {
orderedPlaylistIds.push(playlistId);
}
});
console.log(`๐ Starting sequential sync for ${orderedPlaylistIds.length} playlists`);
// Show sidebar for sync progress
showSyncSidebar();
// Start sequential sync
sequentialSyncManager.start(orderedPlaylistIds);
// Disable playlist selection during sync
disablePlaylistSelection(true);
}
function disablePlaylistSelection(disabled) {
const checkboxes = document.querySelectorAll('.playlist-checkbox');
checkboxes.forEach(checkbox => {
checkbox.disabled = disabled;
});
}
function hasActiveOperations() {
const hasActiveSyncs = Object.keys(activeSyncPollers).length > 0;
// Only check non-wishlist download processes for sync page refresh button
const hasActiveDownloads = Object.entries(activeDownloadProcesses)
.filter(([playlistId, process]) => playlistId !== 'wishlist') // Exclude wishlist
.some(([_, process]) => process.status === 'running');
const hasSequentialSync = sequentialSyncManager && sequentialSyncManager.isRunning;
return hasActiveSyncs || hasActiveDownloads || hasSequentialSync;
}
function updateRefreshButtonState() {
const refreshBtn = document.getElementById('spotify-refresh-btn');
if (!refreshBtn) return;
if (hasActiveOperations()) {
refreshBtn.disabled = true;
// Provide context-specific text
const hasActiveSyncs = Object.keys(activeSyncPollers).length > 0;
const hasSequentialSync = sequentialSyncManager && sequentialSyncManager.isRunning;
if (hasActiveSyncs || hasSequentialSync) {
refreshBtn.textContent = '๐ Syncing...';
} else {
refreshBtn.textContent = '๐ฅ Downloading...';
}
} else {
refreshBtn.disabled = false;
refreshBtn.textContent = '๐ Refresh';
}
}
function updateCardToSyncing(playlistId, percent, progress = null) {
const card = document.querySelector(`.playlist-card[data-playlist-id="${playlistId}"]`);
if (!card) return;
const progressBar = card.querySelector('.sync-progress-indicator');
progressBar.style.display = 'block';
let progressText = 'Starting...';
let actualPercent = percent || 0;
if (progress) {
// Create detailed progress text like the GUI
const matched = progress.matched_tracks || 0;
const failed = progress.failed_tracks || 0;
const total = progress.total_tracks || 0;
const currentStep = progress.current_step || 'Processing';
// Calculate actual progress as processed/total, not just successful/total
if (total > 0) {
const processed = matched + failed;
actualPercent = Math.round((processed / total) * 100);
progressText = `${currentStep}: ${processed}/${total} (${matched} matched, ${failed} failed)`;
} else {
progressText = currentStep;
}
// If there's a current track being processed, show it
if (progress.current_track) {
progressText += ` - ${progress.current_track}`;
}
}
// Build live status counter HTML (same as modal)
let statusCounterHTML = '';
if (progress && progress.total_tracks > 0) {
const matched = progress.matched_tracks || 0;
const failed = progress.failed_tracks || 0;
const total = progress.total_tracks || 0;
const processed = matched + failed;
const percentage = total > 0 ? Math.round((processed / total) * 100) : 0;
statusCounterHTML = `
โช ${total}
/
โ ${matched}
/
โ ${failed}
(${percentage}%)
`;
}
progressBar.innerHTML = `
${statusCounterHTML}
${progressText}
`;
}
function updateCardToDefault(playlistId, finalState = null) {
const card = document.querySelector(`.playlist-card[data-playlist-id="${playlistId}"]`);
if (!card) return;
const progressBar = card.querySelector('.sync-progress-indicator');
progressBar.style.display = 'none';
progressBar.innerHTML = '';
const statusEl = card.querySelector('.playlist-card-status');
if (finalState) {
if (finalState.status === 'finished') {
statusEl.textContent = `Synced: Just now`;
statusEl.className = 'playlist-card-status status-synced';
// Check if any tracks were added to wishlist
const wishlistCount = finalState.progress?.wishlist_added_count || finalState.result?.wishlist_added_count || 0;
const playlistName = card.querySelector('.playlist-card-name').textContent;
if (wishlistCount > 0) {
showToast(`Sync complete for "${playlistName}". Added ${wishlistCount} missing track${wishlistCount > 1 ? 's' : ''} to wishlist.`, 'success');
} else {
showToast(`Sync complete for "${playlistName}"`, 'success');
}
} else {
statusEl.textContent = `Sync Failed`;
statusEl.className = 'playlist-card-status status-needs-sync'; // Or a new error class
showToast(`Sync failed: ${finalState.error || 'Unknown error'}`, 'error');
}
}
}
// Update the modal's sync progress display (matches GUI functionality)
function updateModalSyncProgress(playlistId, progress) {
const modal = document.getElementById('playlist-details-modal');
if (modal && modal.style.display !== 'none') {
console.log(`๐ Updating modal sync progress for ${playlistId}:`, progress);
// Show sync status display
const statusDisplay = document.getElementById(`modal-sync-status-${playlistId}`);
if (statusDisplay) {
statusDisplay.style.display = 'flex';
// Update counters (matching GUI exactly)
const totalEl = document.getElementById(`modal-total-${playlistId}`);
const matchedEl = document.getElementById(`modal-matched-${playlistId}`);
const failedEl = document.getElementById(`modal-failed-${playlistId}`);
const percentageEl = document.getElementById(`modal-percentage-${playlistId}`);
const total = progress.total_tracks || 0;
const matched = progress.matched_tracks || 0;
const failed = progress.failed_tracks || 0;
if (totalEl) totalEl.textContent = total;
if (matchedEl) matchedEl.textContent = matched;
if (failedEl) failedEl.textContent = failed;
// Calculate percentage like GUI
if (total > 0) {
const processed = matched + failed;
const percentage = Math.round((processed / total) * 100);
if (percentageEl) percentageEl.textContent = percentage;
}
console.log(`๐ Modal updated: โช ${total} / โ ${matched} / โ ${failed} (${Math.round((matched + failed) / total * 100)}%)`);
} else {
console.warn(`โ Modal sync status display not found for ${playlistId}`);
}
} else {
console.log(`๐ Modal not open for ${playlistId}, skipping update`);
}
}
// Download tracking state management - matching GUI functionality
let activeDownloads = {};
let finishedDownloads = {};
let downloadStatusInterval = null;
let isDownloadPollingActive = false;
async function loadDownloadsData() {
// Downloads page loads search results dynamically
console.log('Downloads page loaded');
// Event listeners are already set up in initializeSearch() - don't duplicate them
const clearButton = document.querySelector('.controls-panel__clear-btn');
const cancelAllButton = document.querySelector('.controls-panel__cancel-all-btn');
if (clearButton) {
clearButton.addEventListener('click', clearFinishedDownloads);
}
if (cancelAllButton) {
cancelAllButton.addEventListener('click', cancelAllDownloads);
}
// Start sophisticated polling system (1-second interval like GUI)
startDownloadPolling();
// Initialize tab management
initializeDownloadTabs();
}
function startDownloadPolling() {
if (isDownloadPollingActive) return;
console.log('Starting download status polling (1-second interval)');
isDownloadPollingActive = true;
// Initial call
updateDownloadQueues();
// Start 1-second polling (matching GUI's 1000ms timer)
downloadStatusInterval = setInterval(updateDownloadQueues, 1000);
}
function stopDownloadPolling() {
if (downloadStatusInterval) {
clearInterval(downloadStatusInterval);
downloadStatusInterval = null;
}
isDownloadPollingActive = false;
console.log('Stopped download status polling');
}
async function updateDownloadQueues() {
if (document.hidden) return; // Skip polling when tab is not visible
try {
const response = await fetch('/api/downloads/status');
const data = await response.json();
if (data.error) {
console.error("Error fetching download status:", data.error);
return;
}
const newActive = {};
const newFinished = {};
// Terminal states matching GUI logic
const terminalStates = ['Completed', 'Succeeded', 'Cancelled', 'Canceled', 'Failed', 'Errored'];
// Process transfers exactly like GUI
data.transfers.forEach(item => {
const isTerminal = terminalStates.some(state =>
item.state && item.state.includes(state)
);
if (isTerminal) {
newFinished[item.id] = item;
} else {
newActive[item.id] = item;
}
});
// Update global state
activeDownloads = newActive;
finishedDownloads = newFinished;
// Render both queues
renderQueue('active-queue', activeDownloads, true);
renderQueue('finished-queue', finishedDownloads, false);
// Update tab counts
updateTabCounts();
// Update stats in the side panel
updateDownloadStats();
} catch (error) {
// Only log errors occasionally to avoid console spam
if (Math.random() < 0.1) {
console.error("Failed to update download queues:", error);
}
}
}
function renderQueue(containerId, downloads, isActiveQueue) {
const container = document.getElementById(containerId);
if (!container) return;
const downloadIds = Object.keys(downloads);
if (downloadIds.length === 0) {
container.innerHTML = `${isActiveQueue ? 'No active downloads.' : 'No finished downloads.'}
`;
return;
}
let html = '';
for (const id of downloadIds) {
const item = downloads[id];
// Extract display title from filename
let title = 'Unknown File';
if (item.filename) {
// YouTube/Tidal filenames are encoded as "id||title"
if ((item.username === 'youtube' || item.username === 'tidal') && item.filename.includes('||')) {
const parts = item.filename.split('||');
title = parts[1] || parts[0]; // Use title part, fallback to id
} else {
// Regular Soulseek filename - extract last part of path
title = item.filename.split(/[\\/]/).pop();
}
}
const progress = item.percentComplete || 0;
const bytesTransferred = item.bytesTransferred || 0;
const totalBytes = item.size || 0;
const speed = item.averageSpeed || 0;
// Format file size
const formatSize = (bytes) => {
if (!bytes) return 'Unknown size';
const units = ['B', 'KB', 'MB', 'GB'];
let size = bytes;
let unitIndex = 0;
while (size >= 1024 && unitIndex < units.length - 1) {
size /= 1024;
unitIndex++;
}
return `${size.toFixed(1)} ${units[unitIndex]}`;
};
// Format speed
const formatSpeed = (bytesPerSecond) => {
if (!bytesPerSecond || bytesPerSecond <= 0) return '';
return `${formatSize(bytesPerSecond)}/s`;
};
let actionButtonHTML = '';
if (isActiveQueue) {
// Active items get progress bar and cancel button
actionButtonHTML = `
${item.state} - ${progress.toFixed(1)}%
${speed > 0 ? `โข ${formatSpeed(speed)}` : ''}
${totalBytes > 0 ? `โข ${formatSize(bytesTransferred)} / ${formatSize(totalBytes)}` : ''}
โ Cancel
`;
} 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}
๐ Open
`;
}
html += `
`;
}
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 = '';
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 += `Disc ${discNum}
`;
}
const trackSize = track.size ? `${(track.size / 1024 / 1024).toFixed(1)} MB` : 'Unknown size';
const trackBitrate = track.bitrate ? `${track.bitrate}kbps` : '';
trackListHtml += `
${escapeHtml(track.title || `Track ${trackIndex + 1}`)}
${track.track_number ? `${track.track_number}. ` : ''}${escapeHtml(track.artist || result.artist || 'Unknown Artist')} โข ${trackSize} โข ${escapeHtml(track.quality || 'Unknown')} ${trackBitrate}
Stream โถ
Download โฌ
Matched Download ๐ฏ
`;
});
}
html += `
`;
} else {
const sizeText = result.size ? `${(result.size / 1024 / 1024).toFixed(1)} MB` : 'Unknown size';
const bitrateText = result.bitrate ? `${result.bitrate}kbps` : '';
html += `
๐ต
${escapeHtml(result.title || 'Unknown Title')}
by ${escapeHtml(result.artist || 'Unknown Artist')}
${sizeText} โข ${escapeHtml(result.quality || 'Unknown')} ${bitrateText}
Shared by ${escapeHtml(result.username || 'Unknown')}
Stream โถ
Download โฌ
Matched Download๐ฏ
`;
}
});
resultsArea.innerHTML = html;
// Store results globally for download functions
window.currentSearchResults = results;
}
async function downloadTrack(index) {
const results = window.currentSearchResults;
if (!results || !results[index]) return;
const track = results[index];
try {
const response = await fetch('/api/download', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(track)
});
const data = await response.json();
if (data.success) {
showToast(`Download started: ${track.title}`, 'success');
} else {
showToast(`Download failed: ${data.error}`, 'error');
}
} catch (error) {
console.error('Download error:', error);
showToast('Failed to start download', 'error');
}
}
async function downloadAlbum(index) {
const results = window.currentSearchResults;
if (!results || !results[index]) return;
const album = results[index];
try {
const response = await fetch('/api/download', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(album)
});
const data = await response.json();
if (data.success) {
showToast(data.message, 'success');
} else {
showToast(`Album download failed: ${data.error}`, 'error');
}
} catch (error) {
console.error('Album download error:', error);
showToast('Failed to start album download', 'error');
}
}
// Matched download functions
function matchedDownloadTrack(index) {
const results = window.currentSearchResults;
if (!results || !results[index]) return;
const track = results[index];
console.log('๐ฏ Starting matched download for single track:', track);
// Open matching modal for single track
openMatchingModal(track, false, null);
}
function matchedDownloadAlbum(index) {
const results = window.currentSearchResults;
if (!results || !results[index]) return;
const album = results[index];
console.log('๐ฏ Starting matched download for album:', album);
// Open matching modal for album download
openMatchingModal(album, true, album);
}
function matchedDownloadAlbumTrack(albumIndex, trackIndex) {
const results = window.currentSearchResults;
if (!results || !results[albumIndex]) return;
const album = results[albumIndex];
if (!album.tracks || !album.tracks[trackIndex]) return;
const track = album.tracks[trackIndex];
// Ensure track has necessary properties from parent album
track.username = album.username;
track.artist = track.artist || album.artist;
track.album = album.album_title || album.title;
console.log('๐ฏ Starting matched download for album track:', track);
// Open matching modal for single track (from album context)
openMatchingModal(track, false, null);
}
function toggleAlbumExpansion(albumIndex) {
const albumCard = document.querySelector(`[data-album-index="${albumIndex}"]`);
if (!albumCard) return;
const trackList = albumCard.querySelector('.album-track-list');
const indicator = albumCard.querySelector('.album-expand-indicator');
if (trackList.style.display === 'none' || !trackList.style.display) {
// Expand
trackList.style.display = 'block';
indicator.textContent = 'โผ';
albumCard.classList.add('expanded');
} else {
// Collapse
trackList.style.display = 'none';
indicator.textContent = 'โถ';
albumCard.classList.remove('expanded');
}
}
async function downloadAlbumTrack(albumIndex, trackIndex) {
const results = window.currentSearchResults;
if (!results || !results[albumIndex] || !results[albumIndex].tracks || !results[albumIndex].tracks[trackIndex]) return;
const track = results[albumIndex].tracks[trackIndex];
try {
const response = await fetch('/api/download', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
...track,
result_type: 'track'
})
});
const data = await response.json();
if (data.success) {
showToast(`Download started: ${track.title}`, 'success');
} else {
showToast(`Track download failed: ${data.error}`, 'error');
}
} catch (error) {
console.error('Track download error:', error);
showToast('Failed to start track download', 'error');
}
}
// ===============================
// STREAMING WRAPPER FUNCTIONS
// ===============================
async function streamTrack(index) {
// Stream a single track from search results
try {
console.log(`๐ต streamTrack called with index: ${index}`);
console.log(`๐ต window.currentSearchResults:`, window.currentSearchResults);
if (!window.currentSearchResults || !window.currentSearchResults[index]) {
console.error(`โ No search results or invalid index. Results length: ${window.currentSearchResults ? window.currentSearchResults.length : 'undefined'}`);
showToast('Track not found', 'error');
return;
}
const result = window.currentSearchResults[index];
console.log(`๐ต Streaming track:`, result);
// Check for unsupported formats before streaming (YouTube/Tidal use encoded filenames, skip check)
const isStreamingSource = result.username === 'youtube' || result.username === 'tidal';
if (!isStreamingSource && result.filename) {
const format = getFileExtension(result.filename);
console.log(`๐ต [STREAM CHECK] File: ${result.filename}, Extension: ${format}`);
const isSupported = isAudioFormatSupported(result.filename);
console.log(`๐ต [STREAM CHECK] Format ${format} supported: ${isSupported}`);
if (!isSupported) {
showToast(`Sorry, ${format.toUpperCase()} format is not supported in your browser. Try downloading instead.`, 'error');
return;
}
}
await startStream(result);
} catch (error) {
console.error('Track streaming error:', error);
showToast('Failed to start track stream', 'error');
}
}
async function streamAlbumTrack(albumIndex, trackIndex) {
// Stream a specific track from an album
try {
console.log(`๐ต streamAlbumTrack called with albumIndex: ${albumIndex}, trackIndex: ${trackIndex}`);
console.log(`๐ต window.currentSearchResults:`, window.currentSearchResults);
if (!window.currentSearchResults || !window.currentSearchResults[albumIndex]) {
console.error(`โ No search results or invalid album index. Results length: ${window.currentSearchResults ? window.currentSearchResults.length : 'undefined'}`);
showToast('Album not found', 'error');
return;
}
const album = window.currentSearchResults[albumIndex];
console.log(`๐ต Album data:`, album);
// Surgical Fix: Handle YouTube/Tidal results which are "flat" (no tracks array)
if (album.username === 'youtube' || album.username === 'tidal') {
// For YouTube/Tidal results, the "album" is actually the track itself
const track = album;
const trackData = {
...track,
username: track.username,
filename: track.filename,
artist: track.artist,
album: track.title, // Use title as album name for player
title: track.title
};
console.log(`๐ต Streaming YouTube track directly:`, trackData);
await startStream(trackData);
return;
}
if (!album.tracks || !album.tracks[trackIndex]) {
console.error(`โ No tracks in album or invalid track index. Tracks length: ${album.tracks ? album.tracks.length : 'undefined'}`);
showToast('Track not found in album', 'error');
return;
}
const track = album.tracks[trackIndex];
console.log(`๐ต Streaming album track:`, track);
// Ensure album tracks have required fields
const trackData = {
...track,
username: track.username || album.username,
filename: track.filename || track.path,
artist: track.artist || album.artist,
album: track.album || album.title || album.album
};
console.log(`๐ต Enhanced track data:`, trackData);
// Check for unsupported formats before streaming (YouTube/Tidal use encoded filenames, skip check)
const isStreamingSource2 = trackData.username === 'youtube' || trackData.username === 'tidal';
if (!isStreamingSource2 && trackData.filename && !isAudioFormatSupported(trackData.filename)) {
const format = getFileExtension(trackData.filename);
showToast(`Sorry, ${format.toUpperCase()} format is not supported in web browsers. Try downloading instead.`, 'error');
return;
}
await startStream(trackData);
} catch (error) {
console.error('Album track streaming error:', error);
showToast('Failed to start track stream', 'error');
}
}
async function loadArtistsData() {
try {
const response = await fetch(API.artists);
const data = await response.json();
const artistsGrid = document.getElementById('artists-grid');
if (data.artists && data.artists.length) {
artistsGrid.innerHTML = data.artists.map(artist => `
${artist.image ?
`
` :
'
๐ต
'
}
${escapeHtml(artist.name)}
${artist.album_count || 0} albums
`).join('');
} else {
artistsGrid.innerHTML = 'No artists found
';
}
} catch (error) {
console.error('Error loading artists data:', error);
document.getElementById('artists-grid').innerHTML = 'Error loading artists
';
}
}
// ===============================
// 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 (after a tiny delay to ensure modal is rendered)
setTimeout(() => searchDiscoveryFix(), 100);
}
/**
* 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 useItunes = discoverySource === 'itunes';
const resultsContainer = fixModalOverlay.querySelector('#fix-modal-results');
const sourceLabel = useItunes ? 'iTunes' : 'Spotify';
resultsContainer.innerHTML = `๐ Searching ${sourceLabel}...
`;
try {
// Build search query
const query = `${artistInput} ${trackInput}`.trim();
// Call appropriate search API based on discovery source
const searchEndpoint = useItunes ? '/api/itunes/search_tracks' : '/api/spotify/search_tracks';
const response = await fetch(`${searchEndpoint}?query=${encodeURIComponent(query)}&limit=20`);
const data = await response.json();
if (data.error) {
resultsContainer.innerHTML = `โ ${data.error}
`;
return;
}
if (!data.tracks || data.tracks.length === 0) {
resultsContainer.innerHTML = 'No matches found. Try different search terms.
';
return;
}
// Render results (same format for both Spotify and iTunes)
renderDiscoveryFixResults(data.tracks, fixModalOverlay);
} catch (error) {
console.error('Search error:', error);
resultsContainer.innerHTML = 'โ Search failed. Try again.
';
}
}
/**
* Render search results as clickable cards
*/
function renderDiscoveryFixResults(tracks, fixModalOverlay) {
const resultsContainer = fixModalOverlay.querySelector('#fix-modal-results');
resultsContainer.innerHTML = '';
tracks.forEach(track => {
const card = document.createElement('div');
card.className = 'fix-result-card';
card.onclick = () => selectDiscoveryFixTrack(track);
card.innerHTML = `
${escapeHtml(track.name || 'Unknown Track')}
${escapeHtml((track.artists || ['Unknown Artist']).join(', '))}
${escapeHtml(track.album || 'Unknown Album')}
${formatDuration(track.duration_ms || 0)}
`;
resultsContainer.appendChild(card);
});
}
/**
* User selected a track - update discovery state
*/
async function selectDiscoveryFixTrack(track) {
console.log('โ
User selected track:', track);
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 === '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;
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;
}
}
}
// 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 ? `
` : ''}
`;
return `
${heroBackgroundImage}
${heroContent}
`;
}
/**
* Generate the track list HTML for the wishlist modal
*/
function generateWishlistTrackList(tracks, trackOwnership) {
if (!tracks || tracks.length === 0) {
return 'No tracks found
';
}
return tracks.map((track, index) => {
const trackNumber = track.track_number || (index + 1);
const trackName = escapeHtml(track.name || 'Unknown Track');
const artistsString = formatArtists(track.artists) || 'Unknown Artist';
const duration = formatDuration(track.duration_ms);
const trackData = trackOwnership ? trackOwnership[track.name] : null;
const isOwned = trackData && (trackData.owned === true || trackData === true);
const isKnown = trackData !== null && trackData !== undefined;
const ownershipClass = isOwned ? 'owned' : (isKnown && !isOwned ? 'missing' : '');
const badge = isOwned
? ''
: '';
return `
${trackNumber}
${trackName}
${artistsString}
${duration}
${badge}
`;
}).join('');
}
/**
* Handle the "Add to Wishlist" button click
*/
async function handleAddToWishlist() {
if (!currentWishlistModalData) {
console.error('โ No wishlist modal data available');
return;
}
const { album, artist, tracks, albumType } = currentWishlistModalData;
const addToWishlistBtn = document.getElementById('confirm-add-to-wishlist-btn');
try {
// Show loading state
if (addToWishlistBtn) {
addToWishlistBtn.classList.add('loading');
addToWishlistBtn.textContent = 'Adding...';
addToWishlistBtn.disabled = true;
}
console.log(`๐ Adding ${tracks.length} tracks to wishlist for: ${artist.name} - ${album.name}`);
let successCount = 0;
let errorCount = 0;
// Add each track to wishlist individually
for (const track of tracks) {
try {
// Ensure artists field is in the correct format (array of objects)
let formattedArtists = track.artists;
if (typeof track.artists === 'string') {
// If artists is a string, convert to array of objects
formattedArtists = [{ name: track.artists }];
} else if (Array.isArray(track.artists)) {
// If artists is already an array, ensure each item is an object
formattedArtists = track.artists.map(artistItem => {
if (typeof artistItem === 'string') {
return { name: artistItem };
} else if (typeof artistItem === 'object' && artistItem !== null) {
return artistItem;
} else {
return { name: 'Unknown Artist' };
}
});
} else {
// Fallback to array with single artist object
formattedArtists = [{ name: artist.name }];
}
const formattedTrack = {
...track,
artists: formattedArtists
};
// Use track's album data if available (from API), falling back to modal's album data
// This ensures consistency with how the Artists page handles wishlisting
let trackAlbum = track.album;
let trackAlbumType = albumType || 'album';
if (trackAlbum && typeof trackAlbum === 'object') {
// Track has album data from API - use its album_type
trackAlbumType = trackAlbum.album_type || albumType || 'album';
// Ensure album has required fields
if (!trackAlbum.name) {
trackAlbum.name = album.name;
}
if (!trackAlbum.id) {
trackAlbum.id = album.id;
}
} else {
// Fall back to the album passed to the modal
trackAlbum = album;
}
console.log(`๐ Adding track with formatted artists:`, formattedTrack.name, formattedTrack.artists);
console.log(`๐ Using album_type: ${trackAlbumType} (from ${track.album ? 'track.album' : 'modal album'})`);
const response = await fetch('/api/add-album-to-wishlist', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
track: formattedTrack,
artist: artist,
album: trackAlbum,
source_type: 'album',
source_context: {
album_name: trackAlbum.name,
artist_name: artist.name,
album_type: trackAlbumType
}
})
});
const result = await response.json();
if (result.success) {
successCount++;
console.log(`โ
Added "${track.name}" to wishlist`);
} else {
errorCount++;
console.error(`โ Failed to add "${track.name}" to wishlist: ${result.error}`);
}
} catch (error) {
errorCount++;
console.error(`โ Error adding "${track.name}" to wishlist:`, error);
}
}
// Show completion message
if (successCount > 0) {
const message = errorCount > 0
? `Added ${successCount}/${tracks.length} tracks to wishlist (${errorCount} failed)`
: `Added ${successCount} tracks to wishlist`;
showToast(message, successCount === tracks.length ? 'success' : 'warning');
} else {
showToast('Failed to add any tracks to wishlist', 'error');
}
// Close the modal
closeAddToWishlistModal();
console.log(`โ
Wishlist addition complete: ${successCount} successful, ${errorCount} failed`);
} catch (error) {
console.error('โ Error in handleAddToWishlist:', error);
showToast(`Error adding to wishlist: ${error.message}`, 'error');
} finally {
// Reset button state
if (addToWishlistBtn) {
addToWishlistBtn.classList.remove('loading');
addToWishlistBtn.textContent = 'Add to Wishlist';
addToWishlistBtn.disabled = false;
}
}
}
/**
* Lazy-load per-track ownership indicators into an already-open wishlist modal.
* Fetches ownership from the backend, then updates the modal DOM in-place.
* If all tracks are owned (Spotify metadata discrepancy), also fixes the source card.
*/
async function lazyLoadTrackOwnership(artistName, tracks, sourceCard) {
const myVersion = wishlistModalVersion;
try {
const resp = await fetch('/api/library/check-tracks', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
artist_name: artistName,
tracks: tracks.map(t => ({ name: t.name, track_number: t.track_number }))
})
});
const data = await resp.json();
if (!data.success) return;
// Guard against stale updates if user reopened modal for a different album
if (myVersion !== wishlistModalVersion) return;
const ownership = data.owned_tracks;
const trackItems = document.querySelectorAll('#wishlist-track-list .wishlist-track-item');
let ownedCount = 0;
trackItems.forEach((item, index) => {
const track = tracks[index];
if (!track) return;
const trackData = ownership[track.name];
const isOwned = trackData && trackData.owned === true;
if (isOwned) {
ownedCount++;
item.classList.add('owned');
// Add metadata line below track name
const trackInfo = item.querySelector('.wishlist-track-info');
if (trackInfo && (trackData.format || trackData.bitrate)) {
const metaDiv = document.createElement('div');
metaDiv.className = 'wishlist-track-meta';
let metaHtml = '';
if (trackData.format === 'MP3' && trackData.bitrate) {
metaHtml += `MP3 ${trackData.bitrate} `;
} else {
if (trackData.format) {
metaHtml += `${trackData.format} `;
}
if (trackData.bitrate) {
metaHtml += `${trackData.bitrate} kbps `;
}
}
metaDiv.innerHTML = metaHtml;
trackInfo.appendChild(metaDiv);
}
const badge = document.createElement('div');
badge.className = 'wishlist-track-badge owned';
badge.innerHTML = ' ';
item.appendChild(badge);
} else {
item.classList.add('missing');
}
});
// Aggregate format summary from owned tracks
const formatSet = new Set();
for (const trackName of Object.keys(ownership)) {
const td = ownership[trackName];
if (td && td.owned && td.format) {
if (td.format === 'MP3' && td.bitrate) {
formatSet.add(`MP3-${td.bitrate}`);
} else {
formatSet.add(td.format);
}
}
}
if (formatSet.size > 0) {
const heroDetailsContainer = document.querySelector('.add-to-wishlist-modal-hero-details');
if (heroDetailsContainer) {
// Remove any existing format tag
const existing = heroDetailsContainer.querySelector('.modal-format-tag');
if (existing) existing.remove();
const formatTag = document.createElement('span');
formatTag.className = 'modal-format-tag';
formatTag.textContent = [...formatSet].sort().join(' / ');
heroDetailsContainer.appendChild(formatTag);
}
}
// Update hero subtitle with missing count
const missingCount = tracks.length - ownedCount;
const heroDetails = document.querySelectorAll('.add-to-wishlist-modal-hero-detail');
const trackDetailEl = heroDetails.length > 1 ? heroDetails[heroDetails.length - 1] : null;
if (trackDetailEl && missingCount > 0 && ownedCount > 0) {
trackDetailEl.textContent = `${missingCount} of ${tracks.length} tracks missing`;
}
// If ALL returned tracks are owned, this is a Spotify metadata discrepancy
// (e.g. total_tracks says 15 but API only returns 14, and all 14 are owned)
// Fix the source card to show complete
if (missingCount === 0 && sourceCard && sourceCard._releaseData) {
sourceCard._releaseData.track_completion = {
owned_tracks: ownedCount,
total_tracks: tracks.length,
percentage: 100,
missing_tracks: 0
};
const completionText = sourceCard.querySelector('.completion-text');
if (completionText) {
completionText.textContent = `Complete (${ownedCount})`;
completionText.className = 'completion-text complete';
completionText.title = '';
}
const completionFill = sourceCard.querySelector('.completion-fill');
if (completionFill) {
completionFill.style.width = '100%';
completionFill.classList.remove('partial');
completionFill.classList.add('complete');
}
}
} catch (e) {
console.warn('Could not load track ownership:', e);
}
}
/**
* Close the Add to Wishlist modal
*/
function closeAddToWishlistModal() {
console.log('๐ Closing Add to Wishlist modal');
try {
const overlay = document.getElementById('add-to-wishlist-modal-overlay');
if (overlay) {
overlay.classList.add('hidden');
}
// Clear current modal data
currentWishlistModalData = null;
// Clear hero content
const heroContainer = document.getElementById('add-to-wishlist-modal-hero');
if (heroContainer) {
heroContainer.innerHTML = '';
}
// Clear track list
const trackListContainer = document.getElementById('wishlist-track-list');
if (trackListContainer) {
trackListContainer.innerHTML = '';
}
console.log('โ
Add to Wishlist modal closed successfully');
} catch (error) {
console.error('โ Error closing Add to Wishlist modal:', error);
}
}
/**
* Handle "Download Now" button click from the Add to Wishlist modal.
* Captures modal data, closes the wishlist modal, then opens the download missing tracks modal.
*/
async function handleWishlistDownloadNow() {
if (!currentWishlistModalData) {
showToast('No album data available', 'error');
return;
}
// Capture data before closeAddToWishlistModal clears it
const { album, artist, tracks, albumType } = currentWishlistModalData;
// Close the wishlist modal
closeAddToWishlistModal();
// Build virtual playlist ID and name (same pattern as createArtistAlbumVirtualPlaylist)
const virtualPlaylistId = `artist_album_${artist.id}_${album.id}`;
const playlistName = `[${artist.name}] ${album.name}`;
// If a download process already exists for this album, just show the existing modal
if (activeDownloadProcesses[virtualPlaylistId]) {
const process = activeDownloadProcesses[virtualPlaylistId];
if (process.modalElement) {
process.modalElement.style.display = 'flex';
}
return;
}
// Open download missing modal (reuses existing function)
showLoadingOverlay('Loading album...');
await openDownloadMissingModalForArtistAlbum(
virtualPlaylistId, playlistName, tracks, album, artist, false
);
hideLoadingOverlay();
// Register download bubble (reuses existing artist bubble system)
registerArtistDownload(artist, album, virtualPlaylistId, albumType);
}
/**
* Add all tracks from any download modal to the wishlist
* Universal handler for all modal types (artist albums, playlists, YouTube, Tidal, etc.)
*/
async function addModalTracksToWishlist(playlistId) {
const process = activeDownloadProcesses[playlistId];
if (!process) {
console.error('โ No active process found for:', playlistId);
showToast('Error: Could not find playlist data', 'error');
return;
}
// Verify we have tracks
if (!process.tracks || process.tracks.length === 0) {
console.error('โ No tracks found in process:', process);
showToast('Error: No tracks to add', 'error');
return;
}
// Filter tracks based on checkbox selection (if checkboxes exist in this modal)
const wishlistTbody = document.getElementById(`download-tracks-tbody-${playlistId}`);
let tracks = process.tracks;
if (wishlistTbody) {
const allCbs = wishlistTbody.querySelectorAll('.track-select-cb');
if (allCbs.length > 0) {
const checkedCbs = wishlistTbody.querySelectorAll('.track-select-cb:checked');
const selectedIndices = new Set([...checkedCbs].map(cb => parseInt(cb.dataset.trackIndex)));
tracks = process.tracks.filter((_, i) => selectedIndices.has(i));
}
}
// Get artist/album context if available (for artist album downloads)
const artist = process.artist || { name: 'Unknown Artist', id: null };
const album = process.album || process.playlist || { name: 'Playlist', id: playlistId };
console.log(`๐ Adding ${tracks.length} tracks from "${album.name}" to wishlist`);
// Disable the button to prevent double-clicks
const wishlistBtn = document.getElementById(`add-to-wishlist-btn-${playlistId}`);
if (wishlistBtn) {
wishlistBtn.disabled = true;
wishlistBtn.classList.add('loading');
wishlistBtn.textContent = 'Adding...';
}
try {
let successCount = 0;
let errorCount = 0;
// Add each track to wishlist individually
for (const track of tracks) {
try {
// Format artists field to match backend expectations
let formattedArtists = track.artists;
if (typeof track.artists === 'string') {
formattedArtists = [{ name: track.artists }];
} else if (Array.isArray(track.artists)) {
formattedArtists = track.artists.map(artistItem => {
if (typeof artistItem === 'string') {
return { name: artistItem };
} else if (typeof artistItem === 'object' && artistItem !== null) {
return artistItem;
} else {
return { name: 'Unknown Artist' };
}
});
} else {
formattedArtists = [{ name: artist.name }];
}
const formattedTrack = {
...track,
artists: formattedArtists
};
// Use track's own album data if available
// Convert string album names to objects if needed (no Spotify fetch!)
let trackAlbum = track.album;
let trackAlbumType = 'album';
// Handle both object and string album formats
if (typeof trackAlbum === 'string') {
// Album is just a string - convert to minimal object
trackAlbum = {
name: trackAlbum,
album_type: 'album',
images: []
};
trackAlbumType = 'album';
} else if (trackAlbum && typeof trackAlbum === 'object') {
// Album is already an object - extract album_type
trackAlbumType = trackAlbum.album_type || 'album';
// Ensure it has a name
if (!trackAlbum.name) {
trackAlbum.name = 'Unknown Album';
}
} else {
// No album data at all - create minimal object
trackAlbum = {
name: 'Unknown Album',
album_type: 'album',
images: []
};
trackAlbumType = 'album';
}
const response = await fetch('/api/add-album-to-wishlist', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
track: formattedTrack,
artist: artist,
album: trackAlbum,
source_type: 'album',
source_context: {
album_name: trackAlbum.name,
artist_name: artist.name,
album_type: trackAlbumType
}
})
});
const result = await response.json();
if (result.success) {
successCount++;
} else {
errorCount++;
console.error(`โ Failed to add "${track.name}" to wishlist: ${result.error}`);
}
} catch (error) {
errorCount++;
console.error(`โ Error adding "${track.name}" to wishlist:`, error);
}
}
// Show result toast
if (successCount > 0) {
const message = errorCount > 0
? `Added ${successCount}/${tracks.length} tracks to wishlist (${errorCount} failed)`
: `Added ${successCount} tracks to wishlist`;
showToast(message, 'success');
// Close the modal on success
await closeDownloadMissingModal(playlistId);
} else {
showToast('Failed to add any tracks to wishlist', 'error');
}
} catch (error) {
console.error('โ Error in addModalTracksToWishlist:', error);
showToast(`Error adding to wishlist: ${error.message}`, 'error');
} finally {
// Re-enable button if still on screen (in case of error)
if (wishlistBtn) {
wishlistBtn.disabled = false;
wishlistBtn.classList.remove('loading');
wishlistBtn.textContent = 'Add to Wishlist';
}
}
}
/**
* Format duration from milliseconds to MM:SS format
*/
function formatDuration(durationMs) {
if (!durationMs || durationMs <= 0) {
return '--:--';
}
const totalSeconds = Math.floor(durationMs / 1000);
const minutes = Math.floor(totalSeconds / 60);
const seconds = totalSeconds % 60;
return `${minutes}:${seconds.toString().padStart(2, '0')}`;
}
// Download Missing Tracks Modal functions
window.openDownloadMissingModal = openDownloadMissingModal;
window.closeDownloadMissingModal = closeDownloadMissingModal;
window.startMissingTracksProcess = startMissingTracksProcess;
window.cancelAllOperations = cancelAllOperations;
window.cancelTrackDownload = cancelTrackDownload; // Legacy system
window.cancelTrackDownloadV2 = cancelTrackDownloadV2; // NEW V2 system
window.handleViewProgressClick = handleViewProgressClick;
// Wishlist Modal functions (existing)
window.openDownloadMissingWishlistModal = openDownloadMissingWishlistModal;
window.startWishlistMissingTracksProcess = startWishlistMissingTracksProcess;
window.handleWishlistButtonClick = handleWishlistButtonClick;
// Wishlist Overview Modal functions (new)
window.openWishlistOverviewModal = openWishlistOverviewModal;
window.closeWishlistOverviewModal = closeWishlistOverviewModal;
window.selectWishlistCategory = selectWishlistCategory;
window.backToCategories = backToCategories;
window.downloadSelectedCategory = downloadSelectedCategory;
// Add to Wishlist Modal functions (new)
window.openAddToWishlistModal = openAddToWishlistModal;
window.closeAddToWishlistModal = closeAddToWishlistModal;
window.handleAddToWishlist = handleAddToWishlist;
window.handleWishlistDownloadNow = handleWishlistDownloadNow;
window.addModalTracksToWishlist = addModalTracksToWishlist;
// Helper functions
window.escapeHtml = escapeHtml;
window.formatArtists = formatArtists;
// Artist Download Management functions
window.closeArtistDownloadModal = closeArtistDownloadModal;
window.openArtistDownloadProcess = openArtistDownloadProcess;
window.bulkCompleteArtistDownloads = bulkCompleteArtistDownloads;
window.refreshAllArtistDownloadStatuses = refreshAllArtistDownloadStatuses;
// APPEND THIS JAVASCRIPT SNIPPET (B)
function initializeFilters() {
const toggleBtn = document.getElementById('filter-toggle-btn');
const container = document.getElementById('filters-container');
const content = document.getElementById('filter-content');
if (toggleBtn && container && content) {
// Using .onclick ensures we only ever have one click handler
toggleBtn.onclick = () => {
const isExpanded = container.classList.contains('expanded');
if (isExpanded) {
// Collapse the container
container.classList.remove('expanded');
toggleBtn.textContent = 'โท Filters';
} else {
// Expand the container
content.classList.remove('hidden'); // Make sure content is visible for animation
container.classList.add('expanded');
toggleBtn.textContent = 'โถ Filters';
}
};
}
// This part is correct and doesn't need to change
document.querySelectorAll('.filter-btn').forEach(button => {
button.addEventListener('click', handleFilterClick);
});
}
function handleFilterClick(event) {
const button = event.target;
const filterType = button.dataset.filterType;
const value = button.dataset.value;
if (filterType === 'type') currentFilterType = value;
if (filterType === 'format') currentFilterFormat = value;
if (filterType === 'sort') currentSortBy = value;
if (button.id === 'sort-order-btn') {
isSortReversed = !isSortReversed;
button.textContent = isSortReversed ? 'โ' : 'โ';
}
document.querySelectorAll(`.filter-btn[data-filter-type="${filterType}"]`).forEach(btn => {
btn.classList.remove('active');
});
if (filterType) { // Don't try to activate the sort order button
button.classList.add('active');
}
applyFiltersAndSort();
}
function resetFilters() {
currentFilterType = 'all';
currentFilterFormat = 'all';
currentSortBy = 'quality_score';
isSortReversed = false;
document.querySelectorAll('.filter-btn').forEach(btn => btn.classList.remove('active'));
document.querySelector('.filter-btn[data-filter-type="type"][data-value="all"]').classList.add('active');
document.querySelector('.filter-btn[data-filter-type="format"][data-value="all"]').classList.add('active');
document.querySelector('.filter-btn[data-filter-type="sort"][data-value="quality_score"]').classList.add('active');
document.getElementById('sort-order-btn').textContent = 'โ';
}
function applyFiltersAndSort() {
let processedResults = [...allSearchResults];
const query = document.getElementById('downloads-search-input').value.trim().toLowerCase();
// 1. Filter by Type
if (currentFilterType !== 'all') {
processedResults = processedResults.filter(r => r.result_type === currentFilterType);
}
// 2. Filter by Format
if (currentFilterFormat !== 'all') {
processedResults = processedResults.filter(r => {
const quality = (r.dominant_quality || r.quality || '').toLowerCase();
return quality === currentFilterFormat;
});
}
// 3. Sort Results
processedResults.sort((a, b) => {
let valA, valB;
// Special handling for relevance sort
if (currentSortBy === 'relevance') {
valA = calculateRelevanceScore(a, query);
valB = calculateRelevanceScore(b, query);
return valB - valA; // Higher score is better
}
// Special handling for availability
if (currentSortBy === 'availability') {
valA = (a.free_upload_slots || 0) - (a.queue_length || 0) * 0.1;
valB = (b.free_upload_slots || 0) - (b.queue_length || 0) * 0.1;
return valB - valA;
}
valA = a[currentSortBy] || 0;
valB = b[currentSortBy] || 0;
if (typeof valA === 'string') {
// For name/title sort, use the correct property
const titleA = (a.album_title || a.title || '').toLowerCase();
const titleB = (b.album_title || b.title || '').toLowerCase();
return titleA.localeCompare(titleB);
}
// Default numeric sort (descending)
return valB - valA;
});
// Handle sort direction toggle
const sortDefaults = {
relevance: 'desc', quality_score: 'desc', size: 'desc', bitrate: 'desc',
upload_speed: 'desc', duration: 'desc', availability: 'desc',
title: 'asc', username: 'asc'
};
const defaultOrder = sortDefaults[currentSortBy] || 'desc';
if ((defaultOrder === 'asc' && isSortReversed) || (defaultOrder === 'desc' && !isSortReversed)) {
processedResults.reverse();
}
displayDownloadsResults(processedResults);
}
function calculateRelevanceScore(result, query) {
let score = 0.0;
const queryTerms = query.split(' ').filter(t => t.length > 1);
// 1. Search Term Matching (40%)
let searchableText = `${result.title || ''} ${result.artist || ''} ${result.album || ''} ${result.album_title || ''}`.toLowerCase();
let termMatches = 0;
for (const term of queryTerms) {
if (searchableText.includes(term)) {
termMatches++;
}
}
score += (termMatches / queryTerms.length) * 0.40;
// 2. Quality Score (25%)
score += (result.quality_score || 0) * 0.25;
// 3. User Reliability (Availability & Speed) (20%)
const reliability = ((result.free_upload_slots || 0) > 0 ? 0.5 : 0) + Math.min(1, (result.upload_speed || 0) / 500) * 0.5;
score += reliability * 0.20;
// 4. File Completeness (Bitrate & Duration) (15%)
const completeness = (Math.min(1, (result.bitrate || 0) / 320) * 0.5) + (result.duration > 0 ? 0.5 : 0);
score += completeness * 0.15;
return score;
}
// APPEND THIS JAVASCRIPT SNIPPET (B)
function initializeFilters() {
const toggleBtn = document.getElementById('filter-toggle-btn');
const container = document.getElementById('filters-container');
const content = document.getElementById('filter-content');
if (toggleBtn && container && content) {
// Using .onclick ensures we only ever have one click handler
toggleBtn.onclick = () => {
const isExpanded = container.classList.contains('expanded');
if (isExpanded) {
// Collapse the container
container.classList.remove('expanded');
toggleBtn.textContent = 'โท Filters';
} else {
// Expand the container
content.classList.remove('hidden'); // Make sure content is visible for animation
container.classList.add('expanded');
toggleBtn.textContent = 'โถ Filters';
}
};
}
// This part is correct and doesn't need to change
document.querySelectorAll('.filter-btn').forEach(button => {
button.addEventListener('click', handleFilterClick);
});
}
function handleFilterClick(event) {
const button = event.target;
const filterType = button.dataset.filterType;
const value = button.dataset.value;
if (filterType === 'type') currentFilterType = value;
if (filterType === 'format') currentFilterFormat = value;
if (filterType === 'sort') currentSortBy = value;
if (button.id === 'sort-order-btn') {
isSortReversed = !isSortReversed;
button.textContent = isSortReversed ? 'โ' : 'โ';
}
document.querySelectorAll(`.filter-btn[data-filter-type="${filterType}"]`).forEach(btn => {
btn.classList.remove('active');
});
if (filterType) { // Don't try to activate the sort order button
button.classList.add('active');
}
applyFiltersAndSort();
}
function resetFilters() {
currentFilterType = 'all';
currentFilterFormat = 'all';
currentSortBy = 'quality_score';
isSortReversed = false;
document.querySelectorAll('.filter-btn').forEach(btn => btn.classList.remove('active'));
document.querySelector('.filter-btn[data-filter-type="type"][data-value="all"]').classList.add('active');
document.querySelector('.filter-btn[data-filter-type="format"][data-value="all"]').classList.add('active');
document.querySelector('.filter-btn[data-filter-type="sort"][data-value="quality_score"]').classList.add('active');
document.getElementById('sort-order-btn').textContent = 'โ';
}
function applyFiltersAndSort() {
let processedResults = [...allSearchResults];
const query = document.getElementById('downloads-search-input').value.trim().toLowerCase();
// 1. Filter by Type
if (currentFilterType !== 'all') {
processedResults = processedResults.filter(r => r.result_type === currentFilterType);
}
// 2. Filter by Format
if (currentFilterFormat !== 'all') {
processedResults = processedResults.filter(r => {
const quality = (r.dominant_quality || r.quality || '').toLowerCase();
return quality === currentFilterFormat;
});
}
// 3. Sort Results
processedResults.sort((a, b) => {
let valA, valB;
// Special handling for relevance sort
if (currentSortBy === 'relevance') {
valA = calculateRelevanceScore(a, query);
valB = calculateRelevanceScore(b, query);
return valB - valA; // Higher score is better
}
// Special handling for availability
if (currentSortBy === 'availability') {
valA = (a.free_upload_slots || 0) - (a.queue_length || 0) * 0.1;
valB = (b.free_upload_slots || 0) - (b.queue_length || 0) * 0.1;
return valB - valA;
}
valA = a[currentSortBy] || 0;
valB = b[currentSortBy] || 0;
if (typeof valA === 'string') {
// For name/title sort, use the correct property
const titleA = (a.album_title || a.title || '').toLowerCase();
const titleB = (b.album_title || b.title || '').toLowerCase();
return titleA.localeCompare(titleB);
}
// Default numeric sort (descending)
return valB - valA;
});
// Handle sort direction toggle
const sortDefaults = {
relevance: 'desc', quality_score: 'desc', size: 'desc', bitrate: 'desc',
upload_speed: 'desc', duration: 'desc', availability: 'desc',
title: 'asc', username: 'asc'
};
const defaultOrder = sortDefaults[currentSortBy] || 'desc';
if ((defaultOrder === 'asc' && isSortReversed) || (defaultOrder === 'desc' && !isSortReversed)) {
processedResults.reverse();
}
displayDownloadsResults(processedResults);
}
function calculateRelevanceScore(result, query) {
let score = 0.0;
const queryTerms = query.split(' ').filter(t => t.length > 1);
// 1. Search Term Matching (40%)
let searchableText = `${result.title || ''} ${result.artist || ''} ${result.album || ''} ${result.album_title || ''}`.toLowerCase();
let termMatches = 0;
for (const term of queryTerms) {
if (searchableText.includes(term)) {
termMatches++;
}
}
score += (termMatches / queryTerms.length) * 0.40;
// 2. Quality Score (25%)
score += (result.quality_score || 0) * 0.25;
// 3. User Reliability (Availability & Speed) (20%)
const reliability = ((result.free_upload_slots || 0) > 0 ? 0.5 : 0) + Math.min(1, (result.upload_speed || 0) / 500) * 0.5;
score += reliability * 0.20;
// 4. File Completeness (Bitrate & Duration) (15%)
const completeness = (Math.min(1, (result.bitrate || 0) / 320) * 0.5) + (result.duration > 0 ? 0.5 : 0);
score += completeness * 0.15;
return score;
}
// Add to global scope for onclick
window.handleFilterClick = handleFilterClick;
// ===============================
// MATCHED DOWNLOADS MODAL
// ===============================
// Global state for matching modal
let currentMatchingData = {
searchResult: null,
isAlbumDownload: false,
albumResult: null,
selectedArtist: null,
selectedAlbum: null,
currentStage: 'artist' // 'artist' or 'album'
};
let searchTimers = {
artist: null,
album: null
};
function openMatchingModal(searchResult, isAlbumDownload = false, albumResult = null) {
console.log('๐ฏ Opening matching modal for:', searchResult);
// Store the current matching data
currentMatchingData = {
searchResult: searchResult,
isAlbumDownload: isAlbumDownload,
albumResult: albumResult,
selectedArtist: null,
selectedAlbum: null,
currentStage: 'artist'
};
// Show modal
const overlay = document.getElementById('matching-modal-overlay');
overlay.classList.remove('hidden');
// Reset modal state
resetModalState();
// Set appropriate title and stage
const modalTitle = document.getElementById('matching-modal-title');
const artistStageTitle = document.getElementById('artist-stage-title');
if (isAlbumDownload) {
modalTitle.textContent = 'Match Album Download to Spotify';
artistStageTitle.textContent = 'Step 1: Select the correct Artist';
document.getElementById('album-selection-stage').style.display = 'block';
} else {
modalTitle.textContent = 'Match Download to Spotify';
artistStageTitle.textContent = 'Select the correct Artist for this Single';
document.getElementById('album-selection-stage').style.display = 'none';
}
// Generate initial artist suggestions
fetchArtistSuggestions();
// Setup event listeners
setupModalEventListeners();
}
function closeMatchingModal() {
const overlay = document.getElementById('matching-modal-overlay');
overlay.classList.add('hidden');
// Clear timers
Object.values(searchTimers).forEach(timer => {
if (timer) clearTimeout(timer);
});
// Reset state
currentMatchingData = {
searchResult: null,
isAlbumDownload: false,
albumResult: null,
selectedArtist: null,
selectedAlbum: null,
currentStage: 'artist'
};
}
function resetModalState() {
// Show artist stage, hide album stage
document.getElementById('artist-selection-stage').classList.remove('hidden');
document.getElementById('album-selection-stage').classList.add('hidden');
// Clear all suggestion containers
document.getElementById('artist-suggestions').innerHTML = '';
document.getElementById('artist-manual-results').innerHTML = '';
document.getElementById('album-suggestions').innerHTML = '';
document.getElementById('album-manual-results').innerHTML = '';
// Clear search inputs
document.getElementById('artist-search-input').value = '';
document.getElementById('album-search-input').value = '';
// Reset button states
document.getElementById('confirm-match-btn').disabled = true;
// Reset selections
currentMatchingData.selectedArtist = null;
currentMatchingData.selectedAlbum = null;
currentMatchingData.currentStage = 'artist';
}
function setupModalEventListeners() {
// Search input listeners
const artistInput = document.getElementById('artist-search-input');
const albumInput = document.getElementById('album-search-input');
artistInput.removeEventListener('input', handleArtistSearch);
artistInput.addEventListener('input', handleArtistSearch);
albumInput.removeEventListener('input', handleAlbumSearch);
albumInput.addEventListener('input', handleAlbumSearch);
// Button listeners
const skipBtn = document.getElementById('skip-matching-btn');
const cancelBtn = document.getElementById('cancel-match-btn');
const confirmBtn = document.getElementById('confirm-match-btn');
skipBtn.onclick = skipMatching;
cancelBtn.onclick = closeMatchingModal;
confirmBtn.onclick = confirmMatch;
}
async function fetchArtistSuggestions() {
try {
showLoadingCards('artist-suggestions', 'Finding artist...');
const response = await fetch('/api/match/suggestions', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
search_result: currentMatchingData.searchResult,
context: 'artist',
is_album: currentMatchingData.isAlbumDownload,
album_result: currentMatchingData.albumResult
})
});
const data = await response.json();
if (data.suggestions) {
renderArtistSuggestions(data.suggestions);
} else {
showNoResultsMessage('artist-suggestions', 'No artist suggestions found');
}
} catch (error) {
console.error('Error fetching artist suggestions:', error);
showNoResultsMessage('artist-suggestions', 'Error loading suggestions');
}
}
async function fetchAlbumSuggestions() {
if (!currentMatchingData.selectedArtist) return;
try {
showLoadingCards('album-suggestions', 'Finding album...');
const response = await fetch('/api/match/suggestions', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
search_result: currentMatchingData.searchResult,
context: 'album',
selected_artist: currentMatchingData.selectedArtist
})
});
const data = await response.json();
if (data.suggestions) {
renderAlbumSuggestions(data.suggestions);
} else {
showNoResultsMessage('album-suggestions', 'No album suggestions found');
}
} catch (error) {
console.error('Error fetching album suggestions:', error);
showNoResultsMessage('album-suggestions', 'Error loading suggestions');
}
}
function renderArtistSuggestions(suggestions) {
const container = document.getElementById('artist-suggestions');
container.innerHTML = '';
if (!suggestions.length) {
showNoResultsMessage('artist-suggestions', 'No artist matches found');
return;
}
suggestions.forEach(suggestion => {
const card = createArtistCard(suggestion.artist, suggestion.confidence);
container.appendChild(card);
});
}
function renderAlbumSuggestions(suggestions) {
const container = document.getElementById('album-suggestions');
container.innerHTML = '';
if (!suggestions.length) {
showNoResultsMessage('album-suggestions', 'No album matches found');
return;
}
suggestions.forEach(suggestion => {
const card = createAlbumCard(suggestion.album, suggestion.confidence);
container.appendChild(card);
});
}
function createArtistCard(artist, confidence) {
const card = document.createElement('div');
card.className = 'suggestion-card';
card.onclick = () => selectArtist(artist);
const imageUrl = artist.image_url || '';
const confidencePercent = Math.round(confidence * 100);
// Add data attribute for lazy loading
card.dataset.artistId = artist.id;
card.dataset.needsImage = imageUrl ? 'false' : 'true';
card.innerHTML = `
${escapeHtml(artist.name)}
${artist.genres && artist.genres.length ? escapeHtml(artist.genres.slice(0, 2).join(', ')) : 'Artist'}
${confidencePercent}% match
`;
// Set background image if available
if (imageUrl) {
card.style.backgroundImage = `url(${imageUrl})`;
card.style.backgroundSize = 'cover';
card.style.backgroundPosition = 'center';
}
return card;
}
function createAlbumCard(album, confidence) {
const card = document.createElement('div');
card.className = 'suggestion-card';
card.onclick = () => selectAlbum(album);
const imageUrl = album.image_url || '';
const confidencePercent = Math.round(confidence * 100);
const year = album.release_date ? album.release_date.split('-')[0] : '';
card.innerHTML = `
${escapeHtml(album.name)}
${album.album_type ? escapeHtml(album.album_type.charAt(0).toUpperCase() + album.album_type.slice(1)) : 'Album'}${year ? ` โข ${year}` : ''}
${confidencePercent}% match
`;
// Set background image if available
if (imageUrl) {
card.style.backgroundImage = `url(${imageUrl})`;
card.style.backgroundSize = 'cover';
card.style.backgroundPosition = 'center';
}
return card;
}
function selectArtist(artist) {
// Clear previous selections
document.querySelectorAll('#artist-suggestions .suggestion-card').forEach(card => {
card.classList.remove('selected');
});
document.querySelectorAll('#artist-manual-results .suggestion-card').forEach(card => {
card.classList.remove('selected');
});
// Mark new selection
event.currentTarget.classList.add('selected');
// Store selection
currentMatchingData.selectedArtist = artist;
console.log('๐ฏ Selected artist:', artist.name);
if (currentMatchingData.isAlbumDownload) {
// Transition to album selection stage
transitionToAlbumStage();
} else {
// Enable confirm button for single downloads
document.getElementById('confirm-match-btn').disabled = false;
}
}
function selectAlbum(album) {
// Clear previous selections
document.querySelectorAll('#album-suggestions .suggestion-card').forEach(card => {
card.classList.remove('selected');
});
document.querySelectorAll('#album-manual-results .suggestion-card').forEach(card => {
card.classList.remove('selected');
});
// Mark new selection
event.currentTarget.classList.add('selected');
// Store selection
currentMatchingData.selectedAlbum = album;
console.log('๐ฏ Selected album:', album.name);
// Enable confirm button
document.getElementById('confirm-match-btn').disabled = false;
}
function transitionToAlbumStage() {
// Hide artist stage
document.getElementById('artist-selection-stage').classList.add('hidden');
// Show album stage
const albumStage = document.getElementById('album-selection-stage');
albumStage.classList.remove('hidden');
// Update selected artist name
document.getElementById('selected-artist-name').textContent = currentMatchingData.selectedArtist.name;
// Update current stage
currentMatchingData.currentStage = 'album';
// Fetch album suggestions
fetchAlbumSuggestions();
}
function handleArtistSearch(event) {
const query = event.target.value.trim();
// Clear previous timer
if (searchTimers.artist) {
clearTimeout(searchTimers.artist);
}
if (query.length < 2) {
document.getElementById('artist-manual-results').innerHTML = '';
return;
}
// Debounce search
searchTimers.artist = setTimeout(() => {
performArtistSearch(query);
}, 400);
}
function handleAlbumSearch(event) {
const query = event.target.value.trim();
// Clear previous timer
if (searchTimers.album) {
clearTimeout(searchTimers.album);
}
if (query.length < 2) {
document.getElementById('album-manual-results').innerHTML = '';
return;
}
// Debounce search
searchTimers.album = setTimeout(() => {
performAlbumSearch(query);
}, 400);
}
async function performArtistSearch(query) {
try {
showLoadingCards('artist-manual-results', 'Searching artists...');
const requestBody = {
query: query,
context: 'artist'
};
console.log('Manual search request:', requestBody);
const response = await fetch('/api/match/search', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(requestBody)
});
const data = await response.json();
console.log('Manual search response:', data);
if (data.results) {
console.log('Results array:', data.results);
renderArtistSearchResults(data.results);
} else {
showNoResultsMessage('artist-manual-results', 'No artists found');
}
} catch (error) {
console.error('Error searching artists:', error);
showNoResultsMessage('artist-manual-results', 'Error searching artists');
}
}
async function performAlbumSearch(query) {
if (!currentMatchingData.selectedArtist) return;
try {
showLoadingCards('album-manual-results', 'Searching albums...');
const response = await fetch('/api/match/search', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
query: query,
context: 'album',
artist_id: currentMatchingData.selectedArtist.id
})
});
const data = await response.json();
if (data.results) {
renderAlbumSearchResults(data.results);
} else {
showNoResultsMessage('album-manual-results', 'No albums found');
}
} catch (error) {
console.error('Error searching albums:', error);
showNoResultsMessage('album-manual-results', 'Error searching albums');
}
}
function renderArtistSearchResults(results) {
const container = document.getElementById('artist-manual-results');
container.innerHTML = '';
results.forEach((result, index) => {
console.log(`Manual search result ${index}:`, result);
console.log(` result.artist:`, result.artist);
console.log(` result.confidence:`, result.confidence);
try {
const card = createArtistCard(result.artist, result.confidence);
console.log(`createArtistCard returned:`, card, typeof card, card instanceof Element);
if (card && card instanceof Element) {
container.appendChild(card);
} else {
console.error(`Invalid card returned for result ${index}:`, card);
}
} catch (error) {
console.error(`Error calling createArtistCard for result ${index}:`, error);
}
});
// Lazy load missing artist images
console.log('๐ผ๏ธ Starting lazy load for artist images in matching modal...');
if (typeof lazyLoadArtistImages === 'function') {
lazyLoadArtistImages(container);
} else if (typeof window.lazyLoadArtistImages === 'function') {
window.lazyLoadArtistImages(container);
} else {
console.error('โ lazyLoadArtistImages function not found!');
}
}
function renderAlbumSearchResults(results) {
const container = document.getElementById('album-manual-results');
container.innerHTML = '';
results.forEach(result => {
const card = createAlbumCard(result.album, result.confidence);
container.appendChild(card);
});
}
function showLoadingCards(containerId, message) {
const container = document.getElementById(containerId);
container.innerHTML = `${message}
`;
}
function showNoResultsMessage(containerId, message) {
const container = document.getElementById(containerId);
container.innerHTML = `${message}
`;
}
function skipMatching() {
console.log('๐ฏ Skipping matching, proceeding with normal download');
// Close modal
closeMatchingModal();
// Start normal download
if (currentMatchingData.isAlbumDownload) {
// For albums, we need to download each track
showToast('โฌ๏ธ Starting album download (unmatched)', 'info');
// This would need to be implemented to download all album tracks
} else {
// Single track download
startDownload(window.currentSearchResults.indexOf(currentMatchingData.searchResult));
}
}
function matchSlskdTracksToSpotify(slskdTracks, spotifyTracks) {
/**
* Matches Soulseek tracks to Spotify tracks based on filename analysis.
* Returns enhanced tracks with full Spotify metadata.
*/
console.log(`๐ฏ Starting track matching: ${slskdTracks.length} Soulseek tracks vs ${spotifyTracks.length} Spotify tracks`);
const matched = [];
const unmatched = [];
for (const slskdTrack of slskdTracks) {
const filename = slskdTrack.filename || slskdTrack.title || '';
const parsedMeta = parseTrackFilename(filename);
console.log(`๐ Matching: "${filename}" -> parsed as: "${parsedMeta.title}" (track #${parsedMeta.trackNumber})`);
// Find best matching Spotify track
let bestMatch = null;
let bestScore = 0;
for (const spotifyTrack of spotifyTracks) {
let score = 0;
// Match by track number (highest priority if available)
if (parsedMeta.trackNumber && spotifyTrack.track_number === parsedMeta.trackNumber) {
score += 50;
console.log(` โ Track number match: ${parsedMeta.trackNumber} == ${spotifyTrack.track_number} (+50)`);
}
// Match by title similarity
const titleScore = calculateStringSimilarity(
parsedMeta.title.toLowerCase(),
spotifyTrack.name.toLowerCase()
);
score += titleScore * 50; // Max 50 points for perfect title match
console.log(` Spotify track "${spotifyTrack.name}" (${spotifyTrack.track_number}): score ${score.toFixed(2)}`);
if (score > bestScore) {
bestScore = score;
bestMatch = spotifyTrack;
}
}
// Accept match if score is above threshold (70/100)
if (bestMatch && bestScore >= 70) {
console.log(`โ
MATCHED: "${filename}" -> "${bestMatch.name}" (score: ${bestScore.toFixed(2)})`);
matched.push({
slskd_track: slskdTrack,
spotify_track: bestMatch,
confidence: bestScore / 100
});
} else {
console.log(`โ NO MATCH: "${filename}" (best score: ${bestScore.toFixed(2)})`);
unmatched.push(slskdTrack);
}
}
console.log(`๐ฏ Matching complete: ${matched.length} matched, ${unmatched.length} unmatched`);
return {
matched: matched,
unmatched: unmatched,
total: slskdTracks.length
};
}
function parseTrackFilename(filename) {
/**
* Parse track metadata from filename.
* Handles common patterns like:
* - "01 - Title.flac"
* - "01. Title.flac"
* - "Artist - Title.flac"
* - "Title.flac"
* - YouTube: "video_id||title" (extract title part)
*/
// YouTube special handling: Extract title from encoded format
if (filename && filename.includes('||')) {
const parts = filename.split('||');
const youtubeTitle = parts[1] || parts[0]; // Use title part, fallback to video_id
// Remove common YouTube suffixes
const cleanTitle = youtubeTitle
.replace(/\s*\[.*?\]\s*/g, '') // Remove [Official Video], [Lyrics], etc.
.replace(/\s*\(.*?\)\s*/g, '') // Remove (Official), (Audio), etc.
.trim();
return { title: cleanTitle, trackNumber: null };
}
// Remove file extension and path
let basename = filename.split('/').pop().split('\\').pop();
basename = basename.replace(/\.(flac|mp3|m4a|ogg|wav)$/i, '');
let trackNumber = null;
let title = basename;
// Pattern 1: "01 - Title" or "01. Title"
const pattern1 = /^(\d{1,2})\s*[-\.]\s*(.+)$/;
const match1 = basename.match(pattern1);
if (match1) {
trackNumber = parseInt(match1[1]);
title = match1[2].trim();
return { title, trackNumber };
}
// Pattern 2: "Artist - Title" (extract title only)
const pattern2 = /^.+?\s*[-โ]\s*(.+)$/;
const match2 = basename.match(pattern2);
if (match2) {
title = match2[1].trim();
return { title, trackNumber };
}
// Fallback: use whole basename as title
return { title: basename.trim(), trackNumber };
}
function calculateStringSimilarity(str1, str2) {
/**
* Calculate similarity between two strings (0-1 range).
* Uses Levenshtein distance for fuzzy matching.
*/
// Normalize strings
str1 = str1.trim().toLowerCase();
str2 = str2.trim().toLowerCase();
if (str1 === str2) return 1.0;
// Simple contains check
if (str1.includes(str2) || str2.includes(str1)) {
return 0.9;
}
// Levenshtein distance calculation
const matrix = [];
const len1 = str1.length;
const len2 = str2.length;
for (let i = 0; i <= len1; i++) {
matrix[i] = [i];
}
for (let j = 0; j <= len2; j++) {
matrix[0][j] = j;
}
for (let i = 1; i <= len1; i++) {
for (let j = 1; j <= len2; j++) {
const cost = str1[i - 1] === str2[j - 1] ? 0 : 1;
matrix[i][j] = Math.min(
matrix[i - 1][j] + 1, // deletion
matrix[i][j - 1] + 1, // insertion
matrix[i - 1][j - 1] + cost // substitution
);
}
}
const maxLen = Math.max(len1, len2);
const distance = matrix[len1][len2];
const similarity = 1 - (distance / maxLen);
return Math.max(0, similarity);
}
async function confirmMatch() {
if (!currentMatchingData.selectedArtist) {
showToast('โ ๏ธ Please select an artist first', 'error');
return;
}
if (currentMatchingData.isAlbumDownload && !currentMatchingData.selectedAlbum) {
showToast('โ ๏ธ Please select an album first', 'error');
return;
}
const confirmBtn = document.getElementById('confirm-match-btn');
const originalText = confirmBtn.textContent;
try {
console.log('๐ฏ Confirming match with:', {
artist: currentMatchingData.selectedArtist.name,
album: currentMatchingData.selectedAlbum?.name
});
confirmBtn.disabled = true;
confirmBtn.textContent = 'Starting...';
// Determine the correct data to send
const downloadPayload = currentMatchingData.isAlbumDownload
? currentMatchingData.albumResult
: currentMatchingData.searchResult;
// --- NEW: For album downloads, fetch Spotify tracklist and match tracks ---
if (currentMatchingData.isAlbumDownload && currentMatchingData.selectedAlbum) {
confirmBtn.textContent = 'Matching tracks...';
console.log('๐ต Fetching Spotify tracklist for album:', currentMatchingData.selectedAlbum.name);
try {
// Fetch album tracks (pass name/artist for Hydrabase support)
const artistId = currentMatchingData.selectedArtist.id;
const albumId = currentMatchingData.selectedAlbum.id;
const _aat3 = new URLSearchParams({ name: currentMatchingData.selectedAlbum.name || '', artist: currentMatchingData.selectedArtist.name || '' });
const tracksResponse = await fetch(`/api/artist/${artistId}/album/${albumId}/tracks?${_aat3}`);
if (!tracksResponse.ok) {
throw new Error(`Failed to fetch Spotify tracks: ${tracksResponse.status}`);
}
const tracksData = await tracksResponse.json();
const spotifyTracks = tracksData.tracks || [];
console.log(`โ
Fetched ${spotifyTracks.length} Spotify tracks for matching`);
// Match each Soulseek track to a Spotify track
const enhancedTracks = matchSlskdTracksToSpotify(
downloadPayload.tracks || [],
spotifyTracks
);
console.log(`๐ฏ Matched ${enhancedTracks.matched.length}/${enhancedTracks.total} tracks to Spotify`);
// Send enhanced data with full Spotify track objects
confirmBtn.textContent = 'Downloading...';
const response = await fetch('/api/download/matched', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
search_result: downloadPayload,
spotify_artist: currentMatchingData.selectedArtist,
spotify_album: currentMatchingData.selectedAlbum,
enhanced_tracks: enhancedTracks.matched, // Send matched tracks with full Spotify data
unmatched_tracks: enhancedTracks.unmatched // Send unmatched tracks for basic processing
})
});
const data = await response.json();
if (data.success) {
showToast(`๐ฏ Matched ${enhancedTracks.matched.length} tracks to Spotify`, 'success');
closeMatchingModal();
} else {
throw new Error(data.error || 'Failed to start matched download');
}
} catch (trackMatchError) {
console.error('โ Track matching failed, falling back to simple matching:', trackMatchError);
showToast('โ ๏ธ Track matching failed, using basic matching', 'warning');
// Fallback to simple matching (current behavior)
const response = await fetch('/api/download/matched', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
search_result: downloadPayload,
spotify_artist: currentMatchingData.selectedArtist,
spotify_album: currentMatchingData.selectedAlbum || null
})
});
const data = await response.json();
if (data.success) {
showToast(`๐ฏ Matched download started for "${currentMatchingData.selectedArtist.name}"`, 'success');
closeMatchingModal();
} else {
throw new Error(data.error || 'Failed to start matched download');
}
}
} else {
// Single track download - fetch Spotify track for full metadata
confirmBtn.textContent = 'Searching Spotify...';
try {
// Parse track name from Soulseek filename
const filename = downloadPayload.filename || downloadPayload.title || '';
const parsedMeta = parseTrackFilename(filename);
console.log(`๐ Searching Spotify for: "${parsedMeta.title}" by ${currentMatchingData.selectedArtist.name}`);
// Search Spotify for this track
const searchQuery = `track:${parsedMeta.title} artist:${currentMatchingData.selectedArtist.name}`;
const searchResponse = await fetch(`/api/spotify/search?q=${encodeURIComponent(searchQuery)}&type=track&limit=5`);
if (!searchResponse.ok) {
throw new Error('Failed to search Spotify for track');
}
const searchData = await searchResponse.json();
const spotifyTracks = searchData.tracks?.items || [];
if (spotifyTracks.length === 0) {
throw new Error('No Spotify tracks found for this search');
}
// Find best match (prefer exact artist match)
let bestMatch = spotifyTracks.find(track =>
track.artists.some(artist => artist.id === currentMatchingData.selectedArtist.id)
) || spotifyTracks[0];
console.log(`โ
Found Spotify track: "${bestMatch.name}" (${bestMatch.id})`);
// Get full track details with album info
const trackResponse = await fetch(`/api/spotify/track/${bestMatch.id}`);
if (!trackResponse.ok) {
throw new Error('Failed to fetch Spotify track details');
}
const fullTrack = await trackResponse.json();
// Send with full Spotify metadata (single track enhanced)
confirmBtn.textContent = 'Downloading...';
const response = await fetch('/api/download/matched', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
search_result: downloadPayload,
spotify_artist: currentMatchingData.selectedArtist,
spotify_album: null, // Singles don't have album context
spotify_track: fullTrack, // Full Spotify track object
is_single_track: true // Flag for single track processing
})
});
const data = await response.json();
if (data.success) {
showToast(`๐ฏ Matched single: "${fullTrack.name}"`, 'success');
closeMatchingModal();
} else {
throw new Error(data.error || 'Failed to start matched download');
}
} catch (singleMatchError) {
console.error('โ Spotify track matching failed, falling back to basic:', singleMatchError);
showToast('โ ๏ธ Spotify matching failed, using basic metadata', 'warning');
// Fallback to basic matching (current behavior)
const response = await fetch('/api/download/matched', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
search_result: downloadPayload,
spotify_artist: currentMatchingData.selectedArtist,
spotify_album: currentMatchingData.selectedAlbum || null
})
});
const data = await response.json();
if (data.success) {
showToast(`๐ฏ Matched download started for "${currentMatchingData.selectedArtist.name}"`, 'success');
closeMatchingModal();
} else {
throw new Error(data.error || 'Failed to start matched download');
}
}
}
} catch (error) {
console.error('Error starting matched download:', error);
showToast(`โ Error starting matched download: ${error.message}`, 'error');
// Re-enable confirm button on failure
confirmBtn.disabled = false;
confirmBtn.textContent = originalText;
}
}
function matchedDownloadTrack(trackIndex) {
const results = window.currentSearchResults;
if (!results || !results[trackIndex]) {
console.error('Could not find track for matched download:', trackIndex);
showToast('Error preparing matched download.', 'error');
return;
}
const trackData = results[trackIndex];
// It's a single track, so isAlbumDownload is false and there's no album context.
openMatchingModal(trackData, false, null);
}
function matchedDownloadAlbum(albumIndex) {
const results = window.currentSearchResults;
if (!results || !results[albumIndex]) {
console.error('Could not find album for matched download:', albumIndex);
showToast('Error preparing matched download.', 'error');
return;
}
const albumData = results[albumIndex];
// The first track is used as a reference for the initial artist search.
const firstTrack = albumData.tracks ? albumData.tracks[0] : albumData;
openMatchingModal(firstTrack, true, albumData);
}
function matchedDownloadAlbumTrack(albumIndex, trackIndex) {
const results = window.currentSearchResults;
if (!results || !results[albumIndex] || !results[albumIndex].tracks || !results[albumIndex].tracks[trackIndex]) {
console.error('Could not find album track for matched download:', albumIndex, trackIndex);
showToast('Error preparing matched download.', 'error');
return;
}
const albumData = results[albumIndex];
const trackData = albumData.tracks[trackIndex];
// This is the definitive fix.
// The second argument MUST be 'false' to treat this as a single track download,
// which prevents the modal from asking for an album selection.
openMatchingModal(trackData, false, albumData);
}
// ===========================================
// == DASHBOARD DATABASE UPDATER FUNCTIONALITY ==
// ===========================================
// --- State and Polling Management ---
function stopDbStatsPolling() {
if (dbStatsInterval) {
clearInterval(dbStatsInterval);
dbStatsInterval = null;
}
}
function stopDbUpdatePolling() {
if (dbUpdateStatusInterval) {
console.log('โน๏ธ Stopping database update polling');
clearInterval(dbUpdateStatusInterval);
dbUpdateStatusInterval = null;
}
}
// ===================================================================
// QUALITY SCANNER TOOL
// ===================================================================
async function handleQualityScanButtonClick() {
const button = document.getElementById('quality-scan-button');
const currentAction = button.textContent;
if (currentAction === 'Scan Library') {
const scopeSelect = document.getElementById('quality-scan-scope');
const scope = scopeSelect.value;
try {
button.disabled = true;
button.textContent = 'Starting...';
const response = await fetch('/api/quality-scanner/start', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ scope: scope })
});
if (response.ok) {
showToast('Quality scan started!', 'success');
// Start polling immediately to get live status
checkAndUpdateQualityScanProgress();
} else {
const errorData = await response.json();
showToast(`Error: ${errorData.error}`, 'error');
button.disabled = false;
button.textContent = 'Scan Library';
}
} catch (error) {
showToast('Failed to start quality scan.', 'error');
button.disabled = false;
button.textContent = 'Scan Library';
}
} else { // "Stop Scan"
try {
const response = await fetch('/api/quality-scanner/stop', { method: 'POST' });
if (response.ok) {
showToast('Stop request sent.', 'info');
} else {
showToast('Failed to send stop request.', 'error');
}
} catch (error) {
showToast('Error sending stop request.', 'error');
}
}
}
async function checkAndUpdateQualityScanProgress() {
if (socketConnected) return; // WebSocket handles this
try {
const response = await fetch('/api/quality-scanner/status', {
signal: AbortSignal.timeout(10000) // 10 second timeout
});
if (!response.ok) return;
const state = await response.json();
console.debug('๐ Quality Scanner Status:', state.status, `${state.processed}/${state.total}`, `${state.progress.toFixed(1)}%`);
updateQualityScanProgressUI(state);
// Start polling only if not already polling and status is running
if (state.status === 'running' && !qualityScannerStatusInterval) {
console.log('๐ Starting quality scanner polling (1 second interval)');
qualityScannerStatusInterval = setInterval(checkAndUpdateQualityScanProgress, 1000);
}
} catch (error) {
console.warn('Could not fetch quality scanner status:', error);
// Don't stop polling on network errors - keep trying
}
}
function updateQualityScanProgressFromData(data) {
const prev = _lastToolStatus['quality-scanner'];
_lastToolStatus['quality-scanner'] = data.status;
if (prev !== undefined && data.status === prev && data.status !== 'running') return;
updateQualityScanProgressUI(data);
}
function updateQualityScanProgressUI(state) {
const button = document.getElementById('quality-scan-button');
const phaseLabel = document.getElementById('quality-phase-label');
const progressLabel = document.getElementById('quality-progress-label');
const progressBar = document.getElementById('quality-progress-bar');
const scopeSelect = document.getElementById('quality-scan-scope');
// Stats
const processedStat = document.getElementById('quality-stat-processed');
const metStat = document.getElementById('quality-stat-met');
const lowStat = document.getElementById('quality-stat-low');
const matchedStat = document.getElementById('quality-stat-matched');
if (!button || !phaseLabel || !progressLabel || !progressBar || !scopeSelect) return;
// Update stats
if (processedStat) processedStat.textContent = state.processed || 0;
if (metStat) metStat.textContent = state.quality_met || 0;
if (lowStat) lowStat.textContent = state.low_quality || 0;
if (matchedStat) matchedStat.textContent = state.matched || 0;
if (state.status === 'running') {
button.textContent = 'Stop Scan';
button.disabled = false;
scopeSelect.disabled = true;
phaseLabel.textContent = state.phase || 'Scanning...';
progressLabel.textContent = `${state.processed} / ${state.total} tracks scanned (${state.progress.toFixed(1)}%)`;
progressBar.style.width = `${state.progress}%`;
} else { // idle, finished, or error
stopQualityScannerPolling();
button.textContent = 'Scan Library';
button.disabled = false;
scopeSelect.disabled = false;
if (state.status === 'error') {
phaseLabel.textContent = `Error: ${state.error_message}`;
progressBar.style.backgroundColor = '#ff4444'; // Red for error
} else {
phaseLabel.textContent = state.phase || 'Ready to scan';
progressBar.style.backgroundColor = 'rgb(var(--accent-rgb))'; // Green for normal
}
if (state.status === 'finished') {
// Show completion toast with results
showToast(`Scan complete! ${state.matched} tracks added to wishlist`, 'success');
}
}
}
function stopQualityScannerPolling() {
if (qualityScannerStatusInterval) {
console.log('โน๏ธ Stopping quality scanner polling');
clearInterval(qualityScannerStatusInterval);
qualityScannerStatusInterval = null;
}
}
// ============================================
// == DUPLICATE CLEANER FUNCTIONS ==
// ============================================
async function handleDuplicateCleanButtonClick() {
const button = document.getElementById('duplicate-clean-button');
const currentAction = button.textContent;
if (currentAction === 'Clean Duplicates') {
try {
button.disabled = true;
button.textContent = 'Starting...';
const response = await fetch('/api/duplicate-cleaner/start', {
method: 'POST',
headers: { 'Content-Type': 'application/json' }
});
if (response.ok) {
showToast('Duplicate cleaner started!', 'success');
// Start polling immediately to get live status
checkAndUpdateDuplicateCleanProgress();
} else {
const errorData = await response.json();
showToast(`Error: ${errorData.error}`, 'error');
button.disabled = false;
button.textContent = 'Clean Duplicates';
}
} catch (error) {
showToast('Failed to start duplicate cleaner.', 'error');
button.disabled = false;
button.textContent = 'Clean Duplicates';
}
} else { // "Stop Cleaning"
try {
const response = await fetch('/api/duplicate-cleaner/stop', { method: 'POST' });
if (response.ok) {
showToast('Stop request sent.', 'info');
} else {
showToast('Failed to send stop request.', 'error');
}
} catch (error) {
showToast('Error sending stop request.', 'error');
}
}
}
async function checkAndUpdateDuplicateCleanProgress() {
if (socketConnected) return; // WebSocket handles this
try {
const response = await fetch('/api/duplicate-cleaner/status', {
signal: AbortSignal.timeout(10000) // 10 second timeout
});
if (!response.ok) return;
const state = await response.json();
console.debug('๐งน Duplicate Cleaner Status:', state.status, `${state.files_scanned}/${state.total_files}`, `${state.progress.toFixed(1)}%`);
updateDuplicateCleanProgressUI(state);
// Start polling only if not already polling and status is running
if (state.status === 'running' && !duplicateCleanerStatusInterval) {
console.log('๐ Starting duplicate cleaner polling (1 second interval)');
duplicateCleanerStatusInterval = setInterval(checkAndUpdateDuplicateCleanProgress, 1000);
}
} catch (error) {
console.warn('Could not fetch duplicate cleaner status:', error);
// Don't stop polling on network errors - keep trying
}
}
function updateDuplicateCleanProgressFromData(data) {
const prev = _lastToolStatus['duplicate-cleaner'];
_lastToolStatus['duplicate-cleaner'] = data.status;
if (prev !== undefined && data.status === prev && data.status !== 'running') return;
updateDuplicateCleanProgressUI(data);
}
function updateDuplicateCleanProgressUI(state) {
const button = document.getElementById('duplicate-clean-button');
const phaseLabel = document.getElementById('duplicate-phase-label');
const progressLabel = document.getElementById('duplicate-progress-label');
const progressBar = document.getElementById('duplicate-progress-bar');
// Stats
const scannedStat = document.getElementById('duplicate-stat-scanned');
const foundStat = document.getElementById('duplicate-stat-found');
const deletedStat = document.getElementById('duplicate-stat-deleted');
const spaceStat = document.getElementById('duplicate-stat-space');
if (!button || !phaseLabel || !progressLabel || !progressBar) return;
// Update stats
if (scannedStat) scannedStat.textContent = state.files_scanned || 0;
if (foundStat) foundStat.textContent = state.duplicates_found || 0;
if (deletedStat) deletedStat.textContent = state.deleted || 0;
if (spaceStat) {
const spaceMB = state.space_freed_mb || 0;
if (spaceMB >= 1024) {
spaceStat.textContent = `${(spaceMB / 1024).toFixed(2)} GB`;
} else {
spaceStat.textContent = `${spaceMB.toFixed(2)} MB`;
}
}
if (state.status === 'running') {
button.textContent = 'Stop Cleaning';
button.disabled = false;
phaseLabel.textContent = state.phase || 'Scanning...';
progressLabel.textContent = `${state.files_scanned} / ${state.total_files} files scanned (${state.progress.toFixed(1)}%)`;
progressBar.style.width = `${state.progress}%`;
} else { // idle, finished, or error
stopDuplicateCleanerPolling();
button.textContent = 'Clean Duplicates';
button.disabled = false;
if (state.status === 'error') {
phaseLabel.textContent = `Error: ${state.error_message}`;
progressBar.style.backgroundColor = '#ff4444'; // Red for error
} else {
phaseLabel.textContent = state.phase || 'Ready to scan';
progressBar.style.backgroundColor = 'rgb(var(--accent-rgb))'; // Green for normal
}
if (state.status === 'finished') {
// Show completion toast with results
const spaceMB = state.space_freed_mb || 0;
const spaceDisplay = spaceMB >= 1024 ? `${(spaceMB / 1024).toFixed(2)} GB` : `${spaceMB.toFixed(1)} MB`;
showToast(`Cleaning complete! ${state.deleted} files removed, ${spaceDisplay} freed`, 'success');
}
}
}
function stopDuplicateCleanerPolling() {
if (duplicateCleanerStatusInterval) {
console.log('โน๏ธ Stopping duplicate cleaner polling');
clearInterval(duplicateCleanerStatusInterval);
duplicateCleanerStatusInterval = null;
}
}
// ============================================
// == BACKUP MANAGER ==
// ============================================
async function loadBackupList() {
try {
const res = await fetch('/api/database/backups');
const data = await res.json();
if (data.success) {
updateBackupManagerUI(data);
renderBackupList(data.backups);
}
} catch (e) {
console.error('Failed to load backup list:', e);
}
}
function updateBackupManagerUI(data) {
const lastEl = document.getElementById('backup-stat-last');
const countEl = document.getElementById('backup-stat-count');
const latestSizeEl = document.getElementById('backup-stat-latest-size');
const dbSizeEl = document.getElementById('backup-stat-db-size');
if (countEl) countEl.textContent = data.count;
if (dbSizeEl) dbSizeEl.textContent = data.db_size_mb + ' MB';
if (data.backups && data.backups.length > 0) {
const newest = data.backups[0];
if (lastEl) lastEl.textContent = timeAgo(newest.created);
if (latestSizeEl) latestSizeEl.textContent = newest.size_mb + ' MB';
} else {
if (lastEl) lastEl.textContent = 'Never';
if (latestSizeEl) latestSizeEl.textContent = 'โ';
}
}
function renderBackupList(backups) {
const container = document.getElementById('backup-list-container');
if (!container) return;
if (!backups || backups.length === 0) {
container.innerHTML = '';
return;
}
container.innerHTML = backups.map(b => {
const date = new Date(b.created + (b.created.includes('Z') ? '' : 'Z'));
const dateStr = date.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' })
+ ' ' + date.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' });
const safeName = escapeForInlineJs(b.filename);
const versionBadge = b.version ? `v${escapeHtml(b.version)} ` : '';
return `
${escapeHtml(dateStr)}
${b.size_mb} MB
${versionBadge}
DL
Restore
Del
`;
}).join('');
}
async function handleBackupNowClick() {
const button = document.getElementById('backup-now-button');
if (!button) return;
const origText = button.textContent;
button.disabled = true;
button.textContent = 'Backing up...';
try {
const res = await fetch('/api/database/backup', { method: 'POST' });
const data = await res.json();
if (data.success) {
showToast(`Database backed up (${data.size_mb} MB)`, 'success');
await loadBackupList();
} else {
showToast(`Backup failed: ${data.error}`, 'error');
}
} catch (e) {
showToast('Backup request failed', 'error');
}
button.disabled = false;
button.textContent = origText;
}
function downloadBackup(filename) {
const a = document.createElement('a');
a.href = `/api/database/backups/${encodeURIComponent(filename)}/download`;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
}
async function restoreBackup(filename, force = false) {
if (!force) {
if (!await showConfirmDialog({ title: 'Restore Backup', message: `Restore database from "${filename}"?\n\nA safety backup of the current database will be created first.`, confirmText: 'Restore' })) return;
}
try {
const fetchOpts = { method: 'POST' };
if (force) {
fetchOpts.headers = { 'Content-Type': 'application/json' };
fetchOpts.body = JSON.stringify({ force: true });
}
const res = await fetch(`/api/database/backups/${encodeURIComponent(filename)}/restore`, fetchOpts);
const data = await res.json();
if (data.success) {
let msg = `Database restored from ${data.restored_from} (${data.artist_count} artists). Safety backup: ${data.safety_backup}`;
if (data.version_warning) msg += `\nโ ๏ธ ${data.version_warning}`;
showToast(msg, 'success');
await loadBackupList();
} else if (data.version_mismatch) {
// Version mismatch โ ask user to confirm
const confirmed = await showConfirmDialog({
title: 'Version Mismatch',
message: `This backup was created on SoulSync v${data.backup_version}, but you're running v${data.current_version}.\n\nRestoring an older backup may cause issues if the database schema has changed. A safety backup will be created first.\n\nProceed anyway?`,
confirmText: 'Restore Anyway',
destructive: true
});
if (confirmed) {
await restoreBackup(filename, true);
}
} else {
showToast(`Restore failed: ${data.error}`, 'error');
}
} catch (e) {
showToast('Restore request failed', 'error');
}
}
async function deleteBackup(filename) {
if (!await showConfirmDialog({ title: 'Delete Backup', message: `Delete backup "${filename}"? This cannot be undone.`, confirmText: 'Delete', destructive: true })) return;
try {
const res = await fetch(`/api/database/backups/${encodeURIComponent(filename)}`, { method: 'DELETE' });
const data = await res.json();
if (data.success) {
showToast(`Backup deleted: ${data.deleted}`, 'success');
await loadBackupList();
} else {
showToast(`Delete failed: ${data.error}`, 'error');
}
} catch (e) {
showToast('Delete request failed', 'error');
}
}
// ============================================
// == METADATA CACHE ==
// ============================================
async function loadMetadataCacheStats() {
try {
const response = await fetch('/api/metadata-cache/stats');
if (!response.ok) return;
const stats = await response.json();
const artistsEl = document.getElementById('mcache-stat-artists');
const albumsEl = document.getElementById('mcache-stat-albums');
const tracksEl = document.getElementById('mcache-stat-tracks');
const hitsEl = document.getElementById('mcache-stat-hits');
if (artistsEl) artistsEl.textContent = (stats.artists?.spotify || 0) + (stats.artists?.itunes || 0);
if (albumsEl) albumsEl.textContent = (stats.albums?.spotify || 0) + (stats.albums?.itunes || 0);
if (tracksEl) tracksEl.textContent = (stats.tracks?.spotify || 0) + (stats.tracks?.itunes || 0);
if (hitsEl) hitsEl.textContent = stats.total_hits || 0;
} catch (e) {
// Silently fail โ cache may not be initialized yet
}
}
let _mcacheCurrentTab = 'artist';
let _mcachePage = 0;
let _mcacheSearchTimeout = null;
const MCACHE_PAGE_SIZE = 48;
function openMetadataCacheModal() {
const modal = document.getElementById('mcache-browse-modal');
if (modal) {
modal.style.display = 'flex';
_mcacheCurrentTab = 'artist';
_mcachePage = 0;
// Reset UI
document.querySelectorAll('.mcache-tab').forEach(t => t.classList.remove('active'));
document.querySelector('.mcache-tab[data-tab="artist"]')?.classList.add('active');
const searchInput = document.getElementById('mcache-search');
if (searchInput) searchInput.value = '';
const sourceFilter = document.getElementById('mcache-source-filter');
if (sourceFilter) sourceFilter.value = '';
const sortFilter = document.getElementById('mcache-sort-filter');
if (sortFilter) sortFilter.value = 'last_accessed_at';
loadMetadataCacheBrowseStats();
loadMetadataCacheBrowse();
}
}
function closeMetadataCacheModal() {
const modal = document.getElementById('mcache-browse-modal');
if (modal) modal.style.display = 'none';
}
async function loadMetadataCacheBrowseStats() {
try {
const response = await fetch('/api/metadata-cache/stats');
if (!response.ok) return;
const stats = await response.json();
const el = (id, val) => {
const e = document.getElementById(id);
if (e) e.textContent = val;
};
const spotifyTotal = (stats.artists?.spotify || 0) + (stats.albums?.spotify || 0) + (stats.tracks?.spotify || 0);
const itunesTotal = (stats.artists?.itunes || 0) + (stats.albums?.itunes || 0) + (stats.tracks?.itunes || 0);
el('mcache-browse-spotify-count', spotifyTotal);
el('mcache-browse-itunes-count', itunesTotal);
el('mcache-browse-hits', stats.total_hits || 0);
el('mcache-browse-searches', stats.searches || 0);
} catch (e) { /* ignore */ }
}
function switchMetadataCacheTab(tab) {
_mcacheCurrentTab = tab;
_mcachePage = 0;
document.querySelectorAll('.mcache-tab').forEach(t => {
t.classList.toggle('active', t.dataset.tab === tab);
});
loadMetadataCacheBrowse();
}
async function loadMetadataCacheBrowse() {
const grid = document.getElementById('mcache-grid');
if (!grid) return;
const source = document.getElementById('mcache-source-filter')?.value || '';
const search = document.getElementById('mcache-search')?.value || '';
const sort = document.getElementById('mcache-sort-filter')?.value || 'last_accessed_at';
const params = new URLSearchParams({
type: _mcacheCurrentTab,
sort: sort,
sort_dir: sort === 'name' ? 'asc' : 'desc',
offset: _mcachePage * MCACHE_PAGE_SIZE,
limit: MCACHE_PAGE_SIZE
});
if (source) params.set('source', source);
if (search) params.set('search', search);
grid.innerHTML = '';
try {
const response = await fetch(`/api/metadata-cache/browse?${params}`);
if (!response.ok) throw new Error('Failed to load');
const data = await response.json();
if (!data.items || data.items.length === 0) {
grid.innerHTML = `
๐ฆ
No cached ${_mcacheCurrentTab}s yet
As you search and browse music in SoulSync, API responses will be cached here automatically.
`;
renderMetadataCachePagination(0, 0);
return;
}
renderMetadataCacheGrid(data.items, _mcacheCurrentTab);
renderMetadataCachePagination(data.total, data.offset);
} catch (e) {
grid.innerHTML = 'Failed to load cache data.
';
}
}
function renderMetadataCacheGrid(items, entityType) {
const grid = document.getElementById('mcache-grid');
if (!grid) return;
grid.innerHTML = items.map(item => {
const source = item.source || 'spotify';
const sourceBadge = `${source} `;
const cacheAge = formatCacheAge(item.last_accessed_at);
const hits = item.access_count || 1;
let imageHtml = '';
const isArtist = entityType === 'artist';
const shapeClass = isArtist ? ' artist' : '';
if (item.image_url) {
imageHtml = `${(item.name || '?')[0].toUpperCase()}
`;
} else {
imageHtml = `${(item.name || '?')[0].toUpperCase()}
`;
}
let subText = '';
let metaText = '';
if (entityType === 'artist') {
const genres = item.genres ? (typeof item.genres === 'string' ? JSON.parse(item.genres || '[]') : item.genres) : [];
subText = genres.length > 0 ? genres.slice(0, 2).join(', ') : '';
if (item.popularity) metaText = `Pop: ${item.popularity}`;
} else if (entityType === 'album') {
subText = item.artist_name || '';
const parts = [];
if (item.release_date) parts.push(item.release_date.substring(0, 4));
if (item.total_tracks) parts.push(`${item.total_tracks} tracks`);
if (item.album_type) parts.push(item.album_type);
metaText = parts.join(' ยท ');
} else if (entityType === 'track') {
subText = item.artist_name || '';
const parts = [];
if (item.album_name) parts.push(item.album_name);
if (item.duration_ms) parts.push(formatDuration(item.duration_ms));
metaText = parts.join(' ยท ');
}
return `
${imageHtml}
${item.name || 'Unknown'}
${subText ? `
${subText}
` : ''}
${metaText ? `
${metaText}
` : ''}
${sourceBadge}
${cacheAge} ยท ${hits}x
`;
}).join('');
}
function renderMetadataCachePagination(total, offset) {
const container = document.getElementById('mcache-pagination');
if (!container) return;
const totalPages = Math.ceil(total / MCACHE_PAGE_SIZE);
const currentPage = Math.floor(offset / MCACHE_PAGE_SIZE);
if (totalPages <= 1) {
container.innerHTML = total > 0 ? `${total} result${total !== 1 ? 's' : ''} ` : '';
return;
}
let html = '';
html += `โน `;
const maxVisible = 7;
let start = Math.max(0, currentPage - Math.floor(maxVisible / 2));
let end = Math.min(totalPages, start + maxVisible);
if (end - start < maxVisible) start = Math.max(0, end - maxVisible);
if (start > 0) {
html += `1 `;
if (start > 1) html += `... `;
}
for (let i = start; i < end; i++) {
html += `${i + 1} `;
}
if (end < totalPages) {
if (end < totalPages - 1) html += `... `;
html += `${totalPages} `;
}
html += `= totalPages - 1 ? 'disabled' : ''} onclick="_mcachePage=${currentPage + 1};loadMetadataCacheBrowse()">โบ `;
html += `${total} total `;
container.innerHTML = html;
}
async function openMetadataCacheDetail(source, entityType, entityId) {
const modal = document.getElementById('mcache-detail-modal');
const body = document.getElementById('mcache-detail-body');
const title = document.getElementById('mcache-detail-title');
if (!modal || !body) return;
modal.style.display = 'flex';
body.innerHTML = 'Loading...
';
if (title) title.textContent = 'Loading...';
try {
const response = await fetch(`/api/metadata-cache/entity/${source}/${entityType}/${entityId}`);
if (!response.ok) throw new Error('Not found');
const data = await response.json();
if (title) title.textContent = data.name || 'Unknown';
const isArtist = entityType === 'artist';
const shapeClass = isArtist ? ' artist' : '';
let imageHtml = '';
if (data.image_url) {
imageHtml = `${(data.name || '?')[0].toUpperCase()}
`;
} else {
imageHtml = `${(data.name || '?')[0].toUpperCase()}
`;
}
const sourceBadge = `${source} `;
const typeBadge = `${entityType} `;
// Build structured fields table
let fieldsHtml = '';
const addRow = (label, value) => {
if (value !== null && value !== undefined && value !== '') {
fieldsHtml += `${label} ${value} `;
}
};
addRow('Entity ID', data.entity_id);
addRow('Name', data.name);
if (entityType === 'artist') {
const genres = data.genres ? (typeof data.genres === 'string' ? JSON.parse(data.genres || '[]') : data.genres) : [];
if (genres.length) addRow('Genres', genres.join(', '));
if (data.popularity) addRow('Popularity', data.popularity);
if (data.followers) addRow('Followers', data.followers.toLocaleString());
} else if (entityType === 'album') {
addRow('Artist', data.artist_name);
addRow('Release Date', data.release_date);
addRow('Total Tracks', data.total_tracks);
addRow('Album Type', data.album_type);
addRow('Label', data.label);
} else if (entityType === 'track') {
addRow('Artist', data.artist_name);
addRow('Album', data.album_name);
if (data.duration_ms) addRow('Duration', formatDuration(data.duration_ms));
addRow('Track Number', data.track_number);
addRow('Disc Number', data.disc_number);
addRow('Explicit', data.explicit ? 'Yes' : 'No');
addRow('ISRC', data.isrc);
if (data.preview_url) addRow('Preview', `Listen `);
}
fieldsHtml += '
';
// Cache metadata section
let cacheHtml = 'Cache Metadata
';
cacheHtml += '';
if (data.created_at) cacheHtml += `Cached At ${new Date(data.created_at).toLocaleString()} `;
if (data.last_accessed_at) cacheHtml += `Last Accessed ${new Date(data.last_accessed_at).toLocaleString()} `;
if (data.access_count) cacheHtml += `Access Count ${data.access_count} `;
if (data.ttl_days) cacheHtml += `TTL ${data.ttl_days} days `;
cacheHtml += '
';
// Raw JSON section
let rawJsonHtml = '';
if (data.raw_json) {
const rawStr = typeof data.raw_json === 'string' ? data.raw_json : JSON.stringify(data.raw_json, null, 2);
const escapedJson = rawStr.replace(/&/g, '&').replace(//g, '>');
rawJsonHtml = `
Raw API Response
Show Raw JSON
${escapedJson} `;
}
body.innerHTML = `
${imageHtml}
${data.name || 'Unknown'}
${entityType !== 'artist' && data.artist_name ? `
${data.artist_name}
` : ''}
${sourceBadge}
${typeBadge}
Details
${fieldsHtml}
${cacheHtml}
${rawJsonHtml}`;
} catch (e) {
body.innerHTML = 'Failed to load entity details.
';
}
}
function closeMetadataCacheDetail() {
const modal = document.getElementById('mcache-detail-modal');
if (modal) modal.style.display = 'none';
}
async function clearMetadataCache() {
if (!confirm('Clear all cached metadata? This will remove all cached API responses from Spotify and iTunes.')) return;
try {
const response = await fetch('/api/metadata-cache/clear', { method: 'DELETE' });
const data = await response.json();
if (data.success) {
showToast(`Cleared ${data.cleared} cached entries`, 'success');
loadMetadataCacheBrowseStats();
loadMetadataCacheBrowse();
loadMetadataCacheStats();
} else {
showToast('Failed to clear cache', 'error');
}
} catch (e) {
showToast('Error clearing cache', 'error');
}
}
function debouncedMetadataCacheSearch() {
if (_mcacheSearchTimeout) clearTimeout(_mcacheSearchTimeout);
_mcacheSearchTimeout = setTimeout(() => {
_mcachePage = 0;
loadMetadataCacheBrowse();
}, 400);
}
function formatCacheAge(timestamp) {
if (!timestamp) return 'โ';
const now = new Date();
const then = new Date(timestamp);
const diffMs = now - then;
const diffMin = Math.floor(diffMs / 60000);
if (diffMin < 1) return 'now';
if (diffMin < 60) return `${diffMin}m`;
const diffHr = Math.floor(diffMin / 60);
if (diffHr < 24) return `${diffHr}h`;
const diffDays = Math.floor(diffHr / 24);
if (diffDays < 30) return `${diffDays}d`;
return `${Math.floor(diffDays / 30)}mo`;
}
// ============================================
// == TOOL HELP MODAL ==
// ============================================
const TOOL_HELP_CONTENT = {
'db-updater': {
title: 'Database Updater',
content: `
What does this tool do?
The Database Updater syncs your media server library (Plex, Jellyfin, or Navidrome) with SoulSync's internal database.
Update Modes
Incremental Update: Only scans for new artists, albums, and tracks that have been added since the last update. Fast and efficient for regular updates.
Full Refresh: Completely rebuilds the database from scratch. Use this if you've made significant changes to your library or if data seems out of sync.
When to use it?
After adding new music to your media server
When library statistics seem incorrect
After changing media server settings
Progress Persistence
The update runs in the background. You can close this page and return later - progress will be preserved and continue where it left off.
`
},
'metadata-updater': {
title: 'Metadata Updater',
content: `
What does this tool do?
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.
`
},
'quality-scanner': {
title: 'Quality Scanner',
content: `
What does this tool do?
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
Quality Tiers
Tier 1 (Best): FLAC, WAV, ALAC, AIFF - Lossless formats
Tier 2: OPUS, OGG - High quality lossy
Tier 3: M4A, AAC - Standard lossy
Tier 4: MP3, WMA - Lower quality lossy
Stats Explained
Processed: Total tracks scanned so far
Quality Met: Tracks that meet your quality standards
Low Quality: Tracks below your quality threshold
Matched: Low quality tracks successfully matched to Spotify and added to wishlist
`
},
'duplicate-cleaner': {
title: 'Duplicate Cleaner',
content: `
What does this tool do?
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!
`
},
'retag-tool': {
title: 'Retag Tool',
content: `
What does this tool do?
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
Only one retag operation can run at a time
`
},
'discover-page': {
title: 'Discover Page Guide',
content: `
What is the Discover page?
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+)
Hidden Gems: Underground discoveries (Spotify popularity <40)
Discovery Shuffle: 50 random tracks - different every time you load
Familiar Favorites: Reliable, mid-popularity tracks (40-70)
๐จ Build a Playlist
Create custom playlists using MusicMap similar artists from seed artists you select.
Search for any artist on Spotify (even if not in your library)
Select 1-5 seed artists
Choose playlist size: 25, 50, 75, or 100 tracks
Uses cached MusicMap similar artists from your database
Pulls albums from those similar artists to build the playlist
Download and sync like any other discover playlist
๐ง ListenBrainz Playlists
Access playlists from your ListenBrainz account (requires ListenBrainz authentication).
Created For You: Playlists generated by ListenBrainz for you
Your Playlists: Playlists you've created on ListenBrainz
Collaborative: Collaborative playlists you're part of
Cached locally for performance - click Refresh to update from ListenBrainz
Click any playlist to view tracks and download/sync
โฐ Time Machine (Browse by Decade)
Explore your discovery pool organized by release decade.
Dynamically generated tabs for decades with available content (1950s-2020s)
Each decade shows up to 100 tracks from that era
Great for discovering older catalog releases from your favorite artists
๐ต Browse by Genre
Explore your discovery pool filtered by music genre.
Shows top genres from your discovery pool
Click any genre tab to see up to 100 tracks in that genre
Genres sourced from Spotify metadata
๐พ What is the Discovery Pool?
The discovery pool is a database of tracks from:
Artists in your watchlist
Similar artists found via MusicMap
Populated during watchlist scanner runs (scrapes music-map.com, matches to Spotify)
Filtered to exclude tracks already in your library
Used to generate Fresh Tape, The Archives, and discovery pool playlists
Caches up to 50 top similar artists across your watchlist
๐บ๏ธ How MusicMap Integration Works
SoulSync uses MusicMap (music-map.com) instead of Spotify's recommendation API to find similar artists:
During watchlist scans, each watchlist artist is looked up on MusicMap
MusicMap's artist similarity graph is scraped to find related artists
Similar artist names are matched to Spotify IDs
Up to 10 similar artists per watchlist artist are cached (refreshed every 30 days)
These cached similar artists power all discovery features
This approach gives you more diverse, community-driven recommendations
โฌ๏ธ Download & Sync Features
Most discover playlists support two actions:
Download: Opens modal to match tracks to Soulseek and add to download queue
Sync: Downloads tracks and automatically transfers them to your media server
Sync progress persists - you can close the page and it continues in the background
Sync status shows: โ completed, โณ pending, โ failed
๐ When is data refreshed?
MusicMap Similar Artists: Fetched during watchlist scans, cached for 30 days
Hero, Recent Releases, Fresh Tape, The Archives: Updated during watchlist scanner runs (Dashboard page)
Discovery Pool: Fully refreshed every 24 hours during watchlist scans (50 top similar artists, 10 albums each)
Seasonal Content: Auto-detected based on current date
Personalized Library Playlists: Generated on-demand from current library data
Discovery Pool Playlists: Generated on-demand from current discovery pool
Build a Playlist: Generated on-demand from cached MusicMap similar artists
ListenBrainz: Cached locally, manually refreshed via Refresh button
Time Machine & Genre: Generated on-demand from current discovery pool
๐ก Pro Tips
Curated playlists (Fresh Tape, The Archives) stay consistent until next watchlist scan - great for weekly listening routines
Discovery Shuffle changes every page load - perfect when you want spontaneous recommendations
Use Build a Playlist to explore artists not in your watchlist (if seed artist isn't in watchlist, MusicMap data must be cached first)
The discovery pool only includes tracks you don't own yet - download them to build your collection!
Sync feature is ideal for batch downloading entire playlists to your media server
MusicMap provides more diverse recommendations than Spotify's algorithm - expect deeper cuts and underground artists!
Add more artists to your watchlist to expand your discovery pool with their MusicMap similar artists
`
},
// ==================== Automation Trigger Help ====================
'auto-schedule': {
title: 'Schedule Timer',
content: `
What is this trigger?
Runs your automation on a repeating interval โ every X minutes, hours, or days.
Configuration
Interval: How often to repeat (e.g. every 6 hours)
Unit: Minutes, Hours, or Days
When does it first run?
The timer starts when SoulSync boots. If the automation was previously scheduled, it resumes from where it left off.
Good for
Regular wishlist processing (every 30 minutes)
Periodic database backups (every 12 hours)
Any recurring maintenance task
`
},
'auto-daily_time': {
title: 'Daily Time',
content: `
What is this trigger?
Runs your automation once per day at a specific time.
Configuration
Time: The wall-clock time to run (e.g. 03:00 for 3 AM)
Good for
Nightly watchlist scans
Off-peak database updates
Daily backups at a consistent time
`
},
'auto-weekly_time': {
title: 'Weekly Schedule',
content: `
What is this trigger?
Runs your automation on specific days of the week at a set time.
Configuration
Days: Select one or more days (MonโSun)
Time: The time to run on those days
Good for
Weekend-only quality scans
Weekly playlist refreshes
Scheduled maintenance on quiet days
`
},
'auto-app_started': {
title: 'App Started',
content: `
What is this trigger?
Fires once when SoulSync starts up. Useful for tasks you want to run on every boot.
Good for
Refreshing mirrored playlists on startup
Running a quick database sync
Sending a "SoulSync is online" notification
Note
This trigger fires only once per startup โ it will not fire again until SoulSync is restarted.
`
},
'auto-track_downloaded': {
title: 'Track Downloaded',
content: `
What is this trigger?
Fires every time a single track finishes downloading and post-processing (tagging, moving to library).
Conditions
You can filter which downloads trigger this automation:
Artist: Only fire for specific artists
Title: Match on track title
Album: Match on album name
Quality: Match on file format (FLAC, MP3, etc.)
Available variables for notifications
{artist}, {title}, {album}, {quality}
Note
This fires per-track, not per-album. For an album with 12 tracks, it fires 12 times. Use Batch Complete if you want one event per album.
`
},
'auto-batch_complete': {
title: 'Batch Complete',
content: `
What is this trigger?
Fires when an entire album or playlist download batch finishes โ all tracks in the batch are done (whether successful or failed).
Conditions
Playlist name: Filter by the name of the album or playlist
Available variables for notifications
{playlist_name}, {total_tracks}, {completed_tracks}, {failed_tracks}
Good for
Triggering a media server scan after downloads finish
Sending a notification when an album is fully downloaded
Running a database update after new content arrives
`
},
'auto-watchlist_new_release': {
title: 'New Release Found',
content: `
What is this trigger?
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
`
},
'auto-playlist_synced': {
title: 'Playlist Synced',
content: `
What is this trigger?
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.
Conditions
Playlist name: Only fire for specific playlists
Available variables for notifications
{playlist_name}, {total_tracks}, {matched_tracks}, {synced_tracks}, {failed_tracks}
`
},
'auto-playlist_changed': {
title: 'Playlist Changed',
content: `
What is this trigger?
Fires when a mirrored playlist detects that the source playlist (on Spotify, Tidal, YouTube, etc.) has changed โ tracks were added or removed.
Conditions
Playlist name: Only fire for specific playlists
Available variables for notifications
{playlist_name}, {old_count}, {new_count}, {added}, {removed}
Good for
Auto-discovering new tracks after a playlist updates
Auto-syncing the playlist to your media server
Getting notified when your followed playlists change
`
},
'auto-discovery_completed': {
title: 'Discovery Complete',
content: `
What is this trigger?
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.
Conditions
Playlist name: Only fire for specific playlists
Available variables for notifications
{playlist_name}, {total_tracks}, {discovered_count}, {failed_count}, {skipped_count}
Good for
Auto-syncing a playlist after discovery completes
Getting notified about discovery results (how many matched vs failed)
`
},
'auto-wishlist_processing_completed': {
title: 'Wishlist Processed',
content: `
What is this trigger?
Fires when the auto-wishlist processing batch finishes. This is the automated download cycle that searches Soulseek for wishlist tracks.
Available variables for notifications
{tracks_processed}, {tracks_found}, {tracks_failed}
`
},
'auto-watchlist_scan_completed': {
title: 'Watchlist Scan Done',
content: `
What is this trigger?
Fires when the watchlist artist scan completes. The scan checks all watched artists for new releases and adds new tracks to your wishlist.
Available variables for notifications
{artists_scanned}, {new_tracks_found}, {tracks_added}
`
},
'auto-database_update_completed': {
title: 'Database Updated',
content: `
What is this trigger?
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
Sending a summary notification with library stats
`
},
'auto-download_failed': {
title: 'Download Failed',
content: `
What is this trigger?
Fires when a track permanently fails to download. This means all retry attempts and sources have been exhausted.
Conditions
Artist: Only fire for specific artists
Title: Match on track title
Reason: Match on failure reason
Available variables for notifications
{artist}, {title}, {reason}
`
},
'auto-download_quarantined': {
title: 'File Quarantined',
content: `
What is this trigger?
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.
`
},
'auto-wishlist_item_added': {
title: 'Wishlist Item Added',
content: `
What is this trigger?
Fires when a track is added to your wishlist โ whether manually, by the quality scanner, or by the watchlist scan.
Conditions
Artist: Only fire for specific artists
Title: Match on track title
Available variables for notifications
{artist}, {title}, {reason}
`
},
'auto-watchlist_artist_added': {
title: 'Artist Watched',
content: `
What is this trigger?
Fires when an artist is added to your watchlist. Watched artists are periodically scanned for new releases.
Conditions
Artist: Only fire for specific artists
Available variables for notifications
{artist}, {artist_id}
`
},
'auto-watchlist_artist_removed': {
title: 'Artist Unwatched',
content: `
What is this trigger?
Fires when an artist is removed from your watchlist.
Conditions
Artist: Only fire for specific artists
Available variables for notifications
{artist}, {artist_id}
`
},
'auto-import_completed': {
title: 'Import Complete',
content: `
What is this trigger?
Fires when an album or track import operation finishes. Imports bring music from external sources into your library.
Conditions
Artist: Only fire for specific artists
Album name: Match on album name
Available variables for notifications
{track_count}, {album_name}, {artist}
`
},
'auto-mirrored_playlist_created': {
title: 'Playlist Mirrored',
content: `
What is this trigger?
Fires when a new playlist mirror is created โ a playlist from Spotify, Tidal, YouTube, ListenBrainz, or Beatport is set up for mirroring.
Conditions
Playlist name: Match on playlist name
Source: Match on platform (spotify, tidal, youtube, etc.)
Available variables for notifications
{playlist_name}, {source}, {track_count}
Good for
Auto-discovering tracks immediately after a new mirror is created
Getting notified when new playlists are mirrored
`
},
'auto-quality_scan_completed': {
title: 'Quality Scan Done',
content: `
What is this trigger?
Fires when the quality scanner finishes. The scanner identifies tracks below your quality preferences and adds them to your wishlist for re-downloading.
Available variables for notifications
{quality_met}, {low_quality}, {total_scanned}
`
},
'auto-duplicate_scan_completed': {
title: 'Duplicate Scan Done',
content: `
What is this trigger?
Fires when the duplicate cleaner finishes scanning your transfer folder for duplicate audio files.
Available variables for notifications
{files_scanned}, {duplicates_found}, {space_freed}
`
},
'auto-library_scan_completed': {
title: 'Library Scan Done',
content: `
What is this trigger?
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)
`
},
// ==================== Automation Action Help ====================
'auto-process_wishlist': {
title: 'Process Wishlist',
content: `
What does this action do?
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)
Searches Soulseek for each track
Downloads the best quality match found
Tags and moves files to your library
`
},
'auto-scan_watchlist': {
title: 'Scan Watchlist',
content: `
What does this action do?
Checks all watched artists for new releases you don't already have. New tracks are automatically added to your wishlist for downloading.
How it works
Goes through each artist in your watchlist
Fetches their discography from Spotify
Compares against your library to find missing releases
Adds new tracks to your wishlist
`
},
'auto-scan_library': {
title: 'Scan Library',
content: `
What does this action do?
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.
`
},
'auto-refresh_mirrored': {
title: 'Refresh Mirrored Playlist',
content: `
What does this action do?
Re-fetches a mirrored playlist from its source platform (Spotify, Tidal, YouTube, etc.) and updates the local mirror with any track changes.
Configuration
Playlist: Select a specific mirrored playlist, or check "Refresh all" to update all mirrors
Good for
Keeping mirrors in sync with playlists that change frequently
Detecting added/removed tracks on the source platform
`
},
'auto-sync_playlist': {
title: 'Sync Playlist',
content: `
What does this action do?
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.
`
},
'auto-discover_playlist': {
title: 'Discover Playlist',
content: `
What does this action do?
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
`
},
'auto-notify_only': {
title: 'Notify Only',
content: `
What does this action do?
Nothing โ it performs no action. It just passes the event data through to the notification step.
Good for
Getting notified about events without taking any automated action
Monitoring what's happening in SoulSync (downloads, failures, changes)
Pair with any event trigger + Discord/Telegram/Pushbullet notification
`
},
'auto-start_database_update': {
title: 'Update Database',
content: `
What does this action do?
Refreshes SoulSync's internal library database by syncing with your media server (Plex, Jellyfin, or Navidrome).
Configuration
Full refresh: When checked, completely rebuilds the database from scratch. When unchecked, only scans for new content (faster).
`
},
'auto-run_duplicate_cleaner': {
title: 'Run Duplicate Cleaner',
content: `
What does this action do?
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.
`
},
'auto-clear_quarantine': {
title: 'Clear Quarantine',
content: `
What does this action do?
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.
`
},
'auto-update_discovery_pool': {
title: 'Update Discovery Pool',
content: `
What does this action do?
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.
`
},
'auto-start_quality_scan': {
title: 'Run Quality Scan',
content: `
What does this action do?
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)
`
},
'auto-backup_database': {
title: 'Backup Database',
content: `
What does this action do?
Creates a timestamped backup of SoulSync's SQLite database. Uses the SQLite backup API for a safe hot-copy while the app is running.
Retention
Keeps the last 5 backups automatically. Older backups are cleaned up to save disk space.
Good for
Nightly automated backups
Pre-update safety backups
Peace of mind for your library data
`
},
'auto-refresh_beatport_cache': {
title: 'Refresh Beatport Cache',
content: `
What does this action do?
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)
`
},
'auto-clean_search_history': {
title: 'Clean Search History',
content: `
What does this action do?
Removes old search queries from Soulseek. This keeps your search history clean and prevents buildup over time.
Good for
Periodic housekeeping
Keeping Soulseek search history tidy
`
},
'auto-clean_completed_downloads': {
title: 'Clean Completed Downloads',
content: `
What does this action do?
Clears completed downloads from the transfer list and removes any empty directories left behind in the staging folder.
Good for
Automatic cleanup after batch downloads
Preventing staging folder clutter
Chaining after a batch complete trigger
`
},
'auto-full_cleanup': {
title: 'Full Cleanup',
content: `
What does this action do?
Runs all housekeeping tasks in a single sweep:
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
Running after large batch downloads complete
`
},
'auto-deep_scan_library': {
title: 'Deep Scan Library',
content: `
What does this action do?
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
`
},
// ==================== Notification/Then-Action Help ====================
'auto-discord_webhook': {
title: 'Discord Webhook',
content: `
What does this then-action do?
Sends a notification to a Discord channel via webhook when the automation's action completes.
Configuration
Webhook URL: The Discord webhook URL for your channel (found in Channel Settings โ Integrations โ Webhooks)
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
`
},
'auto-pushbullet': {
title: 'Pushbullet',
content: `
What does this then-action do?
Sends a push notification to your phone or desktop via Pushbullet when the automation's action completes.
Configuration
API Key: Your Pushbullet access token (found in Pushbullet Settings โ Access Tokens)
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
`
},
'auto-telegram': {
title: 'Telegram',
content: `
What does this then-action do?
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.
`
},
'auto-fire_signal': {
title: 'Fire Signal',
content: `
What does this then-action do?
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.
`
},
'backup-manager': {
title: 'Backup Manager',
content: `
What does this tool do?
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
Backups: Total number of backup files available
Latest Size: Size of the most recent backup
DB Size: Current size of the live database
`
},
'metadata-cache': {
title: 'Metadata Cache',
content: `
What is this?
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 = 'Loading downloads...
';
try {
const response = await fetch('/api/retag/groups');
const data = await response.json();
if (!data.success || !data.groups || data.groups.length === 0) {
body.innerHTML = 'No downloads recorded yet. Downloads will appear here after completing album or single downloads.
';
if (clearBtn) clearBtn.style.display = 'none';
return;
}
if (clearBtn) clearBtn.style.display = '';
renderRetagGroups(data.groups, body);
} catch (e) {
body.innerHTML = 'Failed to load downloads.
';
}
}
function closeRetagModal() {
const modal = document.getElementById('retag-modal');
if (modal) modal.style.display = 'none';
document.body.style.overflow = '';
}
function renderRetagGroups(groups, container) {
// Group by artist_name
const byArtist = {};
groups.forEach(g => {
const artist = g.artist_name || 'Unknown Artist';
if (!byArtist[artist]) byArtist[artist] = [];
byArtist[artist].push(g);
});
let html = '';
Object.keys(byArtist).sort((a, b) => a.localeCompare(b)).forEach(artist => {
html += `
${escapeHtml(artist)}
`;
byArtist[artist].forEach(group => {
const imgHtml = group.image_url
? `
`
: '
';
const trackCount = group.track_count || group.total_tracks || 0;
const typeLabel = (group.group_type || 'album').charAt(0).toUpperCase() + (group.group_type || 'album').slice(1);
const releaseDate = group.release_date ? group.release_date.substring(0, 4) : '';
const defaultQuery = (artist + ' ' + (group.album_name || '')).trim();
html += `
`;
});
html += `
`;
});
container.innerHTML = html;
_attachRetagDelegation(container);
}
function _attachRetagDelegation(container) {
// Single click handler for all retag group interactions
container.addEventListener('click', (e) => {
const target = e.target;
// Skip checkbox wrapper clicks โ handled by change listener
if (target.closest('.retag-group-checkbox')) return;
// Retag button
const retagBtn = target.closest('.retag-group-btn');
if (retagBtn) {
e.stopPropagation();
const groupId = parseInt(retagBtn.dataset.groupId);
const header = retagBtn.closest('.retag-group-header');
const defaultQuery = header ? header.dataset.defaultQuery || '' : '';
openRetagSearch(groupId, defaultQuery);
return;
}
// Delete confirm buttons (dynamically injected)
const confirmYes = target.closest('.retag-confirm-yes');
if (confirmYes) {
e.stopPropagation();
const card = confirmYes.closest('.retag-group-card');
if (card) executeRetagGroupDelete(parseInt(card.dataset.groupId));
return;
}
const confirmNo = target.closest('.retag-confirm-no');
if (confirmNo) {
e.stopPropagation();
const card = confirmNo.closest('.retag-group-card');
if (card) cancelRetagDeleteConfirm(parseInt(card.dataset.groupId));
return;
}
// Delete button
const delBtn = target.closest('.retag-group-delete-btn');
if (delBtn) {
e.stopPropagation();
showRetagDeleteConfirm(parseInt(delBtn.dataset.groupId));
return;
}
// Group header click (expand/collapse)
const header = target.closest('.retag-group-header');
if (header) {
toggleRetagGroup(parseInt(header.dataset.groupId));
return;
}
});
// Separate change handler for checkboxes
container.addEventListener('change', (e) => {
if (e.target.classList.contains('retag-select-cb')) {
updateRetagBatchBar();
}
});
}
async function toggleRetagGroup(groupId) {
const tracksDiv = document.getElementById(`retag-tracks-${groupId}`);
if (!tracksDiv) return;
if (tracksDiv.style.display === 'none') {
tracksDiv.style.display = 'block';
if (tracksDiv.querySelector('.retag-tracks-loading')) {
try {
const response = await fetch(`/api/retag/groups/${groupId}/tracks`);
const data = await response.json();
if (data.success && data.tracks && data.tracks.length > 0) {
tracksDiv.innerHTML = data.tracks.map(t => {
const discPrefix = t.disc_number > 1 ? `${t.disc_number}-` : '';
const trackNum = t.track_number != null ? `${discPrefix}${String(t.track_number).padStart(2, '0')}` : '--';
return `
${trackNum}
${escapeHtml(t.title || 'Unknown')}
${(t.file_format || '').toUpperCase()}
`;
}).join('');
} else {
tracksDiv.innerHTML = 'No tracks found
';
}
} catch (e) {
tracksDiv.innerHTML = 'Failed to load tracks
';
}
}
} else {
tracksDiv.style.display = 'none';
}
}
function openRetagSearch(groupId, defaultQuery) {
retagCurrentGroupId = groupId;
const modal = document.getElementById('retag-search-modal');
if (!modal) return;
modal.style.display = 'flex';
const input = document.getElementById('retag-search-input');
if (input) {
input.value = defaultQuery || '';
input.focus();
if (defaultQuery) {
searchRetagAlbums(defaultQuery);
}
}
}
function closeRetagSearch() {
const modal = document.getElementById('retag-search-modal');
if (modal) modal.style.display = 'none';
retagCurrentGroupId = null;
}
let retagSearchTimeout = null;
document.addEventListener('DOMContentLoaded', () => {
const retagSearchInput = document.getElementById('retag-search-input');
if (retagSearchInput) {
retagSearchInput.addEventListener('input', (e) => {
clearTimeout(retagSearchTimeout);
retagSearchTimeout = setTimeout(() => searchRetagAlbums(e.target.value), 400);
});
retagSearchInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
clearTimeout(retagSearchTimeout);
searchRetagAlbums(e.target.value);
}
});
}
// Close retag modals on escape
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
const searchModal = document.getElementById('retag-search-modal');
if (searchModal && searchModal.style.display === 'flex') {
closeRetagSearch();
return;
}
const mainModal = document.getElementById('retag-modal');
if (mainModal && mainModal.style.display === 'flex') {
closeRetagModal();
}
}
});
// Close retag modal on overlay click
const retagModal = document.getElementById('retag-modal');
if (retagModal) {
retagModal.addEventListener('click', (e) => {
if (e.target === retagModal) closeRetagModal();
});
}
const retagSearchModal = document.getElementById('retag-search-modal');
if (retagSearchModal) {
retagSearchModal.addEventListener('click', (e) => {
if (e.target === retagSearchModal) closeRetagSearch();
});
}
});
async function searchRetagAlbums(query) {
if (!query || !query.trim()) return;
const resultsDiv = document.getElementById('retag-search-results');
if (!resultsDiv) return;
resultsDiv.innerHTML = 'Searching...
';
try {
const response = await fetch(`/api/retag/search?q=${encodeURIComponent(query.trim())}`);
const data = await response.json();
if (data.success && data.albums && data.albums.length > 0) {
resultsDiv.innerHTML = data.albums.map(a => {
const imgHtml = a.image_url
? ` `
: '
';
const typeLabel = (a.album_type || 'album').charAt(0).toUpperCase() + (a.album_type || 'album').slice(1);
const releaseYear = a.release_date ? a.release_date.substring(0, 4) : '';
return `
${imgHtml}
${escapeHtml(a.name || 'Unknown')}
${escapeHtml(a.artist || 'Unknown')}
${typeLabel}${releaseYear ? ' \u00b7 ' + releaseYear : ''} \u00b7 ${a.total_tracks || 0} tracks
`;
}).join('');
} else {
resultsDiv.innerHTML = 'No albums found.
';
}
} catch (e) {
resultsDiv.innerHTML = 'Search failed.
';
}
}
/**
* 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)}"?
Confirm
Cancel
`;
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?
Yes
No
`;
}
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 30 seconds)
stopDbStatsPolling(); // Ensure no duplicates
dbStatsInterval = setInterval(fetchAndUpdateDbStats, 30000);
// 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 30 seconds, matching GUI behavior)
stopWishlistCountPolling(); // Ensure no duplicates
wishlistCountInterval = setInterval(updateWishlistCount, 30000);
// 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 5 seconds for responsiveness)
setInterval(fetchAndUpdateActivityFeed, 5000);
// 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 totalCount = activeArtists.length + activeSearch.length + activeDiscover.length;
if (totalCount === 0) {
section.style.display = 'none';
container.innerHTML = '';
return;
}
section.style.display = '';
let html = '';
// --- Artists group ---
if (activeArtists.length > 0) {
html += `
${activeArtists.map(id => createArtistBubbleCard(artistDownloadBubbles[id])).join('')}
`;
}
// --- Search group ---
if (activeSearch.length > 0) {
html += `
${activeSearch.map(name => createSearchBubbleCard(searchDownloadBubbles[name])).join('')}
`;
}
// --- Discover group ---
if (activeDiscover.length > 0) {
html += `
${activeDiscover.map(pid => createDashboardDiscoverBubble(pid)).join('')}
`;
}
container.innerHTML = html;
// Post-render: attach artist bubble click handlers + dynamic glow
activeArtists.forEach(artistId => {
const card = container.querySelector(`.artist-bubble-card[data-artist-id="${artistId}"]`);
if (card) {
card.addEventListener('click', () => openArtistDownloadModal(artistId));
const artist = artistDownloadBubbles[artistId].artist;
if (artist.image_url) {
extractImageColors(artist.image_url, (colors) => {
applyDynamicGlow(card, colors);
});
}
}
});
// Search and discover cards use inline onclick โ no post-render needed
}
/**
* Create a 150px circle card for a discover download (dashboard variant).
* Matches artist/search bubble sizing.
*/
function createDashboardDiscoverBubble(playlistId) {
const download = discoverDownloads[playlistId];
if (!download) return '';
const isCompleted = download.status === 'completed';
const imageUrl = download.imageUrl || '';
const backgroundStyle = imageUrl
? `background-image: url('${imageUrl}');`
: `background: linear-gradient(135deg, rgba(29, 185, 84, 0.3) 0%, rgba(24, 156, 71, 0.2) 100%);`;
return `
${escapeHtml(download.name)}
${isCompleted ? 'Completed' : 'In Progress'}
`;
}
function updateDbUpdaterCardInfo(stats) {
// Update the detailed stats within the DB Updater tool card
const lastRefreshEl = document.getElementById('db-last-refresh');
const artistsStatEl = document.getElementById('db-stat-artists');
const albumsStatEl = document.getElementById('db-stat-albums');
const tracksStatEl = document.getElementById('db-stat-tracks');
const sizeStatEl = document.getElementById('db-stat-size');
if (lastRefreshEl) {
if (stats.last_full_refresh) {
const date = new Date(stats.last_full_refresh);
lastRefreshEl.textContent = date.toLocaleString();
} else {
lastRefreshEl.textContent = 'Never';
}
}
if (artistsStatEl) artistsStatEl.textContent = stats.artists.toLocaleString() || '0';
if (albumsStatEl) albumsStatEl.textContent = stats.albums.toLocaleString() || '0';
if (tracksStatEl) tracksStatEl.textContent = stats.tracks.toLocaleString() || '0';
if (sizeStatEl) sizeStatEl.textContent = `${stats.database_size_mb.toFixed(2)} MB`;
// Update the title of the tool card to show which server is active
const toolCardTitle = document.querySelector('#db-updater-card .tool-card-title');
if (toolCardTitle && stats.server_source) {
const serverName = stats.server_source.charAt(0).toUpperCase() + stats.server_source.slice(1);
toolCardTitle.textContent = `${serverName} Database Updater`;
}
}
// --- Wishlist Count Functions ---
async function updateWishlistCount() {
if (socketConnected) return; // WebSocket handles this
try {
const response = await fetch('/api/wishlist/count');
if (!response.ok) return;
const data = await response.json();
const count = data.count || 0;
const wishlistButton = document.getElementById('wishlist-button');
if (wishlistButton) {
wishlistButton.textContent = `๐ต Wishlist (${count})`;
// Update button styling based on count (matching GUI behavior)
if (count === 0) {
wishlistButton.classList.remove('wishlist-active');
wishlistButton.classList.add('wishlist-inactive');
} else {
wishlistButton.classList.remove('wishlist-inactive');
wishlistButton.classList.add('wishlist-active');
}
}
// Check for auto-initiated wishlist processes that user should see immediately
await checkForAutoInitiatedWishlistProcess();
} catch (error) {
console.warn('Could not fetch wishlist count:', error);
}
}
async function checkForAutoInitiatedWishlistProcess() {
try {
const playlistId = 'wishlist';
// Only check if we're on the dashboard and no modal is currently visible
if (currentPage !== 'dashboard') {
return;
}
// Don't override if user has manually closed the modal during auto-processing
if (WishlistModalState.wasUserClosed()) {
return;
}
// Check for active wishlist processes
const response = await fetch('/api/active-processes');
if (!response.ok) return;
const data = await response.json();
const processes = data.active_processes || [];
const serverWishlistProcess = processes.find(p => p.playlist_id === playlistId);
const clientWishlistProcess = activeDownloadProcesses[playlistId];
if (serverWishlistProcess && serverWishlistProcess.auto_initiated) {
console.log('๐ค [Auto-Processing] Detected auto-initiated wishlist process during polling');
// Only sync frontend state if needed, but don't auto-show modal
const needsSync = !clientWishlistProcess ||
clientWishlistProcess.batchId !== serverWishlistProcess.batch_id ||
!clientWishlistProcess.modalElement ||
!document.body.contains(clientWishlistProcess.modalElement);
if (needsSync) {
console.log('๐ [Auto-Processing] Syncing frontend state for auto-processing (background mode)');
await rehydrateModal(serverWishlistProcess, false); // Background sync only
}
// Note: Modal visibility is controlled by user interaction only
// User must click wishlist button to see auto-processing progress
}
} catch (error) {
console.warn('Error checking for auto-initiated wishlist process:', error);
}
}
async function checkAndUpdateDbProgress() {
if (socketConnected) return; // WebSocket handles this
try {
const response = await fetch('/api/database/update/status', {
signal: AbortSignal.timeout(10000) // 10 second timeout
});
if (!response.ok) return;
const state = await response.json();
console.debug('๐ DB Status:', state.status, `${state.processed}/${state.total}`, `${state.progress.toFixed(1)}%`);
updateDbProgressUI(state);
// Start polling only if not already polling and status is running
if (state.status === 'running' && !dbUpdateStatusInterval) {
console.log('๐ Starting database update polling (1 second interval)');
dbUpdateStatusInterval = setInterval(checkAndUpdateDbProgress, 1000);
}
} catch (error) {
console.warn('Could not fetch DB update status:', error);
// Don't stop polling on network errors - keep trying
}
}
function updateDbProgressFromData(data) {
const prev = _lastToolStatus['db-update'];
_lastToolStatus['db-update'] = data.status;
if (prev !== undefined && data.status === prev && data.status !== 'running') return;
updateDbProgressUI(data);
}
function updateDbProgressUI(state) {
const button = document.getElementById('db-update-button');
const phaseLabel = document.getElementById('db-phase-label');
const progressLabel = document.getElementById('db-progress-label');
const progressBar = document.getElementById('db-progress-bar');
const refreshSelect = document.getElementById('db-refresh-type');
if (!button || !phaseLabel || !progressLabel || !progressBar || !refreshSelect) return;
if (state.status === 'running') {
button.textContent = 'Stop Update';
button.disabled = false;
refreshSelect.disabled = true;
phaseLabel.textContent = state.phase || 'Processing...';
progressLabel.textContent = `${state.processed} / ${state.total} artists (${state.progress.toFixed(1)}%)`;
progressBar.style.width = `${state.progress}%`;
} else { // idle, finished, or error
stopDbUpdatePolling();
button.textContent = 'Update Database';
button.disabled = false;
refreshSelect.disabled = false;
if (state.status === 'error') {
phaseLabel.textContent = `Error: ${state.error_message}`;
progressBar.style.backgroundColor = '#ff4444'; // Red for error
} else {
phaseLabel.textContent = state.phase || 'Idle';
progressBar.style.backgroundColor = 'rgb(var(--accent-rgb))'; // Green for normal
}
if (state.status === 'finished' || state.status === 'error') {
// Final stats refresh after completion/error
setTimeout(fetchAndUpdateDbStats, 500);
}
}
}
// ===================================================================
// TIDAL PLAYLIST MANAGEMENT (YouTube-style cards with Tidal colors)
// ===================================================================
async function loadTidalPlaylists() {
const container = document.getElementById('tidal-playlist-container');
const refreshBtn = document.getElementById('tidal-refresh-btn');
container.innerHTML = `๐ Loading Tidal playlists...
`;
refreshBtn.disabled = true;
refreshBtn.textContent = '๐ Loading...';
try {
const response = await fetch('/api/tidal/playlists');
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || 'Failed to fetch Tidal playlists');
}
tidalPlaylists = await response.json();
renderTidalPlaylists();
tidalPlaylistsLoaded = true;
console.log(`๐ต Loaded ${tidalPlaylists.length} Tidal playlists`);
// Auto-mirror Tidal playlists that have tracks
tidalPlaylists.forEach(p => {
if (p.tracks && p.tracks.length > 0) {
mirrorPlaylist('tidal', p.id, p.name, p.tracks.map(t => ({
track_name: t.name || '', artist_name: Array.isArray(t.artists) ? t.artists[0] : (t.artists || ''),
album_name: typeof t.album === 'string' ? t.album : '', duration_ms: t.duration_ms || 0,
source_track_id: t.id || ''
})), { owner: p.owner, image_url: p.image_url, description: p.description });
}
});
// Load and apply saved discovery states from backend (like YouTube)
await loadTidalPlaylistStatesFromBackend();
} catch (error) {
container.innerHTML = `โ Error: ${error.message}
`;
showToast(`Error loading Tidal playlists: ${error.message}`, 'error');
} finally {
refreshBtn.disabled = false;
refreshBtn.textContent = '๐ Refresh';
}
}
function renderTidalPlaylists() {
const container = document.getElementById('tidal-playlist-container');
if (tidalPlaylists.length === 0) {
container.innerHTML = `No Tidal playlists found.
`;
return;
}
container.innerHTML = tidalPlaylists.map(p => {
// Initialize state if not exists (fresh state like sync.py)
if (!tidalPlaylistStates[p.id]) {
tidalPlaylistStates[p.id] = {
phase: 'fresh',
playlist: p
};
}
return createTidalCard(p);
}).join('');
// Add click handlers to cards
tidalPlaylists.forEach(p => {
const card = document.getElementById(`tidal-card-${p.id}`);
if (card) {
card.addEventListener('click', () => handleTidalCardClick(p.id));
}
});
}
function createTidalCard(playlist) {
const state = tidalPlaylistStates[playlist.id];
const phase = state.phase;
// Get phase-specific button text (like YouTube cards)
let buttonText = getActionButtonText(phase);
let phaseText = getPhaseText(phase);
let phaseColor = getPhaseColor(phase);
return `
๐ต
${escapeHtml(playlist.name)}
${playlist.track_count} tracks
${phaseText}
${buttonText}
`;
}
async function handleTidalCardClick(playlistId) {
// Robust state validation
const state = tidalPlaylistStates[playlistId];
if (!state) {
console.error(`โ [Card Click] No state found for Tidal playlist: ${playlistId}`);
showToast('Playlist state not found - try refreshing the page', 'error');
return;
}
// Validate required state data
if (!state.playlist) {
console.error(`โ [Card Click] No playlist data found for Tidal playlist: ${playlistId}`);
showToast('Playlist data missing - try refreshing the page', 'error');
return;
}
// Validate phase
if (!state.phase) {
console.warn(`โ ๏ธ [Card Click] No phase set for Tidal playlist ${playlistId} - defaulting to 'fresh'`);
state.phase = 'fresh';
}
console.log(`๐ต [Card Click] Tidal card clicked: ${playlistId}, Phase: ${state.phase}`);
if (state.phase === 'fresh') {
// No need to fetch data - we already have all tracks from initial load (like sync.py)
console.log(`๐ต Using pre-loaded Tidal playlist data for: ${state.playlist.name}`);
console.log(`๐ต Ready with ${state.playlist.tracks.length} Tidal tracks for discovery`);
// Open discovery modal - phase will be updated when discovery actually starts
openTidalDiscoveryModal(playlistId, state.playlist);
} else if (state.phase === 'discovering' || state.phase === 'discovered' || state.phase === 'syncing' || state.phase === 'sync_complete') {
// Reopen existing modal with preserved discovery results (like GUI sync.py)
console.log(`๐ต [Card Click] Opening Tidal discovery modal for ${state.phase} phase`);
// Validate that we have discovery results to show
if (state.phase === 'discovered' && (!state.discovery_results || state.discovery_results.length === 0)) {
console.warn(`โ ๏ธ [Card Click] Discovered phase but no discovery results found - attempting to reload from backend`);
// Try to fetch from backend as fallback
try {
const stateResponse = await fetch(`/api/tidal/state/${playlistId}`);
if (stateResponse.ok) {
const fullState = await stateResponse.json();
if (fullState.discovery_results) {
// Merge backend state with current state
state.discovery_results = fullState.discovery_results;
state.spotify_matches = fullState.spotify_matches || state.spotify_matches;
state.discovery_progress = fullState.discovery_progress || state.discovery_progress;
tidalPlaylistStates[playlistId] = { ...tidalPlaylistStates[playlistId], ...state };
console.log(`โ
[Card Click] Restored ${fullState.discovery_results.length} discovery results from backend`);
}
}
} catch (error) {
console.error(`โ [Card Click] Failed to fetch discovery results from backend: ${error}`);
}
}
openTidalDiscoveryModal(playlistId, state.playlist);
} else if (state.phase === 'downloading' || state.phase === 'download_complete') {
// Open download modal if we have the converted playlist ID
if (state.convertedSpotifyPlaylistId) {
console.log(`๐ [Card Click] Opening download modal for Tidal playlist: ${state.playlist.name} (phase: ${state.phase})`);
// Check if modal already exists, if not create it
if (activeDownloadProcesses[state.convertedSpotifyPlaylistId]) {
const process = activeDownloadProcesses[state.convertedSpotifyPlaylistId];
if (process.modalElement) {
console.log(`๐ฑ [Card Click] Showing existing download modal for ${state.phase} phase`);
process.modalElement.style.display = 'flex';
} else {
console.warn(`โ ๏ธ [Card Click] Download process exists but modal element missing - rehydrating`);
await rehydrateTidalDownloadModal(playlistId, state);
}
} else {
// Need to create the download modal - fetch the discovery results
console.log(`๐ง [Card Click] Rehydrating Tidal download modal for ${state.phase} phase`);
await rehydrateTidalDownloadModal(playlistId, state);
}
} else {
console.error('โ [Card Click] No converted Spotify playlist ID found for Tidal download modal');
console.log('๐ [Card Click] Available state data:', Object.keys(state));
// Fallback: try to open discovery modal if we have discovery results
if (state.discovery_results && state.discovery_results.length > 0) {
console.log(`๐ [Card Click] Fallback: Opening discovery modal with ${state.discovery_results.length} results`);
openTidalDiscoveryModal(playlistId, state.playlist);
} else {
showToast('Unable to open download modal - missing playlist data', 'error');
}
}
}
}
async function rehydrateTidalDownloadModal(playlistId, state) {
try {
// Robust state validation for rehydration
if (!state || !state.playlist) {
console.error(`โ [Rehydration] Invalid state data for Tidal playlist: ${playlistId}`);
showToast('Cannot open download modal - invalid playlist data', 'error');
return;
}
console.log(`๐ง [Rehydration] Rehydrating Tidal download modal for: ${state.playlist.name}`);
// Get discovery results from backend if not already loaded
if (!state.discovery_results) {
console.log(`๐ Fetching discovery results from backend for Tidal playlist: ${playlistId}`);
const stateResponse = await fetch(`/api/tidal/state/${playlistId}`);
if (stateResponse.ok) {
const fullState = await stateResponse.json();
state.discovery_results = fullState.discovery_results;
state.convertedSpotifyPlaylistId = fullState.converted_spotify_playlist_id;
state.download_process_id = fullState.download_process_id;
console.log(`โ
Loaded ${fullState.discovery_results?.length || 0} discovery results from backend`);
} else {
console.error('โ Failed to fetch Tidal discovery results from backend');
showToast('Error loading playlist data', 'error');
return;
}
}
// Extract Spotify tracks from discovery results
const spotifyTracks = [];
for (const result of state.discovery_results) {
if (result.spotify_data) {
spotifyTracks.push(result.spotify_data);
}
}
if (spotifyTracks.length === 0) {
console.error('โ No Spotify tracks found for download modal');
showToast('No Spotify matches found for download', 'error');
return;
}
const virtualPlaylistId = state.convertedSpotifyPlaylistId;
const playlistName = state.playlist.name;
// Create the download modal
await openDownloadMissingModalForTidal(virtualPlaylistId, playlistName, spotifyTracks);
// If we have a download process ID, set up the modal for the running state
if (state.download_process_id) {
const process = activeDownloadProcesses[virtualPlaylistId];
if (process) {
process.status = state.phase === 'download_complete' ? 'complete' : 'running';
process.batchId = state.download_process_id;
// Update UI based on phase
const beginBtn = document.getElementById(`begin-analysis-btn-${virtualPlaylistId}`);
const cancelBtn = document.getElementById(`cancel-all-btn-${virtualPlaylistId}`);
if (state.phase === 'downloading') {
if (beginBtn) beginBtn.style.display = 'none';
if (cancelBtn) cancelBtn.style.display = 'inline-block';
// Start polling for live updates
startModalDownloadPolling(virtualPlaylistId);
console.log(`๐ Started polling for active Tidal download: ${state.download_process_id}`);
} else if (state.phase === 'download_complete') {
if (beginBtn) beginBtn.style.display = 'none';
if (cancelBtn) cancelBtn.style.display = 'none';
console.log(`โ
Showing completed Tidal download results: ${state.download_process_id}`);
// For completed downloads, fetch the final results once to populate the modal
try {
const response = await fetch(`/api/playlists/${state.download_process_id}/download_status`);
if (response.ok) {
const data = await response.json();
if (data.phase === 'complete' && data.tasks) {
console.log(`๐ [Rehydration] Loading ${data.tasks.length} completed tasks for modal display`);
// Process the completed tasks to update modal display
updateCompletedModalResults(virtualPlaylistId, data);
} else {
console.warn(`โ ๏ธ [Rehydration] Unexpected data from download_status: phase=${data.phase}, tasks=${data.tasks?.length || 0}`);
}
} else {
console.error(`โ [Rehydration] Failed to fetch download status: ${response.status} ${response.statusText}`);
}
} catch (error) {
console.error(`โ [Rehydration] Error fetching final results for completed download: ${error}`);
// Show a user-friendly message but still allow modal to open
showToast('Could not load download results - modal may show incomplete data', 'warning', 3000);
}
}
}
}
console.log(`โ
Successfully rehydrated Tidal download modal for: ${state.playlist.name}`);
} catch (error) {
console.error(`โ Error rehydrating Tidal download modal:`, error);
showToast('Error opening download modal', 'error');
}
}
function updateCompletedModalResults(playlistId, downloadData) {
/**
* Update a completed download modal with final results
* This reuses the existing status polling logic but applies it once for completed state
*/
console.log(`๐ [Completed Results] Updating modal ${playlistId} with final download results`);
// Validate input data
if (!downloadData || !downloadData.tasks) {
console.error(`โ [Completed Results] Invalid download data for playlist ${playlistId}:`, downloadData);
return;
}
try {
// Update analysis progress to 100%
const analysisProgressFill = document.getElementById(`analysis-progress-fill-${playlistId}`);
const analysisProgressText = document.getElementById(`analysis-progress-text-${playlistId}`);
if (analysisProgressFill) analysisProgressFill.style.width = '100%';
if (analysisProgressText) analysisProgressText.textContent = 'Analysis complete!';
// Update analysis results and stats
if (downloadData.analysis_results) {
updateTrackAnalysisResults(playlistId, downloadData.analysis_results);
const foundCount = downloadData.analysis_results.filter(r => r.found).length;
const missingCount = downloadData.analysis_results.filter(r => !r.found).length;
const statFound = document.getElementById(`stat-found-${playlistId}`);
const statMissing = document.getElementById(`stat-missing-${playlistId}`);
if (statFound) statFound.textContent = foundCount;
if (statMissing) statMissing.textContent = missingCount;
}
// Process completed tasks to update individual track statuses
const missingTracks = (downloadData.analysis_results || []).filter(r => !r.found);
let completedCount = 0;
let failedOrCancelledCount = 0;
let notFoundCount = 0;
(downloadData.tasks || []).forEach(task => {
const row = document.querySelector(`#download-missing-modal-${CSS.escape(playlistId)} tr[data-track-index="${task.track_index}"]`);
if (!row) return;
row.dataset.taskId = task.task_id;
const statusEl = document.getElementById(`download-${playlistId}-${task.track_index}`);
const actionsEl = document.getElementById(`actions-${playlistId}-${task.track_index}`);
let statusText = '';
switch (task.status) {
case 'pending': statusText = 'โธ๏ธ Pending'; break;
case 'searching': statusText = '๐ Searching...'; break;
case 'downloading': statusText = `โฌ Downloading... ${Math.round(task.progress || 0)}%`; break;
case 'post_processing': statusText = 'โ Processing...'; break; // NEW VERIFICATION WORKFLOW
case 'completed': statusText = 'โ
Completed'; completedCount++; break;
case 'not_found': statusText = '๐ Not Found'; notFoundCount++; break;
case 'failed': statusText = 'โ Failed'; failedOrCancelledCount++; break;
case 'cancelled': statusText = '๐ซ Cancelled'; failedOrCancelledCount++; break;
default: statusText = `โช ${task.status}`; break;
}
if (statusEl) {
statusEl.textContent = statusText;
if ((task.status === 'failed' || task.status === 'cancelled' || task.status === 'not_found') && task.error_message) {
statusEl.classList.add('has-error-tooltip');
statusEl.dataset.errorMsg = task.error_message;
_ensureErrorTooltipListeners(statusEl);
}
if (task.status === 'not_found' && task.has_candidates) {
statusEl.classList.add('has-candidates');
statusEl.dataset.taskId = task.task_id;
_ensureCandidatesClickListener(statusEl);
}
}
if (actionsEl) actionsEl.innerHTML = '-'; // Remove action buttons for completed tasks
});
// Update download progress to final state
const totalFinished = completedCount + failedOrCancelledCount + notFoundCount;
const missingCount = missingTracks.length;
const progressPercent = missingCount > 0 ? (totalFinished / missingCount) * 100 : 100;
const downloadProgressFill = document.getElementById(`download-progress-fill-${playlistId}`);
const downloadProgressText = document.getElementById(`download-progress-text-${playlistId}`);
const statDownloaded = document.getElementById(`stat-downloaded-${playlistId}`);
if (downloadProgressFill) downloadProgressFill.style.width = `${progressPercent}%`;
if (downloadProgressText) downloadProgressText.textContent = `${completedCount}/${missingCount} completed (${progressPercent.toFixed(0)}%)`;
if (statDownloaded) statDownloaded.textContent = completedCount;
console.log(`โ
[Completed Results] Updated modal with ${completedCount} completed, ${notFoundCount} not found, ${failedOrCancelledCount} failed tasks`);
} catch (error) {
console.error(`โ [Completed Results] Error updating completed modal results:`, error);
}
}
function updateTidalCardPhase(playlistId, phase) {
const state = tidalPlaylistStates[playlistId];
if (!state) return;
state.phase = phase;
// Re-render the card with new phase
const card = document.getElementById(`tidal-card-${playlistId}`);
if (card) {
const oldButtonText = card.querySelector('.playlist-card-action-btn')?.textContent || 'unknown';
const newCardHtml = createTidalCard(state.playlist);
card.outerHTML = newCardHtml;
// Verify the card was actually updated
const updatedCard = document.getElementById(`tidal-card-${playlistId}`);
const newButtonText = updatedCard?.querySelector('.playlist-card-action-btn')?.textContent || 'unknown';
console.log(`๐ [Card Update] Re-rendered Tidal card ${playlistId}:`);
console.log(` ๐ Phase: ${phase}`);
console.log(` ๐ Button text: "${oldButtonText}" โ "${newButtonText}"`);
console.log(` โ
Expected: "${getActionButtonText(phase)}"`);
if (newButtonText !== getActionButtonText(phase)) {
console.error(`โ [Card Update] Button text mismatch! Expected "${getActionButtonText(phase)}", got "${newButtonText}"`);
}
// Re-attach click handler
const newCard = document.getElementById(`tidal-card-${playlistId}`);
if (newCard) {
newCard.addEventListener('click', () => handleTidalCardClick(playlistId));
console.debug(`๐ [Card Update] Reattached click handler for Tidal card: ${playlistId}`);
} else {
console.error(`โ [Card Update] Failed to find new card after rendering: tidal-card-${playlistId}`);
}
// If we have sync progress and we're in sync/sync_complete phase, restore it
if ((phase === 'syncing' || phase === 'sync_complete') && state.lastSyncProgress) {
setTimeout(() => {
updateTidalCardSyncProgress(playlistId, state.lastSyncProgress);
}, 0);
}
}
console.log(`๐ต Updated Tidal card phase: ${playlistId} -> ${phase}`);
}
async function openTidalDiscoveryModal(playlistId, playlistData) {
console.log(`๐ต Opening Tidal discovery modal (reusing YouTube modal): ${playlistData.name}`);
// Create a fake YouTube-style urlHash for the modal system
const fakeUrlHash = `tidal_${playlistId}`;
// Get current Tidal card state to check if discovery is already done or in progress
const tidalCardState = tidalPlaylistStates[playlistId];
const isAlreadyDiscovered = tidalCardState && (tidalCardState.phase === 'discovered' || tidalCardState.phase === 'syncing' || tidalCardState.phase === 'sync_complete');
const isCurrentlyDiscovering = tidalCardState && tidalCardState.phase === 'discovering';
// Prepare discovery results in the correct format for modal
let transformedResults = [];
let actualMatches = 0;
if (isAlreadyDiscovered && tidalCardState.discovery_results) {
transformedResults = tidalCardState.discovery_results.map((result, index) => {
// Check multiple status formats
const isFound = result.status === 'found' ||
result.status === 'โ
Found' ||
result.status_class === 'found' ||
result.spotify_data ||
result.spotify_track;
if (isFound) actualMatches++;
return {
index: index,
yt_track: result.tidal_track ? result.tidal_track.name : 'Unknown',
yt_artist: result.tidal_track ? (result.tidal_track.artists ? result.tidal_track.artists.join(', ') : 'Unknown') : 'Unknown',
status: isFound ? 'โ
Found' : 'โ Not Found',
status_class: isFound ? 'found' : 'not-found',
spotify_track: result.spotify_data ? result.spotify_data.name : (result.spotify_track || '-'),
spotify_artist: result.spotify_data && result.spotify_data.artists ?
(Array.isArray(result.spotify_data.artists) ? result.spotify_data.artists.join(', ') : result.spotify_data.artists) : (result.spotify_artist || '-'),
spotify_album: result.spotify_data ? (typeof result.spotify_data.album === 'object' ? result.spotify_data.album.name : result.spotify_data.album) : (result.spotify_album || '-'),
spotify_data: result.spotify_data, // Pass through spotify_data
spotify_id: result.spotify_id, // Pass through spotify_id
manual_match: result.manual_match // Pass through manual match flag
};
});
console.log(`๐ต Tidal modal: Calculated ${actualMatches} matches from ${transformedResults.length} results`);
}
// Create YouTube-compatible state structure
const modalPhase = tidalCardState ? tidalCardState.phase : 'fresh';
youtubePlaylistStates[fakeUrlHash] = {
phase: modalPhase,
playlist: {
name: playlistData.name,
tracks: playlistData.tracks
},
is_tidal_playlist: true, // Flag to identify this as Tidal
tidal_playlist_id: playlistId,
discovery_progress: isAlreadyDiscovered ? 100 : 0,
spotify_matches: isAlreadyDiscovered ? actualMatches : 0, // Backend format (snake_case)
spotifyMatches: isAlreadyDiscovered ? actualMatches : 0, // Frontend format (camelCase) - for button logic
spotify_total: playlistData.tracks.length,
discovery_results: transformedResults,
discoveryResults: transformedResults, // Both formats for compatibility
discoveryProgress: isAlreadyDiscovered ? 100 : 0 // Frontend format for modal progress display
};
// Only start discovery if not already discovered AND not currently discovering
if (!isAlreadyDiscovered && !isCurrentlyDiscovering) {
// Start Tidal discovery process automatically (like sync.py)
try {
console.log(`๐ Starting Tidal discovery for: ${playlistData.name}`);
const response = await fetch(`/api/tidal/discovery/start/${playlistId}`, {
method: 'POST'
});
const result = await response.json();
if (result.error) {
console.error('โ Error starting Tidal discovery:', result.error);
showToast(`Error starting discovery: ${result.error}`, 'error');
return;
}
console.log('โ
Tidal discovery started, beginning polling...');
// Update phase to discovering now that backend discovery is actually started
tidalPlaylistStates[playlistId].phase = 'discovering';
updateTidalCardPhase(playlistId, 'discovering');
// Update modal phase to match
youtubePlaylistStates[fakeUrlHash].phase = 'discovering';
// Start polling for progress
startTidalDiscoveryPolling(fakeUrlHash, playlistId);
} catch (error) {
console.error('โ Error starting Tidal discovery:', error);
showToast(`Error starting discovery: ${error.message}`, 'error');
}
} else if (isCurrentlyDiscovering) {
// Resume polling if discovery is already in progress (like YouTube)
console.log(`๐ Resuming Tidal discovery polling for: ${playlistData.name}`);
startTidalDiscoveryPolling(fakeUrlHash, playlistId);
} else if (tidalCardState && tidalCardState.phase === 'syncing') {
// Resume sync polling if sync is in progress
console.log(`๐ Resuming Tidal sync polling for: ${playlistData.name}`);
startTidalSyncPolling(fakeUrlHash);
} else {
console.log('โ
Using existing results - no need to re-discover');
}
// Reuse YouTube discovery modal (exact sync.py pattern)
openYouTubeDiscoveryModal(fakeUrlHash);
}
function startTidalDiscoveryPolling(fakeUrlHash, playlistId) {
console.log(`๐ Starting Tidal discovery polling for: ${playlistId}`);
// Stop any existing polling
if (activeYouTubePollers[fakeUrlHash]) {
clearInterval(activeYouTubePollers[fakeUrlHash]);
}
// Phase 5: Subscribe via WebSocket
if (socketConnected) {
socket.emit('discovery:subscribe', { ids: [playlistId] });
_discoveryProgressCallbacks[playlistId] = (data) => {
if (data.error) {
if (activeYouTubePollers[fakeUrlHash]) { clearInterval(activeYouTubePollers[fakeUrlHash]); delete activeYouTubePollers[fakeUrlHash]; }
socket.emit('discovery:unsubscribe', { ids: [playlistId] }); delete _discoveryProgressCallbacks[playlistId];
return;
}
// Transform to YouTube modal format
const transformed = {
progress: data.progress, spotify_matches: data.spotify_matches, spotify_total: data.spotify_total,
results: (data.results || []).map((r, i) => {
const isFound = r.status === 'found' || r.status === 'โ
Found' || r.status_class === 'found' || r.spotify_data || r.spotify_track;
return {
index: i, yt_track: r.tidal_track ? r.tidal_track.name : 'Unknown',
yt_artist: r.tidal_track ? (r.tidal_track.artists ? r.tidal_track.artists.join(', ') : 'Unknown') : 'Unknown',
status: isFound ? 'โ
Found' : 'โ Not Found', status_class: isFound ? 'found' : 'not-found',
spotify_track: r.spotify_data ? r.spotify_data.name : (r.spotify_track || '-'),
spotify_artist: r.spotify_data && r.spotify_data.artists ? (Array.isArray(r.spotify_data.artists) ? r.spotify_data.artists.join(', ') : r.spotify_data.artists) : (r.spotify_artist || '-'),
spotify_album: r.spotify_data ? (typeof r.spotify_data.album === 'object' ? r.spotify_data.album.name : r.spotify_data.album) : (r.spotify_album || '-'),
spotify_data: r.spotify_data, spotify_id: r.spotify_id, manual_match: r.manual_match
};
})
};
const st = youtubePlaylistStates[fakeUrlHash];
if (st) {
st.discovery_progress = data.progress; st.discoveryProgress = data.progress;
st.spotify_matches = data.spotify_matches; st.spotifyMatches = data.spotify_matches;
st.discovery_results = data.results; st.discoveryResults = transformed.results;
st.phase = data.phase;
updateYouTubeDiscoveryModal(fakeUrlHash, transformed);
}
if (tidalPlaylistStates[playlistId]) {
tidalPlaylistStates[playlistId].phase = data.phase;
tidalPlaylistStates[playlistId].discovery_results = data.results;
tidalPlaylistStates[playlistId].spotify_matches = data.spotify_matches;
tidalPlaylistStates[playlistId].discovery_progress = data.progress;
updateTidalCardPhase(playlistId, data.phase);
}
updateTidalCardProgress(playlistId, data);
if (data.complete) {
if (activeYouTubePollers[fakeUrlHash]) { clearInterval(activeYouTubePollers[fakeUrlHash]); delete activeYouTubePollers[fakeUrlHash]; }
socket.emit('discovery:unsubscribe', { ids: [playlistId] }); delete _discoveryProgressCallbacks[playlistId];
}
};
}
const pollInterval = setInterval(async () => {
if (socketConnected) return; // Phase 5: WS handles updates
try {
const response = await fetch(`/api/tidal/discovery/status/${playlistId}`);
const status = await response.json();
if (status.error) {
console.error('โ Error polling Tidal discovery status:', status.error);
clearInterval(pollInterval);
delete activeYouTubePollers[fakeUrlHash];
return;
}
// Transform Tidal results to YouTube modal format first
const transformedStatus = {
progress: status.progress,
spotify_matches: status.spotify_matches,
spotify_total: status.spotify_total,
results: status.results.map((result, index) => {
const isFound = result.status === 'found' ||
result.status === 'โ
Found' ||
result.status_class === 'found' ||
result.spotify_data ||
result.spotify_track;
return {
index: index,
yt_track: result.tidal_track ? result.tidal_track.name : 'Unknown',
yt_artist: result.tidal_track ? (result.tidal_track.artists ? result.tidal_track.artists.join(', ') : 'Unknown') : 'Unknown',
status: isFound ? 'โ
Found' : 'โ Not Found',
status_class: isFound ? 'found' : 'not-found',
spotify_track: result.spotify_data ? result.spotify_data.name : (result.spotify_track || '-'),
spotify_artist: result.spotify_data && result.spotify_data.artists ?
(Array.isArray(result.spotify_data.artists) ? result.spotify_data.artists.join(', ') : result.spotify_data.artists) : (result.spotify_artist || '-'),
spotify_album: result.spotify_data ? (typeof result.spotify_data.album === 'object' ? result.spotify_data.album.name : result.spotify_data.album) : (result.spotify_album || '-'),
spotify_data: result.spotify_data, // Pass through
spotify_id: result.spotify_id, // Pass through
manual_match: result.manual_match // Pass through
};
})
};
// Update fake YouTube state with Tidal discovery results
const state = youtubePlaylistStates[fakeUrlHash];
if (state) {
state.discovery_progress = status.progress; // Backend format
state.discoveryProgress = status.progress; // Frontend format - for modal progress display
state.spotify_matches = status.spotify_matches; // Backend format
state.spotifyMatches = status.spotify_matches; // Frontend format - for button logic
state.discovery_results = status.results; // Backend format
state.discoveryResults = transformedStatus.results; // Frontend format - for button logic
state.phase = status.phase;
// Update modal with transformed data (reuse YouTube modal update logic)
updateYouTubeDiscoveryModal(fakeUrlHash, transformedStatus);
// Update Tidal card phase and save discovery results FIRST
if (tidalPlaylistStates[playlistId]) {
tidalPlaylistStates[playlistId].phase = status.phase;
tidalPlaylistStates[playlistId].discovery_results = status.results;
tidalPlaylistStates[playlistId].spotify_matches = status.spotify_matches;
tidalPlaylistStates[playlistId].discovery_progress = status.progress;
updateTidalCardPhase(playlistId, status.phase);
}
// Update Tidal card progress AFTER phase update to avoid being overwritten
updateTidalCardProgress(playlistId, status);
console.log(`๐ Tidal discovery progress: ${status.progress}% (${status.spotify_matches}/${status.spotify_total} found)`);
}
// Stop polling when complete
if (status.complete) {
console.log(`โ
Tidal discovery complete: ${status.spotify_matches}/${status.spotify_total} tracks found`);
clearInterval(pollInterval);
delete activeYouTubePollers[fakeUrlHash];
}
} catch (error) {
console.error('โ Error polling Tidal discovery:', error);
clearInterval(pollInterval);
delete activeYouTubePollers[fakeUrlHash];
}
}, 1000); // Poll every second like YouTube
// Store poller reference (reuse YouTube poller storage)
activeYouTubePollers[fakeUrlHash] = pollInterval;
}
async function loadTidalPlaylistStatesFromBackend() {
// Load all stored Tidal playlist discovery states from backend (similar to YouTube hydration)
try {
console.log('๐ต Loading Tidal playlist states from backend...');
const response = await fetch('/api/tidal/playlists/states');
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || 'Failed to fetch Tidal playlist states');
}
const data = await response.json();
const states = data.states || [];
console.log(`๐ต Found ${states.length} stored Tidal playlist states in backend`);
if (states.length === 0) {
console.log('๐ต No Tidal playlist states to hydrate');
return;
}
// Apply states to existing playlist cards
for (const stateInfo of states) {
await applyTidalPlaylistState(stateInfo);
}
// Rehydrate download modals for Tidal playlists in downloading/download_complete phases
for (const stateInfo of states) {
if ((stateInfo.phase === 'downloading' || stateInfo.phase === 'download_complete') &&
stateInfo.converted_spotify_playlist_id && stateInfo.download_process_id) {
const convertedPlaylistId = stateInfo.converted_spotify_playlist_id;
if (!activeDownloadProcesses[convertedPlaylistId]) {
console.log(`๐ง Rehydrating download modal for Tidal playlist: ${stateInfo.playlist_id}`);
try {
// Get the playlist data
const playlistData = tidalPlaylists.find(p => p.id === stateInfo.playlist_id);
if (!playlistData) {
console.warn(`โ ๏ธ Playlist data not found for rehydration: ${stateInfo.playlist_id}`);
continue;
}
// Create the download modal using the Tidal-specific function
const spotifyTracks = tidalPlaylistStates[stateInfo.playlist_id]?.discovery_results
?.filter(result => result.spotify_data)
?.map(result => result.spotify_data) || [];
if (spotifyTracks.length > 0) {
await openDownloadMissingModalForTidal(
convertedPlaylistId,
playlistData.name,
spotifyTracks
);
// Set the modal to running state with the correct batch ID
const process = activeDownloadProcesses[convertedPlaylistId];
if (process) {
process.status = 'running';
process.batchId = stateInfo.download_process_id;
// Update UI to running state
const beginBtn = document.getElementById(`begin-analysis-btn-${convertedPlaylistId}`);
const cancelBtn = document.getElementById(`cancel-all-btn-${convertedPlaylistId}`);
if (beginBtn) beginBtn.style.display = 'none';
if (cancelBtn) cancelBtn.style.display = 'inline-block';
// Start polling for this process
startModalDownloadPolling(convertedPlaylistId);
console.log(`โ
Rehydrated Tidal download modal for batch ${stateInfo.download_process_id}`);
}
} else {
console.warn(`โ ๏ธ No Spotify tracks found for Tidal playlist rehydration: ${stateInfo.playlist_id}`);
}
} catch (error) {
console.error(`โ Error rehydrating Tidal download modal for ${stateInfo.playlist_id}:`, error);
}
}
}
}
console.log('โ
Tidal playlist states loaded and applied');
} catch (error) {
console.error('โ Error loading Tidal playlist states:', error);
}
}
async function applyTidalPlaylistState(stateInfo) {
const { playlist_id, phase, discovery_progress, spotify_matches, discovery_results, converted_spotify_playlist_id, download_process_id } = stateInfo;
try {
console.log(`๐ต Applying saved state for Tidal playlist: ${playlist_id}, Phase: ${phase}`);
// Find the playlist data from the loaded playlists
const playlistData = tidalPlaylists.find(p => p.id === playlist_id);
if (!playlistData) {
console.warn(`โ ๏ธ Playlist data not found for state ${playlist_id} - skipping`);
return;
}
// Update local state
if (!tidalPlaylistStates[playlist_id]) {
// Initialize state if it doesn't exist
tidalPlaylistStates[playlist_id] = {
playlist: playlistData,
phase: 'fresh'
};
}
// Update with backend state
tidalPlaylistStates[playlist_id].phase = phase;
tidalPlaylistStates[playlist_id].discovery_progress = discovery_progress;
tidalPlaylistStates[playlist_id].spotify_matches = spotify_matches;
tidalPlaylistStates[playlist_id].discovery_results = discovery_results;
tidalPlaylistStates[playlist_id].convertedSpotifyPlaylistId = converted_spotify_playlist_id;
tidalPlaylistStates[playlist_id].download_process_id = download_process_id;
tidalPlaylistStates[playlist_id].playlist = playlistData; // Ensure playlist data is set
// Fetch full discovery results for non-fresh playlists (matching YouTube pattern)
if (phase !== 'fresh' && phase !== 'discovering') {
try {
console.log(`๐ Fetching full discovery results for Tidal playlist: ${playlistData.name}`);
const stateResponse = await fetch(`/api/tidal/state/${playlist_id}`);
if (stateResponse.ok) {
const fullState = await stateResponse.json();
console.log(`๐ Retrieved full Tidal state with ${fullState.discovery_results?.length || 0} discovery results`);
// Store full discovery results in local state (matching YouTube pattern)
if (fullState.discovery_results && tidalPlaylistStates[playlist_id]) {
tidalPlaylistStates[playlist_id].discovery_results = fullState.discovery_results;
tidalPlaylistStates[playlist_id].discovery_progress = fullState.discovery_progress;
tidalPlaylistStates[playlist_id].spotify_matches = fullState.spotify_matches;
tidalPlaylistStates[playlist_id].convertedSpotifyPlaylistId = fullState.converted_spotify_playlist_id;
tidalPlaylistStates[playlist_id].download_process_id = fullState.download_process_id;
console.log(`โ
Restored ${fullState.discovery_results.length} discovery results for Tidal playlist: ${playlistData.name}`);
}
} else {
console.warn(`โ ๏ธ Could not fetch full discovery results for Tidal playlist: ${playlistData.name}`);
}
} catch (error) {
console.warn(`โ ๏ธ Error fetching full discovery results for Tidal playlist ${playlistData.name}:`, error.message);
}
}
// Update the card UI to reflect the saved state
updateTidalCardPhase(playlist_id, phase);
// Update card progress if we have discovery results
if (phase === 'discovered' && tidalPlaylistStates[playlist_id]) {
const progressInfo = {
spotify_total: playlistData.track_count || playlistData.tracks?.length || 0,
spotify_matches: tidalPlaylistStates[playlist_id].spotify_matches || 0
};
updateTidalCardProgress(playlist_id, progressInfo);
}
// Handle active polling resumption (matching YouTube/Beatport pattern)
if (phase === 'discovering') {
console.log(`๐ Resuming discovery polling for Tidal: ${playlistData.name}`);
const fakeUrlHash = `tidal_${playlist_id}`;
startTidalDiscoveryPolling(fakeUrlHash, playlist_id);
} else if (phase === 'syncing') {
console.log(`๐ Resuming sync polling for Tidal: ${playlistData.name}`);
const fakeUrlHash = `tidal_${playlist_id}`;
startTidalSyncPolling(fakeUrlHash);
}
console.log(`โ
Applied saved state for Tidal playlist: ${playlist_id} -> ${phase}`);
} catch (error) {
console.error(`โ Error applying Tidal playlist state for ${playlist_id}:`, error);
}
}
function updateTidalCardProgress(playlistId, progress) {
const state = tidalPlaylistStates[playlistId];
if (!state) return;
const card = document.getElementById(`tidal-card-${playlistId}`);
if (!card) return;
const progressElement = card.querySelector('.playlist-card-progress');
if (!progressElement) return;
const total = progress.spotify_total || 0;
const matches = progress.spotify_matches || 0;
const failed = total - matches;
const percentage = total > 0 ? Math.round((matches / total) * 100) : 0;
progressElement.textContent = `โช ${total} / โ ${matches} / โ ${failed} / ${percentage}%`;
progressElement.classList.remove('hidden'); // Show progress during discovery
console.log('๐ต Updated Tidal card progress:', playlistId, `${matches}/${total} (${percentage}%)`);
}
// ===============================
// TIDAL SYNC FUNCTIONALITY
// ===============================
async function startTidalPlaylistSync(urlHash) {
try {
console.log('๐ต Starting Tidal playlist sync:', urlHash);
const state = youtubePlaylistStates[urlHash];
if (!state || !state.is_tidal_playlist) {
console.error('โ Invalid Tidal playlist state for sync');
return;
}
const playlistId = state.tidal_playlist_id;
const response = await fetch(`/api/tidal/sync/start/${playlistId}`, {
method: 'POST'
});
const result = await response.json();
if (result.error) {
showToast(`Error starting sync: ${result.error}`, 'error');
return;
}
// Capture sync_playlist_id for WebSocket subscription
const syncPlaylistId = result.sync_playlist_id;
if (state) state.syncPlaylistId = syncPlaylistId;
// Update card and modal to syncing phase
updateTidalCardPhase(playlistId, 'syncing');
// Update modal buttons if modal is open
updateTidalModalButtons(urlHash, 'syncing');
// Start sync polling
startTidalSyncPolling(urlHash, syncPlaylistId);
showToast('Tidal playlist sync started!', 'success');
} catch (error) {
console.error('โ Error starting Tidal sync:', error);
showToast(`Error starting sync: ${error.message}`, 'error');
}
}
function startTidalSyncPolling(urlHash, syncPlaylistId) {
// Stop any existing polling
if (activeYouTubePollers[urlHash]) {
clearInterval(activeYouTubePollers[urlHash]);
}
const state = youtubePlaylistStates[urlHash];
const playlistId = state.tidal_playlist_id;
// Resolve syncPlaylistId from argument or stored state
syncPlaylistId = syncPlaylistId || (state && state.syncPlaylistId);
// Phase 6: Subscribe via WebSocket
if (socketConnected && syncPlaylistId) {
socket.emit('sync:subscribe', { playlist_ids: [syncPlaylistId] });
_syncProgressCallbacks[syncPlaylistId] = (data) => {
const progress = data.progress || {};
updateTidalCardSyncProgress(playlistId, progress);
updateTidalModalSyncProgress(urlHash, progress);
if (data.status === 'finished') {
if (activeYouTubePollers[urlHash]) { clearInterval(activeYouTubePollers[urlHash]); delete activeYouTubePollers[urlHash]; }
socket.emit('sync:unsubscribe', { playlist_ids: [syncPlaylistId] });
delete _syncProgressCallbacks[syncPlaylistId];
if (tidalPlaylistStates[playlistId]) tidalPlaylistStates[playlistId].phase = 'sync_complete';
if (youtubePlaylistStates[urlHash]) youtubePlaylistStates[urlHash].phase = 'sync_complete';
updateTidalCardPhase(playlistId, 'sync_complete');
updateTidalModalButtons(urlHash, 'sync_complete');
showToast('Tidal playlist sync complete!', 'success');
} else if (data.status === 'error' || data.status === 'cancelled') {
if (activeYouTubePollers[urlHash]) { clearInterval(activeYouTubePollers[urlHash]); delete activeYouTubePollers[urlHash]; }
socket.emit('sync:unsubscribe', { playlist_ids: [syncPlaylistId] });
delete _syncProgressCallbacks[syncPlaylistId];
if (tidalPlaylistStates[playlistId]) tidalPlaylistStates[playlistId].phase = 'discovered';
if (youtubePlaylistStates[urlHash]) youtubePlaylistStates[urlHash].phase = 'discovered';
updateTidalCardPhase(playlistId, 'discovered');
updateTidalModalButtons(urlHash, 'discovered');
showToast(`Sync failed: ${data.error || 'Unknown error'}`, 'error');
}
};
}
// Define the polling function (HTTP fallback)
const pollFunction = async () => {
if (socketConnected) return; // Phase 6: WS handles updates
try {
const response = await fetch(`/api/tidal/sync/status/${playlistId}`);
const status = await response.json();
if (status.error) {
console.error('โ Error polling Tidal sync status:', status.error);
clearInterval(pollInterval);
delete activeYouTubePollers[urlHash];
return;
}
updateTidalCardSyncProgress(playlistId, status.progress);
updateTidalModalSyncProgress(urlHash, status.progress);
if (status.complete) {
clearInterval(pollInterval);
delete activeYouTubePollers[urlHash];
if (tidalPlaylistStates[playlistId]) tidalPlaylistStates[playlistId].phase = 'sync_complete';
if (youtubePlaylistStates[urlHash]) youtubePlaylistStates[urlHash].phase = 'sync_complete';
updateTidalCardPhase(playlistId, 'sync_complete');
updateTidalModalButtons(urlHash, 'sync_complete');
showToast('Tidal playlist sync complete!', 'success');
} else if (status.sync_status === 'error') {
clearInterval(pollInterval);
delete activeYouTubePollers[urlHash];
if (tidalPlaylistStates[playlistId]) tidalPlaylistStates[playlistId].phase = 'discovered';
if (youtubePlaylistStates[urlHash]) youtubePlaylistStates[urlHash].phase = 'discovered';
updateTidalCardPhase(playlistId, 'discovered');
updateTidalModalButtons(urlHash, 'discovered');
showToast(`Sync failed: ${status.error || 'Unknown error'}`, 'error');
}
} catch (error) {
console.error('โ Error polling Tidal sync:', error);
if (activeYouTubePollers[urlHash]) {
clearInterval(activeYouTubePollers[urlHash]);
delete activeYouTubePollers[urlHash];
}
}
};
// Run immediately to get current status (skip if WS active)
if (!socketConnected) pollFunction();
// Then continue polling at regular intervals
const pollInterval = setInterval(pollFunction, 1000);
activeYouTubePollers[urlHash] = pollInterval;
}
async function cancelTidalSync(urlHash) {
try {
console.log('โ Cancelling Tidal sync:', urlHash);
const state = youtubePlaylistStates[urlHash];
if (!state || !state.is_tidal_playlist) {
console.error('โ Invalid Tidal playlist state');
return;
}
const playlistId = state.tidal_playlist_id;
const response = await fetch(`/api/tidal/sync/cancel/${playlistId}`, {
method: 'POST'
});
const result = await response.json();
if (result.error) {
showToast(`Error cancelling sync: ${result.error}`, 'error');
return;
}
// Stop polling
if (activeYouTubePollers[urlHash]) {
clearInterval(activeYouTubePollers[urlHash]);
delete activeYouTubePollers[urlHash];
}
// Phase 6: Clean up WS subscription
const syncId = state && state.syncPlaylistId;
if (syncId && _syncProgressCallbacks[syncId]) {
if (socketConnected) socket.emit('sync:unsubscribe', { playlist_ids: [syncId] });
delete _syncProgressCallbacks[syncId];
}
// Revert to discovered phase
updateTidalCardPhase(playlistId, 'discovered');
updateTidalModalButtons(urlHash, 'discovered');
showToast('Tidal sync cancelled', 'info');
} catch (error) {
console.error('โ Error cancelling Tidal sync:', error);
showToast(`Error cancelling sync: ${error.message}`, 'error');
}
}
function updateTidalCardSyncProgress(playlistId, progress) {
const state = tidalPlaylistStates[playlistId];
if (!state || !state.playlist || !progress) return;
// Save the progress for later restoration
state.lastSyncProgress = progress;
const card = document.getElementById(`tidal-card-${playlistId}`);
if (!card) return;
const progressElement = card.querySelector('.playlist-card-progress');
// Build clean status counter HTML exactly like YouTube cards
let statusCounterHTML = '';
if (progress && progress.total_tracks > 0) {
const matched = progress.matched_tracks || 0;
const failed = progress.failed_tracks || 0;
const total = progress.total_tracks || 0;
const processed = matched + failed;
const percentage = total > 0 ? Math.round((processed / total) * 100) : 0;
statusCounterHTML = `
โช ${total}
/
โ ${matched}
/
โ ${failed}
(${percentage}%)
`;
}
// Only update if we have valid sync progress, otherwise preserve existing discovery results
if (statusCounterHTML) {
progressElement.innerHTML = statusCounterHTML;
}
console.log(`๐ต Updated Tidal card sync progress: โช ${progress?.total_tracks || 0} / โ ${progress?.matched_tracks || 0} / โ ${progress?.failed_tracks || 0}`);
}
function updateTidalModalSyncProgress(urlHash, progress) {
const statusDisplay = document.getElementById(`tidal-sync-status-${urlHash}`);
if (!statusDisplay || !progress) return;
console.log(`๐ Updating Tidal modal sync progress for ${urlHash}:`, progress);
// Update individual counters exactly like YouTube sync
const totalEl = document.getElementById(`tidal-total-${urlHash}`);
const matchedEl = document.getElementById(`tidal-matched-${urlHash}`);
const failedEl = document.getElementById(`tidal-failed-${urlHash}`);
const percentageEl = document.getElementById(`tidal-percentage-${urlHash}`);
const total = progress.total_tracks || 0;
const matched = progress.matched_tracks || 0;
const failed = progress.failed_tracks || 0;
if (totalEl) totalEl.textContent = total;
if (matchedEl) matchedEl.textContent = matched;
if (failedEl) failedEl.textContent = failed;
// Calculate percentage like YouTube sync
if (total > 0) {
const processed = matched + failed;
const percentage = Math.round((processed / total) * 100);
if (percentageEl) percentageEl.textContent = percentage;
}
console.log(`๐ Tidal modal updated: โช ${total} / โ ${matched} / โ ${failed} (${Math.round((matched + failed) / total * 100)}%)`);
}
function updateTidalModalButtons(urlHash, phase) {
const modal = document.getElementById(`youtube-discovery-modal-${urlHash}`);
if (!modal) return;
const footerLeft = modal.querySelector('.modal-footer-left');
if (footerLeft) {
footerLeft.innerHTML = getModalActionButtons(urlHash, phase);
}
}
async function startTidalDownloadMissing(urlHash) {
try {
console.log('๐ Starting download missing tracks for Tidal playlist:', urlHash);
const state = youtubePlaylistStates[urlHash];
if (!state || !state.is_tidal_playlist) {
console.error('โ Invalid Tidal playlist state for download');
return;
}
// Tidal reuses youtubePlaylistStates infrastructure, so get results from there
const discoveryResults = state.discoveryResults || state.discovery_results;
if (!discoveryResults) {
showToast('No discovery results available for download', 'error');
return;
}
// Convert Tidal discovery results to Spotify tracks format (same as YouTube)
const spotifyTracks = [];
for (const result of discoveryResults) {
if (result.spotify_data) {
spotifyTracks.push(result.spotify_data);
} else if (result.spotify_track && result.status_class === 'found') {
// Build from individual fields (automatic discovery format)
// Convert album to proper object format for wishlist compatibility
const albumData = result.spotify_album || 'Unknown Album';
const albumObject = typeof albumData === 'object' && albumData !== null
? albumData
: {
name: typeof albumData === 'string' ? albumData : 'Unknown Album',
album_type: 'album',
images: []
};
spotifyTracks.push({
id: result.spotify_id || 'unknown',
name: result.spotify_track || 'Unknown Track',
artists: result.spotify_artist ? [result.spotify_artist] : ['Unknown Artist'],
album: albumObject,
duration_ms: 0
});
}
}
if (spotifyTracks.length === 0) {
showToast('No Spotify matches found for download', 'error');
return;
}
// Create a virtual playlist for the download system
const virtualPlaylistId = `tidal_${state.tidal_playlist_id}`;
const playlistName = state.playlist.name;
// Store reference for card navigation (same as YouTube)
state.convertedSpotifyPlaylistId = virtualPlaylistId;
// Close the discovery modal if it's open (same as YouTube)
const discoveryModal = document.getElementById(`youtube-discovery-modal-${urlHash}`);
if (discoveryModal) {
discoveryModal.classList.add('hidden');
console.log('๐ Closed Tidal discovery modal to show download modal');
}
// Open download missing tracks modal for Tidal playlist
await openDownloadMissingModalForTidal(virtualPlaylistId, playlistName, spotifyTracks);
// Phase will change to 'downloading' when user clicks "Begin Analysis" button
} catch (error) {
console.error('โ Error starting download missing tracks:', error);
showToast(`Error starting downloads: ${error.message}`, 'error');
}
}
async function openDownloadMissingModalForTidal(virtualPlaylistId, playlistName, spotifyTracks) {
showLoadingOverlay('Loading Tidal playlist...');
// Check if a process is already active for this virtual playlist
if (activeDownloadProcesses[virtualPlaylistId]) {
console.log(`Modal for ${virtualPlaylistId} already exists. Showing it.`);
const process = activeDownloadProcesses[virtualPlaylistId];
if (process.modalElement) {
if (process.status === 'complete') {
showToast('Showing previous results. Close this modal to start a new analysis.', 'info');
}
process.modalElement.style.display = 'flex';
}
return;
}
console.log(`๐ฅ Opening Download Missing Tracks modal for Tidal playlist: ${virtualPlaylistId}`);
// Create virtual playlist object for compatibility with existing modal logic
const virtualPlaylist = {
id: virtualPlaylistId,
name: playlistName,
track_count: spotifyTracks.length
};
// Store the tracks in the cache for the modal to use
playlistTrackCache[virtualPlaylistId] = spotifyTracks;
currentPlaylistTracks = spotifyTracks;
currentModalPlaylistId = virtualPlaylistId;
let modal = document.createElement('div');
modal.id = `download-missing-modal-${virtualPlaylistId}`;
modal.className = 'download-missing-modal';
modal.style.display = 'none';
document.body.appendChild(modal);
// Register the new process in our global state tracker using the same structure as Spotify
activeDownloadProcesses[virtualPlaylistId] = {
status: 'idle',
modalElement: modal,
poller: null,
batchId: null,
playlist: virtualPlaylist,
tracks: spotifyTracks
};
// Generate hero section with dynamic source detection (same as YouTube/Beatport)
const source = virtualPlaylistId.startsWith('beatport_') ? 'Beatport' :
virtualPlaylistId.startsWith('tidal_') ? 'Tidal' :
virtualPlaylistId.startsWith('listenbrainz_') ? 'ListenBrainz' :
virtualPlaylistId.startsWith('discover_') ? 'SoulSync' :
virtualPlaylistId.startsWith('seasonal_') ? 'SoulSync' :
virtualPlaylistId.startsWith('spotify_library_') ? 'SoulSync' :
virtualPlaylistId.startsWith('build_playlist_') ? 'SoulSync' :
virtualPlaylistId.startsWith('decade_') ? 'SoulSync' :
virtualPlaylistId === 'build_playlist_custom' ? 'SoulSync' :
'YouTube';
const heroContext = {
type: 'playlist',
playlist: { name: playlistName, owner: source },
trackCount: spotifyTracks.length,
playlistId: virtualPlaylistId
};
// Use the exact same modal HTML structure as the existing Spotify modal
modal.innerHTML = `
๐ Library Analysis
Ready to start
โฌ Downloads
Waiting for analysis
`;
applyProgressiveTrackRendering(virtualPlaylistId, spotifyTracks.length);
modal.style.display = 'flex';
hideLoadingOverlay();
}
// ===================================================================
// DEEZER PLAYLIST MANAGEMENT (URL-input like YouTube, reuses YouTube modal)
// ===================================================================
async function loadDeezerPlaylist() {
const urlInput = document.getElementById('deezer-url-input');
if (!urlInput) return;
const rawUrl = urlInput.value.trim();
if (!rawUrl) {
showToast('Please paste a Deezer playlist URL', 'error');
return;
}
// Extract playlist ID from URL
// Supports: deezer.com/playlist/{id}, deezer.com/{locale}/playlist/{id}, or raw numeric ID
let playlistId = null;
const urlMatch = rawUrl.match(/deezer\.com\/(?:[a-z]{2}\/)?playlist\/(\d+)/i);
if (urlMatch) {
playlistId = urlMatch[1];
} else if (/^\d+$/.test(rawUrl)) {
playlistId = rawUrl;
}
if (!playlistId) {
showToast('Invalid Deezer playlist URL. Expected format: deezer.com/playlist/{id}', 'error');
return;
}
// Check if already loaded
if (deezerPlaylists.find(p => String(p.id) === String(playlistId))) {
showToast('This playlist is already loaded', 'info');
urlInput.value = '';
return;
}
const parseBtn = document.getElementById('deezer-parse-btn');
if (parseBtn) {
parseBtn.disabled = true;
parseBtn.textContent = 'Loading...';
}
try {
const response = await fetch(`/api/deezer/playlist/${playlistId}`);
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || 'Failed to fetch Deezer playlist');
}
const playlist = await response.json();
deezerPlaylists.push(playlist);
// Auto-mirror Deezer playlist
if (playlist.tracks && playlist.tracks.length > 0) {
mirrorPlaylist('deezer', playlist.id, playlist.name, playlist.tracks.map(t => ({
track_name: t.name || '', artist_name: Array.isArray(t.artists) ? t.artists[0] : (t.artists || ''),
album_name: typeof t.album === 'string' ? t.album : '', duration_ms: t.duration_ms || 0,
source_track_id: t.id || ''
})), { owner: playlist.owner, image_url: playlist.image_url, description: playlist.description });
}
renderDeezerPlaylists();
await loadDeezerPlaylistStatesFromBackend();
urlInput.value = '';
showToast(`Deezer playlist loaded: ${playlist.name} (${playlist.track_count || playlist.tracks.length} tracks)`, 'success');
console.log(`๐ต Loaded Deezer playlist: ${playlist.name}`);
} catch (error) {
showToast(`Error loading Deezer playlist: ${error.message}`, 'error');
} finally {
if (parseBtn) {
parseBtn.disabled = false;
parseBtn.textContent = 'Load Playlist';
}
}
}
function renderDeezerPlaylists() {
const container = document.getElementById('deezer-playlist-container');
if (deezerPlaylists.length === 0) {
container.innerHTML = `Paste a Deezer playlist URL above to get started.
`;
return;
}
container.innerHTML = deezerPlaylists.map(p => {
if (!deezerPlaylistStates[p.id]) {
deezerPlaylistStates[p.id] = {
phase: 'fresh',
playlist: p
};
}
return createDeezerCard(p);
}).join('');
// Add click handlers to cards
deezerPlaylists.forEach(p => {
const card = document.getElementById(`deezer-card-${p.id}`);
if (card) {
card.addEventListener('click', () => handleDeezerCardClick(p.id));
}
});
}
function createDeezerCard(playlist) {
const state = deezerPlaylistStates[playlist.id];
const phase = state.phase;
let buttonText = getActionButtonText(phase);
let phaseText = getPhaseText(phase);
let phaseColor = getPhaseColor(phase);
return `
๐ต
${escapeHtml(playlist.name)}
${playlist.track_count || playlist.tracks.length} tracks
${phaseText}
${buttonText}
`;
}
async function handleDeezerCardClick(playlistId) {
const state = deezerPlaylistStates[playlistId];
if (!state) {
console.error(`No state found for Deezer playlist: ${playlistId}`);
showToast('Playlist state not found - try refreshing the page', 'error');
return;
}
if (!state.playlist) {
console.error(`No playlist data found for Deezer playlist: ${playlistId}`);
showToast('Playlist data missing - try refreshing the page', 'error');
return;
}
if (!state.phase) {
state.phase = 'fresh';
}
console.log(`๐ต [Card Click] Deezer card clicked: ${playlistId}, Phase: ${state.phase}`);
if (state.phase === 'fresh') {
console.log(`๐ต Using pre-loaded Deezer playlist data for: ${state.playlist.name}`);
openDeezerDiscoveryModal(playlistId, state.playlist);
} else if (state.phase === 'discovering' || state.phase === 'discovered' || state.phase === 'syncing' || state.phase === 'sync_complete') {
console.log(`๐ต [Card Click] Opening Deezer discovery modal for ${state.phase} phase`);
if (state.phase === 'discovered' && (!state.discovery_results || state.discovery_results.length === 0)) {
try {
const stateResponse = await fetch(`/api/deezer/state/${playlistId}`);
if (stateResponse.ok) {
const fullState = await stateResponse.json();
if (fullState.discovery_results) {
state.discovery_results = fullState.discovery_results;
state.spotify_matches = fullState.spotify_matches || state.spotify_matches;
state.discovery_progress = fullState.discovery_progress || state.discovery_progress;
deezerPlaylistStates[playlistId] = { ...deezerPlaylistStates[playlistId], ...state };
console.log(`Restored ${fullState.discovery_results.length} discovery results from backend`);
}
}
} catch (error) {
console.error(`Failed to fetch discovery results from backend: ${error}`);
}
}
openDeezerDiscoveryModal(playlistId, state.playlist);
} else if (state.phase === 'downloading' || state.phase === 'download_complete') {
if (state.convertedSpotifyPlaylistId) {
if (activeDownloadProcesses[state.convertedSpotifyPlaylistId]) {
const process = activeDownloadProcesses[state.convertedSpotifyPlaylistId];
if (process.modalElement) {
process.modalElement.style.display = 'flex';
} else {
await rehydrateDeezerDownloadModal(playlistId, state);
}
} else {
await rehydrateDeezerDownloadModal(playlistId, state);
}
} else {
if (state.discovery_results && state.discovery_results.length > 0) {
openDeezerDiscoveryModal(playlistId, state.playlist);
} else {
showToast('Unable to open download modal - missing playlist data', 'error');
}
}
}
}
async function rehydrateDeezerDownloadModal(playlistId, state) {
try {
if (!state || !state.playlist) {
showToast('Cannot open download modal - invalid playlist data', 'error');
return;
}
const spotifyTracks = state.discovery_results
?.filter(result => result.spotify_data)
?.map(result => result.spotify_data) || [];
if (spotifyTracks.length > 0) {
const virtualPlaylistId = state.convertedSpotifyPlaylistId || `deezer_${playlistId}`;
await openDownloadMissingModalForTidal(virtualPlaylistId, state.playlist.name, spotifyTracks);
if (state.download_process_id) {
const process = activeDownloadProcesses[virtualPlaylistId];
if (process) {
process.status = 'running';
process.batchId = state.download_process_id;
const beginBtn = document.getElementById(`begin-analysis-btn-${virtualPlaylistId}`);
const cancelBtn = document.getElementById(`cancel-all-btn-${virtualPlaylistId}`);
if (beginBtn) beginBtn.style.display = 'none';
if (cancelBtn) cancelBtn.style.display = 'inline-block';
startModalDownloadPolling(virtualPlaylistId);
}
}
} else {
showToast('No Spotify tracks found for download', 'error');
}
} catch (error) {
console.error(`Error rehydrating Deezer download modal: ${error}`);
}
}
async function openDeezerDiscoveryModal(playlistId, playlistData) {
console.log(`๐ต Opening Deezer discovery modal (reusing YouTube modal): ${playlistData.name}`);
const fakeUrlHash = `deezer_${playlistId}`;
const deezerCardState = deezerPlaylistStates[playlistId];
const isAlreadyDiscovered = deezerCardState && (deezerCardState.phase === 'discovered' || deezerCardState.phase === 'syncing' || deezerCardState.phase === 'sync_complete');
const isCurrentlyDiscovering = deezerCardState && deezerCardState.phase === 'discovering';
let transformedResults = [];
let actualMatches = 0;
if (isAlreadyDiscovered && deezerCardState.discovery_results) {
transformedResults = deezerCardState.discovery_results.map((result, index) => {
const isFound = result.status === 'found' ||
result.status === 'โ
Found' ||
result.status_class === 'found' ||
result.spotify_data ||
result.spotify_track;
if (isFound) actualMatches++;
return {
index: index,
yt_track: result.deezer_track ? result.deezer_track.name : 'Unknown',
yt_artist: result.deezer_track ? (result.deezer_track.artists ? result.deezer_track.artists.join(', ') : 'Unknown') : 'Unknown',
status: isFound ? 'โ
Found' : 'โ Not Found',
status_class: isFound ? 'found' : 'not-found',
spotify_track: result.spotify_data ? result.spotify_data.name : (result.spotify_track || '-'),
spotify_artist: result.spotify_data && result.spotify_data.artists ?
(Array.isArray(result.spotify_data.artists) ? result.spotify_data.artists.join(', ') : result.spotify_data.artists) : (result.spotify_artist || '-'),
spotify_album: result.spotify_data ? (typeof result.spotify_data.album === 'object' ? result.spotify_data.album.name : result.spotify_data.album) : (result.spotify_album || '-'),
spotify_data: result.spotify_data,
spotify_id: result.spotify_id,
manual_match: result.manual_match
};
});
console.log(`๐ต Deezer modal: Calculated ${actualMatches} matches from ${transformedResults.length} results`);
}
const modalPhase = deezerCardState ? deezerCardState.phase : 'fresh';
youtubePlaylistStates[fakeUrlHash] = {
phase: modalPhase,
playlist: {
name: playlistData.name,
tracks: playlistData.tracks
},
is_deezer_playlist: true,
deezer_playlist_id: playlistId,
discovery_progress: isAlreadyDiscovered ? 100 : 0,
spotify_matches: isAlreadyDiscovered ? actualMatches : 0,
spotifyMatches: isAlreadyDiscovered ? actualMatches : 0,
spotify_total: playlistData.tracks.length,
discovery_results: transformedResults,
discoveryResults: transformedResults,
discoveryProgress: isAlreadyDiscovered ? 100 : 0
};
if (!isAlreadyDiscovered && !isCurrentlyDiscovering) {
try {
console.log(`๐ Starting Deezer discovery for: ${playlistData.name}`);
const response = await fetch(`/api/deezer/discovery/start/${playlistId}`, {
method: 'POST'
});
const result = await response.json();
if (result.error) {
console.error('Error starting Deezer discovery:', result.error);
showToast(`Error starting discovery: ${result.error}`, 'error');
return;
}
console.log('Deezer discovery started, beginning polling...');
deezerPlaylistStates[playlistId].phase = 'discovering';
updateDeezerCardPhase(playlistId, 'discovering');
youtubePlaylistStates[fakeUrlHash].phase = 'discovering';
startDeezerDiscoveryPolling(fakeUrlHash, playlistId);
} catch (error) {
console.error('Error starting Deezer discovery:', error);
showToast(`Error starting discovery: ${error.message}`, 'error');
}
} else if (isCurrentlyDiscovering) {
console.log(`๐ Resuming Deezer discovery polling for: ${playlistData.name}`);
startDeezerDiscoveryPolling(fakeUrlHash, playlistId);
} else if (deezerCardState && deezerCardState.phase === 'syncing') {
console.log(`๐ Resuming Deezer sync polling for: ${playlistData.name}`);
startDeezerSyncPolling(fakeUrlHash);
} else {
console.log('Using existing results - no need to re-discover');
}
openYouTubeDiscoveryModal(fakeUrlHash);
}
function startDeezerDiscoveryPolling(fakeUrlHash, playlistId) {
console.log(`๐ Starting Deezer discovery polling for: ${playlistId}`);
if (activeYouTubePollers[fakeUrlHash]) {
clearInterval(activeYouTubePollers[fakeUrlHash]);
}
// WebSocket subscription
if (socketConnected) {
socket.emit('discovery:subscribe', { ids: [playlistId] });
_discoveryProgressCallbacks[playlistId] = (data) => {
if (data.error) {
if (activeYouTubePollers[fakeUrlHash]) { clearInterval(activeYouTubePollers[fakeUrlHash]); delete activeYouTubePollers[fakeUrlHash]; }
socket.emit('discovery:unsubscribe', { ids: [playlistId] }); delete _discoveryProgressCallbacks[playlistId];
return;
}
const transformed = {
progress: data.progress, spotify_matches: data.spotify_matches, spotify_total: data.spotify_total,
results: (data.results || []).map((r, i) => {
const isFound = r.status === 'found' || r.status === 'โ
Found' || r.status_class === 'found' || r.spotify_data || r.spotify_track;
return {
index: i, yt_track: r.deezer_track ? r.deezer_track.name : 'Unknown',
yt_artist: r.deezer_track ? (r.deezer_track.artists ? r.deezer_track.artists.join(', ') : 'Unknown') : 'Unknown',
status: isFound ? 'โ
Found' : 'โ Not Found', status_class: isFound ? 'found' : 'not-found',
spotify_track: r.spotify_data ? r.spotify_data.name : (r.spotify_track || '-'),
spotify_artist: r.spotify_data && r.spotify_data.artists ? (Array.isArray(r.spotify_data.artists) ? r.spotify_data.artists.join(', ') : r.spotify_data.artists) : (r.spotify_artist || '-'),
spotify_album: r.spotify_data ? (typeof r.spotify_data.album === 'object' ? r.spotify_data.album.name : r.spotify_data.album) : (r.spotify_album || '-'),
spotify_data: r.spotify_data, spotify_id: r.spotify_id, manual_match: r.manual_match
};
})
};
const st = youtubePlaylistStates[fakeUrlHash];
if (st) {
st.discovery_progress = data.progress; st.discoveryProgress = data.progress;
st.spotify_matches = data.spotify_matches; st.spotifyMatches = data.spotify_matches;
st.discovery_results = data.results; st.discoveryResults = transformed.results;
st.phase = data.phase;
updateYouTubeDiscoveryModal(fakeUrlHash, transformed);
}
if (deezerPlaylistStates[playlistId]) {
deezerPlaylistStates[playlistId].phase = data.phase;
deezerPlaylistStates[playlistId].discovery_results = data.results;
deezerPlaylistStates[playlistId].spotify_matches = data.spotify_matches;
deezerPlaylistStates[playlistId].discovery_progress = data.progress;
updateDeezerCardPhase(playlistId, data.phase);
}
updateDeezerCardProgress(playlistId, data);
if (data.complete) {
if (activeYouTubePollers[fakeUrlHash]) { clearInterval(activeYouTubePollers[fakeUrlHash]); delete activeYouTubePollers[fakeUrlHash]; }
socket.emit('discovery:unsubscribe', { ids: [playlistId] }); delete _discoveryProgressCallbacks[playlistId];
}
};
}
const pollInterval = setInterval(async () => {
if (socketConnected) return;
try {
const response = await fetch(`/api/deezer/discovery/status/${playlistId}`);
const status = await response.json();
if (status.error) {
console.error('Error polling Deezer discovery status:', status.error);
clearInterval(pollInterval);
delete activeYouTubePollers[fakeUrlHash];
return;
}
const transformedStatus = {
progress: status.progress,
spotify_matches: status.spotify_matches,
spotify_total: status.spotify_total,
results: status.results.map((result, index) => {
const isFound = result.status === 'found' ||
result.status === 'โ
Found' ||
result.status_class === 'found' ||
result.spotify_data ||
result.spotify_track;
return {
index: index,
yt_track: result.deezer_track ? result.deezer_track.name : 'Unknown',
yt_artist: result.deezer_track ? (result.deezer_track.artists ? result.deezer_track.artists.join(', ') : 'Unknown') : 'Unknown',
status: isFound ? 'โ
Found' : 'โ Not Found',
status_class: isFound ? 'found' : 'not-found',
spotify_track: result.spotify_data ? result.spotify_data.name : (result.spotify_track || '-'),
spotify_artist: result.spotify_data && result.spotify_data.artists ?
(Array.isArray(result.spotify_data.artists) ? result.spotify_data.artists.join(', ') : result.spotify_data.artists) : (result.spotify_artist || '-'),
spotify_album: result.spotify_data ? (typeof result.spotify_data.album === 'object' ? result.spotify_data.album.name : result.spotify_data.album) : (result.spotify_album || '-'),
spotify_data: result.spotify_data,
spotify_id: result.spotify_id,
manual_match: result.manual_match
};
})
};
const state = youtubePlaylistStates[fakeUrlHash];
if (state) {
state.discovery_progress = status.progress;
state.discoveryProgress = status.progress;
state.spotify_matches = status.spotify_matches;
state.spotifyMatches = status.spotify_matches;
state.discovery_results = status.results;
state.discoveryResults = transformedStatus.results;
state.phase = status.phase;
updateYouTubeDiscoveryModal(fakeUrlHash, transformedStatus);
if (deezerPlaylistStates[playlistId]) {
deezerPlaylistStates[playlistId].phase = status.phase;
deezerPlaylistStates[playlistId].discovery_results = status.results;
deezerPlaylistStates[playlistId].spotify_matches = status.spotify_matches;
deezerPlaylistStates[playlistId].discovery_progress = status.progress;
updateDeezerCardPhase(playlistId, status.phase);
}
updateDeezerCardProgress(playlistId, status);
console.log(`๐ Deezer discovery progress: ${status.progress}% (${status.spotify_matches}/${status.spotify_total} found)`);
}
if (status.complete) {
console.log(`Deezer discovery complete: ${status.spotify_matches}/${status.spotify_total} tracks found`);
clearInterval(pollInterval);
delete activeYouTubePollers[fakeUrlHash];
}
} catch (error) {
console.error('Error polling Deezer discovery:', error);
clearInterval(pollInterval);
delete activeYouTubePollers[fakeUrlHash];
}
}, 1000);
activeYouTubePollers[fakeUrlHash] = pollInterval;
}
async function loadDeezerPlaylistStatesFromBackend() {
try {
console.log('๐ต Loading Deezer playlist states from backend...');
const response = await fetch('/api/deezer/playlists/states');
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || 'Failed to fetch Deezer playlist states');
}
const data = await response.json();
const states = data.states || [];
console.log(`๐ต Found ${states.length} stored Deezer playlist states in backend`);
if (states.length === 0) return;
for (const stateInfo of states) {
await applyDeezerPlaylistState(stateInfo);
}
// Rehydrate download modals for Deezer playlists in downloading/download_complete phases
for (const stateInfo of states) {
if ((stateInfo.phase === 'downloading' || stateInfo.phase === 'download_complete') &&
stateInfo.converted_spotify_playlist_id && stateInfo.download_process_id) {
const convertedPlaylistId = stateInfo.converted_spotify_playlist_id;
if (!activeDownloadProcesses[convertedPlaylistId]) {
console.log(`Rehydrating download modal for Deezer playlist: ${stateInfo.playlist_id}`);
try {
const playlistData = deezerPlaylists.find(p => String(p.id) === String(stateInfo.playlist_id));
if (!playlistData) continue;
const spotifyTracks = deezerPlaylistStates[stateInfo.playlist_id]?.discovery_results
?.filter(result => result.spotify_data)
?.map(result => result.spotify_data) || [];
if (spotifyTracks.length > 0) {
await openDownloadMissingModalForTidal(
convertedPlaylistId,
playlistData.name,
spotifyTracks
);
const process = activeDownloadProcesses[convertedPlaylistId];
if (process) {
process.status = 'running';
process.batchId = stateInfo.download_process_id;
const beginBtn = document.getElementById(`begin-analysis-btn-${convertedPlaylistId}`);
const cancelBtn = document.getElementById(`cancel-all-btn-${convertedPlaylistId}`);
if (beginBtn) beginBtn.style.display = 'none';
if (cancelBtn) cancelBtn.style.display = 'inline-block';
startModalDownloadPolling(convertedPlaylistId);
}
}
} catch (error) {
console.error(`Error rehydrating Deezer download modal for ${stateInfo.playlist_id}:`, error);
}
}
}
}
console.log('Deezer playlist states loaded and applied');
} catch (error) {
console.error('Error loading Deezer playlist states:', error);
}
}
async function applyDeezerPlaylistState(stateInfo) {
const { playlist_id, phase, discovery_progress, spotify_matches, discovery_results, converted_spotify_playlist_id, download_process_id } = stateInfo;
try {
console.log(`๐ต Applying saved state for Deezer playlist: ${playlist_id}, Phase: ${phase}`);
const playlistData = deezerPlaylists.find(p => String(p.id) === String(playlist_id));
if (!playlistData) {
console.warn(`Playlist data not found for state ${playlist_id} - skipping`);
return;
}
if (!deezerPlaylistStates[playlist_id]) {
deezerPlaylistStates[playlist_id] = {
playlist: playlistData,
phase: 'fresh'
};
}
deezerPlaylistStates[playlist_id].phase = phase;
deezerPlaylistStates[playlist_id].discovery_progress = discovery_progress;
deezerPlaylistStates[playlist_id].spotify_matches = spotify_matches;
deezerPlaylistStates[playlist_id].discovery_results = discovery_results;
deezerPlaylistStates[playlist_id].convertedSpotifyPlaylistId = converted_spotify_playlist_id;
deezerPlaylistStates[playlist_id].download_process_id = download_process_id;
deezerPlaylistStates[playlist_id].playlist = playlistData;
if (phase !== 'fresh' && phase !== 'discovering') {
try {
const stateResponse = await fetch(`/api/deezer/state/${playlist_id}`);
if (stateResponse.ok) {
const fullState = await stateResponse.json();
if (fullState.discovery_results && deezerPlaylistStates[playlist_id]) {
deezerPlaylistStates[playlist_id].discovery_results = fullState.discovery_results;
deezerPlaylistStates[playlist_id].discovery_progress = fullState.discovery_progress;
deezerPlaylistStates[playlist_id].spotify_matches = fullState.spotify_matches;
deezerPlaylistStates[playlist_id].convertedSpotifyPlaylistId = fullState.converted_spotify_playlist_id;
deezerPlaylistStates[playlist_id].download_process_id = fullState.download_process_id;
}
}
} catch (error) {
console.warn(`Error fetching full discovery results for Deezer playlist ${playlistData.name}:`, error.message);
}
}
updateDeezerCardPhase(playlist_id, phase);
if (phase === 'discovered' && deezerPlaylistStates[playlist_id]) {
const progressInfo = {
spotify_total: playlistData.track_count || playlistData.tracks?.length || 0,
spotify_matches: deezerPlaylistStates[playlist_id].spotify_matches || 0
};
updateDeezerCardProgress(playlist_id, progressInfo);
}
if (phase === 'discovering') {
const fakeUrlHash = `deezer_${playlist_id}`;
startDeezerDiscoveryPolling(fakeUrlHash, playlist_id);
} else if (phase === 'syncing') {
const fakeUrlHash = `deezer_${playlist_id}`;
startDeezerSyncPolling(fakeUrlHash);
}
} catch (error) {
console.error(`Error applying Deezer playlist state for ${playlist_id}:`, error);
}
}
function updateDeezerCardPhase(playlistId, phase) {
const state = deezerPlaylistStates[playlistId];
if (!state) return;
state.phase = phase;
const card = document.getElementById(`deezer-card-${playlistId}`);
if (card) {
const newCardHtml = createDeezerCard(state.playlist);
card.outerHTML = newCardHtml;
const newCard = document.getElementById(`deezer-card-${playlistId}`);
if (newCard) {
newCard.addEventListener('click', () => handleDeezerCardClick(playlistId));
}
if ((phase === 'syncing' || phase === 'sync_complete') && state.lastSyncProgress) {
setTimeout(() => {
updateDeezerCardSyncProgress(playlistId, state.lastSyncProgress);
}, 0);
}
}
}
function updateDeezerCardProgress(playlistId, progress) {
const state = deezerPlaylistStates[playlistId];
if (!state) return;
const card = document.getElementById(`deezer-card-${playlistId}`);
if (!card) return;
const progressElement = card.querySelector('.playlist-card-progress');
if (!progressElement) return;
progressElement.classList.remove('hidden');
const total = progress.spotify_total || 0;
const matches = progress.spotify_matches || 0;
if (total > 0) {
progressElement.innerHTML = `
โ ${matches}
/
โช ${total}
`;
}
}
// ===============================
// DEEZER SYNC FUNCTIONALITY
// ===============================
async function startDeezerPlaylistSync(urlHash) {
try {
console.log('๐ต Starting Deezer playlist sync:', urlHash);
const state = youtubePlaylistStates[urlHash];
if (!state || !state.is_deezer_playlist) {
console.error('Invalid Deezer playlist state for sync');
return;
}
const playlistId = state.deezer_playlist_id;
const response = await fetch(`/api/deezer/sync/start/${playlistId}`, {
method: 'POST'
});
const result = await response.json();
if (result.error) {
showToast(`Error starting sync: ${result.error}`, 'error');
return;
}
const syncPlaylistId = result.sync_playlist_id;
if (state) state.syncPlaylistId = syncPlaylistId;
updateDeezerCardPhase(playlistId, 'syncing');
updateDeezerModalButtons(urlHash, 'syncing');
startDeezerSyncPolling(urlHash, syncPlaylistId);
showToast('Deezer playlist sync started!', 'success');
} catch (error) {
console.error('Error starting Deezer sync:', error);
showToast(`Error starting sync: ${error.message}`, 'error');
}
}
function startDeezerSyncPolling(urlHash, syncPlaylistId) {
if (activeYouTubePollers[urlHash]) {
clearInterval(activeYouTubePollers[urlHash]);
}
const state = youtubePlaylistStates[urlHash];
const playlistId = state.deezer_playlist_id;
syncPlaylistId = syncPlaylistId || (state && state.syncPlaylistId);
// WebSocket subscription
if (socketConnected && syncPlaylistId) {
socket.emit('sync:subscribe', { playlist_ids: [syncPlaylistId] });
_syncProgressCallbacks[syncPlaylistId] = (data) => {
const progress = data.progress || {};
updateDeezerCardSyncProgress(playlistId, progress);
updateDeezerModalSyncProgress(urlHash, progress);
if (data.status === 'finished') {
if (activeYouTubePollers[urlHash]) { clearInterval(activeYouTubePollers[urlHash]); delete activeYouTubePollers[urlHash]; }
socket.emit('sync:unsubscribe', { playlist_ids: [syncPlaylistId] });
delete _syncProgressCallbacks[syncPlaylistId];
if (deezerPlaylistStates[playlistId]) deezerPlaylistStates[playlistId].phase = 'sync_complete';
if (youtubePlaylistStates[urlHash]) youtubePlaylistStates[urlHash].phase = 'sync_complete';
updateDeezerCardPhase(playlistId, 'sync_complete');
updateDeezerModalButtons(urlHash, 'sync_complete');
showToast('Deezer playlist sync complete!', 'success');
} else if (data.status === 'error' || data.status === 'cancelled') {
if (activeYouTubePollers[urlHash]) { clearInterval(activeYouTubePollers[urlHash]); delete activeYouTubePollers[urlHash]; }
socket.emit('sync:unsubscribe', { playlist_ids: [syncPlaylistId] });
delete _syncProgressCallbacks[syncPlaylistId];
if (deezerPlaylistStates[playlistId]) deezerPlaylistStates[playlistId].phase = 'discovered';
if (youtubePlaylistStates[urlHash]) youtubePlaylistStates[urlHash].phase = 'discovered';
updateDeezerCardPhase(playlistId, 'discovered');
updateDeezerModalButtons(urlHash, 'discovered');
showToast(`Sync failed: ${data.error || 'Unknown error'}`, 'error');
}
};
}
const pollFunction = async () => {
if (socketConnected) return;
try {
const response = await fetch(`/api/deezer/sync/status/${playlistId}`);
const status = await response.json();
if (status.error) {
console.error('Error polling Deezer sync status:', status.error);
clearInterval(pollInterval);
delete activeYouTubePollers[urlHash];
return;
}
updateDeezerCardSyncProgress(playlistId, status.progress);
updateDeezerModalSyncProgress(urlHash, status.progress);
if (status.complete) {
clearInterval(pollInterval);
delete activeYouTubePollers[urlHash];
if (deezerPlaylistStates[playlistId]) deezerPlaylistStates[playlistId].phase = 'sync_complete';
if (youtubePlaylistStates[urlHash]) youtubePlaylistStates[urlHash].phase = 'sync_complete';
updateDeezerCardPhase(playlistId, 'sync_complete');
updateDeezerModalButtons(urlHash, 'sync_complete');
showToast('Deezer playlist sync complete!', 'success');
} else if (status.sync_status === 'error') {
clearInterval(pollInterval);
delete activeYouTubePollers[urlHash];
if (deezerPlaylistStates[playlistId]) deezerPlaylistStates[playlistId].phase = 'discovered';
if (youtubePlaylistStates[urlHash]) youtubePlaylistStates[urlHash].phase = 'discovered';
updateDeezerCardPhase(playlistId, 'discovered');
updateDeezerModalButtons(urlHash, 'discovered');
showToast(`Sync failed: ${status.error || 'Unknown error'}`, 'error');
}
} catch (error) {
console.error('Error polling Deezer sync:', error);
if (activeYouTubePollers[urlHash]) {
clearInterval(activeYouTubePollers[urlHash]);
delete activeYouTubePollers[urlHash];
}
}
};
if (!socketConnected) pollFunction();
const pollInterval = setInterval(pollFunction, 1000);
activeYouTubePollers[urlHash] = pollInterval;
}
async function cancelDeezerSync(urlHash) {
try {
console.log('Cancelling Deezer sync:', urlHash);
const state = youtubePlaylistStates[urlHash];
if (!state || !state.is_deezer_playlist) {
console.error('Invalid Deezer playlist state');
return;
}
const playlistId = state.deezer_playlist_id;
const response = await fetch(`/api/deezer/sync/cancel/${playlistId}`, {
method: 'POST'
});
const result = await response.json();
if (result.error) {
showToast(`Error cancelling sync: ${result.error}`, 'error');
return;
}
if (activeYouTubePollers[urlHash]) {
clearInterval(activeYouTubePollers[urlHash]);
delete activeYouTubePollers[urlHash];
}
const syncId = state && state.syncPlaylistId;
if (syncId && _syncProgressCallbacks[syncId]) {
if (socketConnected) socket.emit('sync:unsubscribe', { playlist_ids: [syncId] });
delete _syncProgressCallbacks[syncId];
}
updateDeezerCardPhase(playlistId, 'discovered');
updateDeezerModalButtons(urlHash, 'discovered');
showToast('Deezer sync cancelled', 'info');
} catch (error) {
console.error('Error cancelling Deezer sync:', error);
showToast(`Error cancelling sync: ${error.message}`, 'error');
}
}
function updateDeezerCardSyncProgress(playlistId, progress) {
const state = deezerPlaylistStates[playlistId];
if (!state || !state.playlist || !progress) return;
state.lastSyncProgress = progress;
const card = document.getElementById(`deezer-card-${playlistId}`);
if (!card) return;
const progressElement = card.querySelector('.playlist-card-progress');
let statusCounterHTML = '';
if (progress && progress.total_tracks > 0) {
const matched = progress.matched_tracks || 0;
const failed = progress.failed_tracks || 0;
const total = progress.total_tracks || 0;
const processed = matched + failed;
const percentage = total > 0 ? Math.round((processed / total) * 100) : 0;
statusCounterHTML = `
โช ${total}
/
โ ${matched}
/
โ ${failed}
(${percentage}%)
`;
}
if (statusCounterHTML) {
progressElement.innerHTML = statusCounterHTML;
}
}
function updateDeezerModalSyncProgress(urlHash, progress) {
const statusDisplay = document.getElementById(`deezer-sync-status-${urlHash}`);
if (!statusDisplay || !progress) return;
const totalEl = document.getElementById(`deezer-total-${urlHash}`);
const matchedEl = document.getElementById(`deezer-matched-${urlHash}`);
const failedEl = document.getElementById(`deezer-failed-${urlHash}`);
const percentageEl = document.getElementById(`deezer-percentage-${urlHash}`);
const total = progress.total_tracks || 0;
const matched = progress.matched_tracks || 0;
const failed = progress.failed_tracks || 0;
if (totalEl) totalEl.textContent = total;
if (matchedEl) matchedEl.textContent = matched;
if (failedEl) failedEl.textContent = failed;
if (total > 0) {
const processed = matched + failed;
const percentage = Math.round((processed / total) * 100);
if (percentageEl) percentageEl.textContent = percentage;
}
}
function updateDeezerModalButtons(urlHash, phase) {
const modal = document.getElementById(`youtube-discovery-modal-${urlHash}`);
if (!modal) return;
const footerLeft = modal.querySelector('.modal-footer-left');
if (footerLeft) {
footerLeft.innerHTML = getModalActionButtons(urlHash, phase);
}
}
async function startDeezerDownloadMissing(urlHash) {
try {
console.log('๐ Starting download missing tracks for Deezer playlist:', urlHash);
const state = youtubePlaylistStates[urlHash];
if (!state || !state.is_deezer_playlist) {
console.error('Invalid Deezer playlist state for download');
return;
}
const discoveryResults = state.discoveryResults || state.discovery_results;
if (!discoveryResults) {
showToast('No discovery results available for download', 'error');
return;
}
const spotifyTracks = [];
for (const result of discoveryResults) {
if (result.spotify_data) {
spotifyTracks.push(result.spotify_data);
} else if (result.spotify_track && result.status_class === 'found') {
const albumData = result.spotify_album || 'Unknown Album';
const albumObject = typeof albumData === 'object' && albumData !== null
? albumData
: {
name: typeof albumData === 'string' ? albumData : 'Unknown Album',
album_type: 'album',
images: []
};
spotifyTracks.push({
id: result.spotify_id || 'unknown',
name: result.spotify_track || 'Unknown Track',
artists: result.spotify_artist ? [result.spotify_artist] : ['Unknown Artist'],
album: albumObject,
duration_ms: 0
});
}
}
if (spotifyTracks.length === 0) {
showToast('No Spotify matches found for download', 'error');
return;
}
const virtualPlaylistId = `deezer_${state.deezer_playlist_id}`;
const playlistName = state.playlist.name;
state.convertedSpotifyPlaylistId = virtualPlaylistId;
const discoveryModal = document.getElementById(`youtube-discovery-modal-${urlHash}`);
if (discoveryModal) {
discoveryModal.classList.add('hidden');
}
await openDownloadMissingModalForTidal(virtualPlaylistId, playlistName, spotifyTracks);
} catch (error) {
console.error('Error starting download missing tracks:', error);
showToast(`Error starting downloads: ${error.message}`, 'error');
}
}
// ===============================
// SYNC PAGE FUNCTIONALITY (REDESIGNED)
// ===============================
function initializeSyncPage() {
// Logic for tab switching
const tabButtons = document.querySelectorAll('.sync-tab-button');
const syncSidebar = document.querySelector('.sync-sidebar');
const syncContentArea = document.querySelector('.sync-content-area');
tabButtons.forEach(button => {
button.addEventListener('click', () => {
const tabId = button.dataset.tab;
// Update button active state
tabButtons.forEach(btn => btn.classList.remove('active'));
button.classList.add('active');
// Update content active state
document.querySelectorAll('.sync-tab-content').forEach(content => {
content.classList.remove('active');
});
document.getElementById(`${tabId}-tab-content`).classList.add('active');
// Show/hide sidebar based on active tab (skip on mobile where sidebar is always hidden)
if (syncSidebar && syncContentArea) {
const isMobile = window.innerWidth <= 1300;
// Sidebar always hidden by default โ shown only when sync is active
syncSidebar.style.display = 'none';
syncContentArea.style.gridTemplateColumns = '1fr';
}
// Auto-load mirrored playlists on first tab activation
if (tabId === 'mirrored' && !mirroredPlaylistsLoaded) {
loadMirroredPlaylists();
}
});
});
// Logic for the Spotify refresh button
const refreshBtn = document.getElementById('spotify-refresh-btn');
if (refreshBtn) {
// Remove any old listeners to be safe, then add the new one
refreshBtn.removeEventListener('click', loadSpotifyPlaylists);
refreshBtn.addEventListener('click', loadSpotifyPlaylists);
}
// Logic for the Tidal refresh button
const tidalRefreshBtn = document.getElementById('tidal-refresh-btn');
if (tidalRefreshBtn) {
tidalRefreshBtn.removeEventListener('click', loadTidalPlaylists);
tidalRefreshBtn.addEventListener('click', loadTidalPlaylists);
}
// Logic for the Deezer parse button
const deezerParseBtn = document.getElementById('deezer-parse-btn');
if (deezerParseBtn) {
deezerParseBtn.addEventListener('click', loadDeezerPlaylist);
}
// Also allow Enter key in the Deezer input
const deezerUrlInput = document.getElementById('deezer-url-input');
if (deezerUrlInput) {
deezerUrlInput.addEventListener('keypress', (e) => {
if (e.key === 'Enter') loadDeezerPlaylist();
});
}
// Logic for the Mirrored refresh button
const mirroredRefreshBtn = document.getElementById('mirrored-refresh-btn');
if (mirroredRefreshBtn) {
mirroredRefreshBtn.addEventListener('click', loadMirroredPlaylists);
}
// Initialize import file tab
_initImportFileTab();
// Logic for the Beatport clear button
const beatportClearBtn = document.getElementById('beatport-clear-btn');
if (beatportClearBtn) {
beatportClearBtn.addEventListener('click', clearBeatportPlaylists);
// Set initial clear button state
updateBeatportClearButtonState();
}
// Logic for Beatport nested tabs
const beatportTabButtons = document.querySelectorAll('.beatport-tab-button');
beatportTabButtons.forEach(button => {
button.addEventListener('click', () => {
const tabId = button.dataset.beatportTab;
// Update button active state
beatportTabButtons.forEach(btn => btn.classList.remove('active'));
button.classList.add('active');
// Update content active state
document.querySelectorAll('.beatport-tab-content').forEach(content => {
content.classList.remove('active');
});
document.getElementById(`beatport-${tabId}-content`).classList.add('active');
// Initialize rebuild slider if rebuild tab is selected
if (tabId === 'rebuild') {
initializeBeatportRebuildSlider();
loadBeatportTop10Lists();
loadBeatportTop10Releases();
initializeBeatportReleasesSlider();
initializeBeatportHypePicksSlider();
initializeBeatportChartsSlider();
initializeBeatportDJSlider();
}
});
});
// Logic for Homepage Genre Explorer card
const genreExplorerCard = document.querySelector('[data-action="show-genres"]');
if (genreExplorerCard) {
genreExplorerCard.addEventListener('click', () => {
console.log('๐ต Genre Explorer card clicked');
showBeatportSubView('genres');
loadBeatportGenres();
});
}
// Setup homepage chart handlers (following genre page pattern to prevent duplicates)
setupHomepageChartTypeHandlers();
// Load homepage chart collections automatically (disabled since Browse Charts tab is hidden)
// loadDJChartsInline();
// loadFeaturedChartsInline();
// Logic for Beatport breadcrumb back buttons
const beatportBackButtons = document.querySelectorAll('.breadcrumb-back');
beatportBackButtons.forEach(button => {
button.addEventListener('click', () => {
// Handle different back button types
if (button.id === 'genre-detail-back') {
showBeatportGenresView();
} else if (button.id === 'genre-charts-list-back') {
showBeatportGenreDetailViewFromBack();
} else {
showBeatportMainView();
}
});
});
// Logic for Beatport chart items
const beatportChartItems = document.querySelectorAll('.beatport-chart-item');
beatportChartItems.forEach(item => {
item.addEventListener('click', () => {
const chartType = item.dataset.chartType;
const chartId = item.dataset.chartId;
const chartName = item.dataset.chartName;
const chartEndpoint = item.dataset.chartEndpoint;
handleBeatportChartClick(chartType, chartId, chartName, chartEndpoint);
});
});
// Logic for Beatport genre items
const beatportGenreItems = document.querySelectorAll('.beatport-genre-item');
beatportGenreItems.forEach(item => {
item.addEventListener('click', () => {
const genreSlug = item.dataset.genreSlug;
const genreId = item.dataset.genreId;
handleBeatportGenreClick(genreSlug, genreId);
});
});
// Logic for Rebuild page Top 10 containers - Beatport Top 10
const beatportTop10Container = document.getElementById('beatport-top10-list');
if (beatportTop10Container) {
beatportTop10Container.addEventListener('click', () => {
console.log('๐ต Beatport Top 10 container clicked on rebuild page');
handleRebuildBeatportTop10Click();
});
}
// Logic for Rebuild page Top 10 containers - Hype Top 10
const beatportHype10Container = document.getElementById('beatport-hype10-list');
if (beatportHype10Container) {
beatportHype10Container.addEventListener('click', () => {
console.log('๐ฅ Hype Top 10 container clicked on rebuild page');
handleRebuildHypeTop10Click();
});
}
// Logic for Rebuild page Hero Slider - individual slide click handlers will be set up in populateBeatportSlider
// Container-level click handler removed to allow individual slide clicks like top 10 releases
// Logic for the Start Sync button
const startSyncBtn = document.getElementById('start-sync-btn');
if (startSyncBtn) {
startSyncBtn.addEventListener('click', startSequentialSync);
}
// Logic for the YouTube parse button
const youtubeParseBtn = document.getElementById('youtube-parse-btn');
if (youtubeParseBtn) {
youtubeParseBtn.addEventListener('click', parseYouTubePlaylist);
}
// Logic for YouTube URL input (Enter key support)
const youtubeUrlInput = document.getElementById('youtube-url-input');
if (youtubeUrlInput) {
youtubeUrlInput.addEventListener('keypress', (e) => {
if (e.key === 'Enter') {
parseYouTubePlaylist();
}
});
}
// Logic for Beatport Top 100 button
const beatportTop100Btn = document.getElementById('beatport-top100-btn');
if (beatportTop100Btn) {
beatportTop100Btn.addEventListener('click', handleBeatportTop100Click);
}
// Logic for Hype Top 100 button
const hypeTop100Btn = document.getElementById('hype-top100-btn');
if (hypeTop100Btn) {
hypeTop100Btn.addEventListener('click', handleHypeTop100Click);
}
// Initialize live log viewer
initializeLiveLogViewer();
}
// --- Event Handlers ---
// --- Find and REPLACE the existing handleDbUpdateButtonClick function ---
async function handleDbUpdateButtonClick() {
const button = document.getElementById('db-update-button');
const currentAction = button.textContent;
if (currentAction === 'Update Database') {
const refreshSelect = document.getElementById('db-refresh-type');
const isFullRefresh = refreshSelect.value === 'full';
if (isFullRefresh) {
// Replicates the QMessageBox confirmation from the GUI
const confirmed = await showConfirmDialog({ title: 'Full Refresh', message: 'This will clear and rebuild the database for the active server. It can take a long time.\n\nAre you sure you want to proceed?', confirmText: 'Proceed' });
if (!confirmed) return;
}
try {
button.disabled = true;
button.textContent = 'Starting...';
const response = await fetch('/api/database/update', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ full_refresh: isFullRefresh })
});
if (response.ok) {
showToast('Database update started!', 'success');
// Start polling immediately to get live status
checkAndUpdateDbProgress();
} else {
const errorData = await response.json();
showToast(`Error: ${errorData.error}`, 'error');
button.disabled = false;
button.textContent = 'Update Database';
}
} catch (error) {
showToast('Failed to start update process.', 'error');
button.disabled = false;
button.textContent = 'Update Database';
}
} else { // "Stop Update"
try {
const response = await fetch('/api/database/update/stop', { method: 'POST' });
if (response.ok) {
showToast('Stop request sent.', 'info');
} else {
showToast('Failed to send stop request.', 'error');
}
} catch (error) {
showToast('Error sending stop request.', 'error');
}
}
}
async function handleWishlistButtonClick() {
try {
const playlistId = 'wishlist';
console.log('๐ต [Wishlist Button] User clicked wishlist button - checking server state first');
// STEP 1: Always check server state first to detect any active wishlist processes
const response = await fetch('/api/active-processes');
if (!response.ok) {
throw new Error(`Failed to fetch active processes: ${response.status}`);
}
const data = await response.json();
const processes = data.active_processes || [];
const serverWishlistProcess = processes.find(p => p.playlist_id === playlistId);
// STEP 2: Handle active server process - show current state immediately
if (serverWishlistProcess) {
console.log('๐ฏ [Wishlist Button] Server has active wishlist process:', {
batch_id: serverWishlistProcess.batch_id,
phase: serverWishlistProcess.phase,
auto_initiated: serverWishlistProcess.auto_initiated,
should_show: serverWishlistProcess.should_show_modal
});
// Clear any user-closed state since user explicitly requested to see modal
WishlistModalState.clearUserClosed();
// Check if we need to create/sync the frontend modal
const clientWishlistProcess = activeDownloadProcesses[playlistId];
const needsRehydration = !clientWishlistProcess ||
clientWishlistProcess.batchId !== serverWishlistProcess.batch_id ||
!clientWishlistProcess.modalElement ||
!document.body.contains(clientWishlistProcess.modalElement);
if (needsRehydration) {
console.log('๐ [Wishlist Button] Frontend modal needs sync/creation');
await rehydrateModal(serverWishlistProcess, true); // user-requested = true
} else {
console.log('โ
[Wishlist Button] Frontend modal already synced, showing existing modal');
clientWishlistProcess.modalElement.style.display = 'flex';
WishlistModalState.setVisible();
}
return;
}
// STEP 3: No active server process - check wishlist count and create fresh modal
console.log('๐ญ [Wishlist Button] No active server process, checking wishlist content');
const countResponse = await fetch('/api/wishlist/count');
if (!countResponse.ok) {
throw new Error(`Failed to fetch wishlist count: ${countResponse.status}`);
}
const countData = await countResponse.json();
if (countData.count === 0) {
showToast('Wishlist is empty. No tracks to download.', 'info');
return;
}
// STEP 4: Open wishlist overview modal (NEW - category selection)
console.log(`๐ [Wishlist Button] Opening wishlist overview for ${countData.count} tracks`);
await openWishlistOverviewModal();
} catch (error) {
console.error('โ [Wishlist Button] Error handling wishlist button click:', error);
showToast(`Error opening wishlist: ${error.message}`, 'error');
}
}
async function cleanupWishlist(playlistId) {
try {
// Show information dialog
const confirmed = await showConfirmDialog({
title: 'Cleanup Wishlist',
message: 'This will check all wishlist tracks against your music library and automatically remove any tracks that already exist in your database.\n\nThis is a safe operation that only removes tracks you already have. Continue with cleanup?'
});
if (!confirmed) {
return;
}
// Disable the cleanup button during the operation
const cleanupBtn = document.getElementById(`cleanup-wishlist-btn-${playlistId}`);
if (cleanupBtn) {
cleanupBtn.disabled = true;
cleanupBtn.textContent = '๐งน Cleaning...';
}
const response = await fetch('/api/wishlist/cleanup', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
}
});
const result = await response.json();
if (result.success) {
const removedCount = result.removed_count || 0;
const processedCount = result.processed_count || 0;
if (removedCount > 0) {
showToast(`Wishlist cleanup completed: ${removedCount} tracks removed (${processedCount} checked)`, 'success');
// Refresh the modal content to show updated state
setTimeout(() => {
openDownloadMissingWishlistModal();
}, 500);
// Update the wishlist count in the main dashboard
await updateWishlistCount();
} else {
showToast(`Wishlist cleanup completed: No tracks to remove (${processedCount} checked)`, 'info');
}
} else {
showToast(`Error cleaning wishlist: ${result.error}`, 'error');
}
} catch (error) {
console.error('Error cleaning wishlist:', error);
showToast(`Error cleaning wishlist: ${error.message}`, 'error');
} finally {
// Re-enable the cleanup button
const cleanupBtn = document.getElementById(`cleanup-wishlist-btn-${playlistId}`);
if (cleanupBtn) {
cleanupBtn.disabled = false;
cleanupBtn.textContent = '๐งน Cleanup Wishlist';
}
}
}
async function clearWishlist(playlistId) {
try {
// Show confirmation dialog
const confirmed = await showConfirmDialog({
title: 'Clear Wishlist',
message: 'Are you sure you want to clear the entire wishlist?\n\nThis will permanently remove all failed tracks from the wishlist. This action cannot be undone.',
confirmText: 'Clear All',
destructive: true
});
if (!confirmed) {
return;
}
// Disable the clear button during the operation
const clearBtn = document.getElementById(`clear-wishlist-btn-${playlistId}`);
if (clearBtn) {
clearBtn.disabled = true;
clearBtn.textContent = 'Clearing...';
}
// Call the clear API endpoint
const response = await fetch('/api/wishlist/clear', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
}
});
const result = await response.json();
if (result.success) {
showToast('Wishlist cleared successfully', 'success');
// Close the modal since there are no more tracks
closeDownloadMissingModal(playlistId);
// Update the wishlist count in the main dashboard
await updateWishlistCount();
} else {
showToast(`Failed to clear wishlist: ${result.error || 'Unknown error'}`, 'error');
}
} catch (error) {
console.error('Error clearing wishlist:', error);
showToast(`Error clearing wishlist: ${error.message}`, 'error');
} finally {
// Re-enable the clear button
const clearBtn = document.getElementById(`clear-wishlist-btn-${playlistId}`);
if (clearBtn) {
clearBtn.disabled = false;
clearBtn.textContent = '๐๏ธ Clear Wishlist';
}
}
}
// ===============================
// BEATPORT CHARTS FUNCTIONALITY
// ===============================
function updateBeatportClearButtonState() {
const clearBtn = document.getElementById('beatport-clear-btn');
if (!clearBtn) return;
// Check if any Beatport cards are in active states
const activeCharts = Object.values(beatportChartStates).filter(state =>
state.phase === 'discovering' || state.phase === 'syncing' || state.phase === 'downloading'
);
const hasActiveCharts = activeCharts.length > 0;
const hasAnyCharts = Object.keys(beatportChartStates).length > 0;
if (!hasAnyCharts) {
// No charts at all
clearBtn.disabled = true;
clearBtn.textContent = '๐๏ธ Clear';
clearBtn.style.opacity = '0.5';
clearBtn.style.cursor = 'not-allowed';
clearBtn.title = 'No Beatport charts to clear';
} else if (hasActiveCharts) {
// Has charts but some are active
clearBtn.disabled = true;
clearBtn.textContent = '๐ซ Clear Blocked';
clearBtn.style.opacity = '0.6';
clearBtn.style.cursor = 'not-allowed';
const activeNames = activeCharts.map(state => state.chart?.name || 'Unknown').join(', ');
clearBtn.title = `Cannot clear: ${activeCharts.length} chart(s) are currently active: ${activeNames}`;
} else {
// Has charts and none are active
clearBtn.disabled = false;
clearBtn.textContent = '๐๏ธ Clear';
clearBtn.style.opacity = '1';
clearBtn.style.cursor = 'pointer';
clearBtn.title = 'Clear all Beatport charts';
}
}
async function clearBeatportPlaylists() {
const container = document.getElementById('beatport-playlist-container');
const clearBtn = document.getElementById('beatport-clear-btn');
if (Object.keys(beatportChartStates).length === 0) {
showToast('No Beatport playlists to clear', 'info');
return;
}
// Check if any Beatport cards are in active states (discovering, syncing, or downloading)
const activeCharts = Object.values(beatportChartStates).filter(state =>
state.phase === 'discovering' || state.phase === 'syncing' || state.phase === 'downloading'
);
if (activeCharts.length > 0) {
const activeNames = activeCharts.map(state => state.chart?.name || 'Unknown').join(', ');
showToast(`Cannot clear: ${activeCharts.length} chart(s) are currently discovering, syncing, or downloading: ${activeNames}`, 'warning');
return;
}
// Show loading state
clearBtn.disabled = true;
clearBtn.textContent = '๐๏ธ Clearing...';
try {
// Clear all Beatport chart states
Object.keys(beatportChartStates).forEach(chartHash => {
// Close any open modals for this chart
const modal = document.getElementById(`youtube-discovery-modal-${chartHash}`);
if (modal) {
modal.remove();
}
// Remove from YouTube states (since Beatport reuses that infrastructure)
if (youtubePlaylistStates[chartHash]) {
// Clean up any active download processes for this Beatport chart
const ytState = youtubePlaylistStates[chartHash];
if (ytState.is_beatport_playlist && ytState.convertedSpotifyPlaylistId) {
const downloadProcess = activeDownloadProcesses[ytState.convertedSpotifyPlaylistId];
if (downloadProcess) {
console.log(`๐๏ธ Cleaning up download process for Beatport chart: ${chartHash}`);
if (downloadProcess.modalElement) {
downloadProcess.modalElement.remove();
}
delete activeDownloadProcesses[ytState.convertedSpotifyPlaylistId];
}
}
delete youtubePlaylistStates[chartHash];
}
});
// Clear Beatport states
const chartHashesToClear = Object.keys(beatportChartStates);
beatportChartStates = {};
// Clear backend state for all charts
for (const chartHash of chartHashesToClear) {
try {
await fetch(`/api/beatport/charts/delete/${chartHash}`, {
method: 'DELETE'
});
console.log(`๐๏ธ Deleted backend state for Beatport chart: ${chartHash}`);
} catch (error) {
console.warn(`โ ๏ธ Error deleting backend state for chart ${chartHash}:`, error);
}
}
// Reset container to placeholder
container.innerHTML = `
Your created Beatport playlists will appear here.
`;
console.log(`๐๏ธ Cleared ${chartHashesToClear.length} Beatport charts from frontend and backend`);
showToast('Cleared all Beatport playlists', 'success');
// Update clear button state after clearing all charts
updateBeatportClearButtonState();
} catch (error) {
console.error('Error clearing Beatport playlists:', error);
showToast(`Error clearing playlists: ${error.message}`, 'error');
} finally {
clearBtn.disabled = false;
clearBtn.textContent = '๐๏ธ Clear';
}
}
function handleBeatportCategoryClick(category) {
console.log(`๐ต Beatport category clicked: ${category}`);
// Only handle genres category now - homepage has direct chart buttons
switch (category) {
case 'genres':
showBeatportSubView('genres');
loadBeatportGenres(); // Load genres dynamically
break;
default:
showToast(`Unknown category: ${category}`, 'error');
}
}
async function loadBeatportGenres() {
console.log('๐ Loading Beatport genres dynamically...');
const genreGrid = document.querySelector('#beatport-genres-view .beatport-genre-grid');
if (!genreGrid) {
console.error('โ Could not find genre grid element');
return;
}
// Show loading state
genreGrid.innerHTML = `
๐ Discovering current Beatport genres...
`;
try {
// First, fetch genres quickly without images
console.log('๐ Fetching genres without images for fast loading...');
const fastResponse = await fetch('/api/beatport/genres');
if (!fastResponse.ok) {
throw new Error(`API returned ${fastResponse.status}: ${fastResponse.statusText}`);
}
const fastData = await fastResponse.json();
const genres = fastData.genres || [];
if (genres.length === 0) {
genreGrid.innerHTML = `
โ ๏ธ No genres available
๐ Retry
`;
return;
}
// Generate genre cards dynamically (without images first)
const genreCardsHTML = genres.map(genre => `
๐ต
${genre.name}
Top 100
`).join('');
genreGrid.innerHTML = genreCardsHTML;
// Add click handlers to dynamically created genre items
const genreItems = genreGrid.querySelectorAll('.beatport-genre-item');
genreItems.forEach(item => {
item.addEventListener('click', () => {
const genreSlug = item.dataset.genreSlug;
const genreId = item.dataset.genreId;
const genreName = item.dataset.genreName;
handleBeatportGenreClick(genreSlug, genreId, genreName);
});
});
console.log(`โ
Loaded ${genres.length} Beatport genres dynamically (fast mode)`);
showToast(`Loaded ${genres.length} current Beatport genres`, 'success');
// Now fetch images progressively in the background if there are many genres
if (genres.length > 10) {
console.log('๐ผ๏ธ Loading genre images progressively...');
loadGenreImagesProgressively(genres);
}
} catch (error) {
console.error('โ Error loading Beatport genres:', error);
genreGrid.innerHTML = `
โ Failed to load genres: ${error.message}
๐ Retry
`;
showToast(`Error loading Beatport genres: ${error.message}`, 'error');
}
}
async function loadGenreImagesProgressively(genres) {
// Load genre images with 2 concurrent workers for faster loading
const imageQueue = [...genres]; // Create a copy for processing
let imagesLoaded = 0;
const maxWorkers = 2;
console.log(`๐ผ๏ธ Starting progressive image loading with ${maxWorkers} workers for ${imageQueue.length} genres`);
// Function to process a single image
async function processImage(genre) {
try {
// Fetch individual genre image from backend
const response = await fetch(`/api/beatport/genre-image/${genre.slug}/${genre.id}`);
if (response.ok) {
const data = await response.json();
if (data.success && data.image_url) {
// Find the genre item in the DOM
const genreItem = document.querySelector(
`[data-genre-slug="${genre.slug}"][data-genre-id="${genre.id}"]`
);
if (genreItem) {
const iconElement = genreItem.querySelector('.genre-icon');
if (iconElement) {
// Create new image element with smooth transition
const imageDiv = document.createElement('div');
imageDiv.className = 'genre-image';
imageDiv.style.backgroundImage = `url('${data.image_url}')`;
imageDiv.style.opacity = '0';
imageDiv.style.transition = 'opacity 0.3s ease';
// Replace icon with image
iconElement.replaceWith(imageDiv);
// Trigger fade-in animation
setTimeout(() => {
imageDiv.style.opacity = '1';
}, 50);
imagesLoaded++;
console.log(`๐ผ๏ธ [${imagesLoaded}/${imageQueue.length}] Loaded image for ${genre.name}`);
}
}
}
}
} catch (error) {
console.warn(`โ ๏ธ Failed to load image for ${genre.name}:`, error);
}
}
// Worker function that processes images from the queue
async function imageWorker(workerId) {
while (imageQueue.length > 0) {
const genre = imageQueue.shift(); // Take next image from queue
if (genre) {
await processImage(genre);
// Small delay between requests to be respectful (500ms per worker = ~2 images per second total)
await new Promise(resolve => setTimeout(resolve, 500));
}
}
console.log(`โ
Worker ${workerId} finished`);
}
// Start the workers
const workers = [];
for (let i = 0; i < maxWorkers; i++) {
workers.push(imageWorker(i + 1));
}
// Wait for all workers to complete
await Promise.all(workers);
console.log(`โ
Progressive image loading complete: ${imagesLoaded}/${genres.length} images loaded`);
}
function setupHomepageChartTypeHandlers() {
console.log('๐ง Setting up homepage chart type handlers...');
// Select all homepage chart type cards (following genre page pattern)
const chartTypeCards = document.querySelectorAll('.homepage-main-charts-section .genre-chart-type-card[data-chart-type], .homepage-releases-section .genre-chart-type-card[data-chart-type], .homepage-hype-section .genre-chart-type-card[data-chart-type]');
chartTypeCards.forEach(card => {
// Remove existing listeners by cloning (following genre page pattern)
card.replaceWith(card.cloneNode(true));
});
// Re-select after cloning to ensure clean event listeners (following genre page pattern)
const newChartTypeCards = document.querySelectorAll('.homepage-main-charts-section .genre-chart-type-card[data-chart-type], .homepage-releases-section .genre-chart-type-card[data-chart-type], .homepage-hype-section .genre-chart-type-card[data-chart-type]');
newChartTypeCards.forEach(card => {
card.addEventListener('click', () => {
const chartType = card.dataset.chartType;
const chartEndpoint = card.dataset.chartEndpoint;
const chartName = card.querySelector('.chart-type-info h3').textContent;
console.log(`๐ฅ Homepage chart clicked: ${chartName} (${chartType})`);
handleHomepageChartTypeClick(chartType, chartEndpoint, chartName);
});
});
console.log(`โ
Setup ${newChartTypeCards.length} homepage chart handlers`);
}
async function handleHomepageChartTypeClick(chartType, chartEndpoint, chartName) {
console.log(`๐ฅ Homepage chart type clicked: ${chartType} (${chartName})`);
// Map chart types to API endpoints and create descriptive names (following genre page pattern)
const chartTypeMap = {
'top-10': {
endpoint: `/api/beatport/top-100`, // Use top-100 endpoint and limit to 10
name: `Beatport Top 10`,
limit: 10
},
'top-100': {
endpoint: `/api/beatport/top-100`,
name: `Beatport Top 100`,
limit: 100
},
'releases-top-10': {
endpoint: `/api/beatport/homepage/top-10-releases`, // Working route
name: `Top 10 Releases`,
limit: 10
},
'releases-top-100': {
endpoint: `/api/beatport/top-100-releases`,
name: `Top 100 Releases`,
limit: 100
},
'latest-releases': {
endpoint: `/api/beatport/homepage/new-releases`, // Use new-releases as fallback for now
name: `Latest Releases`,
limit: 50
},
'hype-top-10': {
endpoint: `/api/beatport/hype-top-100`, // Use hype-100 endpoint and limit to 10
name: `Hype Top 10`,
limit: 10
},
'hype-top-100': {
endpoint: `/api/beatport/hype-top-100`,
name: `Hype Top 100`,
limit: 100
},
'hype-picks': {
endpoint: `/api/beatport/homepage/hype-picks`, // Working route
name: `Hype Picks`,
limit: 50
}
};
const chartConfig = chartTypeMap[chartType];
if (!chartConfig) {
console.error(`โ Unknown homepage chart type: ${chartType}`);
showToast(`Unknown chart type: ${chartType}`, 'error');
return;
}
try {
// Check if we already have a card for this specific chart type (following genre page pattern)
const existingState = Object.values(beatportChartStates).find(state =>
state.chart && state.chart.name === chartConfig.name && state.chart.chart_type === `homepage_${chartType}`
);
if (existingState) {
console.log(`๐ Found existing Beatport card for ${chartConfig.name}, opening existing modal`);
handleBeatportCardClick(existingState.chart.hash);
return;
}
// Create a chart hash for state management (following genre page pattern)
const chartHash = `homepage_${chartType}_${Date.now()}`;
showToast(`Loading ${chartConfig.name}...`, 'info');
showLoadingOverlay(`Loading ${chartConfig.name}...`);
// Fetch tracks from the specific endpoint (following genre page pattern)
const response = await fetch(`${chartConfig.endpoint}?limit=${chartConfig.limit}`);
if (!response.ok) {
throw new Error(`Failed to fetch ${chartConfig.name}: ${response.status}`);
}
const data = await response.json();
if (!data.success || !data.tracks || data.tracks.length === 0) {
throw new Error(`No tracks found in ${chartConfig.name}`);
}
// Create chart data object for playlist card (following genre page pattern)
const chartData = {
hash: chartHash,
name: chartConfig.name,
chart_type: `homepage_${chartType}`,
track_count: data.tracks.length,
tracks: data.tracks.map(track => ({
name: track.title || 'Unknown Title',
artists: [track.artist || 'Unknown Artist'],
album: chartConfig.name,
duration_ms: 0,
external_urls: { beatport: track.url || '' },
source: 'beatport'
}))
};
// Add card to container (in background, like YouTube does - following genre page pattern)
console.log(`๐ Creating Beatport playlist card for: ${chartConfig.name}`);
addBeatportCardToContainer(chartData);
// Automatically open discovery modal (like when you click a YouTube or Tidal card in fresh state - following genre page pattern)
hideLoadingOverlay();
handleBeatportCardClick(chartHash);
console.log(`โ
Created Beatport card and opened discovery modal for ${chartConfig.name}`);
} catch (error) {
console.error(`โ Error loading ${chartConfig.name}:`, error);
hideLoadingOverlay();
showToast(`Error loading ${chartConfig.name}: ${error.message}`, 'error');
}
}
async function openBeatportDiscoveryModal(chartHash, chartData) {
console.log(`๐ต Opening Beatport discovery modal (reusing YouTube modal): ${chartData.name}`);
// Create YouTube-style state entry for this Beatport chart
const beatportState = {
phase: 'fresh',
playlist: {
name: chartData.name,
tracks: chartData.tracks,
description: `${chartData.track_count} tracks from ${chartData.name}`,
source: 'beatport'
},
is_beatport_playlist: true,
beatport_chart_type: chartData.chart_type,
beatport_chart_hash: chartHash // Link to Beatport card state
};
// Store in YouTube playlist states (reusing the infrastructure)
youtubePlaylistStates[chartHash] = beatportState;
// Start discovery automatically (like Tidal does)
try {
console.log(`๐ Starting Beatport discovery for: ${chartData.name}`);
// Update card phase to discovering immediately
updateBeatportCardPhase(chartHash, 'discovering');
// Call the discovery start endpoint with chart data
const response = await fetch(`/api/beatport/discovery/start/${chartHash}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
chart_data: chartData
})
});
const result = await response.json();
if (result.success) {
// Update state to discovering
youtubePlaylistStates[chartHash].phase = 'discovering';
// Start polling for progress
startBeatportDiscoveryPolling(chartHash);
console.log(`โ
Started Beatport discovery for: ${chartData.name}`);
} else {
console.error('โ Error starting Beatport discovery:', result.error);
showToast(`Error starting discovery: ${result.error}`, 'error');
// Revert card phase on error
updateBeatportCardPhase(chartHash, 'fresh');
}
} catch (error) {
console.error('โ Error starting Beatport discovery:', error);
showToast(`Error starting discovery: ${error.message}`, 'error');
// Revert card phase on error
updateBeatportCardPhase(chartHash, 'fresh');
}
// Open the existing YouTube discovery modal infrastructure
openYouTubeDiscoveryModal(chartHash);
console.log(`โ
Beatport discovery modal opened for ${chartData.name} with ${chartData.tracks.length} tracks`);
}
function startBeatportDiscoveryPolling(urlHash) {
console.log(`๐ Starting Beatport discovery polling for: ${urlHash}`);
// Stop any existing polling (reuse YouTube polling infrastructure)
if (activeYouTubePollers[urlHash]) {
clearInterval(activeYouTubePollers[urlHash]);
}
// Phase 5: Subscribe via WebSocket
if (socketConnected) {
socket.emit('discovery:subscribe', { ids: [urlHash] });
_discoveryProgressCallbacks[urlHash] = (data) => {
if (data.error) {
if (activeYouTubePollers[urlHash]) { clearInterval(activeYouTubePollers[urlHash]); delete activeYouTubePollers[urlHash]; }
socket.emit('discovery:unsubscribe', { ids: [urlHash] }); delete _discoveryProgressCallbacks[urlHash];
return;
}
if (youtubePlaylistStates[urlHash]) {
const transformed = {
progress: data.progress || 0, spotify_matches: data.spotify_matches || 0, spotify_total: data.spotify_total || 0,
results: (data.results || []).map((r, i) => ({
index: r.index !== undefined ? r.index : i,
yt_track: r.beatport_track ? r.beatport_track.title : 'Unknown',
yt_artist: r.beatport_track ? r.beatport_track.artist : 'Unknown',
status: (r.status === 'found' || r.status === 'โ
Found' || r.status_class === 'found') ? 'โ
Found' : (r.status === 'error' ? 'โ Error' : 'โ Not Found'),
status_class: r.status_class || ((r.status === 'found' || r.status === 'โ
Found') ? 'found' : (r.status === 'error' ? 'error' : 'not-found')),
spotify_track: r.spotify_data ? r.spotify_data.name : (r.spotify_track || '-'),
spotify_artist: r.spotify_data && r.spotify_data.artists ? r.spotify_data.artists.map(a => a.name || a).join(', ') : (r.spotify_artist || '-'),
spotify_album: r.spotify_data ? (typeof r.spotify_data.album === 'object' ? r.spotify_data.album.name : r.spotify_data.album) : (r.spotify_album || '-'),
spotify_data: r.spotify_data, spotify_id: r.spotify_id, manual_match: r.manual_match
}))
};
const st = youtubePlaylistStates[urlHash];
st.discovery_progress = data.progress; st.discoveryProgress = data.progress;
st.spotify_matches = data.spotify_matches; st.spotifyMatches = data.spotify_matches;
st.discovery_results = data.results; st.discoveryResults = transformed.results;
st.phase = data.phase || 'discovering';
const chartHash = st.beatport_chart_hash || urlHash;
updateBeatportCardPhase(chartHash, data.phase || 'discovering');
updateBeatportCardProgress(chartHash, { spotify_total: data.spotify_total || 0, spotify_matches: data.spotify_matches || 0, failed: (data.spotify_total || 0) - (data.spotify_matches || 0) });
if (beatportChartStates[chartHash]) beatportChartStates[chartHash].phase = data.phase || 'discovering';
updateYouTubeDiscoveryModal(urlHash, transformed);
}
if (data.phase === 'discovered' || data.phase === 'error') {
if (activeYouTubePollers[urlHash]) { clearInterval(activeYouTubePollers[urlHash]); delete activeYouTubePollers[urlHash]; }
socket.emit('discovery:unsubscribe', { ids: [urlHash] }); delete _discoveryProgressCallbacks[urlHash];
}
};
}
const pollInterval = setInterval(async () => {
if (socketConnected) return; // Phase 5: WS handles updates
try {
const response = await fetch(`/api/beatport/discovery/status/${urlHash}`);
const status = await response.json();
if (status.error) {
console.error('โ Error polling Beatport discovery status:', status.error);
clearInterval(pollInterval);
delete activeYouTubePollers[urlHash];
return;
}
// Update state and modal (reuse YouTube infrastructure like Tidal)
if (youtubePlaylistStates[urlHash]) {
// Transform Beatport results to YouTube modal format (like Tidal does)
const transformedStatus = {
progress: status.progress || 0,
spotify_matches: status.spotify_matches || 0,
spotify_total: status.spotify_total || 0,
results: (status.results || []).map((result, index) => ({
index: result.index !== undefined ? result.index : index,
yt_track: result.beatport_track ? result.beatport_track.title : 'Unknown',
yt_artist: result.beatport_track ? result.beatport_track.artist : 'Unknown',
status: result.status === 'found' || result.status === 'โ
Found' || result.status_class === 'found' ? 'โ
Found' : (result.status === 'error' ? 'โ Error' : 'โ Not Found'),
status_class: result.status_class || (result.status === 'found' || result.status === 'โ
Found' ? 'found' : (result.status === 'error' ? 'error' : 'not-found')),
spotify_track: result.spotify_data ? result.spotify_data.name : (result.spotify_track || '-'),
spotify_artist: result.spotify_data && result.spotify_data.artists ?
result.spotify_data.artists.map(a => a.name || a).join(', ') : (result.spotify_artist || '-'),
spotify_album: result.spotify_data ? (typeof result.spotify_data.album === 'object' ? result.spotify_data.album.name : result.spotify_data.album) : (result.spotify_album || '-'),
spotify_data: result.spotify_data, // Pass through
spotify_id: result.spotify_id, // Pass through
manual_match: result.manual_match // Pass through
}))
};
// Update state with both backend and frontend formats (like Tidal)
const state = youtubePlaylistStates[urlHash];
state.discovery_progress = status.progress; // Backend format
state.discoveryProgress = status.progress; // Frontend format - for modal progress display
state.spotify_matches = status.spotify_matches; // Backend format
state.spotifyMatches = status.spotify_matches; // Frontend format - for button logic
state.discovery_results = status.results; // Backend format
state.discoveryResults = transformedStatus.results; // Frontend format - for button logic
state.phase = status.phase || 'discovering';
// Update Beatport card phase and progress
const chartHash = state.beatport_chart_hash || urlHash;
updateBeatportCardPhase(chartHash, status.phase || 'discovering');
updateBeatportCardProgress(chartHash, {
spotify_total: status.spotify_total || 0,
spotify_matches: status.spotify_matches || 0,
failed: (status.spotify_total || 0) - (status.spotify_matches || 0)
});
// Sync with backend Beatport chart state
if (beatportChartStates[chartHash]) {
beatportChartStates[chartHash].phase = status.phase || 'discovering';
}
// Update modal display with transformed data
updateYouTubeDiscoveryModal(urlHash, transformedStatus);
}
// Stop polling when discovery is complete
if (status.phase === 'discovered' || status.phase === 'error') {
console.log(`โ
Beatport discovery polling complete for: ${urlHash}`);
clearInterval(pollInterval);
delete activeYouTubePollers[urlHash];
}
} catch (error) {
console.error('โ Error polling Beatport discovery:', error);
clearInterval(pollInterval);
delete activeYouTubePollers[urlHash];
}
}, 2000); // Poll every 2 seconds like Tidal
// Store the interval so we can clean it up later
activeYouTubePollers[urlHash] = pollInterval;
}
function showBeatportSubView(viewType) {
// Hide main category view
const mainView = document.getElementById('beatport-main-view');
if (mainView) {
mainView.classList.remove('active');
}
// Hide all sub-views
document.querySelectorAll('.beatport-sub-view').forEach(view => {
view.classList.remove('active');
});
// Show the requested sub-view
const targetView = document.getElementById(`beatport-${viewType}-view`);
if (targetView) {
targetView.classList.add('active');
console.log(`๐ต Showing Beatport ${viewType} view`);
} else {
console.error(`๐ต Could not find view: beatport-${viewType}-view`);
}
}
function showBeatportMainView() {
// Hide all sub-views
document.querySelectorAll('.beatport-sub-view').forEach(view => {
view.classList.remove('active');
});
// Show main category view
const mainView = document.getElementById('beatport-main-view');
if (mainView) {
mainView.classList.add('active');
console.log('๐ต Showing Beatport main view');
}
}
// ===============================
// REBUILD PAGE TOP 10 FUNCTIONALITY
// ===============================
// Global variable to store rebuild page track data for reuse
let rebuildPageTrackData = {
beatport_top10: null,
hype_top10: null
// hero_slider removed - now uses individual slide click handlers
};
async function handleRebuildBeatportTop10Click() {
console.log('๐ต Handling Beatport Top 10 click on rebuild page');
// Use the existing chart creation pattern from Browse Charts EXACTLY
await handleRebuildChartClick('beatport_top10', 'Beatport Top 10', 'rebuild_beatport_top10');
}
async function handleRebuildHypeTop10Click() {
console.log('๐ฅ Handling Hype Top 10 click on rebuild page');
// Use the existing chart creation pattern from Browse Charts EXACTLY
await handleRebuildChartClick('hype_top10', 'Hype Top 10', 'rebuild_hype_top10');
}
// Hero slider now uses individual slide click handlers instead of container-level clicking
// The old handleRebuildHeroSliderClick function has been removed in favor of individual release discovery
async function handleRebuildChartClick(trackDataKey, chartName, chartType) {
try {
// Create chart hash (following Browse Charts pattern)
const chartHash = `${chartType}_${Date.now()}`;
// Check if we already have an existing state (following Browse Charts pattern)
const existingState = Object.values(beatportChartStates).find(state =>
state.chart && state.chart.name === chartName && state.chart.chart_type === chartType
);
if (existingState) {
console.log(`๐ Found existing ${chartName} card, opening existing modal`);
// Use existing card click handler (following Browse Charts pattern)
handleBeatportCardClick(existingState.chart.hash);
return;
}
// Get track data from rebuild page data (instead of API scraping)
const trackData = await getRebuildPageTrackData(trackDataKey);
if (!trackData || trackData.length === 0) {
throw new Error(`No track data found for ${chartName}`);
}
// Transform rebuild data to Browse Charts format EXACTLY
const chartData = {
hash: chartHash,
name: chartName,
chart_type: chartType,
track_count: trackData.length,
tracks: trackData.map(track => ({
name: cleanTrackText(track.title || 'Unknown Title'),
artists: [cleanTrackText(track.artist || 'Unknown Artist')],
album: chartName,
duration_ms: 0,
external_urls: { beatport: track.url || '' },
source: 'beatport'
}))
};
// Follow Browse Charts pattern EXACTLY:
// 1. Add card to container (creates playlist card)
console.log(`๐ Creating Beatport playlist card for: ${chartData.name}`);
addBeatportCardToContainer(chartData);
// 2. Automatically open discovery modal (like when you click a card in fresh state)
handleBeatportCardClick(chartHash);
console.log(`โ
Created ${chartName} card and opened discovery modal`);
} catch (error) {
console.error(`โ Error handling ${chartName} click:`, error);
showToast(`Error loading ${chartName}: ${error.message}`, 'error');
}
}
async function getRebuildPageTrackData(trackDataKey) {
// First check if we have cached data from when the rebuild page was loaded
if (rebuildPageTrackData[trackDataKey]) {
console.log(`๐ฆ Using cached ${trackDataKey} data`);
return rebuildPageTrackData[trackDataKey];
}
// If no cached data, extract from DOM (fallback)
console.log(`๐ Extracting ${trackDataKey} data from rebuild page DOM`);
let containerSelector, cardSelector;
if (trackDataKey === 'beatport_top10') {
containerSelector = '#beatport-top10-list';
cardSelector = '.beatport-top10-card[data-url]';
} else if (trackDataKey === 'hype_top10') {
containerSelector = '#beatport-hype10-list';
cardSelector = '.beatport-hype10-card[data-url]';
} else {
throw new Error(`Unknown track data key: ${trackDataKey}`);
}
const container = document.querySelector(containerSelector);
if (!container) {
throw new Error(`Container ${containerSelector} not found`);
}
const trackCards = container.querySelectorAll(cardSelector);
if (trackCards.length === 0) {
throw new Error(`No track cards found in ${containerSelector}`);
}
// Extract track data from DOM cards
const tracks = Array.from(trackCards).map(card => {
const title = card.querySelector('.beatport-top10-card-title, .beatport-hype10-card-title')?.textContent?.trim() || 'Unknown Title';
const artist = card.querySelector('.beatport-top10-card-artist, .beatport-hype10-card-artist')?.textContent?.trim() || 'Unknown Artist';
const label = card.querySelector('.beatport-top10-card-label, .beatport-hype10-card-label')?.textContent?.trim() || 'Unknown Label';
const url = card.getAttribute('data-url') || '';
const rank = card.querySelector('.beatport-top10-card-rank, .beatport-hype10-card-rank')?.textContent?.trim() || '';
return {
title: title,
artist: artist,
label: label,
url: url,
rank: rank
};
});
console.log(`๐ Extracted ${tracks.length} tracks from ${containerSelector}`);
// Cache for future use
rebuildPageTrackData[trackDataKey] = tracks;
return tracks;
}
// getHeroSliderTrackData function removed - hero slider now uses individual slide click handlers
// Each slide will create its own discovery modal using handleBeatportReleaseCardClick
// Hook into the loadBeatportTop10Lists function to cache track data
const originalLoadBeatportTop10Lists = window.loadBeatportTop10Lists;
if (originalLoadBeatportTop10Lists) {
window.loadBeatportTop10Lists = async function () {
const result = await originalLoadBeatportTop10Lists.apply(this, arguments);
// If the load was successful, we can potentially cache the track data
// But for now, we'll rely on DOM extraction as it's more reliable
return result;
};
}
// ===============================
// BEATPORT CHART FUNCTIONALITY
// ===============================
function createBeatportCard(chartData) {
const state = beatportChartStates[chartData.hash];
const phase = state ? state.phase : 'fresh';
let buttonText = getActionButtonText(phase);
let phaseText = getPhaseText(phase);
let phaseColor = getPhaseColor(phase);
return `
๐ง
${escapeHtml(chartData.name)}
${chartData.track_count} tracks
${phaseText}
${buttonText}
`;
}
function addBeatportCardToContainer(chartData) {
const container = document.getElementById('beatport-playlist-container');
// Remove placeholder if it exists
const placeholder = container.querySelector('.playlist-placeholder');
if (placeholder) {
placeholder.remove();
}
// Check if card already exists
const existingCard = document.getElementById(`beatport-card-${chartData.hash}`);
if (existingCard) {
console.log(`Card already exists for ${chartData.name}, updating instead`);
return;
}
// Create and add the card
const cardHtml = createBeatportCard(chartData);
container.insertAdjacentHTML('beforeend', cardHtml);
// Initialize state
beatportChartStates[chartData.hash] = {
phase: 'fresh',
chart: chartData,
cardElement: document.getElementById(`beatport-card-${chartData.hash}`)
};
// Add click handler
const card = document.getElementById(`beatport-card-${chartData.hash}`);
if (card) {
card.addEventListener('click', async () => await handleBeatportCardClick(chartData.hash));
}
console.log(`๐ Created Beatport card: ${chartData.name}`);
// Auto-mirror this Beatport chart
if (chartData.tracks && chartData.tracks.length > 0) {
mirrorPlaylist('beatport', chartData.hash, chartData.name, chartData.tracks.map(t => ({
track_name: t.name || t.title || '', artist_name: Array.isArray(t.artists) ? t.artists[0] : (t.artist || ''),
album_name: t.album || '', duration_ms: t.duration_ms || 0,
source_track_id: t.id || '', image_url: t.image_url || null
})));
}
// Update clear button state after creating card
updateBeatportClearButtonState();
}
async function handleBeatportCardClick(chartHash) {
const state = beatportChartStates[chartHash];
if (!state) {
console.error(`โ [Card Click] No state found for Beatport chart: ${chartHash}`);
showToast('Chart state not found - try refreshing the page', 'error');
return;
}
if (!state.chart) {
console.error(`โ [Card Click] No chart data found for Beatport chart: ${chartHash}`);
showToast('Chart data missing - try refreshing the page', 'error');
return;
}
console.log(`๐ง [Card Click] Beatport card clicked: ${chartHash}, Phase: ${state.phase}`);
if (state.phase === 'fresh') {
// Open discovery modal and start discovery
openBeatportDiscoveryModal(chartHash, state.chart);
} else if (state.phase === 'discovering' || state.phase === 'discovered' || state.phase === 'syncing' || state.phase === 'sync_complete') {
// Reopen existing modal with preserved discovery results
console.log(`๐ง [Card Click] Opening Beatport discovery modal for ${state.phase} phase`);
// Check if we have the required state data
const ytState = youtubePlaylistStates[chartHash];
if (!ytState || !ytState.playlist) {
console.log(`๐ [Card Click] Missing playlist data for ${state.phase} phase, fetching from backend...`);
try {
// Fetch the full state from backend
const stateResponse = await fetch(`/api/beatport/charts/status/${chartHash}`);
if (stateResponse.ok) {
const fullState = await stateResponse.json();
// Restore the missing playlist data
if (fullState.chart_data) {
if (!youtubePlaylistStates[chartHash]) {
youtubePlaylistStates[chartHash] = {};
}
youtubePlaylistStates[chartHash].playlist = fullState.chart_data;
youtubePlaylistStates[chartHash].is_beatport_playlist = true;
youtubePlaylistStates[chartHash].beatport_chart_hash = chartHash;
// Also restore discovery results if available
if (fullState.discovery_results) {
youtubePlaylistStates[chartHash].discovery_results = fullState.discovery_results;
console.log(`๐ [Hydration] Restored ${fullState.discovery_results.length} discovery results`);
console.log(`๐ [Hydration] First result:`, fullState.discovery_results[0]);
}
// Restore discovery progress state
if (fullState.discovery_progress !== undefined) {
youtubePlaylistStates[chartHash].discovery_progress = fullState.discovery_progress;
}
if (fullState.spotify_matches !== undefined) {
youtubePlaylistStates[chartHash].spotify_matches = fullState.spotify_matches;
console.log(`๐ [Hydration] Restored spotify_matches: ${fullState.spotify_matches}`);
}
if (fullState.spotify_total !== undefined) {
youtubePlaylistStates[chartHash].spotify_total = fullState.spotify_total;
}
console.log(`โ
[Card Click] Restored playlist data for ${state.phase} phase`);
}
} else {
console.error(`โ [Card Click] Failed to fetch state for chart: ${chartHash}`);
showToast('Error loading chart data', 'error');
return;
}
} catch (error) {
console.error(`โ [Card Click] Error fetching chart state:`, error);
showToast('Error loading chart data', 'error');
return;
}
}
openYouTubeDiscoveryModal(chartHash);
// If still in discovering phase, start polling for live updates
if (state.phase === 'discovering') {
console.log(`๐ [Card Click] Starting discovery polling for ${state.phase} phase`);
// Let the polling handle all modal updates to avoid data structure mismatches
console.log(`๐ [Card Click] Starting polling - it will update modal with current progress`);
startBeatportDiscoveryPolling(chartHash);
}
} else if (state.phase === 'downloading' || state.phase === 'download_complete') {
// Open download modal if we have the converted playlist ID (following YouTube/Tidal pattern)
const ytState = youtubePlaylistStates[chartHash];
if (ytState && ytState.is_beatport_playlist && ytState.convertedSpotifyPlaylistId) {
console.log(`๐ฅ [Card Click] Opening download modal for Beatport chart: ${ytState.playlist.name} (phase: ${state.phase})`);
// Check if modal already exists, if not create it (like Tidal implementation)
if (activeDownloadProcesses[ytState.convertedSpotifyPlaylistId]) {
const process = activeDownloadProcesses[ytState.convertedSpotifyPlaylistId];
if (process.modalElement) {
console.log(`๐ฑ [Card Click] Showing existing download modal for ${state.phase} phase`);
process.modalElement.style.display = 'flex';
} else {
console.warn(`โ ๏ธ [Card Click] Download process exists but modal element missing - rehydrating`);
await rehydrateBeatportDownloadModal(chartHash, ytState);
}
} else {
// Need to create the download modal - fetch the discovery results if needed
console.log(`๐ง [Card Click] Rehydrating Beatport download modal for ${state.phase} phase`);
await rehydrateBeatportDownloadModal(chartHash, ytState);
}
} else {
console.error('โ [Card Click] No converted Spotify playlist ID found for Beatport download modal');
console.log('๐ [Card Click] Available state data:', Object.keys(ytState || {}));
// Fallback: try to open discovery modal if we have discovery results
if (ytState && ytState.discovery_results && ytState.discovery_results.length > 0) {
console.log(`๐ [Card Click] Fallback: Opening discovery modal with ${ytState.discovery_results.length} results`);
openYouTubeDiscoveryModal(chartHash);
} else {
showToast('Unable to open download modal - missing playlist data', 'error');
}
}
}
}
async function rehydrateBeatportDownloadModal(chartHash, ytState) {
try {
console.log(`๐ง [Rehydration] Attempting fallback rehydration for Beatport chart: ${chartHash}`);
// This function is only called as a fallback when the modal wasn't created during backend loading
// In most cases, the modal should already exist from loadBeatportChartsFromBackend()
if (!ytState || !ytState.playlist || !ytState.convertedSpotifyPlaylistId) {
console.error(`โ [Rehydration] Invalid state data for Beatport chart: ${chartHash}`);
showToast('Cannot open download modal - invalid playlist data', 'error');
return;
}
// Get discovery results from backend if not already loaded
if (!ytState.discovery_results) {
console.log(`๐ Fetching discovery results from backend for Beatport chart: ${chartHash}`);
const stateResponse = await fetch(`/api/beatport/charts/status/${chartHash}`);
if (stateResponse.ok) {
const fullState = await stateResponse.json();
ytState.discovery_results = fullState.discovery_results;
ytState.download_process_id = fullState.download_process_id;
console.log(`โ
Loaded ${fullState.discovery_results?.length || 0} discovery results from backend`);
} else {
console.error('โ Failed to fetch Beatport discovery results from backend');
showToast('Error loading playlist data', 'error');
return;
}
}
// Extract Spotify tracks from discovery results
const spotifyTracks = ytState.discovery_results
.filter(result => result.spotify_data)
.map(result => {
const track = result.spotify_data;
// Ensure artists is an array of strings
if (track.artists && Array.isArray(track.artists)) {
track.artists = track.artists.map(artist =>
typeof artist === 'string' ? artist : (artist.name || artist)
);
} else if (track.artists && typeof track.artists === 'string') {
track.artists = [track.artists];
} else {
track.artists = ['Unknown Artist'];
}
return {
id: track.id,
name: track.name,
artists: track.artists,
album: track.album || 'Unknown Album',
duration_ms: track.duration_ms || 0,
external_urls: track.external_urls || {}
};
});
if (spotifyTracks.length === 0) {
console.error('โ No Spotify tracks found for download modal');
showToast('No Spotify matches found for download', 'error');
return;
}
const virtualPlaylistId = ytState.convertedSpotifyPlaylistId;
const playlistName = ytState.playlist.name;
// Create the download modal
await openDownloadMissingModalForYouTube(virtualPlaylistId, playlistName, spotifyTracks);
// Set up the modal for the running state if we have a download process ID
if (ytState.download_process_id) {
const process = activeDownloadProcesses[virtualPlaylistId];
if (process) {
process.status = 'running';
process.batchId = ytState.download_process_id;
// Update UI to reflect running state
const beginBtn = document.getElementById(`begin-analysis-btn-${virtualPlaylistId}`);
const cancelBtn = document.getElementById(`cancel-all-btn-${virtualPlaylistId}`);
if (beginBtn) beginBtn.style.display = 'none';
if (cancelBtn) cancelBtn.style.display = 'inline-block';
// Start polling for this process
startModalDownloadPolling(virtualPlaylistId);
console.log(`โ
[Rehydration] Fallback modal rehydrated for running download process`);
}
}
} catch (error) {
console.error(`โ [Rehydration] Error in fallback rehydration for Beatport chart:`, error);
showToast('Error opening download modal', 'error');
hideLoadingOverlay();
}
}
function updateBeatportCardPhase(chartHash, phase) {
const state = beatportChartStates[chartHash];
if (!state) return;
state.phase = phase;
// Re-render the card with new phase
const card = document.getElementById(`beatport-card-${chartHash}`);
if (card) {
const newCardHtml = createBeatportCard(state.chart);
card.outerHTML = newCardHtml;
// Re-attach click handler
const newCard = document.getElementById(`beatport-card-${chartHash}`);
if (newCard) {
newCard.addEventListener('click', async () => await handleBeatportCardClick(chartHash));
state.cardElement = newCard;
}
}
// Update clear button state after phase change
updateBeatportClearButtonState();
}
function updateBeatportCardProgress(chartHash, progress) {
const state = beatportChartStates[chartHash];
if (!state) return;
const card = document.getElementById(`beatport-card-${chartHash}`);
if (!card) return;
const progressElement = card.querySelector('.playlist-card-progress');
if (!progressElement) return;
const { spotify_total, spotify_matches, failed } = progress;
const percentage = spotify_total > 0 ? Math.round((spotify_matches / spotify_total) * 100) : 0;
progressElement.textContent = `โช ${spotify_total} / โ ${spotify_matches} / โ ${failed} / ${percentage}%`;
progressElement.classList.remove('hidden');
console.log('๐ง Updated Beatport card progress:', chartHash, `${spotify_matches}/${spotify_total} (${percentage}%)`);
}
function switchToBeatportPlaylistsTab() {
// Switch from "Browse Charts" to "My Playlists" tab
const browseTab = document.querySelector('.beatport-tab-button[data-beatport-tab="browse"]');
const playlistsTab = document.querySelector('.beatport-tab-button[data-beatport-tab="playlists"]');
const browseContent = document.getElementById('beatport-browse-content');
const playlistsContent = document.getElementById('beatport-playlists-content');
if (browseTab && playlistsTab && browseContent && playlistsContent) {
// Update tab buttons
browseTab.classList.remove('active');
playlistsTab.classList.add('active');
// Update tab content
browseContent.classList.remove('active');
playlistsContent.classList.add('active');
console.log('๐ Switched to Beatport "My Playlists" tab');
}
}
// ===============================
// BEATPORT SYNC FUNCTIONALITY
// ===============================
async function startBeatportPlaylistSync(urlHash) {
try {
console.log('๐ง Starting Beatport playlist sync:', urlHash);
const state = youtubePlaylistStates[urlHash];
if (!state || !state.is_beatport_playlist) {
console.error('โ Invalid Beatport playlist state for sync');
showToast('Invalid Beatport playlist state', 'error');
return;
}
// Call Beatport sync endpoint
const response = await fetch(`/api/beatport/sync/start/${urlHash}`, {
method: 'POST'
});
const result = await response.json();
if (result.error) {
showToast(`Error starting sync: ${result.error}`, 'error');
return;
}
// Capture sync_playlist_id for WebSocket subscription (Beatport returns sync_id)
const syncPlaylistId = result.sync_id || result.sync_playlist_id;
if (state) state.syncPlaylistId = syncPlaylistId;
// Update state to syncing
state.phase = 'syncing';
updateBeatportCardPhase(state.beatport_chart_hash || urlHash, 'syncing');
// Update modal buttons and start polling
updateBeatportModalButtons(urlHash, 'syncing');
startBeatportSyncPolling(urlHash, syncPlaylistId);
showToast('Starting Beatport playlist sync...', 'success');
} catch (error) {
console.error('โ Error starting Beatport sync:', error);
showToast(`Error starting sync: ${error.message}`, 'error');
}
}
function startBeatportSyncPolling(urlHash, syncPlaylistId) {
// Stop any existing polling (reuse activeYouTubePollers for Beatport)
if (activeYouTubePollers[urlHash]) {
clearInterval(activeYouTubePollers[urlHash]);
}
// Resolve syncPlaylistId from argument or stored state
const bpState = youtubePlaylistStates[urlHash];
syncPlaylistId = syncPlaylistId || (bpState && bpState.syncPlaylistId);
// Phase 6: Subscribe via WebSocket
if (socketConnected && syncPlaylistId) {
socket.emit('sync:subscribe', { playlist_ids: [syncPlaylistId] });
_syncProgressCallbacks[syncPlaylistId] = (data) => {
const progress = data.progress || {};
updateBeatportModalSyncProgress(urlHash, progress);
if (data.status === 'finished' || data.status === 'error' || data.status === 'cancelled') {
if (activeYouTubePollers[urlHash]) { clearInterval(activeYouTubePollers[urlHash]); delete activeYouTubePollers[urlHash]; }
socket.emit('sync:unsubscribe', { playlist_ids: [syncPlaylistId] });
delete _syncProgressCallbacks[syncPlaylistId];
const state = youtubePlaylistStates[urlHash];
if (state) {
const chartHash = state.beatport_chart_hash || urlHash;
if (data.status === 'finished') {
state.phase = 'sync_complete';
updateBeatportCardPhase(chartHash, 'sync_complete');
updateBeatportModalButtons(urlHash, 'sync_complete');
if (beatportChartStates[chartHash]) beatportChartStates[chartHash].phase = 'sync_complete';
} else {
state.phase = 'discovered';
updateBeatportCardPhase(chartHash, 'discovered');
if (beatportChartStates[chartHash]) beatportChartStates[chartHash].phase = 'discovered';
}
}
}
};
}
// Define the polling function (HTTP fallback)
const pollFunction = async () => {
if (socketConnected) return; // Phase 6: WS handles updates
try {
const response = await fetch(`/api/beatport/sync/status/${urlHash}`);
const status = await response.json();
if (status.error) {
console.error('โ Error polling Beatport sync:', status.error);
clearInterval(pollInterval);
delete activeYouTubePollers[urlHash];
return;
}
updateBeatportModalSyncProgress(urlHash, status.progress);
if (status.complete || status.status === 'error') {
const state = youtubePlaylistStates[urlHash];
if (state) {
const chartHash = state.beatport_chart_hash || urlHash;
if (status.complete) {
state.phase = 'sync_complete';
state.convertedSpotifyPlaylistId = status.converted_spotify_playlist_id;
updateBeatportCardPhase(chartHash, 'sync_complete');
updateBeatportModalButtons(urlHash, 'sync_complete');
if (beatportChartStates[chartHash]) beatportChartStates[chartHash].phase = 'sync_complete';
} else {
state.phase = 'discovered';
updateBeatportCardPhase(chartHash, 'discovered');
if (beatportChartStates[chartHash]) beatportChartStates[chartHash].phase = 'discovered';
}
}
clearInterval(pollInterval);
delete activeYouTubePollers[urlHash];
}
} catch (error) {
console.error('โ Error polling Beatport sync:', error);
if (activeYouTubePollers[urlHash]) {
clearInterval(activeYouTubePollers[urlHash]);
delete activeYouTubePollers[urlHash];
}
}
};
// Run immediately to get current status (skip if WS active)
if (!socketConnected) pollFunction();
// Then continue polling at regular intervals
const pollInterval = setInterval(pollFunction, 2000);
activeYouTubePollers[urlHash] = pollInterval;
}
async function cancelBeatportSync(urlHash) {
try {
console.log('โ Cancelling Beatport sync:', urlHash);
const state = youtubePlaylistStates[urlHash];
if (!state || !state.is_beatport_playlist) {
console.error('โ Invalid Beatport playlist state');
return;
}
const response = await fetch(`/api/beatport/sync/cancel/${urlHash}`, {
method: 'POST'
});
const result = await response.json();
if (result.error) {
showToast(`Error cancelling sync: ${result.error}`, 'error');
return;
}
// Stop polling
if (activeYouTubePollers[urlHash]) {
clearInterval(activeYouTubePollers[urlHash]);
delete activeYouTubePollers[urlHash];
}
// Phase 6: Clean up WS subscription
const bpSyncId = state && state.syncPlaylistId;
if (bpSyncId && _syncProgressCallbacks[bpSyncId]) {
if (socketConnected) socket.emit('sync:unsubscribe', { playlist_ids: [bpSyncId] });
delete _syncProgressCallbacks[bpSyncId];
}
// Revert to discovered phase
const chartHash = state.beatport_chart_hash || urlHash;
state.phase = 'discovered';
updateBeatportCardPhase(chartHash, 'discovered');
updateBeatportModalButtons(urlHash, 'discovered');
// Sync with backend Beatport chart state
if (beatportChartStates[chartHash]) {
beatportChartStates[chartHash].phase = 'discovered';
}
showToast('Beatport sync cancelled', 'info');
} catch (error) {
console.error('โ Error cancelling Beatport sync:', error);
showToast(`Error cancelling sync: ${error.message}`, 'error');
}
}
function updateBeatportModalSyncProgress(urlHash, progress) {
const statusDisplay = document.getElementById(`beatport-sync-status-${urlHash}`);
if (!statusDisplay || !progress) return;
console.log(`๐ Updating Beatport modal sync progress for ${urlHash}:`, progress);
// Update individual counters with Beatport-specific IDs
const totalEl = document.getElementById(`beatport-total-${urlHash}`);
const matchedEl = document.getElementById(`beatport-matched-${urlHash}`);
const failedEl = document.getElementById(`beatport-failed-${urlHash}`);
const percentageEl = document.getElementById(`beatport-percentage-${urlHash}`);
const total = progress.total_tracks || 0;
const matched = progress.matched_tracks || 0;
const failed = progress.failed_tracks || 0;
const percentage = total > 0 ? Math.round((matched / total) * 100) : 0;
if (totalEl) totalEl.textContent = total;
if (matchedEl) matchedEl.textContent = matched;
if (failedEl) failedEl.textContent = failed;
if (percentageEl) percentageEl.textContent = percentage;
}
function updateBeatportModalButtons(urlHash, phase) {
const modal = document.getElementById(`youtube-discovery-modal-${urlHash}`);
if (!modal) return;
const footerLeft = modal.querySelector('.modal-footer-left');
if (footerLeft) {
footerLeft.innerHTML = getModalActionButtons(urlHash, phase);
}
}
async function startBeatportDownloadMissing(urlHash) {
try {
console.log('๐ Starting download missing tracks for Beatport chart:', urlHash);
const state = youtubePlaylistStates[urlHash];
// Support both camelCase and snake_case
const discoveryResults = state?.discoveryResults || state?.discovery_results;
if (!state || !discoveryResults) {
showToast('No discovery results available for download', 'error');
return;
}
if (!state.is_beatport_playlist) {
console.error('โ State is not a Beatport playlist');
showToast('Invalid Beatport chart state', 'error');
return;
}
// Convert Beatport discovery results to Spotify tracks format (like Tidal does)
console.log(`๐ Total discovery results: ${discoveryResults.length}`);
console.log(`๐ First result (full object):`, JSON.stringify(discoveryResults[0], null, 2));
console.log(`๐ Second result (full object):`, JSON.stringify(discoveryResults[1], null, 2));
console.log(`๐ Results with spotify_data:`, discoveryResults.filter(r => r.spotify_data).length);
console.log(`๐ Results with spotify_id:`, discoveryResults.filter(r => r.spotify_id).length);
const spotifyTracks = discoveryResults
.filter(result => {
// Accept if has spotify_data OR if has spotify_track (from automatic discovery)
return result.spotify_data || (result.spotify_track && result.status_class === 'found');
})
.map(result => {
// Use spotify_data if available, otherwise build from individual fields
let track;
if (result.spotify_data) {
track = result.spotify_data;
} else {
// Build from individual fields (automatic discovery format)
// Convert album to proper object format for wishlist compatibility
const albumData = result.spotify_album || 'Unknown Album';
const albumObject = typeof albumData === 'object' && albumData !== null
? albumData
: {
name: typeof albumData === 'string' ? albumData : 'Unknown Album',
album_type: 'album',
images: []
};
track = {
id: result.spotify_id || 'unknown',
name: result.spotify_track || 'Unknown Track',
artists: result.spotify_artist ? [result.spotify_artist] : ['Unknown Artist'],
album: albumObject,
duration_ms: 0
};
}
// Ensure artists is an array of strings
if (track.artists && Array.isArray(track.artists)) {
track.artists = track.artists.map(artist =>
typeof artist === 'string' ? artist : (artist.name || artist)
);
} else if (track.artists && typeof track.artists === 'string') {
track.artists = [track.artists];
} else {
track.artists = ['Unknown Artist'];
}
// Ensure album is an object (in case it was converted back to string somehow)
const albumForReturn = typeof track.album === 'object' && track.album !== null
? track.album
: {
name: typeof track.album === 'string' ? track.album : 'Unknown Album',
album_type: 'album',
images: []
};
return {
id: track.id,
name: track.name,
artists: track.artists,
album: albumForReturn,
duration_ms: track.duration_ms || 0,
external_urls: track.external_urls || {}
};
});
if (spotifyTracks.length === 0) {
showToast('No Spotify matches found for download', 'error');
return;
}
console.log(`๐ง Found ${spotifyTracks.length} Spotify tracks for Beatport download`);
// Create a virtual playlist for the download system
const virtualPlaylistId = `beatport_${urlHash}`;
const playlistName = state.playlist.name;
// Store reference for card navigation (but don't change phase yet)
state.convertedSpotifyPlaylistId = virtualPlaylistId;
// Store converted playlist ID in backend but keep current phase
const chartHash = state.beatport_chart_hash || urlHash;
if (beatportChartStates[chartHash]) {
try {
await fetch(`/api/beatport/charts/update-phase/${chartHash}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
phase: state.phase, // Keep current phase (should be 'discovered')
converted_spotify_playlist_id: virtualPlaylistId
})
});
console.log('โ
Updated backend with Beatport converted playlist ID (phase unchanged)');
} catch (error) {
console.warn('โ ๏ธ Error updating backend Beatport state:', error);
}
}
// Close the discovery modal if it's open
const discoveryModal = document.getElementById(`youtube-discovery-modal-${urlHash}`);
if (discoveryModal) {
discoveryModal.classList.add('hidden');
console.log('๐ Closed Beatport discovery modal to show download modal');
}
// DON'T update card phase here - let the download modal handle phase changes when "Begin Analysis" is clicked
// Open download missing tracks modal using the same system as YouTube/Tidal
await openDownloadMissingModalForYouTube(virtualPlaylistId, playlistName, spotifyTracks);
console.log(`โ
Opened download modal for Beatport chart: ${state.playlist.name}`);
} catch (error) {
console.error('โ Error starting Beatport download missing tracks:', error);
showToast(`Error starting downloads: ${error.message}`, 'error');
}
}
async function handleBeatportChartClick(chartType, chartId, chartName, chartEndpoint) {
console.log(`๐ต Beatport chart clicked: ${chartType} - ${chartId} - ${chartName}`);
try {
// Check if we already have a card for this chart
const existingState = Object.values(beatportChartStates).find(state =>
state.chart && state.chart.name === chartName && state.chart.chart_type === chartType
);
if (existingState) {
console.log(`๐ Found existing Beatport card for ${chartName}, opening existing modal`);
handleBeatportCardClick(existingState.chart.hash);
return;
}
// First, create a chart hash for state management
const chartHash = `${chartType}_${chartId}_${Date.now()}`;
// Load chart data from backend using the specific endpoint
console.log(`๐ Loading ${chartName} tracks from ${chartEndpoint}...`);
showToast(`Loading ${chartName}...`, 'info');
const response = await fetch(`${chartEndpoint}?limit=100`);
if (!response.ok) {
throw new Error(`Failed to fetch ${chartName}: ${response.status}`);
}
const data = await response.json();
if (!data.success || !data.tracks || data.tracks.length === 0) {
throw new Error(`No tracks found in ${chartName}`);
}
// Create chart data object
const chartData = {
hash: chartHash,
name: chartName,
chart_type: chartType,
track_count: data.tracks.length,
tracks: data.tracks.map(track => ({
name: track.title || 'Unknown Title',
artists: [track.artist || 'Unknown Artist'],
album: chartName,
duration_ms: 0,
external_urls: { beatport: track.url || '' },
source: 'beatport'
}))
};
// Add card to container (in background, like YouTube does)
addBeatportCardToContainer(chartData);
// Automatically open discovery modal (like when you click a YouTube or Tidal card in fresh state)
handleBeatportCardClick(chartHash);
console.log(`โ
Created Beatport card and opened discovery modal for ${chartName}`);
} catch (error) {
console.error(`โ Error handling Beatport chart click:`, error);
showToast(`Error loading ${chartName || chartId}: ${error.message}`, 'error');
}
}
function handleBeatportGenreClick(genreSlug, genreId, genreName) {
console.log(`๐ต Beatport genre clicked: ${genreName} (${genreSlug}/${genreId}) - SHOWING GENRE DETAIL VIEW`);
console.log(`๐ Debug: Parameters received - Slug: ${genreSlug}, ID: ${genreId}, Name: ${genreName}`);
// Navigate to genre detail view with proper parameters
showBeatportGenreDetailView(genreSlug, genreId, genreName);
}
function showBeatportGenreDetailView(genreSlug, genreId, genreName) {
console.log(`๐ฏ Showing genre detail view for: ${genreName}`);
console.log(`๐ Debug: Function called with - Slug: ${genreSlug}, ID: ${genreId}, Name: ${genreName}`);
// Hide all other beatport views
document.querySelectorAll('.beatport-sub-view').forEach(view => {
view.classList.remove('active');
});
const mainView = document.getElementById('beatport-main-view');
if (mainView) {
mainView.classList.remove('active');
}
// Show genre detail view
const genreDetailView = document.getElementById('beatport-genre-detail-view');
if (genreDetailView) {
genreDetailView.classList.add('active');
console.log(`๐ Debug: Genre detail view element found and activated`);
// Update view content
const titleElement = document.getElementById('genre-detail-title');
const breadcrumbElement = document.getElementById('genre-detail-breadcrumb');
console.log(`๐ Debug: Title element found: ${!!titleElement}, Breadcrumb element found: ${!!breadcrumbElement}`);
if (titleElement) {
titleElement.textContent = genreName;
console.log(`๐ Debug: Updated title to: ${genreName}`);
}
if (breadcrumbElement) {
breadcrumbElement.textContent = `Browse Charts > Genre Explorer > ${genreName} Charts`;
console.log(`๐ Debug: Updated breadcrumb`);
}
// Update chart type titles with genre name
const chartTitles = [
'genre-top-10-title',
'genre-top-100-title',
'genre-releases-top-10-title',
'genre-releases-top-100-title',
'genre-staff-picks-title',
'genre-latest-releases-title',
'genre-new-charts-title'
];
chartTitles.forEach(titleId => {
const element = document.getElementById(titleId);
if (element) {
console.log(`๐ Debug: Found chart title element: ${titleId}`);
} else {
console.log(`๐ Debug: Missing chart title element: ${titleId}`);
}
});
document.getElementById('genre-top-10-title').textContent = `Top 10 ${genreName}`;
document.getElementById('genre-top-100-title').textContent = `Top 100 ${genreName}`;
document.getElementById('genre-releases-top-10-title').textContent = `Top 10 ${genreName} Releases`;
document.getElementById('genre-releases-top-100-title').textContent = `Top 100 ${genreName} Releases`;
document.getElementById('genre-staff-picks-title').textContent = `${genreName} Staff Picks`;
document.getElementById('genre-latest-releases-title').textContent = `Latest ${genreName} Releases`;
// Update Hype section titles
document.getElementById('genre-hype-top-10-title').textContent = `${genreName} Hype Top 10`;
document.getElementById('genre-hype-top-100-title').textContent = `${genreName} Hype Top 100`;
document.getElementById('genre-hype-picks-title').textContent = `${genreName} Hype Picks`;
// Load new charts directly (no expansion needed)
console.log(`๐ Auto-loading new charts for ${genreName}...`);
loadNewChartsInline(genreSlug, genreId, genreName);
// Store current genre data for chart type handlers
genreDetailView.dataset.genreSlug = genreSlug;
genreDetailView.dataset.genreId = genreId;
genreDetailView.dataset.genreName = genreName;
// Add click handlers to chart type cards
setupGenreChartTypeHandlers();
console.log(`โ
Genre detail view shown for ${genreName}`);
} else {
console.error('โ Genre detail view element not found');
}
}
function setupGenreChartTypeHandlers() {
const chartTypeCards = document.querySelectorAll('#beatport-genre-detail-view .genre-chart-type-card');
chartTypeCards.forEach(card => {
// Remove existing listeners
card.replaceWith(card.cloneNode(true));
});
// Re-select after cloning
const newChartTypeCards = document.querySelectorAll('#beatport-genre-detail-view .genre-chart-type-card');
newChartTypeCards.forEach(card => {
card.addEventListener('click', () => {
const chartType = card.dataset.chartType;
const genreDetailView = document.getElementById('beatport-genre-detail-view');
const genreSlug = genreDetailView.dataset.genreSlug;
const genreId = genreDetailView.dataset.genreId;
const genreName = genreDetailView.dataset.genreName;
// All chart types now go directly to discovery modal
handleGenreChartTypeClick(genreSlug, genreId, genreName, chartType);
});
});
}
function showBeatportGenresView() {
// Hide genre detail view and show genres view
document.querySelectorAll('.beatport-sub-view').forEach(view => {
view.classList.remove('active');
});
const genresView = document.getElementById('beatport-genres-view');
if (genresView) {
genresView.classList.add('active');
}
}
async function toggleNewChartsExpansion(genreSlug, genreId, genreName) {
console.log(`๐ Toggling new charts expansion for: ${genreName}`);
const expandedContent = document.getElementById('new-charts-expanded');
const expandIndicator = document.getElementById('expand-indicator');
const chartsCount = document.getElementById('new-charts-count');
if (!expandedContent || !expandIndicator) {
console.error('โ New charts expansion elements not found');
return;
}
// Check if already expanded
const isExpanded = expandedContent.style.display !== 'none';
if (isExpanded) {
// Collapse
expandedContent.style.display = 'none';
expandIndicator.classList.remove('expanded');
console.log('๐ Collapsed new charts section');
} else {
// Expand and load charts
expandedContent.style.display = 'block';
expandIndicator.classList.add('expanded');
// Load charts if not already loaded
await loadNewChartsInline(genreSlug, genreId, genreName);
console.log('๐ Expanded new charts section');
}
}
async function loadNewChartsInline(genreSlug, genreId, genreName) {
const chartsGrid = document.getElementById('new-charts-grid');
const loadingInline = document.getElementById('charts-loading-inline');
if (!chartsGrid || !loadingInline) {
console.error('โ Inline charts elements not found');
return;
}
// Show loading state
loadingInline.style.display = 'block';
chartsGrid.style.display = 'none';
chartsGrid.innerHTML = '';
try {
console.log(`๐ Loading inline charts for ${genreName}...`);
// Fetch charts from the new-charts endpoint
const response = await fetch(`/api/beatport/genre/${genreSlug}/${genreId}/new-charts?limit=20`);
if (!response.ok) {
throw new Error(`Failed to fetch charts: ${response.status}`);
}
const data = await response.json();
if (!data.success || !data.tracks || data.tracks.length === 0) {
// Show empty state
chartsGrid.innerHTML = `
No Charts Available
No curated charts found for ${genreName} at the moment.
`;
} else {
// Populate charts grid
const chartsHTML = data.tracks.map((chart, index) => {
const chartName = chart.title || 'Untitled Chart';
const artistName = chart.artist || 'Various Artists';
const chartUrl = chart.url || '';
return `
Curated ${genreName} chart collection
`;
}).join('');
chartsGrid.innerHTML = chartsHTML;
// Add click handlers to chart items
setupNewChartItemHandlers(genreSlug, genreId, genreName);
}
// Hide loading and show grid
loadingInline.style.display = 'none';
chartsGrid.style.display = 'grid';
console.log(`โ
Loaded ${data.tracks?.length || 0} inline charts for ${genreName}`);
showToast(`Found ${data.tracks?.length || 0} chart collections`, 'success');
} catch (error) {
console.error(`โ Error loading inline charts for ${genreName}:`, error);
// Show error state
chartsGrid.innerHTML = `
Error Loading Charts
Unable to load chart collections for ${genreName}.
`;
loadingInline.style.display = 'none';
chartsGrid.style.display = 'grid';
showToast(`Error loading charts: ${error.message}`, 'error');
}
}
async function loadDJChartsInline() {
const chartsGrid = document.getElementById('dj-charts-grid');
const loadingInline = document.getElementById('dj-charts-loading-inline');
if (!chartsGrid || !loadingInline) {
console.error('โ DJ charts elements not found');
return;
}
// Show loading state
loadingInline.style.display = 'block';
chartsGrid.style.display = 'none';
chartsGrid.innerHTML = '';
try {
console.log('๐ Loading DJ charts...');
// Fetch charts from the dj-charts-improved endpoint
const response = await fetch('/api/beatport/dj-charts-improved?limit=20');
if (!response.ok) {
throw new Error(`Failed to fetch DJ charts: ${response.status}`);
}
const data = await response.json();
if (!data.success || !data.charts || data.charts.length === 0) {
// Show empty state
chartsGrid.innerHTML = `
No DJ Charts Available
No DJ curated charts found at the moment.
`;
loadingInline.style.display = 'none';
chartsGrid.style.display = 'grid';
return;
}
// Create chart items using New Charts structure
const chartsHTML = data.charts.map(chart => {
const chartName = chart.name || chart.title || 'Untitled Chart';
const artistName = chart.artist || chart.curator || 'Various Artists';
const chartUrl = chart.url || chart.chart_url || '';
return `
DJ curated chart collection
`;
}).join('');
chartsGrid.innerHTML = chartsHTML;
// Hide loading, show content
loadingInline.style.display = 'none';
chartsGrid.style.display = 'grid';
// Setup click handlers for chart items
setupDJChartItemHandlers();
console.log(`โ
Loaded ${data.charts.length} DJ charts`);
} catch (error) {
console.error('โ Error loading DJ charts:', error);
// Show error state
chartsGrid.innerHTML = `
Error Loading DJ Charts
Unable to load DJ chart collections.
`;
loadingInline.style.display = 'none';
chartsGrid.style.display = 'grid';
showToast(`Error loading DJ charts: ${error.message}`, 'error');
}
}
async function loadFeaturedChartsInline() {
const chartsGrid = document.getElementById('featured-charts-grid');
const loadingInline = document.getElementById('featured-charts-loading-inline');
if (!chartsGrid || !loadingInline) {
console.error('โ Featured charts elements not found');
return;
}
// Show loading state
loadingInline.style.display = 'block';
chartsGrid.style.display = 'none';
chartsGrid.innerHTML = '';
try {
console.log('๐ Loading Featured charts...');
// Fetch charts from the homepage/featured-charts endpoint
const response = await fetch('/api/beatport/homepage/featured-charts?limit=20');
if (!response.ok) {
throw new Error(`Failed to fetch Featured charts: ${response.status}`);
}
const data = await response.json();
if (!data.success || !data.tracks || data.tracks.length === 0) {
// Show empty state
chartsGrid.innerHTML = `
No Featured Charts Available
No featured curated charts found at the moment.
`;
loadingInline.style.display = 'none';
chartsGrid.style.display = 'grid';
return;
}
// Create chart items using New Charts structure
const chartsHTML = data.tracks.map(chart => {
const chartName = chart.name || chart.title || 'Untitled Chart';
const artistName = chart.artist || chart.curator || 'Various Artists';
const chartUrl = chart.url || chart.chart_url || '';
return `
Editor curated chart collection
`;
}).join('');
chartsGrid.innerHTML = chartsHTML;
// Hide loading, show content
loadingInline.style.display = 'none';
chartsGrid.style.display = 'grid';
// Setup click handlers for chart items
setupFeaturedChartItemHandlers();
console.log(`โ
Loaded ${data.tracks.length} Featured charts`);
} catch (error) {
console.error('โ Error loading Featured charts:', error);
// Show error state
chartsGrid.innerHTML = `
Error Loading Featured Charts
Unable to load featured chart collections.
`;
loadingInline.style.display = 'none';
chartsGrid.style.display = 'grid';
showToast(`Error loading Featured charts: ${error.message}`, 'error');
}
}
function setupDJChartItemHandlers() {
const chartItems = document.querySelectorAll('#dj-charts-grid .new-chart-item');
chartItems.forEach(item => {
item.addEventListener('click', async () => {
const chartName = item.dataset.chartName;
const chartUrl = item.dataset.chartUrl;
console.log(`๐ง DJ Chart clicked: ${chartName}`);
// Check if state already exists by name and type (follow same pattern as homepage Beatport cards)
const existingState = Object.values(beatportChartStates).find(state =>
state.chart && state.chart.name === chartName && state.chart.chart_type === 'dj-chart'
);
if (existingState) {
console.log(`๐ Found existing DJ chart state for ${chartName}, opening existing modal`);
handleBeatportCardClick(existingState.chart.hash);
return;
}
try {
showToast(`Loading ${chartName}...`, 'info');
showLoadingOverlay(`Loading ${chartName}...`);
// Extract tracks from the DJ chart
const response = await fetch('/api/beatport/chart/extract', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
chart_url: chartUrl,
chart_name: chartName,
limit: 100
})
});
if (!response.ok) {
throw new Error(`Failed to extract chart tracks: ${response.status}`);
}
const data = await response.json();
if (data.success && data.tracks && data.tracks.length > 0) {
console.log(`โ
Extracted ${data.tracks.length} tracks from DJ chart: ${chartName}`);
// Generate a unique hash for state management (following homepage pattern)
const chartHash = `dj_chart_${Date.now()}`;
// Create chart data in the format expected by the state system
const chartData = {
hash: chartHash,
name: chartName,
chart_type: 'dj-chart',
track_count: data.tracks.length,
tracks: data.tracks.map(track => ({
name: track.title || 'Unknown Title',
artists: [track.artist || 'Unknown Artist'],
album: chartName,
duration_ms: 0,
external_urls: { spotify: null },
preview_url: null,
popularity: 0,
explicit: false,
track_number: track.position || 1,
disc_number: 1,
id: `dj_chart_${chartHash}_${track.position || Math.random()}`,
uri: null,
type: 'track',
is_local: false,
source: 'beatport_dj_chart'
}))
};
// Create state in beatportChartStates (follow same pattern as other Beatport cards)
beatportChartStates[chartHash] = {
chart: chartData,
phase: 'fresh',
cardElement: null, // Will be set when actual card is created
discovery_results: [],
discoveryProgress: 0
};
// Use the same click handler as other Beatport cards
hideLoadingOverlay();
handleBeatportCardClick(chartHash);
} else {
throw new Error('No tracks found in chart');
}
} catch (error) {
console.error('โ Error extracting DJ chart tracks:', error);
hideLoadingOverlay();
showToast(`Error loading chart: ${error.message}`, 'error');
}
});
});
}
function setupFeaturedChartItemHandlers() {
const chartItems = document.querySelectorAll('#featured-charts-grid .new-chart-item');
chartItems.forEach(item => {
item.addEventListener('click', async () => {
const chartName = item.dataset.chartName;
const chartUrl = item.dataset.chartUrl;
console.log(`โญ Featured Chart clicked: ${chartName}`);
// Check if state already exists by name and type (follow same pattern as homepage Beatport cards)
const existingState = Object.values(beatportChartStates).find(state =>
state.chart && state.chart.name === chartName && state.chart.chart_type === 'featured-chart'
);
if (existingState) {
console.log(`๐ Found existing Featured chart state for ${chartName}, opening existing modal`);
handleBeatportCardClick(existingState.chart.hash);
return;
}
try {
showToast(`Loading ${chartName}...`, 'info');
showLoadingOverlay(`Loading ${chartName}...`);
// Extract tracks from the Featured chart
const response = await fetch('/api/beatport/chart/extract', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
chart_url: chartUrl,
chart_name: chartName,
limit: 100
})
});
if (!response.ok) {
throw new Error(`Failed to extract chart tracks: ${response.status}`);
}
const data = await response.json();
if (data.success && data.tracks && data.tracks.length > 0) {
console.log(`โ
Extracted ${data.tracks.length} tracks from Featured chart: ${chartName}`);
// Generate a unique hash for state management (following homepage pattern)
const chartHash = `featured_chart_${Date.now()}`;
// Create chart data in the format expected by the state system
const chartData = {
hash: chartHash,
name: chartName,
chart_type: 'featured-chart',
track_count: data.tracks.length,
tracks: data.tracks.map(track => ({
name: track.title || 'Unknown Title',
artists: [track.artist || 'Unknown Artist'],
album: chartName,
duration_ms: 0,
external_urls: { spotify: null },
preview_url: null,
popularity: 0,
explicit: false,
track_number: track.position || 1,
disc_number: 1,
id: `featured_chart_${chartHash}_${track.position || Math.random()}`,
uri: null,
type: 'track',
is_local: false,
source: 'beatport_featured_chart'
}))
};
// Create state in beatportChartStates (follow same pattern as other Beatport cards)
beatportChartStates[chartHash] = {
chart: chartData,
phase: 'fresh',
cardElement: null, // Will be set when actual card is created
discovery_results: [],
discoveryProgress: 0
};
// Use the same click handler as other Beatport cards
hideLoadingOverlay();
handleBeatportCardClick(chartHash);
} else {
throw new Error('No tracks found in chart');
}
} catch (error) {
console.error('โ Error extracting Featured chart tracks:', error);
hideLoadingOverlay();
showToast(`Error loading chart: ${error.message}`, 'error');
}
});
});
}
function setupNewChartItemHandlers(genreSlug, genreId, genreName) {
const chartItems = document.querySelectorAll('#new-charts-grid .new-chart-item');
chartItems.forEach(item => {
item.addEventListener('click', async () => {
const chartName = item.dataset.chartName;
const chartArtist = item.dataset.chartArtist;
const chartUrl = item.dataset.chartUrl;
console.log(`๐ต Chart clicked: ${chartName} by ${chartArtist}`);
console.log(`๐ Chart URL: ${chartUrl}`);
const fullChartName = `${chartName} (${genreName})`;
// Check if state already exists by name and type (follow same pattern as homepage Beatport cards)
const existingState = Object.values(beatportChartStates).find(state =>
state.chart && state.chart.name === fullChartName && state.chart.chart_type === 'individual_chart'
);
if (existingState) {
console.log(`๐ Found existing individual chart state for ${fullChartName}, opening existing modal`);
handleBeatportCardClick(existingState.chart.hash);
return;
}
try {
showToast(`Loading ${chartName}...`, 'info');
showLoadingOverlay(`Loading ${chartName}...`);
// Use the new chart extraction endpoint with the actual chart URL
const response = await fetch('/api/beatport/chart/extract', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
chart_url: chartUrl,
chart_name: chartName,
limit: 100
})
});
if (!response.ok) {
throw new Error(`Failed to fetch chart content: ${response.status}`);
}
const data = await response.json();
if (!data.success || !data.tracks || data.tracks.length === 0) {
throw new Error(`No tracks found in chart`);
}
// Generate a unique hash for state management (following homepage pattern)
const chartHash = `individual_chart_${Date.now()}`;
// Create chart data object for playlist card
const chartData = {
hash: chartHash,
name: fullChartName,
chart_type: 'individual_chart',
track_count: data.tracks.length,
tracks: data.tracks.map(track => ({
name: track.title || 'Unknown Title',
artists: [track.artist || 'Unknown Artist'],
album: fullChartName,
duration_ms: 0,
external_urls: { beatport: track.url || chartUrl },
source: 'beatport'
}))
};
// Add card to container (in background, like YouTube does)
console.log(`๐ Creating Beatport playlist card for: ${fullChartName}`);
addBeatportCardToContainer(chartData);
// Automatically open discovery modal
hideLoadingOverlay();
handleBeatportCardClick(chartHash);
console.log(`โ
Created Beatport card and opened discovery modal for ${fullChartName}`);
} catch (error) {
console.error(`โ Error loading chart: ${error.message}`);
hideLoadingOverlay();
showToast(`Error loading chart: ${error.message}`, 'error');
}
});
});
}
function showBeatportGenreDetailViewFromBack() {
// Show genre detail view (used by charts list back button)
document.querySelectorAll('.beatport-sub-view').forEach(view => {
view.classList.remove('active');
});
const genreDetailView = document.getElementById('beatport-genre-detail-view');
if (genreDetailView) {
genreDetailView.classList.add('active');
}
}
async function showBeatportGenreChartsListView(genreSlug, genreId, genreName) {
console.log(`๐ Showing charts list for: ${genreName}`);
// Hide all other beatport views
document.querySelectorAll('.beatport-sub-view').forEach(view => {
view.classList.remove('active');
});
const mainView = document.getElementById('beatport-main-view');
if (mainView) {
mainView.classList.remove('active');
}
// Show charts list view
const chartsListView = document.getElementById('beatport-genre-charts-list-view');
if (chartsListView) {
chartsListView.classList.add('active');
// Update view content
document.getElementById('genre-charts-list-title').textContent = `New ${genreName} Charts`;
document.getElementById('genre-charts-list-breadcrumb').textContent = `Browse Charts > Genre Explorer > ${genreName} Charts > New Charts`;
// Store current genre data for individual chart handlers
chartsListView.dataset.genreSlug = genreSlug;
chartsListView.dataset.genreId = genreId;
chartsListView.dataset.genreName = genreName;
// Load charts for this genre
await loadGenreChartsList(genreSlug, genreId, genreName);
console.log(`โ
Charts list view shown for ${genreName}`);
} else {
console.error('โ Charts list view element not found');
}
}
async function loadGenreChartsList(genreSlug, genreId, genreName) {
const chartsGrid = document.getElementById('genre-charts-grid');
const loadingPlaceholder = document.getElementById('charts-loading-placeholder');
if (!chartsGrid || !loadingPlaceholder) {
console.error('โ Charts grid or loading placeholder not found');
return;
}
// Show loading state
loadingPlaceholder.style.display = 'block';
chartsGrid.style.display = 'none';
chartsGrid.innerHTML = '';
try {
console.log(`๐ Loading charts for ${genreName}...`);
// Fetch charts from the new-charts endpoint
const response = await fetch(`/api/beatport/genre/${genreSlug}/${genreId}/new-charts?limit=50`);
if (!response.ok) {
throw new Error(`Failed to fetch charts: ${response.status}`);
}
const data = await response.json();
if (!data.success || !data.tracks || data.tracks.length === 0) {
// Show empty state
chartsGrid.innerHTML = `
No Charts Available
No curated charts found for ${genreName} at the moment. Check back later for new DJ and artist chart collections.
`;
} else {
// Populate charts grid
const chartsHTML = data.tracks.map((chart, index) => {
const chartName = chart.title || 'Untitled Chart';
const artistName = chart.artist || 'Various Artists';
const chartUrl = chart.url || '';
// Extract chart ID from URL for click handling
const chartId = chartUrl.split('/').pop() || `chart_${index}`;
return `
Curated chart collection featuring ${genreName} tracks
`;
}).join('');
chartsGrid.innerHTML = chartsHTML;
// Add click handlers to chart items
setupGenreChartItemHandlers(genreSlug, genreId, genreName);
}
// Hide loading and show grid
loadingPlaceholder.style.display = 'none';
chartsGrid.style.display = 'grid';
console.log(`โ
Loaded ${data.tracks?.length || 0} charts for ${genreName}`);
showToast(`Found ${data.tracks?.length || 0} chart collections`, 'success');
} catch (error) {
console.error(`โ Error loading charts for ${genreName}:`, error);
// Show error state
chartsGrid.innerHTML = `
Error Loading Charts
Unable to load chart collections for ${genreName}. Please try again later.
`;
loadingPlaceholder.style.display = 'none';
chartsGrid.style.display = 'grid';
showToast(`Error loading charts: ${error.message}`, 'error');
}
}
function setupGenreChartItemHandlers(genreSlug, genreId, genreName) {
const chartItems = document.querySelectorAll('#genre-charts-grid .genre-chart-item');
chartItems.forEach(item => {
item.addEventListener('click', async () => {
const chartName = item.dataset.chartName;
const chartArtist = item.dataset.chartArtist;
const chartUrl = item.dataset.chartUrl;
console.log(`๐ต Chart clicked: ${chartName} by ${chartArtist}`);
console.log(`๐ Chart URL: ${chartUrl}`);
try {
// Create a virtual chart data object
const chartHash = `individual_chart_${genreSlug}_${Date.now()}`;
const fullChartName = `${chartName} (${genreName})`;
showToast(`Loading ${chartName}...`, 'info');
showLoadingOverlay(`Loading ${chartName}...`);
// Use the new chart extraction endpoint with the actual chart URL
const response = await fetch('/api/beatport/chart/extract', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
chart_url: chartUrl,
chart_name: chartName,
limit: 100
})
});
if (!response.ok) {
throw new Error(`Failed to fetch chart content: ${response.status}`);
}
const data = await response.json();
if (!data.success || !data.tracks || data.tracks.length === 0) {
throw new Error(`No tracks found in chart`);
}
// Create chart data object for playlist card
const chartData = {
hash: chartHash,
name: fullChartName,
chart_type: 'individual_chart',
track_count: data.tracks.length,
tracks: data.tracks.map(track => ({
name: track.title || 'Unknown Title',
artists: [track.artist || 'Unknown Artist'],
album: fullChartName,
duration_ms: 0,
external_urls: { beatport: track.url || chartUrl },
source: 'beatport'
}))
};
// Add card to container (in background, like YouTube does)
console.log(`๐ Creating Beatport playlist card for: ${fullChartName}`);
addBeatportCardToContainer(chartData);
// Automatically open discovery modal
hideLoadingOverlay();
handleBeatportCardClick(chartHash);
console.log(`โ
Created Beatport card and opened discovery modal for ${fullChartName}`);
} catch (error) {
console.error(`โ Error loading chart: ${error.message}`);
hideLoadingOverlay();
showToast(`Error loading chart: ${error.message}`, 'error');
}
});
});
}
async function handleGenreChartTypeClick(genreSlug, genreId, genreName, chartType) {
console.log(`๐ฏ Genre chart type clicked: ${chartType} for ${genreName} (${genreSlug}/${genreId})`);
// Map chart types to API endpoints and create descriptive names
const chartTypeMap = {
'top-10': {
endpoint: `/api/beatport/genre/${genreSlug}/${genreId}/top-10`,
name: `Top 10 ${genreName}`,
limit: 10
},
'top-100': {
endpoint: `/api/beatport/genre/${genreSlug}/${genreId}/tracks`,
name: `Top 100 ${genreName}`,
limit: 100
},
'releases-top-10': {
endpoint: `/api/beatport/genre/${genreSlug}/${genreId}/releases-top-10`,
name: `Top 10 ${genreName} Releases`,
limit: 10
},
'releases-top-100': {
endpoint: `/api/beatport/genre/${genreSlug}/${genreId}/releases-top-100`,
name: `Top 100 ${genreName} Releases`,
limit: 100
},
'staff-picks': {
endpoint: `/api/beatport/genre/${genreSlug}/${genreId}/staff-picks`,
name: `${genreName} Staff Picks`,
limit: 50
},
'latest-releases': {
endpoint: `/api/beatport/genre/${genreSlug}/${genreId}/latest-releases`,
name: `Latest ${genreName} Releases`,
limit: 50
},
'hype-top-10': {
endpoint: `/api/beatport/genre/${genreSlug}/${genreId}/hype-top-10`,
name: `${genreName} Hype Top 10`,
limit: 10
},
'hype-top-100': {
endpoint: `/api/beatport/genre/${genreSlug}/${genreId}/hype-top-100`,
name: `${genreName} Hype Top 100`,
limit: 100
},
'hype-picks': {
endpoint: `/api/beatport/genre/${genreSlug}/${genreId}/hype-picks`,
name: `${genreName} Hype Picks`,
limit: 50
},
'new-charts': {
endpoint: `/api/beatport/genre/${genreSlug}/${genreId}/new-charts`,
name: `New ${genreName} Charts`,
limit: 100
}
};
const chartConfig = chartTypeMap[chartType];
if (!chartConfig) {
console.error(`โ Unknown chart type: ${chartType}`);
showToast(`Unknown chart type: ${chartType}`, 'error');
return;
}
try {
// Check if we already have a card for this specific chart type
const existingState = Object.values(beatportChartStates).find(state =>
state.chart && state.chart.name === chartConfig.name && state.chart.chart_type === `genre_${chartType}`
);
if (existingState) {
console.log(`๐ Found existing Beatport card for ${chartConfig.name}, opening existing modal`);
handleBeatportCardClick(existingState.chart.hash);
return;
}
// Create a chart hash for state management
const chartHash = `genre_${chartType}_${genreSlug}_${genreId}_${Date.now()}`;
showToast(`Loading ${chartConfig.name}...`, 'info');
showLoadingOverlay(`Loading ${chartConfig.name}...`);
// Fetch tracks from the specific endpoint
const response = await fetch(`${chartConfig.endpoint}?limit=${chartConfig.limit}`);
if (!response.ok) {
throw new Error(`Failed to fetch ${chartConfig.name}: ${response.status}`);
}
const data = await response.json();
if (!data.success || !data.tracks || data.tracks.length === 0) {
throw new Error(`No tracks found in ${chartConfig.name}`);
}
// Create chart data object for playlist card
const chartData = {
hash: chartHash,
name: chartConfig.name,
chart_type: `genre_${chartType}`,
track_count: data.tracks.length,
tracks: data.tracks.map(track => ({
name: track.title || 'Unknown Title',
artists: [track.artist || 'Unknown Artist'],
album: chartConfig.name,
duration_ms: 0,
external_urls: { beatport: track.url || '' },
source: 'beatport'
}))
};
// Add card to container (in background, like YouTube does)
console.log(`๐ Creating Beatport playlist card for: ${chartConfig.name}`);
addBeatportCardToContainer(chartData);
// Automatically open discovery modal (like when you click a YouTube or Tidal card in fresh state)
hideLoadingOverlay();
handleBeatportCardClick(chartHash);
console.log(`โ
Created Beatport card and opened discovery modal for ${chartConfig.name}`);
} catch (error) {
console.error(`โ Error loading ${chartConfig.name}:`, error);
hideLoadingOverlay();
showToast(`Error loading ${chartConfig.name}: ${error.message}`, 'error');
}
}
// ===============================
// YOUTUBE PLAYLIST FUNCTIONALITY
// ===============================
async function parseYouTubePlaylist() {
const urlInput = document.getElementById('youtube-url-input');
const url = urlInput.value.trim();
if (!url) {
showToast('Please enter a YouTube playlist URL', 'error');
return;
}
// Validate URL format
if (!url.includes('youtube.com/playlist') && !url.includes('music.youtube.com/playlist')) {
showToast('Please enter a valid YouTube playlist URL', 'error');
return;
}
try {
console.log('๐ฌ Parsing YouTube playlist:', url);
// Create card immediately in 'fresh' phase
createYouTubeCard(url, 'fresh');
// Parse playlist via API
const response = await fetch('/api/youtube/parse', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ url: url })
});
const result = await response.json();
if (result.error) {
showToast(`Error parsing YouTube playlist: ${result.error}`, 'error');
removeYouTubeCard(url);
return;
}
console.log('โ
YouTube playlist parsed:', result.name, `(${result.tracks.length} tracks)`);
// Update card with parsed data and stay in 'fresh' phase
updateYouTubeCardData(result.url_hash, result);
updateYouTubeCardPhase(result.url_hash, 'fresh');
// Auto-mirror this YouTube playlist
mirrorPlaylist('youtube', result.url_hash, result.name, result.tracks.map(t => ({
track_name: t.name || t.title || '', artist_name: Array.isArray(t.artists) ? t.artists[0] : (t.artist || ''),
album_name: '', duration_ms: t.duration_ms || 0, source_track_id: t.id || ''
})));
// Clear input
urlInput.value = '';
// Show success message
showToast(`YouTube playlist parsed: ${result.name} (${result.tracks.length} tracks)`, 'success');
} catch (error) {
console.error('โ Error parsing YouTube playlist:', error);
showToast(`Error parsing YouTube playlist: ${error.message}`, 'error');
removeYouTubeCard(url);
}
}
function createYouTubeCard(url, phase = 'fresh') {
const container = document.getElementById('youtube-playlist-container');
const placeholder = container.querySelector('.playlist-placeholder');
// Remove placeholder if it exists
if (placeholder) {
placeholder.style.display = 'none';
}
// Create temporary URL hash for initial card
const tempHash = btoa(url).substring(0, 8);
const cardHtml = `
โถ
Parsing YouTube playlist...
-- tracks
Loading...
โช 0 / โ 0 / โ 0 / 0%
Parsing...
`;
container.insertAdjacentHTML('beforeend', cardHtml);
// Store temporary state
youtubePlaylistStates[tempHash] = {
phase: phase,
url: url,
cardElement: document.getElementById(`youtube-card-${tempHash}`),
tempHash: tempHash
};
console.log('๐ Created YouTube card for URL:', url);
}
function updateYouTubeCardData(urlHash, playlistData) {
// Find the card by URL or temp hash
let state = youtubePlaylistStates[urlHash];
if (!state) {
// Look for temporary card by URL
const tempState = Object.values(youtubePlaylistStates).find(s => s.url === playlistData.url);
if (tempState) {
// Update the state with real hash
delete youtubePlaylistStates[tempState.tempHash];
youtubePlaylistStates[urlHash] = tempState;
state = tempState;
// Update card ID
if (state.cardElement) {
state.cardElement.id = `youtube-card-${urlHash}`;
}
}
}
if (!state || !state.cardElement) {
console.error('โ Could not find YouTube card for hash:', urlHash);
return;
}
const card = state.cardElement;
// Update card content
const nameElement = card.querySelector('.playlist-card-name');
const trackCountElement = card.querySelector('.playlist-card-track-count');
nameElement.textContent = playlistData.name;
trackCountElement.textContent = `${playlistData.tracks.length} tracks`;
// Store playlist data
state.playlist = playlistData;
state.urlHash = urlHash;
// Add click handler for card and action button
const handleCardClick = () => handleYouTubeCardClick(urlHash);
const actionBtn = card.querySelector('.playlist-card-action-btn');
card.addEventListener('click', handleCardClick);
actionBtn.addEventListener('click', (e) => {
e.stopPropagation(); // Prevent card click
handleCardClick();
});
console.log('๐ Updated YouTube card data:', playlistData.name);
}
function updateYouTubeCardPhase(urlHash, phase) {
const state = youtubePlaylistStates[urlHash];
if (!state || !state.cardElement) return;
const card = state.cardElement;
const phaseTextElement = card.querySelector('.playlist-card-phase-text');
const actionBtn = card.querySelector('.playlist-card-action-btn');
const progressElement = card.querySelector('.playlist-card-progress');
state.phase = phase;
switch (phase) {
case 'fresh':
phaseTextElement.textContent = 'Ready to discover';
phaseTextElement.style.color = '#999';
actionBtn.textContent = 'Start Discovery';
actionBtn.disabled = false;
progressElement.classList.add('hidden');
break;
case 'discovering':
phaseTextElement.textContent = 'Discovering...';
phaseTextElement.style.color = '#ffa500'; // Orange
actionBtn.textContent = 'View Progress';
actionBtn.disabled = false;
progressElement.classList.remove('hidden');
break;
case 'discovered':
phaseTextElement.textContent = 'Discovery Complete';
phaseTextElement.style.color = 'rgb(var(--accent-rgb))'; // Green
actionBtn.textContent = 'View Details';
actionBtn.disabled = false;
progressElement.classList.add('hidden');
break;
case 'syncing':
phaseTextElement.textContent = 'Syncing...';
phaseTextElement.style.color = '#ffa500'; // Orange
actionBtn.textContent = 'View Progress';
actionBtn.disabled = false;
progressElement.classList.remove('hidden');
break;
case 'sync_complete':
phaseTextElement.textContent = 'Sync Complete';
phaseTextElement.style.color = 'rgb(var(--accent-rgb))'; // Green
actionBtn.textContent = 'View Details';
actionBtn.disabled = false;
progressElement.classList.add('hidden');
break;
case 'downloading':
phaseTextElement.textContent = 'Downloading...';
phaseTextElement.style.color = '#ffa500'; // Orange
actionBtn.textContent = 'View Downloads';
actionBtn.disabled = false;
progressElement.classList.remove('hidden');
break;
case 'download_complete':
phaseTextElement.textContent = 'Download Complete';
phaseTextElement.style.color = 'rgb(var(--accent-rgb))'; // Green
actionBtn.textContent = 'View Results';
actionBtn.disabled = false;
progressElement.classList.add('hidden');
break;
}
console.log('๐ Updated YouTube card phase:', urlHash, phase);
}
function handleYouTubeCardClick(urlHash) {
const state = youtubePlaylistStates[urlHash];
if (!state) return;
switch (state.phase) {
case 'fresh':
// First click: Start discovery and open modal
console.log('๐ฌ Starting YouTube discovery for first time:', urlHash);
updateYouTubeCardPhase(urlHash, 'discovering');
startYouTubeDiscovery(urlHash);
openYouTubeDiscoveryModal(urlHash);
break;
case 'discovering':
case 'discovered':
case 'syncing':
case 'sync_complete':
// Open discovery modal with current state
console.log('๐ฌ Opening YouTube discovery modal:', urlHash);
openYouTubeDiscoveryModal(urlHash);
break;
case 'downloading':
case 'download_complete':
// Open download missing tracks modal
console.log('๐ฌ Opening download modal for YouTube playlist:', urlHash);
// Need to get playlist ID from converted Spotify data
const spotifyPlaylistId = state.convertedSpotifyPlaylistId;
if (spotifyPlaylistId) {
// Check if we have discovery results, if not load them first
if (!state.discoveryResults || state.discoveryResults.length === 0) {
console.log('๐ Loading discovery results for download modal...');
fetch(`/api/youtube/state/${urlHash}`)
.then(response => response.json())
.then(fullState => {
if (fullState.discovery_results) {
state.discoveryResults = fullState.discovery_results;
console.log(`โ
Loaded ${state.discoveryResults.length} discovery results`);
// Now open the modal with the loaded data
const playlistName = state.playlist.name;
const spotifyTracks = state.discoveryResults
.filter(result => result.spotify_data)
.map(result => result.spotify_data);
openDownloadMissingModalForYouTube(spotifyPlaylistId, playlistName, spotifyTracks);
} else {
console.error('โ No discovery results found for downloads');
showToast('Unable to open download modal - no discovery data', 'error');
}
})
.catch(error => {
console.error('โ Error loading discovery results:', error);
showToast('Error loading playlist data', 'error');
});
} else {
// Use the YouTube-specific function to maintain proper state linking
const playlistName = state.playlist.name;
const spotifyTracks = state.discoveryResults
.filter(result => result.spotify_data)
.map(result => result.spotify_data);
openDownloadMissingModalForYouTube(spotifyPlaylistId, playlistName, spotifyTracks);
}
} else {
console.error('โ No converted Spotify playlist ID found for downloads');
showToast('Unable to open download modal - missing playlist data', 'error');
}
break;
}
}
function updateYouTubeCardProgress(urlHash, progress) {
const state = youtubePlaylistStates[urlHash];
if (!state || !state.cardElement) return;
const card = state.cardElement;
const progressElement = card.querySelector('.playlist-card-progress');
const total = progress.spotify_total || 0;
const matches = progress.spotify_matches || 0;
const failed = total - matches;
const percentage = total > 0 ? Math.round((matches / total) * 100) : 0;
progressElement.textContent = `โช ${total} / โ ${matches} / โ ${failed} / ${percentage}%`;
console.log('๐ Updated YouTube card progress:', urlHash, `${matches}/${total} (${percentage}%)`);
}
function removeYouTubeCard(url) {
const state = Object.values(youtubePlaylistStates).find(s => s.url === url);
if (state && state.cardElement) {
state.cardElement.remove();
// Remove from state
if (state.urlHash) {
delete youtubePlaylistStates[state.urlHash];
} else if (state.tempHash) {
delete youtubePlaylistStates[state.tempHash];
}
}
// Show placeholder if no cards left
const container = document.getElementById('youtube-playlist-container');
const cards = container.querySelectorAll('.youtube-playlist-card');
const placeholder = container.querySelector('.playlist-placeholder');
if (cards.length === 0 && placeholder) {
placeholder.style.display = 'block';
}
}
async function startYouTubeDiscovery(urlHash) {
try {
console.log('๐ Starting YouTube Spotify discovery for:', urlHash);
const response = await fetch(`/api/youtube/discovery/start/${urlHash}`, {
method: 'POST'
});
const result = await response.json();
if (result.error) {
showToast(`Error starting discovery: ${result.error}`, 'error');
return;
}
// Update frontend phase to match backend
const state = listenbrainzPlaylistStates[urlHash] || youtubePlaylistStates[urlHash];
if (state) {
state.phase = 'discovering';
}
// Update modal buttons to show "Discovering..." instead of "Start Discovery"
updateYouTubeModalButtons(urlHash, 'discovering');
// Start polling for progress
startYouTubeDiscoveryPolling(urlHash);
// Open discovery modal
openYouTubeDiscoveryModal(urlHash);
} catch (error) {
console.error('โ Error starting YouTube discovery:', error);
showToast(`Error starting discovery: ${error.message}`, 'error');
}
}
function startYouTubeDiscoveryPolling(urlHash) {
// Stop any existing polling
if (activeYouTubePollers[urlHash]) {
clearInterval(activeYouTubePollers[urlHash]);
}
// Phase 5: Subscribe via WebSocket
if (socketConnected) {
socket.emit('discovery:subscribe', { ids: [urlHash] });
_discoveryProgressCallbacks[urlHash] = (data) => {
if (data.error) {
if (activeYouTubePollers[urlHash]) { clearInterval(activeYouTubePollers[urlHash]); delete activeYouTubePollers[urlHash]; }
socket.emit('discovery:unsubscribe', { ids: [urlHash] }); delete _discoveryProgressCallbacks[urlHash];
return;
}
updateYouTubeCardProgress(urlHash, data);
const st = youtubePlaylistStates[urlHash];
if (st) { st.discoveryResults = data.results || []; st.discovery_results = data.results || []; st.discoveryProgress = data.progress || 0; st.spotifyMatches = data.spotify_matches || 0; st.spotify_matches = data.spotify_matches || 0; }
updateYouTubeDiscoveryModal(urlHash, data);
if (data.complete) {
if (activeYouTubePollers[urlHash]) { clearInterval(activeYouTubePollers[urlHash]); delete activeYouTubePollers[urlHash]; }
socket.emit('discovery:unsubscribe', { ids: [urlHash] }); delete _discoveryProgressCallbacks[urlHash];
// Update phase in state directly (updateYouTubeCardPhase may skip if no cardElement)
if (st) st.phase = 'discovered';
updateYouTubeCardPhase(urlHash, 'discovered');
updateYouTubeModalButtons(urlHash, 'discovered');
showToast('Discovery complete!', 'success');
}
};
}
const pollInterval = setInterval(async () => {
if (socketConnected) return; // Phase 5: WS handles updates
try {
const response = await fetch(`/api/youtube/discovery/status/${urlHash}`);
const status = await response.json();
if (status.error) {
console.error('โ Error polling YouTube discovery status:', status.error);
clearInterval(pollInterval);
delete activeYouTubePollers[urlHash];
return;
}
// Update card progress
updateYouTubeCardProgress(urlHash, status);
// Store discovery results and progress in state
const state = youtubePlaylistStates[urlHash];
if (state) {
state.discoveryResults = status.results || [];
state.discovery_results = status.results || [];
state.discoveryProgress = status.progress || 0;
state.spotifyMatches = status.spotify_matches || 0;
state.spotify_matches = status.spotify_matches || 0;
}
// Update modal if open
updateYouTubeDiscoveryModal(urlHash, status);
// Check if complete
if (status.complete) {
clearInterval(pollInterval);
delete activeYouTubePollers[urlHash];
// Update phase in state directly (updateYouTubeCardPhase may skip if no cardElement)
if (state) state.phase = 'discovered';
// Update card phase to discovered
updateYouTubeCardPhase(urlHash, 'discovered');
// Update modal buttons to show sync and download buttons
updateYouTubeModalButtons(urlHash, 'discovered');
console.log('โ
Discovery complete:', urlHash);
showToast('Discovery complete!', 'success');
}
} catch (error) {
console.error('โ Error polling YouTube discovery:', error);
clearInterval(pollInterval);
delete activeYouTubePollers[urlHash];
}
}, 1000);
activeYouTubePollers[urlHash] = pollInterval;
}
function stopYouTubeDiscoveryPolling(urlHash) {
if (activeYouTubePollers[urlHash]) {
clearInterval(activeYouTubePollers[urlHash]);
delete activeYouTubePollers[urlHash];
console.log('โน Stopped YouTube discovery polling for:', urlHash);
}
}
function openYouTubeDiscoveryModal(urlHash) {
// Check ListenBrainz state first, then fallback to YouTube state
const state = listenbrainzPlaylistStates[urlHash] || youtubePlaylistStates[urlHash];
if (!state || !state.playlist) {
console.error('โ No playlist data found for identifier:', urlHash);
return;
}
console.log('๐ต Opening discovery modal for:', state.playlist.name);
// Check if modal already exists
let modal = document.getElementById(`youtube-discovery-modal-${urlHash}`);
if (modal) {
// Modal exists, just show it
modal.classList.remove('hidden');
console.log('๐ Showing existing modal with preserved state');
console.log('๐ Current discovery results count:', state.discoveryResults?.length || state.discovery_results?.length || 0);
// Resume polling if discovery or sync is in progress
if (state.phase === 'discovering' && !activeYouTubePollers[urlHash]) {
console.log('๐ Resuming discovery polling...');
startYouTubeDiscoveryPolling(urlHash);
} else if (state.phase === 'syncing' && !activeYouTubePollers[urlHash]) {
console.log('๐ Resuming sync polling...');
if (state.is_tidal_playlist) {
startTidalSyncPolling(urlHash);
} else if (state.is_deezer_playlist) {
startDeezerSyncPolling(urlHash);
} else if (state.is_beatport_playlist) {
startBeatportSyncPolling(urlHash);
} else if (state.is_listenbrainz_playlist) {
startListenBrainzSyncPolling(urlHash);
} else {
startYouTubeSyncPolling(urlHash);
}
}
} else {
// Create new modal (support YouTube, Tidal, Deezer, Beatport, ListenBrainz, and Mirrored)
const isTidal = state.is_tidal_playlist;
const isDeezer = state.is_deezer_playlist;
const isBeatport = state.is_beatport_playlist;
const isListenBrainz = state.is_listenbrainz_playlist;
const isMirrored = state.is_mirrored_playlist;
const modalTitle = isMirrored ? '๐ต Mirrored Playlist Discovery' :
isDeezer ? '๐ต Deezer Playlist Discovery' :
isTidal ? '๐ต Tidal Playlist Discovery' :
isBeatport ? '๐ต Beatport Chart Discovery' :
isListenBrainz ? '๐ต ListenBrainz Playlist Discovery' :
'๐ต YouTube Playlist Discovery';
const sourceLabel = isMirrored ? (state.mirrored_source ? state.mirrored_source.charAt(0).toUpperCase() + state.mirrored_source.slice(1) : 'Source') :
isDeezer ? 'Deezer' :
isTidal ? 'Tidal' :
isBeatport ? 'Beatport' :
isListenBrainz ? 'LB' :
'YT';
const modalHtml = `
๐ ${currentMusicSourceName} Discovery Progress
${getInitialProgressText(state.phase, isTidal, isBeatport, isListenBrainz)}
${sourceLabel} Track
${sourceLabel} Artist
Status
${currentMusicSourceName} Track
${currentMusicSourceName} Artist
Album
Actions
${generateTableRowsFromState(state, urlHash)}
`;
// Add modal to DOM
document.body.insertAdjacentHTML('beforeend', modalHtml);
modal = document.getElementById(`youtube-discovery-modal-${urlHash}`);
// Store modal reference
state.modalElement = modal;
// Set initial progress if we have discovery results
if (state.discoveryResults && state.discoveryResults.length > 0) {
// Compute progress from results if discoveryProgress is missing/zero
let progress = state.discoveryProgress || 0;
const matches = state.spotifyMatches || 0;
if (progress === 0 && state.discoveryResults.length > 0 && state.playlist.tracks.length > 0) {
progress = Math.min(100, Math.round((state.discoveryResults.length / state.playlist.tracks.length) * 100));
}
const progressData = {
progress: progress,
spotify_matches: matches || state.discoveryResults.filter(r => r.status_class === 'found').length,
spotify_total: state.playlist.tracks.length,
results: state.discoveryResults
};
updateYouTubeDiscoveryModal(urlHash, progressData);
}
// Start polling immediately if modal is opened in syncing phase
if (state.phase === 'syncing') {
console.log('๐ Modal opened in syncing phase - starting immediate polling...');
if (state.is_tidal_playlist) {
startTidalSyncPolling(urlHash);
} else if (state.is_deezer_playlist) {
startDeezerSyncPolling(urlHash);
} else if (state.is_beatport_playlist) {
startBeatportSyncPolling(urlHash);
} else {
startYouTubeSyncPolling(urlHash);
}
}
console.log('โจ Created new modal with current state');
}
}
function getModalActionButtons(urlHash, phase, state = null) {
// Get state if not provided
if (!state) {
state = listenbrainzPlaylistStates[urlHash] || youtubePlaylistStates[urlHash];
}
const isTidal = state && state.is_tidal_playlist;
const isDeezer = state && state.is_deezer_playlist;
const isBeatport = state && state.is_beatport_playlist;
const isListenBrainz = state && state.is_listenbrainz_playlist;
// Validate data availability for buttons (support both naming conventions)
const hasDiscoveryResults = state && ((state.discoveryResults && state.discoveryResults.length > 0) || (state.discovery_results && state.discovery_results.length > 0));
const hasSpotifyMatches = state && ((state.spotifyMatches > 0) || (state.spotify_matches > 0));
const hasConvertedPlaylistId = state && state.convertedSpotifyPlaylistId;
switch (phase) {
case 'fresh':
case 'discovering':
// Show start discovery button for fresh playlists
if (phase === 'fresh') {
if (isListenBrainz) {
return `๐ Start Discovery `;
} else if (isTidal) {
return `๐ Start Discovery `;
} else if (isDeezer) {
return `๐ Start Discovery `;
} else if (isBeatport) {
return `๐ Start Discovery `;
} else {
return `๐ Start Discovery `;
}
} else {
// Discovering phase - show progress
return `๐ Discovering ${currentMusicSourceName} matches...
`;
}
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 += `๐ Sync This Playlist `;
} else if (isTidal) {
buttons += `๐ Sync This Playlist `;
} else if (isDeezer) {
buttons += `๐ Sync This Playlist `;
} else if (isBeatport) {
buttons += `๐ Sync This Playlist `;
} else {
buttons += `๐ Sync This Playlist `;
}
}
// Only show download button if we have matches or a converted playlist ID
if (hasSpotifyMatches || hasConvertedPlaylistId) {
if (isListenBrainz) {
// ListenBrainz uses same download function as others (to be implemented)
buttons += `๐ Download Missing Tracks `;
} else if (isTidal) {
buttons += `๐ Download Missing Tracks `;
} else if (isDeezer) {
buttons += `๐ Download Missing Tracks `;
} else if (isBeatport) {
buttons += `๐ Download Missing Tracks `;
} else {
buttons += `๐ Download Missing Tracks `;
}
}
// 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 += `๐ Retry Failed (${failedCount}) `;
}
}
if (!buttons) {
buttons = `โน๏ธ No Spotify matches found. Discovery complete but no tracks could be matched.
`;
}
return buttons;
case 'syncing':
if (isListenBrainz) {
return `
โ Cancel Sync
โช 0
/
โ 0
/
โ 0
(0 %)
`;
} else if (isTidal) {
return `
โ Cancel Sync
โช 0
/
โ 0
/
โ 0
(0 %)
`;
} else if (isDeezer) {
return `
โ Cancel Sync
โช 0
/
โ 0
/
โ 0
(0 %)
`;
} else if (isBeatport) {
return `
โ Cancel Sync
โช 0
/
โ 0
/
โ 0
(0 %)
`;
} else {
return `
โ Cancel Sync
โช 0
/
โ 0
/
โ 0
(0 %)
`;
}
case 'sync_complete':
let syncCompleteButtons = '';
// Only show sync button if there are Spotify matches
if (hasSpotifyMatches) {
if (isListenBrainz) {
syncCompleteButtons += `๐ Sync This Playlist `;
} else if (isTidal) {
syncCompleteButtons += `๐ Sync This Playlist `;
} else if (isBeatport) {
syncCompleteButtons += `๐ Sync This Playlist `;
} else {
syncCompleteButtons += `๐ Sync This Playlist `;
}
}
// Only show download button if we have matches or a converted playlist ID
if (hasSpotifyMatches || hasConvertedPlaylistId) {
if (isListenBrainz) {
syncCompleteButtons += `๐ Download Missing Tracks `;
} else if (isTidal) {
syncCompleteButtons += `๐ Download Missing Tracks `;
} else if (isBeatport) {
syncCompleteButtons += `๐ Download Missing Tracks `;
} else {
syncCompleteButtons += `๐ Download Missing Tracks `;
}
}
if (isListenBrainz) {
// ListenBrainz playlists don't need reset (they're read-only from ListenBrainz API)
} else if (isTidal) {
// Tidal doesn't have a reset function yet, but could be added
// syncCompleteButtons += `๐ Reset `;
} else if (isBeatport) {
syncCompleteButtons += `๐ Reset `;
} else {
syncCompleteButtons += `๐ Reset `;
}
return syncCompleteButtons;
default:
return '';
}
}
function getModalDescription(phase, isTidal = false, isBeatport = false, isListenBrainz = false, isMirrored = false, isDeezer = false) {
const source = isMirrored ? 'mirrored' : (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 isBeatport = state.is_beatport_playlist;
const isListenBrainz = state.is_listenbrainz_playlist;
const isMirrored = state.is_mirrored_playlist;
const platform = isMirrored ? 'mirrored' : (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 `
${trackName}
${artistName}
${result.status}
${result.spotify_track || '-'}
${result.spotify_artist || '-'}
${result.spotify_album || '-'}
${generateDiscoveryActionButton(result, urlHash, platform)}
`;
}).join('');
} else {
// Generate initial rows from playlist tracks
return generateInitialTableRows(state.playlist.tracks, isTidal, urlHash, isBeatport, isListenBrainz);
}
}
function generateInitialTableRows(tracks, isTidal = false, urlHash = '', isBeatport = false, isListenBrainz = false) {
return tracks.map((track, index) => {
// Handle different track formats based on platform
let trackName, artistName;
if (isListenBrainz) {
// ListenBrainz tracks have track_name and artist_name
trackName = track.track_name || 'Unknown Track';
artistName = track.artist_name || 'Unknown Artist';
} else {
// YouTube/Tidal/Beatport tracks have name and artists
trackName = track.name || 'Unknown Track';
artistName = track.artists ? (Array.isArray(track.artists) ? track.artists.join(', ') : track.artists) : 'Unknown Artist';
}
return `
${trackName}
${artistName}
๐ Pending...
-
-
-
-
`;
}).join('');
}
function formatDuration(durationMs) {
if (!durationMs) return '0:00';
const minutes = Math.floor(durationMs / 60000);
const seconds = Math.floor((durationMs % 60000) / 1000);
return `${minutes}:${seconds.toString().padStart(2, '0')}`;
}
/**
* Generate action button for discovery table row
*/
function generateDiscoveryActionButton(result, identifier, platform) {
// Show fix button for not_found, error, or any non-found status
const isNotFound = result.status === 'not_found' ||
result.status_class === 'not-found' ||
result.status === 'โ Not Found' ||
result.status === 'Not Found';
const isError = result.status === 'error' ||
result.status_class === 'error' ||
result.status === 'โ Error';
const isFound = result.status === 'found' ||
result.status_class === 'found' ||
result.status === 'โ
Found';
if (isNotFound || isError) {
return `
๐ง Fix
`;
}
// For found matches, show optional re-match button
if (isFound) {
return `
โป
`;
}
return '-';
}
function updateYouTubeDiscoveryModal(urlHash, status) {
const progressBar = document.getElementById(`youtube-discovery-progress-${urlHash}`);
const progressText = document.getElementById(`youtube-discovery-progress-text-${urlHash}`);
const tableBody = document.getElementById(`youtube-discovery-table-${urlHash}`);
if (!progressBar || !progressText || !tableBody) {
console.warn(`โ ๏ธ Missing modal elements for ${urlHash}:`, {
progressBar: !!progressBar,
progressText: !!progressText,
tableBody: !!tableBody
});
return;
}
// Update progress bar
progressBar.style.width = `${status.progress}%`;
progressText.textContent = `${status.spotify_matches} / ${status.spotify_total} tracks matched (${status.progress}%)`;
// Update table rows
status.results.forEach(result => {
const row = document.getElementById(`discovery-row-${urlHash}-${result.index}`);
if (!row) return;
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');
statusCell.textContent = result.status;
statusCell.className = `discovery-status ${result.status_class}`;
spotifyTrackCell.textContent = result.spotify_track || '-';
spotifyArtistCell.textContent = result.spotify_artist || '-';
spotifyAlbumCell.textContent = result.spotify_album || '-';
// Update actions cell with appropriate button
if (actionsCell) {
const state = listenbrainzPlaylistStates[urlHash] || youtubePlaylistStates[urlHash];
const platform = state?.is_mirrored_playlist ? 'mirrored' :
(state?.is_deezer_playlist ? 'deezer' :
(state?.is_listenbrainz_playlist ? 'listenbrainz' :
(state?.is_tidal_playlist ? 'tidal' :
(state?.is_beatport_playlist ? 'beatport' : 'youtube'))));
actionsCell.innerHTML = generateDiscoveryActionButton(result, urlHash, platform);
}
});
// Update action buttons if discovery is complete (progress = 100%)
if (status.progress >= 100) {
const state = listenbrainzPlaylistStates[urlHash] || youtubePlaylistStates[urlHash];
if (state && state.phase === 'discovered') {
const actionButtonsContainer = document.querySelector(`#youtube-discovery-modal-${urlHash} .modal-footer-left`);
if (actionButtonsContainer) {
actionButtonsContainer.innerHTML = getModalActionButtons(urlHash, 'discovered', state);
console.log(`โจ Updated action buttons for completed discovery: ${urlHash}`);
}
}
}
}
function refreshYouTubeDiscoveryModalTable(urlHash) {
const state = youtubePlaylistStates[urlHash];
if (!state || !state.modalElement) {
console.warn(`โ ๏ธ Cannot refresh modal table: no state or modal for ${urlHash}`);
return;
}
console.log(`๐ Refreshing modal table with ${state.discoveryResults?.length || 0} discovery results`);
// Update the table body with new discovery results
const tableBody = state.modalElement.querySelector(`#youtube-discovery-table-${urlHash}`);
if (tableBody) {
tableBody.innerHTML = generateTableRowsFromState(state, urlHash);
console.log(`โ
Modal table refreshed with discovery data`);
} else {
console.warn(`โ ๏ธ Could not find table body for modal ${urlHash}`);
}
// Update the progress bar and footer buttons too
if (state.discoveryResults && state.discoveryResults.length > 0) {
const progressData = {
progress: state.discoveryProgress || 100,
spotify_matches: state.spotifyMatches || 0,
spotify_total: state.playlist.tracks.length,
results: state.discoveryResults
};
updateYouTubeDiscoveryModal(urlHash, progressData);
}
}
function closeYouTubeDiscoveryModal(urlHash) {
const modal = document.getElementById(`youtube-discovery-modal-${urlHash}`);
if (modal) {
// Hide modal instead of removing it to preserve state
modal.classList.add('hidden');
console.log('๐ช Hidden YouTube discovery modal (preserving state):', urlHash);
}
// Handle phase reset for completed discovery (Tidal/Beatport pattern)
const state = youtubePlaylistStates[urlHash];
if (state) {
const isTidal = state.is_tidal_playlist;
const isDeezer = state.is_deezer_playlist;
const isBeatport = state.is_beatport_playlist;
// Reset to 'discovered' phase if modal is closed after completion (like Tidal does)
if (state.phase === 'sync_complete' || state.phase === 'download_complete') {
console.log(`๐งน [Modal Close] Resetting ${isDeezer ? 'Deezer' : (isBeatport ? 'Beatport' : (isTidal ? 'Tidal' : 'YouTube'))} state after completion`);
if (isDeezer) {
// Deezer: Extract playlist ID and reset Deezer state
const deezerPlaylistId = state.deezer_playlist_id || null;
if (deezerPlaylistId && deezerPlaylistStates[deezerPlaylistId]) {
const preservedData = {
playlist: deezerPlaylistStates[deezerPlaylistId].playlist,
discovery_results: deezerPlaylistStates[deezerPlaylistId].discovery_results,
spotify_matches: deezerPlaylistStates[deezerPlaylistId].spotify_matches,
discovery_progress: deezerPlaylistStates[deezerPlaylistId].discovery_progress,
convertedSpotifyPlaylistId: deezerPlaylistStates[deezerPlaylistId].convertedSpotifyPlaylistId
};
delete deezerPlaylistStates[deezerPlaylistId].download_process_id;
delete deezerPlaylistStates[deezerPlaylistId].phase;
Object.assign(deezerPlaylistStates[deezerPlaylistId], preservedData);
deezerPlaylistStates[deezerPlaylistId].phase = 'discovered';
updateDeezerCardPhase(deezerPlaylistId, 'discovered');
try {
fetch(`/api/deezer/update_phase/${deezerPlaylistId}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ phase: 'discovered' })
});
} catch (error) {
console.warn('Error updating backend Deezer phase:', error);
}
}
} else if (isTidal) {
// Tidal: Extract playlist ID and reset Tidal state
const tidalPlaylistId = state.tidal_playlist_id || null;
if (tidalPlaylistId && tidalPlaylistStates[tidalPlaylistId]) {
// Preserve discovery data but reset phase
const preservedData = {
playlist: tidalPlaylistStates[tidalPlaylistId].playlist,
discovery_results: tidalPlaylistStates[tidalPlaylistId].discovery_results,
spotify_matches: tidalPlaylistStates[tidalPlaylistId].spotify_matches,
discovery_progress: tidalPlaylistStates[tidalPlaylistId].discovery_progress,
convertedSpotifyPlaylistId: tidalPlaylistStates[tidalPlaylistId].convertedSpotifyPlaylistId
};
// Clear download state
delete tidalPlaylistStates[tidalPlaylistId].download_process_id;
delete tidalPlaylistStates[tidalPlaylistId].phase;
// Restore preserved data and set to discovered phase
Object.assign(tidalPlaylistStates[tidalPlaylistId], preservedData);
tidalPlaylistStates[tidalPlaylistId].phase = 'discovered';
updateTidalCardPhase(tidalPlaylistId, 'discovered');
// Update backend state
try {
fetch(`/api/tidal/update_phase/${tidalPlaylistId}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ phase: 'discovered' })
});
} catch (error) {
console.warn('โ ๏ธ Error updating backend Tidal phase:', error);
}
}
} else if (isBeatport) {
// Beatport: Reset chart state
const chartHash = state.beatport_chart_hash || urlHash;
if (beatportChartStates[chartHash]) {
beatportChartStates[chartHash].phase = 'discovered';
updateBeatportCardPhase(chartHash, 'discovered');
// Update backend state
try {
fetch(`/api/beatport/charts/update-phase/${chartHash}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ phase: 'discovered' })
});
} catch (error) {
console.warn('โ ๏ธ Error updating backend Beatport phase:', error);
}
}
} else {
// YouTube: Reset to discovered phase
updateYouTubeCardPhase(urlHash, 'discovered');
// Update backend state
try {
fetch(`/api/youtube/update_phase/${urlHash}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ phase: 'discovered' })
});
} catch (error) {
console.warn('โ ๏ธ Error updating backend YouTube phase:', error);
}
}
// Reset frontend state to discovered
state.phase = 'discovered';
console.log(`โ
[Modal Close] Reset to discovered phase: ${urlHash}`);
}
}
// Keep modal reference and all state intact
// Discovery polling continues in background if active
}
// ===============================
// YOUTUBE SYNC FUNCTIONALITY
// ===============================
async function startYouTubePlaylistSync(urlHash) {
try {
console.log('๐ Starting YouTube playlist sync:', urlHash);
const response = await fetch(`/api/youtube/sync/start/${urlHash}`, {
method: 'POST'
});
const result = await response.json();
if (result.error) {
showToast(`Error starting sync: ${result.error}`, 'error');
return;
}
// Capture sync_playlist_id for WebSocket subscription
const syncPlaylistId = result.sync_playlist_id;
const ytState = youtubePlaylistStates[urlHash];
if (ytState) ytState.syncPlaylistId = syncPlaylistId;
// Update card and modal to syncing phase
updateYouTubeCardPhase(urlHash, 'syncing');
// Update modal buttons if modal is open
updateYouTubeModalButtons(urlHash, 'syncing');
// Start sync polling
startYouTubeSyncPolling(urlHash, syncPlaylistId);
showToast('YouTube playlist sync started!', 'success');
} catch (error) {
console.error('โ Error starting YouTube sync:', error);
showToast(`Error starting sync: ${error.message}`, 'error');
}
}
function startYouTubeSyncPolling(urlHash, syncPlaylistId) {
// Stop any existing polling
if (activeYouTubePollers[urlHash]) {
clearInterval(activeYouTubePollers[urlHash]);
}
// Resolve syncPlaylistId from argument or stored state
const ytState = youtubePlaylistStates[urlHash];
syncPlaylistId = syncPlaylistId || (ytState && ytState.syncPlaylistId);
// Phase 6: Subscribe via WebSocket
if (socketConnected && syncPlaylistId) {
socket.emit('sync:subscribe', { playlist_ids: [syncPlaylistId] });
_syncProgressCallbacks[syncPlaylistId] = (data) => {
const progress = data.progress || {};
updateYouTubeCardSyncProgress(urlHash, progress);
updateYouTubeModalSyncProgress(urlHash, progress);
if (data.status === 'finished') {
if (activeYouTubePollers[urlHash]) { clearInterval(activeYouTubePollers[urlHash]); delete activeYouTubePollers[urlHash]; }
socket.emit('sync:unsubscribe', { playlist_ids: [syncPlaylistId] });
delete _syncProgressCallbacks[syncPlaylistId];
updateYouTubeCardPhase(urlHash, 'sync_complete');
updateYouTubeModalButtons(urlHash, 'sync_complete');
showToast('YouTube playlist sync complete!', 'success');
} else if (data.status === 'error' || data.status === 'cancelled') {
if (activeYouTubePollers[urlHash]) { clearInterval(activeYouTubePollers[urlHash]); delete activeYouTubePollers[urlHash]; }
socket.emit('sync:unsubscribe', { playlist_ids: [syncPlaylistId] });
delete _syncProgressCallbacks[syncPlaylistId];
updateYouTubeCardPhase(urlHash, 'discovered');
updateYouTubeModalButtons(urlHash, 'discovered');
showToast(`Sync failed: ${data.error || 'Unknown error'}`, 'error');
}
};
}
// Define the polling function (HTTP fallback)
const pollFunction = async () => {
if (socketConnected) return; // Phase 6: WS handles updates
try {
const response = await fetch(`/api/youtube/sync/status/${urlHash}`);
const status = await response.json();
if (status.error) {
console.error('โ Error polling YouTube sync status:', status.error);
clearInterval(pollInterval);
delete activeYouTubePollers[urlHash];
return;
}
updateYouTubeCardSyncProgress(urlHash, status.progress);
updateYouTubeModalSyncProgress(urlHash, status.progress);
if (status.complete) {
clearInterval(pollInterval);
delete activeYouTubePollers[urlHash];
updateYouTubeCardPhase(urlHash, 'sync_complete');
updateYouTubeModalButtons(urlHash, 'sync_complete');
showToast('YouTube playlist sync complete!', 'success');
} else if (status.sync_status === 'error') {
clearInterval(pollInterval);
delete activeYouTubePollers[urlHash];
updateYouTubeCardPhase(urlHash, 'discovered');
updateYouTubeModalButtons(urlHash, 'discovered');
showToast(`Sync failed: ${status.error || 'Unknown error'}`, 'error');
}
} catch (error) {
console.error('โ Error polling YouTube sync:', error);
if (activeYouTubePollers[urlHash]) {
clearInterval(activeYouTubePollers[urlHash]);
delete activeYouTubePollers[urlHash];
}
}
};
// Run immediately to get current status (skip if WS active)
if (!socketConnected) pollFunction();
// Then continue polling at regular intervals
const pollInterval = setInterval(pollFunction, 1000);
activeYouTubePollers[urlHash] = pollInterval;
}
async function cancelYouTubeSync(urlHash) {
try {
console.log('โ Cancelling YouTube sync:', urlHash);
const response = await fetch(`/api/youtube/sync/cancel/${urlHash}`, {
method: 'POST'
});
const result = await response.json();
if (result.error) {
showToast(`Error cancelling sync: ${result.error}`, 'error');
return;
}
// Stop polling
if (activeYouTubePollers[urlHash]) {
clearInterval(activeYouTubePollers[urlHash]);
delete activeYouTubePollers[urlHash];
}
// Phase 6: Clean up WS subscription
const ytCancelState = youtubePlaylistStates[urlHash];
const ytSyncId = ytCancelState && ytCancelState.syncPlaylistId;
if (ytSyncId && _syncProgressCallbacks[ytSyncId]) {
if (socketConnected) socket.emit('sync:unsubscribe', { playlist_ids: [ytSyncId] });
delete _syncProgressCallbacks[ytSyncId];
}
// Revert to discovered phase
updateYouTubeCardPhase(urlHash, 'discovered');
updateYouTubeModalButtons(urlHash, 'discovered');
showToast('YouTube sync cancelled', 'info');
} catch (error) {
console.error('โ Error cancelling YouTube sync:', error);
showToast(`Error cancelling sync: ${error.message}`, 'error');
}
}
function updateYouTubeCardSyncProgress(urlHash, progress) {
const state = youtubePlaylistStates[urlHash];
if (!state || !state.cardElement || !progress) return;
const card = state.cardElement;
const progressElement = card.querySelector('.playlist-card-progress');
// Build clean status counter HTML exactly like Spotify cards
let statusCounterHTML = '';
if (progress && progress.total_tracks > 0) {
const matched = progress.matched_tracks || 0;
const failed = progress.failed_tracks || 0;
const total = progress.total_tracks || 0;
const processed = matched + failed;
const percentage = total > 0 ? Math.round((processed / total) * 100) : 0;
statusCounterHTML = `
โช ${total}
/
โ ${matched}
/
โ ${failed}
(${percentage}%)
`;
}
// Only update if we have valid sync progress, otherwise preserve existing discovery results
if (statusCounterHTML) {
progressElement.innerHTML = statusCounterHTML;
}
console.log(`๐ Updated YouTube sync progress: โช ${progress?.total_tracks || 0} / โ ${progress?.matched_tracks || 0} / โ ${progress?.failed_tracks || 0}`);
}
function updateYouTubeModalSyncProgress(urlHash, progress) {
const statusDisplay = document.getElementById(`youtube-sync-status-${urlHash}`);
if (!statusDisplay || !progress) return;
console.log(`๐ Updating YouTube modal sync progress for ${urlHash}:`, progress);
// Update individual counters exactly like Spotify sync
const totalEl = document.getElementById(`youtube-total-${urlHash}`);
const matchedEl = document.getElementById(`youtube-matched-${urlHash}`);
const failedEl = document.getElementById(`youtube-failed-${urlHash}`);
const percentageEl = document.getElementById(`youtube-percentage-${urlHash}`);
const total = progress.total_tracks || 0;
const matched = progress.matched_tracks || 0;
const failed = progress.failed_tracks || 0;
if (totalEl) totalEl.textContent = total;
if (matchedEl) matchedEl.textContent = matched;
if (failedEl) failedEl.textContent = failed;
// Calculate percentage like Spotify sync
if (total > 0) {
const processed = matched + failed;
const percentage = Math.round((processed / total) * 100);
if (percentageEl) percentageEl.textContent = percentage;
}
console.log(`๐ YouTube modal updated: โช ${total} / โ ${matched} / โ ${failed} (${Math.round((matched + failed) / total * 100)}%)`);
}
function updateYouTubeModalButtons(urlHash, phase) {
const modal = document.getElementById(`youtube-discovery-modal-${urlHash}`);
if (!modal) return;
const footerLeft = modal.querySelector('.modal-footer-left');
if (footerLeft) {
footerLeft.innerHTML = getModalActionButtons(urlHash, phase);
}
}
// ===============================
// YOUTUBE DOWNLOAD MISSING TRACKS
// ===============================
async function startYouTubeDownloadMissing(urlHash) {
try {
console.log('๐ Starting download missing tracks:', urlHash);
// Check both YouTube and ListenBrainz states (like Beatport does)
const state = youtubePlaylistStates[urlHash] || listenbrainzPlaylistStates[urlHash];
// Support both camelCase and snake_case
const discoveryResults = state?.discoveryResults || state?.discovery_results;
if (!state || !discoveryResults) {
showToast('No discovery results available for download', 'error');
return;
}
// Determine source type (prefix removed - no longer needed)
const isListenBrainz = state.is_listenbrainz_playlist;
const isBeatport = state.is_beatport_playlist;
const isTidal = state.is_tidal_playlist;
const isDeezer = state.is_deezer_playlist;
// Convert discovery results to a format compatible with the download modal
const spotifyTracks = discoveryResults
.filter(result => result.spotify_data || (result.spotify_track && result.status_class === 'found'))
.map(result => {
if (result.spotify_data) {
return result.spotify_data;
} else {
// Build from individual fields (automatic discovery format)
// Convert album to proper object format for wishlist compatibility
const albumData = result.spotify_album || 'Unknown Album';
const albumObject = typeof albumData === 'object' && albumData !== null
? albumData
: {
name: typeof albumData === 'string' ? albumData : 'Unknown Album',
album_type: 'album',
images: []
};
return {
id: result.spotify_id || 'unknown',
name: result.spotify_track || 'Unknown Track',
artists: result.spotify_artist ? [result.spotify_artist] : ['Unknown Artist'],
album: albumObject,
duration_ms: 0
};
}
});
if (spotifyTracks.length === 0) {
showToast('No Spotify matches found for download', 'error');
return;
}
// Create a virtual playlist for the download system
const virtualPlaylistId = isListenBrainz ? `listenbrainz_${urlHash}` : (isDeezer ? `deezer_${urlHash}` : (isBeatport ? `beatport_${urlHash}` : (isTidal ? `tidal_${urlHash}` : `youtube_${urlHash}`)));
const playlistName = state.playlist.name;
// Store reference for card navigation
state.convertedSpotifyPlaylistId = virtualPlaylistId;
// Close the discovery modal if it's open
const discoveryModal = document.getElementById(`youtube-discovery-modal-${urlHash}`);
if (discoveryModal) {
discoveryModal.classList.add('hidden');
console.log('๐ Closed YouTube discovery modal to show download modal');
}
// Open download missing tracks modal for YouTube playlist
await openDownloadMissingModalForYouTube(virtualPlaylistId, playlistName, spotifyTracks);
// Phase will change to 'downloading' when user clicks "Begin Analysis" button
} catch (error) {
console.error('โ Error starting download missing tracks:', error);
showToast(`Error starting downloads: ${error.message}`, 'error');
}
}
async function resetYouTubePlaylist(urlHash) {
const state = youtubePlaylistStates[urlHash];
if (!state) return;
try {
console.log(`๐ Resetting YouTube playlist to fresh state: ${state.playlist.name}`);
// Call backend reset endpoint
const response = await fetch(`/api/youtube/reset/${urlHash}`, {
method: 'POST'
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || 'Failed to reset playlist');
}
// Stop any active polling
if (activeYouTubePollers[urlHash]) {
clearInterval(activeYouTubePollers[urlHash]);
delete activeYouTubePollers[urlHash];
}
// Update client state to match backend reset
state.phase = 'fresh';
state.discoveryResults = [];
state.discoveryProgress = 0;
state.spotifyMatches = 0;
state.syncPlaylistId = null;
state.syncProgress = {};
state.convertedSpotifyPlaylistId = null;
// Update card to reflect fresh state
updateYouTubeCardPhase(urlHash, 'fresh');
updateYouTubeCardProgress(urlHash, {
discovery_progress: 0,
spotify_matches: 0,
spotify_total: state.playlist.tracks.length
});
// Close modal
closeYouTubeDiscoveryModal(urlHash);
showToast(`Reset "${state.playlist.name}" to fresh state`, 'success');
console.log(`โ
Successfully reset YouTube playlist: ${state.playlist.name}`);
} catch (error) {
console.error(`โ Error resetting YouTube playlist:`, error);
showToast(`Error resetting playlist: ${error.message}`, 'error');
}
}
async function resetBeatportChart(urlHash) {
const state = youtubePlaylistStates[urlHash];
const chartState = beatportChartStates[urlHash];
if (!state || !state.is_beatport_playlist || !chartState) {
console.error('โ Invalid Beatport chart state for reset');
return;
}
try {
console.log(`๐ Resetting Beatport chart to fresh state: ${state.playlist.name}`);
// Call backend reset endpoint for Beatport
const chartHash = state.beatport_chart_hash || urlHash;
const response = await fetch(`/api/beatport/charts/update-phase/${chartHash}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
phase: 'fresh',
reset: true
})
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || 'Failed to reset Beatport chart');
}
// Stop any active polling
if (activeYouTubePollers[urlHash]) {
clearInterval(activeYouTubePollers[urlHash]);
delete activeYouTubePollers[urlHash];
}
// Update client state to match backend reset
state.phase = 'fresh';
state.discoveryResults = [];
state.discoveryProgress = 0;
state.spotifyMatches = 0;
state.discovery_results = [];
state.discovery_progress = 0;
state.spotify_matches = 0;
state.syncPlaylistId = null;
state.syncProgress = {};
state.convertedSpotifyPlaylistId = null;
// Update Beatport chart state
chartState.phase = 'fresh';
// Update card to reflect fresh state
updateBeatportCardPhase(chartHash, 'fresh');
updateBeatportCardProgress(chartHash, {
spotify_total: state.playlist.tracks.length,
spotify_matches: 0,
failed: 0
});
// Close modal
closeYouTubeDiscoveryModal(urlHash);
showToast(`Reset "${state.playlist.name}" to fresh state`, 'success');
console.log(`โ
Successfully reset Beatport chart: ${state.playlist.name}`);
} catch (error) {
console.error(`โ Error resetting Beatport chart:`, error);
showToast(`Error resetting chart: ${error.message}`, 'error');
}
}
// ============================================================================
// LISTENBRAINZ PLAYLIST DISCOVERY & SYNC
// ============================================================================
function startListenBrainzDiscoveryPolling(playlistMbid) {
console.log(`๐ Starting ListenBrainz discovery polling for: ${playlistMbid}`);
// Stop any existing polling (reuse YouTube polling infrastructure)
if (activeYouTubePollers[playlistMbid]) {
clearInterval(activeYouTubePollers[playlistMbid]);
}
// Phase 5: Subscribe via WebSocket
if (socketConnected) {
socket.emit('discovery:subscribe', { ids: [playlistMbid] });
_discoveryProgressCallbacks[playlistMbid] = (data) => {
if (data.error) {
if (activeYouTubePollers[playlistMbid]) { clearInterval(activeYouTubePollers[playlistMbid]); delete activeYouTubePollers[playlistMbid]; }
socket.emit('discovery:unsubscribe', { ids: [playlistMbid] }); delete _discoveryProgressCallbacks[playlistMbid];
return;
}
if (listenbrainzPlaylistStates[playlistMbid]) {
const transformed = {
progress: data.progress || 0, spotify_matches: data.spotify_matches || 0, spotify_total: data.spotify_total || 0,
results: (data.results || []).map((r, i) => ({
index: r.index !== undefined ? r.index : i,
yt_track: r.lb_track || r.track_name || 'Unknown',
yt_artist: r.lb_artist || r.artist_name || 'Unknown',
status: (r.status === 'found' || r.status === 'โ
Found' || r.status_class === 'found') ? 'โ
Found' : (r.status === 'error' ? 'โ Error' : 'โ Not Found'),
status_class: r.status_class || ((r.status === 'found' || r.status === 'โ
Found') ? 'found' : (r.status === 'error' ? 'error' : 'not-found')),
spotify_track: r.spotify_data ? r.spotify_data.name : (r.spotify_track || '-'),
spotify_artist: r.spotify_data ? (r.spotify_data.artists && r.spotify_data.artists[0] ? (typeof r.spotify_data.artists[0] === 'object' ? r.spotify_data.artists[0].name : r.spotify_data.artists[0]) : '-') : (r.spotify_artist || '-'),
spotify_album: r.spotify_data ? (typeof r.spotify_data.album === 'object' ? r.spotify_data.album.name : r.spotify_data.album) || '-' : (r.spotify_album || '-'),
spotify_data: r.spotify_data, duration: r.duration || '0:00'
})),
complete: data.complete || data.phase === 'discovered'
};
const st = listenbrainzPlaylistStates[playlistMbid];
st.discovery_results = data.results || []; st.discoveryResults = transformed.results;
st.discovery_progress = data.progress || 0; st.discoveryProgress = data.progress || 0;
st.spotify_matches = data.spotify_matches || 0; st.spotifyMatches = data.spotify_matches || 0;
st.spotify_total = data.spotify_total || 0; st.spotifyTotal = data.spotify_total || 0;
updateYouTubeDiscoveryModal(playlistMbid, transformed);
}
if (data.complete || data.phase === 'discovered') {
if (activeYouTubePollers[playlistMbid]) { clearInterval(activeYouTubePollers[playlistMbid]); delete activeYouTubePollers[playlistMbid]; }
socket.emit('discovery:unsubscribe', { ids: [playlistMbid] }); delete _discoveryProgressCallbacks[playlistMbid];
if (listenbrainzPlaylistStates[playlistMbid]) listenbrainzPlaylistStates[playlistMbid].phase = 'discovered';
updateYouTubeModalButtons(playlistMbid, 'discovered');
const playlistIdEl = `discover-lb-playlist-${playlistMbid}`;
const syncBtn = document.getElementById(`${playlistIdEl}-sync-btn`);
if (syncBtn) syncBtn.style.display = 'inline-block';
showToast('ListenBrainz discovery complete!', 'success');
}
};
}
const pollInterval = setInterval(async () => {
if (socketConnected) return; // Phase 5: WS handles updates
try {
const response = await fetch(`/api/listenbrainz/discovery/status/${playlistMbid}`);
const status = await response.json();
if (status.error) {
console.error('โ Error polling ListenBrainz discovery status:', status.error);
clearInterval(pollInterval);
delete activeYouTubePollers[playlistMbid];
return;
}
// Update state and modal (reuse YouTube infrastructure like Beatport/Tidal)
if (listenbrainzPlaylistStates[playlistMbid]) {
// Transform ListenBrainz results to YouTube modal format (like Beatport does)
const transformedStatus = {
progress: status.progress || 0,
spotify_matches: status.spotify_matches || 0,
spotify_total: status.spotify_total || 0,
results: (status.results || []).map((result, index) => ({
index: result.index !== undefined ? result.index : index,
yt_track: result.lb_track || result.track_name || 'Unknown',
yt_artist: result.lb_artist || result.artist_name || 'Unknown',
status: result.status === 'found' || result.status === 'โ
Found' || result.status_class === 'found' ? 'โ
Found' : (result.status === 'error' ? 'โ Error' : 'โ Not Found'),
status_class: result.status_class || (result.status === 'found' || result.status === 'โ
Found' ? 'found' : (result.status === 'error' ? 'error' : 'not-found')),
spotify_track: result.spotify_data ? result.spotify_data.name : (result.spotify_track || '-'),
spotify_artist: result.spotify_data ? (result.spotify_data.artists && result.spotify_data.artists[0] ? (typeof result.spotify_data.artists[0] === 'object' ? result.spotify_data.artists[0].name : result.spotify_data.artists[0]) : '-') : (result.spotify_artist || '-'),
spotify_album: result.spotify_data ? (typeof result.spotify_data.album === 'object' ? result.spotify_data.album.name : result.spotify_data.album) || '-' : (result.spotify_album || '-'),
spotify_data: result.spotify_data,
duration: result.duration || '0:00'
})),
complete: status.complete || status.phase === 'discovered'
};
// Store both raw and transformed results (support both naming conventions)
listenbrainzPlaylistStates[playlistMbid].discovery_results = status.results || [];
listenbrainzPlaylistStates[playlistMbid].discoveryResults = transformedStatus.results;
listenbrainzPlaylistStates[playlistMbid].discovery_progress = status.progress || 0;
listenbrainzPlaylistStates[playlistMbid].discoveryProgress = status.progress || 0;
listenbrainzPlaylistStates[playlistMbid].spotify_matches = status.spotify_matches || 0;
listenbrainzPlaylistStates[playlistMbid].spotifyMatches = status.spotify_matches || 0; // camelCase for modal
listenbrainzPlaylistStates[playlistMbid].spotify_total = status.spotify_total || 0;
listenbrainzPlaylistStates[playlistMbid].spotifyTotal = status.spotify_total || 0; // camelCase for modal
// Update modal if open
updateYouTubeDiscoveryModal(playlistMbid, transformedStatus);
}
// Check if complete
if (status.complete || status.phase === 'discovered') {
clearInterval(pollInterval);
delete activeYouTubePollers[playlistMbid];
// Update phase in backend for persistence (like Beatport does)
try {
await fetch(`/api/listenbrainz/update-phase/${playlistMbid}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ phase: 'discovered' })
});
console.log('โ
Updated ListenBrainz backend phase to discovered');
} catch (error) {
console.warn('โ ๏ธ Failed to update backend phase:', error);
}
// Update phase in frontend state
if (listenbrainzPlaylistStates[playlistMbid]) {
listenbrainzPlaylistStates[playlistMbid].phase = 'discovered';
}
// Update modal buttons to show sync and download buttons
updateYouTubeModalButtons(playlistMbid, 'discovered');
// Show sync button in playlist listing (hidden by default until discovered)
const playlistId = `discover-lb-playlist-${playlistMbid}`;
const syncBtn = document.getElementById(`${playlistId}-sync-btn`);
if (syncBtn) {
syncBtn.style.display = 'inline-block';
console.log('โ
Showing sync button after discovery completion');
}
console.log('โ
ListenBrainz discovery complete:', playlistMbid);
showToast('ListenBrainz discovery complete!', 'success');
}
} catch (error) {
console.error('โ Error polling ListenBrainz discovery:', error);
clearInterval(pollInterval);
delete activeYouTubePollers[playlistMbid];
}
}, 1000);
activeYouTubePollers[playlistMbid] = pollInterval;
}
function startListenBrainzSyncPolling(playlistMbid, syncPlaylistId) {
// Stop any existing polling
if (activeYouTubePollers[playlistMbid]) {
clearInterval(activeYouTubePollers[playlistMbid]);
}
// Resolve syncPlaylistId from argument or stored state
const lbState = listenbrainzPlaylistStates[playlistMbid];
syncPlaylistId = syncPlaylistId || (lbState && lbState.syncPlaylistId);
// Phase 6: Subscribe via WebSocket
if (socketConnected && syncPlaylistId) {
socket.emit('sync:subscribe', { playlist_ids: [syncPlaylistId] });
_syncProgressCallbacks[syncPlaylistId] = (data) => {
const progress = data.progress || {};
updateYouTubeModalSyncProgress(playlistMbid, progress);
if (data.status === 'finished') {
if (activeYouTubePollers[playlistMbid]) { clearInterval(activeYouTubePollers[playlistMbid]); delete activeYouTubePollers[playlistMbid]; }
socket.emit('sync:unsubscribe', { playlist_ids: [syncPlaylistId] });
delete _syncProgressCallbacks[syncPlaylistId];
updateYouTubeModalButtons(playlistMbid, 'sync_complete');
showToast('ListenBrainz playlist sync complete!', 'success');
} else if (data.status === 'error' || data.status === 'cancelled') {
if (activeYouTubePollers[playlistMbid]) { clearInterval(activeYouTubePollers[playlistMbid]); delete activeYouTubePollers[playlistMbid]; }
socket.emit('sync:unsubscribe', { playlist_ids: [syncPlaylistId] });
delete _syncProgressCallbacks[syncPlaylistId];
updateYouTubeModalButtons(playlistMbid, 'discovered');
showToast(`Sync failed: ${data.error || 'Unknown error'}`, 'error');
}
};
}
// Define the polling function (HTTP fallback)
const pollFunction = async () => {
if (socketConnected) return; // Phase 6: WS handles updates
try {
const response = await fetch(`/api/listenbrainz/sync/status/${playlistMbid}`);
const status = await response.json();
if (status.error) {
console.error('โ Error polling ListenBrainz sync status:', status.error);
clearInterval(pollInterval);
delete activeYouTubePollers[playlistMbid];
return;
}
updateYouTubeModalSyncProgress(playlistMbid, status.progress);
if (status.complete) {
clearInterval(pollInterval);
delete activeYouTubePollers[playlistMbid];
updateYouTubeModalButtons(playlistMbid, 'sync_complete');
showToast('ListenBrainz playlist sync complete!', 'success');
} else if (status.sync_status === 'error') {
clearInterval(pollInterval);
delete activeYouTubePollers[playlistMbid];
updateYouTubeModalButtons(playlistMbid, 'discovered');
showToast(`Sync failed: ${status.error || 'Unknown error'}`, 'error');
}
} catch (error) {
console.error('โ Error polling ListenBrainz sync:', error);
if (activeYouTubePollers[playlistMbid]) {
clearInterval(activeYouTubePollers[playlistMbid]);
delete activeYouTubePollers[playlistMbid];
}
}
};
// Run immediately to get current status (skip if WS active)
if (!socketConnected) pollFunction();
// Then continue polling at regular intervals
const pollInterval = setInterval(pollFunction, 1000);
activeYouTubePollers[playlistMbid] = pollInterval;
}
async function startListenBrainzDiscovery(playlistMbid) {
const state = listenbrainzPlaylistStates[playlistMbid];
if (!state) {
console.error('โ No ListenBrainz playlist state found');
return;
}
try {
console.log('๐ Starting ListenBrainz discovery for:', state.playlist.name);
// Update local phase to discovering
state.phase = 'discovering';
state.status = 'discovering';
// Call backend to start discovery worker
const response = await fetch(`/api/listenbrainz/discovery/start/${playlistMbid}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
playlist: state.playlist
})
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || 'Failed to start discovery');
}
console.log('โ
ListenBrainz discovery started on backend');
// Start polling for progress
startListenBrainzDiscoveryPolling(playlistMbid);
// Update modal to show discovering state
updateYouTubeDiscoveryModal(playlistMbid, {
phase: 'discovering',
progress: 0,
results: []
});
showToast('Starting ListenBrainz discovery...', 'info');
} catch (error) {
console.error('โ Error starting ListenBrainz discovery:', error);
showToast(`Error: ${error.message}`, 'error');
// Revert phase on error
state.phase = 'fresh';
state.status = 'pending';
}
}
async function startListenBrainzPlaylistSync(playlistMbid) {
const state = listenbrainzPlaylistStates[playlistMbid];
if (!state) {
console.error('โ No ListenBrainz playlist state found');
return;
}
try {
console.log('๐ Starting ListenBrainz sync for:', state.playlist.name);
// Check if being called from playlist listing (has UI elements) or modal
const listingPlaylistId = `discover-lb-playlist-${playlistMbid}`;
const statusDisplay = document.getElementById(`${listingPlaylistId}-sync-status`);
const isFromListing = statusDisplay !== null;
if (isFromListing) {
console.log('๐ Sync initiated from playlist listing');
// Show status display in listing
statusDisplay.style.display = 'block';
const syncButton = document.getElementById(`${listingPlaylistId}-sync-btn`);
if (syncButton) {
syncButton.disabled = true;
syncButton.style.opacity = '0.5';
}
}
// Call backend to start sync
const response = await fetch(`/api/listenbrainz/sync/start/${playlistMbid}`, {
method: 'POST'
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || 'Failed to start sync');
}
// Capture sync_playlist_id for WebSocket subscription
const result = await response.json();
const syncPlaylistId = result.sync_playlist_id;
if (state) state.syncPlaylistId = syncPlaylistId;
// Update phase to syncing
state.phase = 'syncing';
// Start polling for sync progress
if (isFromListing) {
startListenBrainzListingSyncPolling(playlistMbid, listingPlaylistId, syncPlaylistId);
} else {
startListenBrainzSyncPolling(playlistMbid, syncPlaylistId);
updateYouTubeModalButtons(playlistMbid, 'syncing');
}
showToast('Starting ListenBrainz sync...', 'info');
} catch (error) {
console.error('โ Error starting ListenBrainz sync:', error);
showToast(`Error: ${error.message}`, 'error');
}
}
function startListenBrainzListingSyncPolling(playlistMbid, listingPlaylistId, syncPlaylistId) {
console.log(`๐ Starting listing sync polling for: ${playlistMbid} (UI: ${listingPlaylistId})`);
// Stop any existing polling
if (activeYouTubePollers[playlistMbid]) {
clearInterval(activeYouTubePollers[playlistMbid]);
}
// Resolve syncPlaylistId from argument or stored state
const lbState = listenbrainzPlaylistStates[playlistMbid];
syncPlaylistId = syncPlaylistId || (lbState && lbState.syncPlaylistId);
// Phase 6: Subscribe via WebSocket
if (socketConnected && syncPlaylistId) {
socket.emit('sync:subscribe', { playlist_ids: [syncPlaylistId] });
_syncProgressCallbacks[syncPlaylistId] = (data) => {
const progress = data.progress || {};
const total = progress.total_tracks || 0;
const matched = progress.matched_tracks || 0;
const failed = progress.failed_tracks || 0;
const percentage = total > 0 ? Math.round((matched / total) * 100) : 0;
const totalEl = document.getElementById(`${listingPlaylistId}-sync-total`);
const matchedEl = document.getElementById(`${listingPlaylistId}-sync-matched`);
const failedEl = document.getElementById(`${listingPlaylistId}-sync-failed`);
const percentageEl = document.getElementById(`${listingPlaylistId}-sync-percentage`);
if (totalEl) totalEl.textContent = total;
if (matchedEl) matchedEl.textContent = matched;
if (failedEl) failedEl.textContent = failed;
if (percentageEl) percentageEl.textContent = percentage;
if (data.status === 'finished') {
if (activeYouTubePollers[playlistMbid]) { clearInterval(activeYouTubePollers[playlistMbid]); delete activeYouTubePollers[playlistMbid]; }
socket.emit('sync:unsubscribe', { playlist_ids: [syncPlaylistId] });
delete _syncProgressCallbacks[syncPlaylistId];
const statusDisplay = document.getElementById(`${listingPlaylistId}-sync-status`);
const syncButton = document.getElementById(`${listingPlaylistId}-sync-btn`);
if (statusDisplay) setTimeout(() => { statusDisplay.style.display = 'none'; }, 3000);
if (syncButton) { syncButton.disabled = false; syncButton.style.opacity = '1'; }
if (listenbrainzPlaylistStates[playlistMbid]) {
listenbrainzPlaylistStates[playlistMbid].phase = 'sync_complete';
}
showToast(`Sync complete: ${matched}/${total} tracks matched`, 'success');
} else if (data.status === 'error' || data.status === 'cancelled') {
if (activeYouTubePollers[playlistMbid]) { clearInterval(activeYouTubePollers[playlistMbid]); delete activeYouTubePollers[playlistMbid]; }
socket.emit('sync:unsubscribe', { playlist_ids: [syncPlaylistId] });
delete _syncProgressCallbacks[syncPlaylistId];
showToast(`Sync failed: ${data.error || 'Unknown error'}`, 'error');
}
};
}
const pollInterval = setInterval(async () => {
if (socketConnected) return; // Phase 6: WS handles updates
try {
const response = await fetch(`/api/listenbrainz/sync/status/${playlistMbid}`);
const status = await response.json();
if (status.error) {
console.error('โ Error polling ListenBrainz sync status:', status.error);
clearInterval(pollInterval);
delete activeYouTubePollers[playlistMbid];
return;
}
const totalEl = document.getElementById(`${listingPlaylistId}-sync-total`);
const matchedEl = document.getElementById(`${listingPlaylistId}-sync-matched`);
const failedEl = document.getElementById(`${listingPlaylistId}-sync-failed`);
const percentageEl = document.getElementById(`${listingPlaylistId}-sync-percentage`);
if (totalEl) totalEl.textContent = status.progress?.total_tracks || 0;
if (matchedEl) matchedEl.textContent = status.progress?.matched_tracks || 0;
if (failedEl) failedEl.textContent = status.progress?.failed_tracks || 0;
const percentage = status.progress?.total_tracks > 0
? Math.round(((status.progress?.matched_tracks || 0) / status.progress.total_tracks) * 100)
: 0;
if (percentageEl) percentageEl.textContent = percentage;
if (status.complete) {
clearInterval(pollInterval);
delete activeYouTubePollers[playlistMbid];
const statusDisplay = document.getElementById(`${listingPlaylistId}-sync-status`);
const syncButton = document.getElementById(`${listingPlaylistId}-sync-btn`);
if (statusDisplay) setTimeout(() => { statusDisplay.style.display = 'none'; }, 3000);
if (syncButton) { syncButton.disabled = false; syncButton.style.opacity = '1'; }
if (listenbrainzPlaylistStates[playlistMbid]) {
listenbrainzPlaylistStates[playlistMbid].phase = 'sync_complete';
}
showToast(`Sync complete: ${status.progress?.matched_tracks || 0}/${status.progress?.total_tracks || 0} tracks matched`, 'success');
}
} catch (error) {
console.error('โ Error polling ListenBrainz listing sync:', error);
clearInterval(pollInterval);
delete activeYouTubePollers[playlistMbid];
}
}, 1000);
activeYouTubePollers[playlistMbid] = pollInterval;
}
// ============================================================================
// ARTISTS PAGE FUNCTIONALITY - ELEGANT SEARCH & DISCOVERY
// ============================================================================
/**
* Initialize the artists page when navigated to (only runs once)
*/
function initializeArtistsPage() {
console.log('๐ต Initializing Artists Page (first time)');
// Get DOM elements
const searchInput = document.getElementById('artists-search-input');
const headerSearchInput = document.getElementById('artists-header-search-input');
const searchStatus = document.getElementById('artists-search-status');
const backButton = document.getElementById('artists-back-button');
const detailBackButton = document.getElementById('artist-detail-back-button');
// Set up event listeners (only need to do this once)
if (searchInput) {
searchInput.addEventListener('input', handleArtistsSearchInput);
searchInput.addEventListener('keypress', handleArtistsSearchKeypress);
}
if (headerSearchInput) {
headerSearchInput.addEventListener('input', handleArtistsHeaderSearchInput);
headerSearchInput.addEventListener('keypress', handleArtistsSearchKeypress);
}
if (backButton) {
backButton.addEventListener('click', () => showArtistsSearchState());
}
if (detailBackButton) {
detailBackButton.addEventListener('click', () => showArtistsResultsState());
}
// Initialize tabs (only need to do this once)
initializeArtistTabs();
// Mark as initialized
artistsPageState.isInitialized = true;
// Restore previous state instead of always resetting to search
restoreArtistsPageState();
console.log('โ
Artists Page initialized successfully (ready for navigation)');
}
/**
* Restore the artists page to its previous state
*/
function restoreArtistsPageState() {
console.log(`๐ Restoring artists page state: ${artistsPageState.currentView}`);
switch (artistsPageState.currentView) {
case 'results':
// Restore search results state
if (artistsPageState.searchQuery && artistsPageState.searchResults.length > 0) {
console.log(`๐ฆ Restoring search results for: "${artistsPageState.searchQuery}"`);
// Restore search input values
const searchInput = document.getElementById('artists-search-input');
const headerSearchInput = document.getElementById('artists-header-search-input');
if (searchInput) searchInput.value = artistsPageState.searchQuery;
if (headerSearchInput) headerSearchInput.value = artistsPageState.searchQuery;
// Display the cached results
displayArtistsResults(artistsPageState.searchQuery, artistsPageState.searchResults);
} else {
// No valid results state, fall back to search
showArtistsSearchState();
}
break;
case 'detail':
// Restore artist detail state
if (artistsPageState.selectedArtist && artistsPageState.artistDiscography) {
console.log(`๐ค Restoring artist detail for: ${artistsPageState.selectedArtist.name}`);
// First restore search results if they exist
if (artistsPageState.searchQuery && artistsPageState.searchResults.length > 0) {
const searchInput = document.getElementById('artists-search-input');
const headerSearchInput = document.getElementById('artists-header-search-input');
if (searchInput) searchInput.value = artistsPageState.searchQuery;
if (headerSearchInput) headerSearchInput.value = artistsPageState.searchQuery;
}
// Show artist detail state
showArtistDetailState();
// Update artist info in header
updateArtistDetailHeader(artistsPageState.selectedArtist);
// Display cached discography
if (artistsPageState.artistDiscography.albums || artistsPageState.artistDiscography.singles) {
displayArtistDiscography(artistsPageState.artistDiscography);
// Restore cached completion data instead of re-scanning
restoreCachedCompletionData(artistsPageState.selectedArtist.id);
}
} else {
// No valid detail state, fall back to search or results
if (artistsPageState.searchQuery && artistsPageState.searchResults.length > 0) {
displayArtistsResults(artistsPageState.searchQuery, artistsPageState.searchResults);
} else {
showArtistsSearchState();
}
}
break;
default:
case 'search':
// Show search state (but preserve any existing search query)
if (artistsPageState.searchQuery) {
const searchInput = document.getElementById('artists-search-input');
if (searchInput) searchInput.value = artistsPageState.searchQuery;
}
showArtistsSearchState();
break;
}
}
/**
* Handle search input with debouncing
*/
function handleArtistsSearchInput(event) {
const query = event.target.value.trim();
updateArtistsSearchStatus('searching');
// Clear existing timeout
if (artistsSearchTimeout) {
clearTimeout(artistsSearchTimeout);
}
// Cancel any active search
if (artistsSearchController) {
artistsSearchController.abort();
}
if (query === '') {
updateArtistsSearchStatus('default');
return;
}
// Set up new debounced search
artistsSearchTimeout = setTimeout(() => {
performArtistsSearch(query);
}, 1000); // 1 second debounce
}
/**
* Handle header search input (already in results state)
*/
function handleArtistsHeaderSearchInput(event) {
const query = event.target.value.trim();
// Update main search input to match
const mainInput = document.getElementById('artists-search-input');
if (mainInput) {
mainInput.value = query;
}
// Trigger search with same debouncing logic
handleArtistsSearchInput(event);
}
/**
* Handle Enter key press in search inputs
*/
function handleArtistsSearchKeypress(event) {
if (event.key === 'Enter') {
event.preventDefault();
const query = event.target.value.trim();
if (query && query !== artistsPageState.searchQuery) {
// Clear timeout and search immediately
if (artistsSearchTimeout) {
clearTimeout(artistsSearchTimeout);
}
performArtistsSearch(query);
}
}
}
/**
* Perform artist search with API call
*/
async function performArtistsSearch(query) {
console.log(`๐ Searching for artists: "${query}"`);
// Check cache first
if (artistsPageState.cache.searches[query]) {
console.log('๐ฆ Using cached search results');
displayArtistsResults(query, artistsPageState.cache.searches[query]);
return;
}
// Update status
updateArtistsSearchStatus('searching');
// Show loading cards immediately if we're in results view
if (artistsPageState.currentView === 'results') {
showSearchLoadingCards();
}
try {
// Set up abort controller
artistsSearchController = new AbortController();
const response = await fetch('/api/match/search', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
query: query,
context: 'artist'
}),
signal: artistsSearchController.signal
});
if (!response.ok) {
throw new Error(`Search failed: ${response.status}`);
}
const data = await response.json();
console.log(`โ
Found ${data.results?.length || 0} artists`);
// Transform the results to flatten the nested artist data
const transformedResults = (data.results || []).map(result => {
// Extract artist data from the nested structure
const artist = result.artist || result;
return {
id: artist.id,
name: artist.name,
image_url: artist.image_url,
genres: artist.genres,
popularity: artist.popularity,
confidence: result.confidence || 0
};
});
console.log('๐ง Transformed results:', transformedResults);
// Cache the transformed results
artistsPageState.cache.searches[query] = transformedResults;
// Display results
displayArtistsResults(query, transformedResults);
} catch (error) {
if (error.name !== 'AbortError') {
console.error('โ Artist search failed:', error);
// Provide specific error messages based on the error type
let errorMessage = 'Search failed. Please try again.';
if (error.message.includes('401') || error.message.includes('authentication')) {
errorMessage = 'Spotify not authenticated. Please check your API settings.';
} else if (error.message.includes('network') || error.message.includes('fetch')) {
errorMessage = 'Network error. Please check your connection.';
} else if (error.message.includes('timeout')) {
errorMessage = 'Search timed out. Please try again.';
}
updateArtistsSearchStatus('error', errorMessage);
}
} finally {
artistsSearchController = null;
}
}
/**
* Display artist search results
*/
function displayArtistsResults(query, results) {
console.log(`๐ Displaying ${results.length} artist results`);
// Update state
artistsPageState.searchQuery = query;
artistsPageState.searchResults = results;
artistsPageState.currentView = 'results';
// Update header search input if different
const headerInput = document.getElementById('artists-header-search-input');
if (headerInput && headerInput.value !== query) {
headerInput.value = query;
}
// Show results state
showArtistsResultsState();
// Populate results
const container = document.getElementById('artists-cards-container');
if (!container) return;
if (results.length === 0) {
container.innerHTML = `
๐
No artists found
Try a different search term
`;
return;
}
// Create artist cards
container.innerHTML = results.map(result => createArtistCardHTML(result)).join('');
observeLazyBackgrounds(container);
// Add event listeners to cards
container.querySelectorAll('.artist-card').forEach((card, index) => {
card.addEventListener('click', () => selectArtistForDetail(results[index]));
// Extract colors from artist image for dynamic glow
const artist = results[index];
if (artist.image_url) {
extractImageColors(artist.image_url, (colors) => {
applyDynamicGlow(card, colors);
});
}
});
// Update watchlist status for all cards
updateArtistCardWatchlistStatus();
// Lazy load missing artist images
console.log('๐ผ๏ธ Starting lazy load for artist images on Artists page...');
if (typeof lazyLoadArtistImages === 'function') {
lazyLoadArtistImages(container);
} else if (typeof window.lazyLoadArtistImages === 'function') {
window.lazyLoadArtistImages(container);
} else {
console.error('โ lazyLoadArtistImages function not found!');
}
// Add mouse wheel horizontal scrolling
container.addEventListener('wheel', (event) => {
if (event.deltaY !== 0) {
event.preventDefault();
container.scrollLeft += event.deltaY;
}
});
}
/**
* Lazy load artist images for cards that don't have images yet.
* Fetches images asynchronously so search results appear immediately.
*/
async function lazyLoadArtistImages(container) {
if (!container) {
console.error('โ lazyLoadArtistImages: container is null');
return;
}
// Find all cards that need images
const cardsNeedingImages = container.querySelectorAll('[data-needs-image="true"]');
if (cardsNeedingImages.length === 0) {
console.log('โ
All artist cards have images');
return;
}
console.log(`๐ผ๏ธ Lazy loading images for ${cardsNeedingImages.length} artist cards`);
// Load images in parallel (but with a small batch to avoid overwhelming the server)
const batchSize = 5;
const cards = Array.from(cardsNeedingImages);
for (let i = 0; i < cards.length; i += batchSize) {
const batch = cards.slice(i, i + batchSize);
await Promise.all(batch.map(async (card) => {
const artistId = card.dataset.artistId;
if (!artistId) {
console.warn('โ ๏ธ Card missing artistId:', card);
return;
}
try {
console.log(`๐ Fetching image for artist ${artistId}...`);
const response = await fetch(`/api/artist/${artistId}/image`);
const data = await response.json();
console.log(`๐ฅ Got response for ${artistId}:`, data);
if (data.success && data.image_url) {
// Update the card's background image
// Handle both card types (suggestion-card and artist-card)
if (card.classList.contains('suggestion-card')) {
card.style.backgroundImage = `url(${data.image_url})`;
card.style.backgroundSize = 'cover';
card.style.backgroundPosition = 'center';
} else if (card.classList.contains('artist-card')) {
const bgElement = card.querySelector('.artist-card-background');
if (bgElement) {
// Clear the gradient first, then set the image
bgElement.style.cssText = `background-image: url('${data.image_url}'); background-size: cover; background-position: center;`;
}
}
card.dataset.needsImage = 'false';
console.log(`โ
Loaded image for artist ${artistId}`);
}
} catch (error) {
console.error(`โ Failed to load image for artist ${artistId}:`, error);
}
}));
}
console.log('โ
Finished lazy loading artist images');
}
// Make function globally accessible
window.lazyLoadArtistImages = lazyLoadArtistImages;
/**
* Create HTML for an artist card
*/
function createArtistCardHTML(artist) {
const imageUrl = artist.image_url || '';
const genres = artist.genres && artist.genres.length > 0 ?
artist.genres.slice(0, 3).join(', ') : 'Various genres';
const popularity = artist.popularity || 0;
// 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.3) 0%, rgba(24, 156, 71, 0.2) 100%);"`;
// Format popularity as a percentage for better UX
const popularityText = popularity > 0 ? `${popularity}% Popular` : 'Popularity Unknown';
// Track if image needs to be lazy loaded
const needsImage = imageUrl ? 'false' : 'true';
// Check for MusicBrainz ID
let mbIconHTML = '';
if (artist.musicbrainz_id) {
mbIconHTML = `
`;
}
return `
${mbIconHTML}
${escapeHtml(artist.name)}
${escapeHtml(genres)}
๐ฅ
${popularityText}
๐๏ธ
Add to Watchlist
⚙
`;
}
/**
* Select an artist and show their discography
*/
async function selectArtistForDetail(artist) {
console.log(`๐ค Selected artist: ${artist.name}`);
// Cancel any ongoing completion check from previous artist
if (artistCompletionController) {
console.log('โน๏ธ Canceling previous artist completion check');
artistCompletionController.abort();
artistCompletionController = null;
}
// Cancel any ongoing similar artists stream from previous artist
if (similarArtistsController) {
console.log('โน๏ธ Canceling previous similar artists stream');
similarArtistsController.abort();
similarArtistsController = null;
}
// Update state
artistsPageState.selectedArtist = artist;
artistsPageState.currentView = 'detail';
// Show detail state
showArtistDetailState();
// Update artist info in header
updateArtistDetailHeader(artist);
// Load discography (pass artist name for cross-source fallback)
await loadArtistDiscography(artist.id, artist.name);
}
/**
* Load artist's discography from Spotify or iTunes
* @param {string} artistId - Artist ID (Spotify or iTunes format)
* @param {string} [artistName] - Optional artist name for fallback searches
*/
async function loadArtistDiscography(artistId, artistName = null) {
console.log(`๐ฟ Loading discography for artist: ${artistId} (name: ${artistName})`);
// Check cache first
if (artistsPageState.cache.discography[artistId]) {
console.log('๐ฆ Using cached discography');
const cachedDiscography = artistsPageState.cache.discography[artistId];
displayArtistDiscography(cachedDiscography);
// Load similar artists in parallel (don't wait)
loadSimilarArtists(artistsPageState.selectedArtist?.name).catch(err => {
console.error('โ Error loading similar artists:', err);
});
// Still check completion status for cached data
await checkDiscographyCompletion(artistId, cachedDiscography);
return;
}
try {
// Show loading states
showDiscographyLoading();
// Build URL with optional artist name for fallback
let url = `/api/artist/${artistId}/discography`;
if (artistName) {
url += `?artist_name=${encodeURIComponent(artistName)}`;
}
// Call the real API endpoint
const response = await fetch(url);
if (!response.ok) {
if (response.status === 401) {
throw new Error('Spotify not authenticated. Please check your API settings.');
}
throw new Error(`Failed to load discography: ${response.status}`);
}
const data = await response.json();
if (data.error) {
throw new Error(data.error);
}
const discography = {
albums: data.albums || [],
singles: data.singles || []
};
// Update selected artist with full details from backend (includes MusicBrainz ID)
if (data.artist) {
console.log('โจ Updating artist details with fresh data from backend');
artistsPageState.selectedArtist = {
...artistsPageState.selectedArtist,
...data.artist
};
// Refresh header to show MusicBrainz link
updateArtistDetailHeader(artistsPageState.selectedArtist);
}
console.log(`โ
Loaded ${discography.albums.length} albums and ${discography.singles.length} singles`);
// Cache the results
artistsPageState.cache.discography[artistId] = discography;
artistsPageState.artistDiscography = discography;
// Display results
displayArtistDiscography(discography);
// Load similar artists and check completion in parallel (don't wait)
loadSimilarArtists(artistsPageState.selectedArtist?.name).catch(err => {
console.error('โ Error loading similar artists:', err);
});
// Check completion status for all albums and singles
await checkDiscographyCompletion(artistId, discography);
} catch (error) {
console.error('โ Failed to load discography:', error);
showDiscographyError(error.message);
}
}
/**
* Display artist's discography in tabs
*/
function displayArtistDiscography(discography) {
console.log(`๐ Displaying discography: ${discography.albums?.length || 0} albums, ${discography.singles?.length || 0} singles`);
// Populate albums
const albumsContainer = document.getElementById('album-cards-container');
if (albumsContainer) {
if (discography.albums?.length > 0) {
albumsContainer.innerHTML = discography.albums.map(album => createAlbumCardHTML(album)).join('');
observeLazyBackgrounds(albumsContainer);
// Add dynamic glow effects and click handlers to album cards
albumsContainer.querySelectorAll('.album-card').forEach((card, index) => {
const album = discography.albums[index];
if (album.image_url) {
extractImageColors(album.image_url, (colors) => {
applyDynamicGlow(card, colors);
});
}
// Add click handler for download missing tracks modal
card.addEventListener('click', () => handleArtistAlbumClick(album, 'albums'));
card.style.cursor = 'pointer';
});
} else {
albumsContainer.innerHTML = `
`;
}
}
// Populate singles
const singlesContainer = document.getElementById('singles-cards-container');
if (singlesContainer) {
if (discography.singles?.length > 0) {
singlesContainer.innerHTML = discography.singles.map(single => createAlbumCardHTML(single)).join('');
observeLazyBackgrounds(singlesContainer);
// Add dynamic glow effects and click handlers to singles cards
singlesContainer.querySelectorAll('.album-card').forEach((card, index) => {
const single = discography.singles[index];
if (single.image_url) {
extractImageColors(single.image_url, (colors) => {
applyDynamicGlow(card, colors);
});
}
// Add click handler for download missing tracks modal
card.addEventListener('click', () => handleArtistAlbumClick(single, 'singles'));
card.style.cursor = 'pointer';
});
} else {
singlesContainer.innerHTML = `
๐ต
No singles or EPs found
`;
}
}
// 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 = `
`;
} 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.'}
๐ Retry Search
`;
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 imageElement = document.getElementById('search-artist-detail-image');
const nameElement = document.getElementById('search-artist-detail-name');
const genresElement = document.getElementById('search-artist-detail-genres');
if (imageElement) {
if (artist.image_url) {
imageElement.style.backgroundImage = `url('${artist.image_url}')`;
} else {
// Lazy load image if missing (common for iTunes artists)
console.log(`๐ผ๏ธ Lazy loading detail image for ${artist.name} (${artist.id})`);
fetch(`/api/artist/${artist.id}/image`)
.then(response => response.json())
.then(data => {
if (data.success && data.image_url) {
console.log(`โ
Loaded detail image for ${artist.name}`);
imageElement.style.backgroundImage = `url('${data.image_url}')`;
// Update the artist object in memory too
artist.image_url = data.image_url;
}
})
.catch(err => console.error('โ Failed to load detail image:', err));
}
}
if (nameElement) {
nameElement.textContent = artist.name;
// Add MusicBrainz link if available
if (artist.musicbrainz_id) {
// Remove existing MB link if any
const existingMb = nameElement.parentNode.querySelector('.mb-link-btn');
if (existingMb) existingMb.remove();
const mbLink = document.createElement('a');
mbLink.className = 'mb-link-btn';
mbLink.href = `https://musicbrainz.org/artist/${artist.musicbrainz_id}`;
mbLink.target = '_blank';
mbLink.title = 'View on MusicBrainz';
mbLink.innerHTML = `
`;
// Simplified button style to just be the badge/icon for cleaner look header
mbLink.style.padding = '0';
mbLink.style.border = 'none';
mbLink.style.background = 'transparent';
mbLink.style.marginLeft = '12px';
mbLink.style.verticalAlign = 'middle';
nameElement.appendChild(mbLink);
}
}
if (genresElement) {
const genres = artist.genres?.slice(0, 4).join(' โข ') || 'Various genres';
genresElement.textContent = genres;
}
// Initialize watchlist button
initializeArtistDetailWatchlistButton(artist);
}
/**
* Initialize watchlist button for artist detail page
*/
async function initializeArtistDetailWatchlistButton(artist) {
const button = document.getElementById('artist-detail-watchlist-btn');
if (!button) return;
console.log(`๐ง Initializing watchlist button for artist: ${artist.name} (${artist.id})`);
// Store artist info on the button for settings gear access
button.dataset.artistId = artist.id;
button.dataset.artistName = artist.name;
// Reset button state completely
button.disabled = false;
button.classList.remove('watching');
button.style.background = '';
button.style.cursor = '';
// Remove any existing click handlers to prevent duplicates
button.onclick = null;
// Set up new click handler
button.onclick = (event) => toggleArtistDetailWatchlist(event, artist.id, artist.name);
// Check and update current status
await updateArtistDetailWatchlistButton(artist.id, artist.name);
}
/**
* Toggle watchlist status for artist detail page
*/
async function toggleArtistDetailWatchlist(event, artistId, artistName) {
event.preventDefault();
const button = document.getElementById('artist-detail-watchlist-btn');
const icon = button.querySelector('.watchlist-icon');
const text = button.querySelector('.watchlist-text');
// Show loading state
const originalText = text.textContent;
text.textContent = 'Loading...';
button.disabled = true;
try {
// Check current status
const checkResponse = await fetch('/api/watchlist/check', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ artist_id: artistId })
});
const checkData = await checkResponse.json();
if (!checkData.success) {
throw new Error(checkData.error || 'Failed to check watchlist status');
}
const isWatching = checkData.is_watching;
// Toggle watchlist status
const endpoint = isWatching ? '/api/watchlist/remove' : '/api/watchlist/add';
const payload = isWatching ?
{ artist_id: artistId } :
{ artist_id: artistId, artist_name: artistName };
const response = await fetch(endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
const data = await response.json();
if (!data.success) {
throw new Error(data.error || 'Failed to update watchlist');
}
// Update button appearance
if (isWatching) {
// Was watching, now removed
icon.textContent = '๐๏ธ';
text.textContent = 'Add to Watchlist';
button.classList.remove('watching');
console.log(`โ Removed ${artistName} from watchlist`);
} else {
// Was not watching, now added
icon.textContent = '๐๏ธ';
text.textContent = 'Remove from Watchlist';
button.classList.add('watching');
console.log(`โ
Added ${artistName} to watchlist`);
}
// Show/hide watchlist settings gear
const settingsBtn = document.getElementById('artist-detail-watchlist-settings-btn');
if (settingsBtn) {
if (!isWatching) {
// Just added to watchlist โ show gear
settingsBtn.classList.remove('hidden');
settingsBtn.onclick = () => openWatchlistArtistConfigModal(artistId, artistName);
} else {
// Just removed from watchlist โ hide gear
settingsBtn.classList.add('hidden');
settingsBtn.onclick = null;
}
}
// Update dashboard watchlist count
updateWatchlistButtonCount();
// Update any visible artist cards
updateArtistCardWatchlistStatus();
} catch (error) {
console.error('Error toggling watchlist:', error);
text.textContent = originalText;
// Show error feedback
const originalBackground = button.style.background;
button.style.background = 'rgba(255, 59, 48, 0.3)';
setTimeout(() => {
button.style.background = originalBackground;
}, 2000);
} finally {
button.disabled = false;
}
}
/**
* Update artist detail watchlist button status
*/
async function updateArtistDetailWatchlistButton(artistId, artistName) {
const button = document.getElementById('artist-detail-watchlist-btn');
if (!button) {
console.warn('โ ๏ธ Artist detail watchlist button not found');
return;
}
// Use passed name or fall back to stored data attribute
const name = artistName || button.dataset.artistName || '';
try {
console.log(`๐ Checking watchlist status for artist: ${artistId}`);
const response = await fetch('/api/watchlist/check', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ artist_id: artistId })
});
const data = await response.json();
if (data.success) {
const icon = button.querySelector('.watchlist-icon');
const text = button.querySelector('.watchlist-text');
console.log(`๐ Watchlist status for ${artistId}: ${data.is_watching ? 'WATCHING' : 'NOT WATCHING'}`);
// Ensure button is enabled
button.disabled = false;
// Show/hide watchlist settings gear
const settingsBtn = document.getElementById('artist-detail-watchlist-settings-btn');
if (settingsBtn) {
if (data.is_watching) {
settingsBtn.classList.remove('hidden');
settingsBtn.onclick = () => openWatchlistArtistConfigModal(artistId, name);
} else {
settingsBtn.classList.add('hidden');
settingsBtn.onclick = null;
}
}
if (data.is_watching) {
icon.textContent = '๐๏ธ';
text.textContent = 'Remove from Watchlist';
button.classList.add('watching');
} else {
icon.textContent = '๐๏ธ';
text.textContent = 'Add to Watchlist';
button.classList.remove('watching');
}
} else {
console.error('โ Failed to check watchlist status:', data.error);
}
} catch (error) {
console.error('โ Error checking watchlist status:', error);
// Ensure button doesn't get stuck in bad state
button.disabled = false;
}
}
/**
* Show loading state for discography
*/
function showDiscographyLoading() {
const albumsContainer = document.getElementById('album-cards-container');
const singlesContainer = document.getElementById('singles-cards-container');
const loadingHtml = `
`.repeat(4);
if (albumsContainer) albumsContainer.innerHTML = loadingHtml;
if (singlesContainer) singlesContainer.innerHTML = loadingHtml;
}
/**
* Show error state for discography
*/
function showDiscographyError(message = 'Failed to load discography') {
const albumsContainer = document.getElementById('album-cards-container');
const singlesContainer = document.getElementById('singles-cards-container');
const errorHtml = `
โ ๏ธ
Failed to load discography
${escapeHtml(message)}
`;
if (albumsContainer) albumsContainer.innerHTML = errorHtml;
if (singlesContainer) singlesContainer.innerHTML = errorHtml;
}
/**
* Show loading cards while searching
*/
function showSearchLoadingCards() {
const container = document.getElementById('artists-cards-container');
if (!container) return;
const loadingCardHtml = `
Loading...
Fetching data...
โณ
Loading...
`;
// Show 6 loading cards
container.innerHTML = loadingCardHtml.repeat(6);
}
// ===============================
// ARTIST ALBUM DOWNLOAD MISSING TRACKS INTEGRATION
// ===============================
/**
* Get the completion status of an album from cached data or DOM
* @param {string} albumId - The album ID
* @param {string} albumType - The album type ('albums' or 'singles')
* @returns {Object|null} - Completion status object or null
*/
function getAlbumCompletionStatus(albumId, albumType) {
try {
// First, check cached completion data
const artistId = artistsPageState.selectedArtist?.id;
if (artistId && artistsPageState.cache.completionData[artistId]) {
const cachedData = artistsPageState.cache.completionData[artistId];
const dataArray = albumType === 'albums' ? cachedData.albums : cachedData.singles;
if (dataArray) {
const completionData = dataArray.find(item => item.album_id === albumId || item.id === albumId);
if (completionData) {
console.log(`๐ Found cached completion data for album ${albumId}:`, completionData);
return completionData;
}
}
}
// Fallback: Check DOM completion overlay
const containerId = albumType === 'albums' ? 'album-cards-container' : 'singles-cards-container';
const container = document.getElementById(containerId);
if (container) {
const albumCard = container.querySelector(`[data-album-id="${albumId}"]`);
if (albumCard) {
const overlay = albumCard.querySelector('.completion-overlay');
if (overlay) {
// Extract status from overlay classes
const classList = Array.from(overlay.classList);
const statusClasses = ['completed', 'nearly_complete', 'partial', 'missing', 'downloading', 'downloaded', 'error'];
const status = statusClasses.find(cls => classList.includes(cls));
if (status) {
console.log(`๐ Found DOM completion status for album ${albumId}: ${status}`);
return { status, completion_percentage: status === 'completed' ? 100 : 0 };
}
}
}
}
console.warn(`โ ๏ธ No completion status found for album ${albumId}`);
return null;
} catch (error) {
console.error(`โ Error getting album completion status for ${albumId}:`, error);
return null;
}
}
/**
* Handle album/single/EP click to open download missing tracks modal
*/
async function handleArtistAlbumClick(album, albumType) {
console.log(`๐ต Album clicked: ${album.name} (${album.album_type}) from artist: ${artistsPageState.selectedArtist?.name}`);
if (!artistsPageState.selectedArtist) {
console.error('โ No selected artist found');
showToast('Error: No artist selected', 'error');
return;
}
showLoadingOverlay('Loading album...');
try {
// Check completion status of the album
const completionStatus = getAlbumCompletionStatus(album.id, albumType);
console.log(`๐ Album completion status: ${completionStatus?.status || 'unknown'} (${completionStatus?.completion_percentage || 0}%)`);
// For Artists page, always use Download Missing Tracks modal to analyze and download
console.log(`๐ Opening download missing tracks modal for album analysis`);
// Create virtual playlist ID
const virtualPlaylistId = `artist_album_${artistsPageState.selectedArtist.id}_${album.id}`;
// Check if modal already exists and show it
if (activeDownloadProcesses[virtualPlaylistId]) {
console.log(`๐ฑ Reopening existing modal for ${album.name}`);
const process = activeDownloadProcesses[virtualPlaylistId];
if (process.modalElement) {
if (process.status === 'complete') {
showToast('Showing previous results. Close this modal to start a new analysis.', 'info');
}
process.modalElement.style.display = 'flex';
hideLoadingOverlay();
return;
}
}
// Create virtual playlist and open modal
// Note: Don't hide loading overlay here - let the flow continue through to the modal
await createArtistAlbumVirtualPlaylist(album, albumType);
} catch (error) {
hideLoadingOverlay();
console.error('โ Error handling album click:', error);
showToast(`Error opening download modal: ${error.message}`, 'error');
}
}
/**
* Create virtual playlist for artist album and open download missing tracks modal
*/
async function createArtistAlbumVirtualPlaylist(album, albumType) {
const artist = artistsPageState.selectedArtist;
const virtualPlaylistId = `artist_album_${artist.id}_${album.id}`;
console.log(`๐ต Creating virtual playlist for: ${artist.name} - ${album.name}`);
try {
// Loading overlay already shown by handleArtistAlbumClick
// Fetch album tracks from backend (pass name/artist for Hydrabase support)
const _aat1 = new URLSearchParams({ name: album.name || '', artist: artist.name || '' });
const response = await fetch(`/api/artist/${artist.id}/album/${album.id}/tracks?${_aat1}`);
if (!response.ok) {
if (response.status === 401) {
throw new Error('Spotify not authenticated. Please check your API settings.');
}
throw new Error(`Failed to load album tracks: ${response.status}`);
}
const data = await response.json();
if (!data.success || !data.tracks || data.tracks.length === 0) {
throw new Error('No tracks found for this album');
}
console.log(`โ
Loaded ${data.tracks.length} tracks for ${data.album.name}`);
// Use album data from API response (has complete data including images array)
const fullAlbumData = data.album;
// Format playlist name with artist and album info
const playlistName = `[${artist.name}] ${fullAlbumData.name}`;
// Open download missing tracks modal with formatted tracks
// Pass false for showLoadingOverlay since we already have one from handleArtistAlbumClick
// Use fullAlbumData from API response instead of album parameter
await openDownloadMissingModalForArtistAlbum(virtualPlaylistId, playlistName, data.tracks, fullAlbumData, artist, false);
// Track this download for artist bubble management
registerArtistDownload(artist, album, virtualPlaylistId, albumType);
} catch (error) {
console.error('โ Error creating virtual playlist:', error);
showToast(`Failed to load album: ${error.message}`, 'error');
throw error;
}
}
/**
* Open download missing tracks modal specifically for artist albums
* Similar to openDownloadMissingModalForYouTube but for artist albums
*/
async function openDownloadMissingModalForArtistAlbum(virtualPlaylistId, playlistName, spotifyTracks, album, artist, showLoadingOverlayParam = true) {
if (showLoadingOverlayParam) {
showLoadingOverlay('Loading album...');
}
// Check if a process is already active for this virtual playlist
if (activeDownloadProcesses[virtualPlaylistId]) {
console.log(`Modal for ${virtualPlaylistId} already exists. Showing it.`);
const process = activeDownloadProcesses[virtualPlaylistId];
if (process.modalElement) {
if (process.status === 'complete') {
showToast('Showing previous results. Close this modal to start a new analysis.', 'info');
}
process.modalElement.style.display = 'flex';
if (showLoadingOverlayParam) {
hideLoadingOverlay();
}
}
return;
}
console.log(`๐ฅ Opening Download Missing Tracks modal for artist album: ${virtualPlaylistId}`);
// Create virtual playlist object for compatibility with existing modal logic
const virtualPlaylist = {
id: virtualPlaylistId,
name: playlistName,
track_count: spotifyTracks.length
};
// Store the tracks in the cache for the modal to use
playlistTrackCache[virtualPlaylistId] = spotifyTracks;
currentPlaylistTracks = spotifyTracks;
currentModalPlaylistId = virtualPlaylistId;
let modal = document.createElement('div');
modal.id = `download-missing-modal-${virtualPlaylistId}`;
modal.className = 'download-missing-modal';
modal.style.display = 'none';
document.body.appendChild(modal);
// Register the new process in our global state tracker using the same structure as other modals
activeDownloadProcesses[virtualPlaylistId] = {
status: 'idle',
modalElement: modal,
poller: null,
batchId: null,
playlist: virtualPlaylist,
tracks: spotifyTracks,
// Additional metadata for artist albums
artist: artist,
album: album,
albumType: album.album_type
};
// Generate hero section for artist album context
const heroContext = {
type: 'artist_album',
artist: artist,
album: album,
trackCount: spotifyTracks.length,
playlistId: virtualPlaylistId
};
// Use the exact same modal HTML structure as the existing modals
modal.innerHTML = `
๐ Library Analysis
Ready to start
โฌ Downloads
Waiting for analysis
`;
applyProgressiveTrackRendering(virtualPlaylistId, spotifyTracks.length);
modal.style.display = 'flex';
hideLoadingOverlay();
console.log(`โ
Successfully opened download missing tracks modal for: ${playlistName}`);
}
// ===============================
// ARTIST DOWNLOADS MANAGEMENT SYSTEM
// ===============================
/**
* Register a new artist download for bubble management
*/
function registerArtistDownload(artist, album, virtualPlaylistId, albumType) {
console.log(`๐ Registering artist download: ${artist.name} - ${album.name}`);
const artistId = artist.id;
// Initialize artist bubble if it doesn't exist
if (!artistDownloadBubbles[artistId]) {
artistDownloadBubbles[artistId] = {
artist: artist,
downloads: [],
element: null,
hasCompletedDownloads: false
};
}
// Add this download to the artist's downloads
const downloadInfo = {
virtualPlaylistId: virtualPlaylistId,
album: album,
albumType: albumType,
status: 'in_progress', // 'in_progress', 'completed', 'view_results'
startTime: new Date()
};
artistDownloadBubbles[artistId].downloads.push(downloadInfo);
// Show/update the artist downloads section
updateArtistDownloadsSection();
// Save snapshot of current state
saveArtistBubbleSnapshot();
// Monitor this download for completion
monitorArtistDownload(artistId, virtualPlaylistId);
}
/**
* Debounced update for artist downloads section to prevent rapid updates
*/
function updateArtistDownloadsSection() {
if (downloadsUpdateTimeout) {
clearTimeout(downloadsUpdateTimeout);
}
downloadsUpdateTimeout = setTimeout(() => {
showArtistDownloadsSection();
showLibraryDownloadsSection();
updateDashboardDownloads();
}, 300); // 300ms debounce
}
// --- Artist Bubble Snapshot System ---
let snapshotSaveTimeout = null; // Debounce snapshot saves
async function saveArtistBubbleSnapshot() {
/**
* Saves current artistDownloadBubbles state to backend for persistence.
* Debounced to prevent excessive backend calls.
*/
// Clear any existing timeout
if (snapshotSaveTimeout) {
clearTimeout(snapshotSaveTimeout);
}
// Debounce the actual save
snapshotSaveTimeout = setTimeout(async () => {
try {
const bubbleCount = Object.keys(artistDownloadBubbles).length;
// Don't save empty state
if (bubbleCount === 0) {
console.log('๐ธ Skipping snapshot save - no artist bubbles to save');
return;
}
console.log(`๐ธ Saving artist bubble snapshot: ${bubbleCount} artists`);
// Prepare snapshot data (clean up DOM references)
const cleanBubbles = {};
for (const [artistId, bubbleData] of Object.entries(artistDownloadBubbles)) {
cleanBubbles[artistId] = {
artist: bubbleData.artist,
downloads: bubbleData.downloads.map(download => ({
virtualPlaylistId: download.virtualPlaylistId,
album: download.album,
albumType: download.albumType,
status: download.status,
startTime: download.startTime instanceof Date ? download.startTime.toISOString() : download.startTime
})),
hasCompletedDownloads: bubbleData.hasCompletedDownloads
};
}
const response = await fetch('/api/artist_bubbles/snapshot', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
bubbles: cleanBubbles
})
});
const data = await response.json();
if (data.success) {
console.log(`โ
Artist bubble snapshot saved: ${bubbleCount} artists`);
} else {
console.error('โ Failed to save artist bubble snapshot:', data.error);
}
} catch (error) {
console.error('โ Error saving artist bubble snapshot:', error);
}
}, 1000); // 1 second debounce
}
async function hydrateArtistBubblesFromSnapshot() {
/**
* Hydrates artist download bubbles from backend snapshot with live status.
* Called on page load to restore bubble state.
*/
try {
console.log('๐ Loading artist bubble snapshot from backend...');
const response = await fetch('/api/artist_bubbles/hydrate');
const data = await response.json();
if (!data.success) {
console.error('โ Failed to load artist bubble snapshot:', data.error);
return;
}
const bubbles = data.bubbles || {};
const stats = data.stats || {};
console.log(`๐ Loaded bubble snapshot: ${stats.total_artists || 0} artists, ${stats.active_downloads || 0} active, ${stats.completed_downloads || 0} completed`);
if (Object.keys(bubbles).length === 0) {
console.log('โน๏ธ No artist bubbles to hydrate');
return;
}
// Clear existing state
artistDownloadBubbles = {};
// Restore artistDownloadBubbles with hydrated data
for (const [artistId, bubbleData] of Object.entries(bubbles)) {
artistDownloadBubbles[artistId] = {
artist: bubbleData.artist,
downloads: bubbleData.downloads.map(download => ({
virtualPlaylistId: download.virtualPlaylistId,
album: download.album,
albumType: download.albumType,
status: download.status, // Live status from backend
startTime: new Date(download.startTime)
})),
element: null, // Will be created when UI updates
hasCompletedDownloads: bubbleData.hasCompletedDownloads
};
console.log(`๐ Hydrated artist: ${bubbleData.artist.name} (${bubbleData.downloads.length} downloads)`);
// Start monitoring for any in-progress downloads
for (const download of bubbleData.downloads) {
if (download.status === 'in_progress') {
console.log(`๐ก Starting monitoring for: ${download.album.name}`);
monitorArtistDownload(artistId, download.virtualPlaylistId);
}
}
}
// Update UI to show hydrated bubbles
updateArtistDownloadsSection();
const totalArtists = Object.keys(artistDownloadBubbles).length;
console.log(`โ
Successfully hydrated ${totalArtists} artist download bubbles`);
} catch (error) {
console.error('โ Error hydrating artist bubbles from snapshot:', error);
}
}
// --- Search Bubble Snapshot System ---
async function saveSearchBubbleSnapshot() {
/**
* Saves current searchDownloadBubbles state to backend for persistence.
*/
try {
// Rate limit saves to avoid spamming backend
if (saveSearchBubbleSnapshot.lastSaveTime) {
const timeSinceLastSave = Date.now() - saveSearchBubbleSnapshot.lastSaveTime;
if (timeSinceLastSave < 2000) {
console.log('โฑ๏ธ Skipping search bubble snapshot save (rate limited)');
return;
}
}
const bubbleCount = Object.keys(searchDownloadBubbles).length;
if (bubbleCount === 0) {
console.log('๐ธ Skipping snapshot save - no search bubbles to save');
return;
}
console.log(`๐ธ Saving search bubble snapshot: ${bubbleCount} artists`);
// Convert search bubbles to plain objects for serialization
const bubblesToSave = {};
for (const [artistName, bubbleData] of Object.entries(searchDownloadBubbles)) {
bubblesToSave[artistName] = {
artist: bubbleData.artist,
downloads: bubbleData.downloads.map(d => ({
virtualPlaylistId: d.virtualPlaylistId,
item: d.item,
type: d.type,
status: d.status,
startTime: d.startTime
}))
};
}
const response = await fetch('/api/search_bubbles/snapshot', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ bubbles: bubblesToSave })
});
const data = await response.json();
if (data.success) {
console.log(`โ
Search bubble snapshot saved: ${bubbleCount} artists`);
saveSearchBubbleSnapshot.lastSaveTime = Date.now();
} else {
console.error('โ Failed to save search bubble snapshot:', data.error);
}
} catch (error) {
console.error('โ Error saving search bubble snapshot:', error);
}
}
async function hydrateSearchBubblesFromSnapshot() {
/**
* Hydrates search download bubbles from backend snapshot with live status.
*/
try {
console.log('๐ Loading search bubble snapshot from backend...');
const response = await fetch('/api/search_bubbles/hydrate');
const data = await response.json();
if (!data.success) {
console.error('โ Failed to load search bubble snapshot:', data.error);
return;
}
const bubbles = data.bubbles || {};
const stats = data.stats || {};
if (Object.keys(bubbles).length === 0) {
console.log('โน๏ธ No search bubbles to hydrate');
return;
}
// Clear and restore search bubbles
searchDownloadBubbles = {};
for (const [artistName, bubbleData] of Object.entries(bubbles)) {
searchDownloadBubbles[artistName] = {
artist: bubbleData.artist,
downloads: bubbleData.downloads || []
};
console.log(`๐ Hydrated artist: ${artistName} (${bubbleData.downloads.length} downloads)`);
// Setup monitoring for each download
for (const download of bubbleData.downloads) {
if (download.status === 'in_progress') {
monitorSearchDownload(artistName, download.virtualPlaylistId);
}
}
}
const totalArtists = Object.keys(searchDownloadBubbles).length;
console.log(`โ
Successfully hydrated ${totalArtists} search download bubbles`);
// Refresh display
showSearchDownloadBubbles();
} catch (error) {
console.error('โ Error hydrating search bubbles from snapshot:', error);
}
}
/**
* Register a new search download for bubble management (grouped by artist)
*/
function registerSearchDownload(item, type, virtualPlaylistId, artistName) {
console.log(`๐ [REGISTER] Registering search download: ${item.name} (${type}) by ${artistName}`);
// Initialize artist bubble if it doesn't exist
if (!searchDownloadBubbles[artistName]) {
searchDownloadBubbles[artistName] = {
artist: {
name: artistName,
image_url: item.image_url || (item.images && item.images[0]?.url) || null
},
downloads: []
};
}
// Add this download to the artist's downloads
const downloadInfo = {
virtualPlaylistId: virtualPlaylistId,
item: item,
type: type, // 'album' or 'track'
status: 'in_progress',
startTime: new Date().toISOString()
};
searchDownloadBubbles[artistName].downloads.push(downloadInfo);
console.log(`โ
[REGISTER] Registered search download for ${artistName} - ${item.name}`);
// Save snapshot
saveSearchBubbleSnapshot();
// Setup monitoring
monitorSearchDownload(artistName, virtualPlaylistId);
// Refresh display
updateSearchDownloadsSection();
}
/**
* Debounced update for search downloads section
*/
function updateSearchDownloadsSection() {
if (window.searchUpdateTimeout) {
clearTimeout(window.searchUpdateTimeout);
}
window.searchUpdateTimeout = setTimeout(() => {
showSearchDownloadBubbles();
updateDashboardDownloads();
}, 300);
}
/**
* Monitor a search download for completion status changes
*/
function monitorSearchDownload(artistName, virtualPlaylistId) {
const checkCompletion = setInterval(() => {
const process = activeDownloadProcesses[virtualPlaylistId];
if (!process || !searchDownloadBubbles[artistName]) {
clearInterval(checkCompletion);
return;
}
// Find the download in the artist's downloads
const download = searchDownloadBubbles[artistName].downloads.find(
d => d.virtualPlaylistId === virtualPlaylistId
);
if (!download) {
clearInterval(checkCompletion);
return;
}
// Update status
const newStatus = process.status === 'complete' || process.status === 'view_results'
? 'view_results'
: 'in_progress';
if (download.status !== newStatus) {
console.log(`๐ [MONITOR] Status changed for ${download.item.name}: ${download.status} -> ${newStatus}`);
download.status = newStatus;
// Save snapshot and refresh
saveSearchBubbleSnapshot();
updateSearchDownloadsSection();
}
}, 2000);
}
/**
* Show or update the search downloads bubble section
*/
function showSearchDownloadBubbles() {
console.log(`๐ [SHOW] showSearchDownloadBubbles() called`);
const resultsArea = document.getElementById('enhanced-main-results-area');
if (!resultsArea) {
console.log(`โญ๏ธ [SHOW] Skipping - no enhanced-main-results-area found`);
return;
}
// Count active artists (those with downloads)
const activeArtists = Object.keys(searchDownloadBubbles).filter(artistName =>
searchDownloadBubbles[artistName].downloads.length > 0
);
if (activeArtists.length === 0) {
// Show placeholder
resultsArea.innerHTML = `
Search results will appear here when you select an album or track.
`;
return;
}
// Create bubbles display
const bubblesHTML = activeArtists.map(artistName =>
createSearchBubbleCard(searchDownloadBubbles[artistName])
).join('');
resultsArea.innerHTML = `
`;
console.log(`โ
[SHOW] Displayed ${activeArtists.length} search bubbles`);
}
/**
* Create HTML for a search bubble card (grouped by artist)
*/
function createSearchBubbleCard(artistBubbleData) {
const { artist, downloads } = artistBubbleData;
const activeCount = downloads.filter(d => d.status === 'in_progress').length;
const completedCount = downloads.filter(d => d.status === 'view_results').length;
const allCompleted = activeCount === 0 && completedCount > 0;
console.log(`๐ต [BUBBLE] Creating bubble for ${artist.name}:`, {
totalDownloads: downloads.length,
activeCount,
completedCount,
allCompleted
});
const imageUrl = artist.image_url || '';
const backgroundStyle = imageUrl ?
`background-image: url('${escapeHtml(imageUrl)}');` :
`background: linear-gradient(135deg, rgba(29, 185, 84, 0.3) 0%, rgba(24, 156, 71, 0.2) 100%);`;
return `
${escapeHtml(artist.name)}
${activeCount > 0 ? `${activeCount} active` : ''}
${completedCount > 0 ? `${completedCount} completed` : ''}
${allCompleted ? `
โ
` : ''}
`;
}
/**
* Open modal showing all downloads for an artist
*/
async function openSearchDownloadModal(artistName) {
const artistBubbleData = searchDownloadBubbles[artistName];
if (!artistBubbleData || searchDownloadModalOpen) return;
console.log(`๐ต [MODAL OPEN] Opening search download modal for: ${artistBubbleData.artist.name}`);
searchDownloadModalOpen = true;
const modal = document.createElement('div');
modal.id = 'search-download-management-modal';
modal.className = 'artist-download-management-modal';
modal.innerHTML = `
${artistBubbleData.artist.image_url
? `
`
: '
๐ต
'
}
${escapeHtml(artistBubbleData.artist.name)}
${artistBubbleData.downloads.length} active download${artistBubbleData.downloads.length !== 1 ? 's' : ''}
×
${artistBubbleData.downloads.map((download, index) => createSearchDownloadItem(download, index)).join('')}
`;
document.body.appendChild(modal);
modal.style.display = 'flex';
// Start monitoring for status changes
// Start monitoring for status changes
monitorSearchDownloadModal(artistName);
// Lazy load artist image if missing (common for iTunes)
if (!artistBubbleData.artist.image_url) {
console.log(`๐ผ๏ธ Lazy loading modal image for ${artistBubbleData.artist.name} (${artistBubbleData.artist.id})`);
fetch(`/api/artist/${artistBubbleData.artist.id}/image`)
.then(response => response.json())
.then(data => {
if (data.success && data.image_url) {
// Update header background
const headerBg = modal.querySelector('.artist-download-modal-hero-bg');
if (headerBg) {
headerBg.style.backgroundImage = `url('${data.image_url}')`;
}
// Update avatar
const avatarContainer = modal.querySelector('.artist-download-modal-hero-avatar');
if (avatarContainer) {
avatarContainer.innerHTML = ` `;
}
// Update artist object in memory
artistBubbleData.artist.image_url = data.image_url;
}
})
.catch(err => console.error('โ Failed to load modal image:', err));
}
}
/**
* Create HTML for a download item in the search modal
*/
function createSearchDownloadItem(download, index) {
const { item, type, status, virtualPlaylistId } = download;
const buttonText = status === 'view_results' ? 'View Results' : 'View Progress';
const buttonClass = status === 'view_results' ? 'completed' : 'active';
const typeLabel = type === 'album' ? 'Album' : type === 'single' ? 'Single' : 'Track';
return `
${item.image_url
? `
`
: `
${type === 'album' ? '๐ฟ' : '๐ต'}
`
}
${escapeHtml(item.name)}
${typeLabel}
${buttonText}
`;
}
/**
* Reopen an individual download modal from the artist modal
*/
async function reopenDownloadModal(virtualPlaylistId) {
const process = activeDownloadProcesses[virtualPlaylistId];
// If process exists, show the existing modal
if (process && process.modalElement) {
console.log(`โ
[REOPEN] Showing existing modal for ${virtualPlaylistId}`);
closeSearchDownloadModal();
setTimeout(() => {
process.modalElement.style.display = 'flex';
}, 100);
return;
}
// Process doesn't exist (after page refresh) - recreate it
console.log(`๐ [REOPEN] Modal not found, recreating for ${virtualPlaylistId}`);
// Find the download in searchDownloadBubbles
let downloadData = null;
for (const artistName in searchDownloadBubbles) {
const bubble = searchDownloadBubbles[artistName];
const download = bubble.downloads.find(d => d.virtualPlaylistId === virtualPlaylistId);
if (download) {
downloadData = download;
break;
}
}
if (!downloadData) {
console.warn(`โ ๏ธ No download data found for ${virtualPlaylistId}`);
return;
}
// Close search modal first
closeSearchDownloadModal();
// Recreate the modal based on type
const { item, type } = downloadData;
if (type === 'album') {
// For albums, we need to fetch the tracks
console.log(`๐ฅ [REOPEN] Recreating album modal for: ${item.name}`);
// Fetch album tracks (pass name/artist for Hydrabase support)
showLoadingOverlay(`Loading ${item.name}...`);
try {
const _sap2 = new URLSearchParams({ name: item.name || '', artist: item.artist || '' });
const response = await fetch(`/api/spotify/album/${item.id}?${_sap2}`);
if (!response.ok) {
throw new Error('Failed to fetch album tracks');
}
const albumData = await response.json();
if (!albumData.tracks || albumData.tracks.length === 0) {
throw new Error('No tracks found in album');
}
const spotifyTracks = albumData.tracks.map(track => ({
id: track.id,
name: track.name,
artists: track.artists || [{ name: item.artists?.[0]?.name || item.artist || 'Unknown Artist' }],
album: {
name: item.name,
images: item.image_url ? [{ url: item.image_url }] : []
},
duration_ms: track.duration_ms || 0
}));
hideLoadingOverlay();
// Open the modal
await openDownloadMissingModalForArtistAlbum(
virtualPlaylistId,
item.name,
spotifyTracks,
item,
{ name: item.artists?.[0]?.name || item.artist || 'Unknown Artist' },
false // Don't show loading overlay again
);
// Sync with backend to check for active batch process
const process = activeDownloadProcesses[virtualPlaylistId];
if (process) {
try {
const processResponse = await fetch('/api/active-processes');
if (processResponse.ok) {
const processData = await processResponse.json();
const activeProcess = processData.active_processes?.find(p => p.playlist_id === virtualPlaylistId);
if (activeProcess) {
console.log(`๐ก [REOPEN] Found active batch for album: ${activeProcess.batch_id}`);
process.status = 'running';
process.batchId = activeProcess.batch_id;
// Update UI to show running state
const beginBtn = document.getElementById(`begin-analysis-btn-${virtualPlaylistId}`);
const cancelBtn = document.getElementById(`cancel-all-btn-${virtualPlaylistId}`);
if (beginBtn) beginBtn.style.display = 'none';
if (cancelBtn) cancelBtn.style.display = 'inline-block';
// Start polling for live updates
startModalDownloadPolling(virtualPlaylistId);
}
}
} catch (err) {
console.warn('Could not check for active processes:', err);
}
}
} catch (error) {
hideLoadingOverlay();
showToast(`Failed to load album: ${error.message}`, 'error');
console.error('Error loading album:', error);
}
} else {
// For tracks, create enriched track and open modal
console.log(`๐ต [REOPEN] Recreating track modal for: ${item.name}`);
const enrichedTrack = {
id: item.id,
name: item.name,
artists: item.artists || [{ name: item.artist || 'Unknown Artist' }],
album: item.album || {
name: item.album?.name || 'Unknown Album',
images: item.image_url ? [{ url: item.image_url }] : []
},
duration_ms: item.duration_ms || 0
};
await openDownloadMissingModalForYouTube(
virtualPlaylistId,
`${enrichedTrack.name} - ${enrichedTrack.artists[0].name || enrichedTrack.artists[0]}`,
[enrichedTrack]
);
// Sync with backend to check for active batch process
const process = activeDownloadProcesses[virtualPlaylistId];
if (process) {
try {
const processResponse = await fetch('/api/active-processes');
if (processResponse.ok) {
const processData = await processResponse.json();
const activeProcess = processData.active_processes?.find(p => p.playlist_id === virtualPlaylistId);
if (activeProcess) {
console.log(`๐ก [REOPEN] Found active batch for track: ${activeProcess.batch_id}`);
process.status = 'running';
process.batchId = activeProcess.batch_id;
// Update UI to show running state
const beginBtn = document.getElementById(`begin-analysis-btn-${virtualPlaylistId}`);
const cancelBtn = document.getElementById(`cancel-all-btn-${virtualPlaylistId}`);
if (beginBtn) beginBtn.style.display = 'none';
if (cancelBtn) cancelBtn.style.display = 'inline-block';
// Start polling for live updates
startModalDownloadPolling(virtualPlaylistId);
}
}
} catch (err) {
console.warn('Could not check for active processes:', err);
}
}
}
}
/**
* Monitor search download modal for status changes
*/
function monitorSearchDownloadModal(artistName) {
const updateModal = () => {
if (!searchDownloadModalOpen) return;
const modal = document.getElementById('search-download-management-modal');
const itemsContainer = document.getElementById('search-download-items');
if (!modal || !itemsContainer || !searchDownloadBubbles[artistName]) return;
const downloads = searchDownloadBubbles[artistName].downloads;
// If no downloads at all, close modal
if (downloads.length === 0) {
closeSearchDownloadModal();
return;
}
// Update modal content and sync status with active processes
let statusChanged = false;
itemsContainer.innerHTML = downloads.map((download, index) => {
const process = activeDownloadProcesses[download.virtualPlaylistId];
// Only update status if process exists (otherwise keep current status)
if (process) {
const newStatus = process.status === 'complete' || process.status === 'view_results'
? 'view_results'
: 'in_progress';
if (download.status !== newStatus) {
console.log(`๐ [MODAL MONITOR] Status changed: ${download.item.name} ${download.status} -> ${newStatus}`);
download.status = newStatus;
statusChanged = true;
}
}
return createSearchDownloadItem(download, index);
}).join('');
// If status changed, refresh bubble display and save
if (statusChanged) {
updateSearchDownloadsSection();
saveSearchBubbleSnapshot();
}
// Continue monitoring
setTimeout(updateModal, 2000);
};
setTimeout(updateModal, 1000);
}
/**
* Close the search download modal
*/
function closeSearchDownloadModal() {
const modal = document.getElementById('search-download-management-modal');
if (modal) {
modal.style.display = 'none';
if (modal.parentElement) {
modal.parentElement.removeChild(modal);
}
}
searchDownloadModalOpen = false;
}
/**
* Bulk complete all downloads for an artist (called when user clicks green checkmark)
*/
function bulkCompleteSearchDownloads(artistName) {
console.log(`๐ฏ Bulk completing downloads for artist: ${artistName}`);
const artistBubbleData = searchDownloadBubbles[artistName];
if (!artistBubbleData) {
console.warn(`โ No artist bubble data found for ${artistName}`);
return;
}
// Find all completed downloads
const completedDownloads = artistBubbleData.downloads.filter(d => d.status === 'view_results');
console.log(`๐ Found ${completedDownloads.length} completed downloads to close:`,
completedDownloads.map(d => d.item.name));
if (completedDownloads.length === 0) {
console.warn(`โ ๏ธ No completed downloads found for bulk close`);
showToast('No completed downloads to close', 'info');
return;
}
// Close all completed modals
completedDownloads.forEach(download => {
const process = activeDownloadProcesses[download.virtualPlaylistId];
if (process && process.modalElement) {
console.log(`๐๏ธ Closing modal for: ${download.item.name}`);
closeDownloadMissingModal(download.virtualPlaylistId);
} else {
// No modal open โ clean up the bubble entry directly
console.log(`๐งน Direct cleanup (no modal) for: ${download.item.name}`);
cleanupSearchDownload(download.virtualPlaylistId);
}
});
showToast(`Completed ${completedDownloads.length} downloads for ${artistBubbleData.artist.name}`, 'success');
}
/**
* Cleanup search download when modal is closed
*/
function cleanupSearchDownload(virtualPlaylistId) {
console.log(`๐ [CLEANUP] Looking for search download to cleanup: ${virtualPlaylistId}`);
// Find which artist this download belongs to
for (const artistName in searchDownloadBubbles) {
const downloads = searchDownloadBubbles[artistName].downloads;
const downloadIndex = downloads.findIndex(d => d.virtualPlaylistId === virtualPlaylistId);
if (downloadIndex !== -1) {
console.log(`๐งน [CLEANUP] Found download in artist ${artistName}: ${downloads[downloadIndex].item.name}`);
// Remove this download
downloads.splice(downloadIndex, 1);
console.log(`๐๏ธ [CLEANUP] Removed download from ${artistName}'s bubble`);
// If no more downloads for this artist, remove the bubble
if (downloads.length === 0) {
delete searchDownloadBubbles[artistName];
console.log(`๐งน [CLEANUP] No more downloads - removed artist bubble: ${artistName}`);
}
// Save snapshot and refresh
saveSearchBubbleSnapshot();
updateSearchDownloadsSection();
return;
}
}
console.log(`โ ๏ธ [CLEANUP] No matching search download found for: ${virtualPlaylistId}`);
}
/**
* Show or update the artist downloads section in search state
*/
function showArtistDownloadsSection() {
console.log(`๐ [SHOW] showArtistDownloadsSection() called - refreshing artist bubbles`);
console.log(`๐ [SHOW] Current view: ${artistsPageState.currentView}, artistDownloadBubbles count: ${Object.keys(artistDownloadBubbles).length}`);
// Only show in search state
if (artistsPageState.currentView !== 'search') {
console.log(`โญ๏ธ [SHOW] Skipping - not in search state (current: ${artistsPageState.currentView})`);
return;
}
const artistsSearchState = document.getElementById('artists-search-state');
if (!artistsSearchState) {
console.log(`โญ๏ธ [SHOW] Skipping - no artists-search-state element found`);
return;
}
let downloadsSection = document.getElementById('artist-downloads-section');
// Create section if it doesn't exist
if (!downloadsSection) {
downloadsSection = document.createElement('div');
downloadsSection.id = 'artist-downloads-section';
downloadsSection.className = 'artist-downloads-section';
// Insert after the search container
const searchContainer = artistsSearchState.querySelector('.artists-search-container');
if (searchContainer) {
searchContainer.insertAdjacentElement('afterend', downloadsSection);
}
}
// Count active artists (those with downloads)
const activeArtists = Object.keys(artistDownloadBubbles).filter(artistId =>
artistDownloadBubbles[artistId].downloads.length > 0
);
if (activeArtists.length === 0) {
downloadsSection.style.display = 'none';
return;
}
// Show and populate the section
downloadsSection.style.display = 'block';
downloadsSection.innerHTML = `
${activeArtists.map(artistId => createArtistBubbleCard(artistDownloadBubbles[artistId])).join('')}
`;
// Add event listeners to bubble cards
activeArtists.forEach(artistId => {
const bubbleCard = downloadsSection.querySelector(`[data-artist-id="${artistId}"]`);
if (bubbleCard) {
bubbleCard.addEventListener('click', () => openArtistDownloadModal(artistId));
// Add dynamic glow effect
const artist = artistDownloadBubbles[artistId].artist;
if (artist.image_url) {
extractImageColors(artist.image_url, (colors) => {
applyDynamicGlow(bubbleCard, colors);
});
}
}
});
}
/**
* Show download bubbles on the Library page (mirrors showArtistDownloadsSection)
*/
function showLibraryDownloadsSection() {
const libraryContent = document.querySelector('.library-content');
if (!libraryContent) return;
let downloadsSection = document.getElementById('library-downloads-section');
// Create section if it doesn't exist
if (!downloadsSection) {
downloadsSection = document.createElement('div');
downloadsSection.id = 'library-downloads-section';
downloadsSection.className = 'artist-downloads-section';
// Insert before the artist grid
const artistGrid = document.getElementById('library-artists-grid');
if (artistGrid) {
libraryContent.insertBefore(downloadsSection, artistGrid);
}
}
// Count active artists (reuses artistDownloadBubbles state)
const activeArtists = Object.keys(artistDownloadBubbles).filter(artistId =>
artistDownloadBubbles[artistId].downloads.length > 0
);
if (activeArtists.length === 0) {
downloadsSection.style.display = 'none';
return;
}
downloadsSection.style.display = 'block';
downloadsSection.innerHTML = `
${activeArtists.map(artistId => createArtistBubbleCard(artistDownloadBubbles[artistId])).join('')}
`;
// Add click handlers + glow effects
activeArtists.forEach(artistId => {
const bubbleCard = downloadsSection.querySelector(`[data-artist-id="${artistId}"]`);
if (bubbleCard) {
bubbleCard.addEventListener('click', () => openArtistDownloadModal(artistId));
const artist = artistDownloadBubbles[artistId].artist;
if (artist.image_url) {
extractImageColors(artist.image_url, (colors) => {
applyDynamicGlow(bubbleCard, colors);
});
}
}
});
}
/**
* Create HTML for an artist bubble card
*/
function createArtistBubbleCard(artistBubbleData) {
const { artist, downloads } = artistBubbleData;
const activeCount = downloads.filter(d => d.status === 'in_progress').length;
const completedCount = downloads.filter(d => d.status === 'view_results').length;
const allCompleted = activeCount === 0 && completedCount > 0;
// Enhanced debug logging for bubble card creation and green checkmark detection
console.log(`๐ต [BUBBLE] Creating bubble for ${artist.name}:`, {
totalDownloads: downloads.length,
activeCount,
completedCount,
allCompleted,
downloadStatuses: downloads.map(d => `${d.album.name}: ${d.status}`)
});
// CRITICAL: Green checkmark detection logging
if (allCompleted) {
console.log(`๐ข [BUBBLE] GREEN CHECKMARK DETECTED for ${artist.name} - all ${downloads.length} downloads completed`);
console.log(`โ
[BUBBLE] This bubble will have 'all-completed' class and green checkmark`);
} else if (activeCount === 0 && completedCount === 0) {
console.log(`โญ [BUBBLE] No active or completed downloads for ${artist.name} - this shouldn't happen`);
} else {
console.log(`โณ [BUBBLE] Still waiting for completion: ${activeCount} active, ${completedCount} completed`);
}
const imageUrl = artist.image_url || '';
const backgroundStyle = imageUrl ?
`background-image: url('${imageUrl}');` :
`background: linear-gradient(135deg, rgba(29, 185, 84, 0.3) 0%, rgba(24, 156, 71, 0.2) 100%);`;
return `
${escapeHtml(artist.name)}
${activeCount > 0 ? `${activeCount} active` : ''}
${completedCount > 0 ? `${completedCount} completed` : ''}
${allCompleted ? `
โ
` : ''}
`;
}
/**
* Monitor an artist download for completion status changes
*/
function monitorArtistDownload(artistId, virtualPlaylistId) {
// Check if the download process exists and monitor its status
const checkStatus = () => {
const process = activeDownloadProcesses[virtualPlaylistId];
if (!process || !artistDownloadBubbles[artistId]) {
return; // Process or artist bubble no longer exists
}
// Find this download in the artist's downloads
const download = artistDownloadBubbles[artistId].downloads.find(d => d.virtualPlaylistId === virtualPlaylistId);
if (!download) return;
// Update download status based on process status
if (process.status === 'complete' && download.status === 'in_progress') {
download.status = 'view_results';
console.log(`โ
Download completed for ${artistDownloadBubbles[artistId].artist.name} - ${download.album.name}`);
console.log(`๐ Artist ${artistId} downloads status:`, artistDownloadBubbles[artistId].downloads.map(d => `${d.album.name}: ${d.status}`));
// Update the downloads section
updateArtistDownloadsSection();
// Save snapshot of updated state
saveArtistBubbleSnapshot();
// Check if all downloads for this artist are now completed
const artistDownloads = artistDownloadBubbles[artistId].downloads;
const allCompleted = artistDownloads.every(d => d.status === 'view_results');
if (allCompleted) {
console.log(`๐ข All downloads completed for ${artistDownloadBubbles[artistId].artist.name} - green checkmark should appear`);
console.log(`๐ฏ [STATUS DEBUG] Green checkmark trigger - forcing bubble refresh`);
// Force immediate bubble refresh to show green checkmark
setTimeout(updateArtistDownloadsSection, 100);
}
}
// Continue monitoring if still active
if (process.status !== 'complete') {
setTimeout(checkStatus, 2000); // Check every 2 seconds
}
};
// Start monitoring after a brief delay
setTimeout(checkStatus, 1000);
}
/**
* Open the artist download management modal
*/
function openArtistDownloadModal(artistId) {
const artistBubbleData = artistDownloadBubbles[artistId];
if (!artistBubbleData || artistDownloadModalOpen) return;
console.log(`๐ต [MODAL OPEN] Opening artist download modal for: ${artistBubbleData.artist.name}`);
console.log(`๐ [MODAL OPEN] Current download statuses:`, artistBubbleData.downloads.map(d => `${d.album.name}: ${d.status}`));
artistDownloadModalOpen = true;
const modal = document.createElement('div');
modal.id = 'artist-download-management-modal';
modal.className = 'artist-download-management-modal';
modal.innerHTML = `
${artistBubbleData.artist.image_url
? `
`
: '
'
}
${escapeHtml(artistBubbleData.artist.name)}
${artistBubbleData.downloads.length} active download${artistBubbleData.downloads.length !== 1 ? 's' : ''}
×
${artistBubbleData.downloads.map((download, index) => createArtistDownloadItem(download, index)).join('')}
`;
document.body.appendChild(modal);
modal.style.display = 'flex';
// Monitor for real-time updates
startArtistDownloadModalMonitoring(artistId);
}
/**
* Create HTML for an individual download item in the artist modal
*/
function createArtistDownloadItem(download, index) {
const { album, albumType, status, virtualPlaylistId } = download;
const buttonText = status === 'view_results' ? 'View Results' : 'View Progress';
const buttonClass = status === 'view_results' ? 'completed' : 'active';
// Enhanced debugging for button text generation
console.log(`๐ฏ [BUTTON] Creating item for ${album.name}: status='${status}' โ buttonText='${buttonText}'`);
return `
${album.image_url
? `
`
: `
`
}
${escapeHtml(album.name)}
${albumType === 'album' ? 'Album' : albumType === 'single' ? 'Single' : 'EP'}
${buttonText}
`;
}
/**
* Monitor artist download modal for real-time updates
*/
function startArtistDownloadModalMonitoring(artistId) {
if (!artistDownloadModalOpen) return;
const updateModal = () => {
const modal = document.getElementById('artist-download-management-modal');
const itemsContainer = document.getElementById(`artist-download-items-${artistId}`);
if (!modal || !itemsContainer || !artistDownloadBubbles[artistId]) return;
// Check for completed downloads that need to be removed
const activeDownloads = artistDownloadBubbles[artistId].downloads.filter(download => {
const process = activeDownloadProcesses[download.virtualPlaylistId];
// Keep if process exists or if it's completed but not yet cleaned up
return process !== undefined;
});
// Update the downloads array
artistDownloadBubbles[artistId].downloads = activeDownloads;
// If no downloads left, close modal
if (activeDownloads.length === 0) {
closeArtistDownloadModal();
return;
}
// Update modal content and synchronize with bubble state
let statusChanged = false;
itemsContainer.innerHTML = activeDownloads.map((download, index) => {
const process = activeDownloadProcesses[download.virtualPlaylistId];
if (process) {
const newStatus = process.status === 'complete' ? 'view_results' : 'in_progress';
if (download.status !== newStatus) {
console.log(`๐ [ARTIST MODAL] Updating ${download.album.name} status from ${download.status} to ${newStatus}`);
download.status = newStatus;
statusChanged = true;
}
}
return createArtistDownloadItem(download, index);
}).join('');
// CRITICAL: If any status changed, immediately refresh artist bubble to show green checkmarks
if (statusChanged) {
console.log(`๐ฏ [SYNC] Status change detected in artist modal - refreshing bubble display`);
updateArtistDownloadsSection();
// Check if all downloads for this artist are now completed
const artistDownloads = artistDownloadBubbles[artistId].downloads;
const allCompleted = artistDownloads.every(d => d.status === 'view_results');
if (allCompleted) {
console.log(`๐ข [ARTIST MODAL] All downloads completed for artist ${artistId} - triggering green checkmark`);
// Force additional refresh after a brief delay to ensure UI updates
setTimeout(() => {
console.log(`โจ [ARTIST MODAL] Forcing final refresh for green checkmark`);
updateArtistDownloadsSection();
}, 200);
}
}
// Continue monitoring
setTimeout(updateModal, 2000);
};
setTimeout(updateModal, 1000);
}
/**
* Open a specific artist download process modal
*/
function openArtistDownloadProcess(virtualPlaylistId) {
const process = activeDownloadProcesses[virtualPlaylistId];
if (process && process.modalElement) {
// Close artist management modal first
closeArtistDownloadModal();
// Show the download process modal
process.modalElement.style.display = 'flex';
if (process.status === 'complete') {
showToast('Review download results and click "Close" to finish.', 'info');
}
}
}
/**
* Close the artist download management modal
*/
function closeArtistDownloadModal() {
const modal = document.getElementById('artist-download-management-modal');
if (modal) {
modal.remove();
}
artistDownloadModalOpen = false;
}
/**
* Bulk complete all downloads for an artist (when all are in 'view_results' state)
*/
function bulkCompleteArtistDownloads(artistId) {
console.log(`๐ฏ Bulk completing downloads for artist: ${artistId}`);
const artistBubbleData = artistDownloadBubbles[artistId];
if (!artistBubbleData) {
console.warn(`โ No artist bubble data found for ${artistId}`);
return;
}
// Find all downloads in 'view_results' state
const completedDownloads = artistBubbleData.downloads.filter(d => d.status === 'view_results');
console.log(`๐ Found ${completedDownloads.length} completed downloads to close:`,
completedDownloads.map(d => d.album.name));
if (completedDownloads.length === 0) {
console.warn(`โ ๏ธ No completed downloads found for bulk close`);
showToast('No completed downloads to close', 'info');
return;
}
// Programmatically close all completed modals
completedDownloads.forEach(download => {
const process = activeDownloadProcesses[download.virtualPlaylistId];
if (process && process.modalElement) {
console.log(`๐๏ธ Closing modal for: ${download.album.name}`);
// Trigger the close function which handles cleanup
closeDownloadMissingModal(download.virtualPlaylistId);
} else {
// No modal open โ clean up the bubble entry directly
console.log(`๐งน Direct cleanup (no modal) for: ${download.album.name}`);
cleanupArtistDownload(download.virtualPlaylistId);
}
});
showToast(`Completed ${completedDownloads.length} downloads for ${artistBubbleData.artist.name}`, 'success');
}
/**
* Clean up artist download when a modal is closed
*/
function cleanupArtistDownload(virtualPlaylistId) {
console.log(`๐ [CLEANUP] Looking for download to cleanup: ${virtualPlaylistId}`);
console.log(`๐ [CLEANUP] Current artist bubbles:`, Object.keys(artistDownloadBubbles));
// Find which artist this download belongs to
for (const artistId in artistDownloadBubbles) {
const downloads = artistDownloadBubbles[artistId].downloads;
const downloadIndex = downloads.findIndex(d => d.virtualPlaylistId === virtualPlaylistId);
console.log(`๐ [CLEANUP] Checking artist ${artistId}: ${downloads.length} downloads`);
downloads.forEach(d => console.log(` - ${d.album.name} (${d.virtualPlaylistId}): ${d.status}`));
if (downloadIndex !== -1) {
const downloadToRemove = downloads[downloadIndex];
console.log(`๐งน [CLEANUP] Found download to cleanup: ${downloadToRemove.album.name} (status: ${downloadToRemove.status})`);
// Remove this download from the artist's downloads
downloads.splice(downloadIndex, 1);
console.log(`โ
[CLEANUP] Removed download from artist ${artistId}. Remaining: ${downloads.length}`);
// If no more downloads for this artist, remove the bubble
if (downloads.length === 0) {
delete artistDownloadBubbles[artistId];
console.log(`๐งน [CLEANUP] No more downloads - removed artist bubble: ${artistId}`);
} else {
console.log(`๐ [CLEANUP] Artist ${artistId} still has ${downloads.length} downloads remaining`);
}
// Update the downloads section
console.log(`๐ [CLEANUP] Updating artist downloads section...`);
updateArtistDownloadsSection();
// Save snapshot of updated state
saveArtistBubbleSnapshot();
break;
}
}
console.log(`โ
[CLEANUP] Cleanup process completed for ${virtualPlaylistId}`);
}
/**
* Force refresh all artist download statuses (useful for debugging)
*/
function refreshAllArtistDownloadStatuses() {
console.log('๐ Force refreshing all artist download statuses...');
for (const artistId in artistDownloadBubbles) {
const artistData = artistDownloadBubbles[artistId];
let hasChanges = false;
artistData.downloads.forEach(download => {
const process = activeDownloadProcesses[download.virtualPlaylistId];
if (process) {
const expectedStatus = process.status === 'complete' ? 'view_results' : 'in_progress';
if (download.status !== expectedStatus) {
console.log(`๐ง Fixing status for ${download.album.name}: ${download.status} โ ${expectedStatus}`);
download.status = expectedStatus;
hasChanges = true;
}
}
});
if (hasChanges) {
console.log(`โ
Updated statuses for ${artistData.artist.name}`);
}
}
// Force update the downloads section
showArtistDownloadsSection();
}
/**
* Extract dominant colors from an image for dynamic glow effects
*/
async function extractImageColors(imageUrl, callback) {
if (!imageUrl) {
callback(getAccentFallbackColors()); // Fallback to Spotify green
return;
}
// Check cache first for performance
if (artistsPageState.cache.colors[imageUrl]) {
callback(artistsPageState.cache.colors[imageUrl]);
return;
}
try {
// Create a canvas to analyze the image
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
const img = new Image();
img.crossOrigin = 'anonymous';
img.onload = function () {
// Resize to small dimensions for faster processing
const size = 50;
canvas.width = size;
canvas.height = size;
// Draw image to canvas
ctx.drawImage(img, 0, 0, size, size);
try {
// Get image data
const imageData = ctx.getImageData(0, 0, size, size);
const data = imageData.data;
// Extract colors (sample every few pixels for performance)
const colors = [];
for (let i = 0; i < data.length; i += 16) { // Sample every 4th pixel
const r = data[i];
const g = data[i + 1];
const b = data[i + 2];
const alpha = data[i + 3];
// Skip transparent or very dark pixels
if (alpha > 128 && (r + g + b) > 150) {
colors.push({ r, g, b });
}
}
if (colors.length === 0) {
callback(getAccentFallbackColors()); // Fallback
return;
}
// Find dominant colors using a simple clustering approach
const dominantColors = findDominantColors(colors, 2);
// Convert to CSS hex colors
const hexColors = dominantColors.map(color =>
`#${((1 << 24) + (color.r << 16) + (color.g << 8) + color.b).toString(16).slice(1)}`
);
// Cache the colors for future use
artistsPageState.cache.colors[imageUrl] = hexColors;
callback(hexColors);
} catch (e) {
console.warn('Color extraction failed, using fallback colors:', e);
callback(getAccentFallbackColors());
}
};
img.onerror = function () {
callback(getAccentFallbackColors()); // Fallback on error
};
img.src = imageUrl;
} catch (error) {
console.warn('Image color extraction error:', error);
callback(getAccentFallbackColors());
}
}
/**
* Simple color clustering to find dominant colors
*/
function findDominantColors(colors, numColors = 2) {
if (colors.length === 0) return [{ r: 29, g: 185, b: 84 }];
// Simple k-means clustering
let centroids = [];
// Initialize centroids randomly
for (let i = 0; i < numColors; i++) {
centroids.push(colors[Math.floor(Math.random() * colors.length)]);
}
// Run a few iterations of k-means
for (let iteration = 0; iteration < 5; iteration++) {
const clusters = Array(numColors).fill().map(() => []);
// Assign each color to nearest centroid
colors.forEach(color => {
let minDistance = Infinity;
let nearestCluster = 0;
centroids.forEach((centroid, i) => {
const distance = Math.sqrt(
Math.pow(color.r - centroid.r, 2) +
Math.pow(color.g - centroid.g, 2) +
Math.pow(color.b - centroid.b, 2)
);
if (distance < minDistance) {
minDistance = distance;
nearestCluster = i;
}
});
clusters[nearestCluster].push(color);
});
// Update centroids
centroids = clusters.map(cluster => {
if (cluster.length === 0) return centroids[0]; // Fallback
const avgR = cluster.reduce((sum, c) => sum + c.r, 0) / cluster.length;
const avgG = cluster.reduce((sum, c) => sum + c.g, 0) / cluster.length;
const avgB = cluster.reduce((sum, c) => sum + c.b, 0) / cluster.length;
return { r: Math.round(avgR), g: Math.round(avgG), b: Math.round(avgB) };
});
}
// Ensure we have vibrant colors by boosting saturation
return centroids.map(color => {
const max = Math.max(color.r, color.g, color.b);
const min = Math.min(color.r, color.g, color.b);
const saturation = max === 0 ? 0 : (max - min) / max;
// Boost low saturation colors
if (saturation < 0.4) {
const factor = 1.3;
return {
r: Math.min(255, Math.round(color.r * factor)),
g: Math.min(255, Math.round(color.g * factor)),
b: Math.min(255, Math.round(color.b * factor))
};
}
return color;
});
}
/**
* Apply dynamic glow effect to a card element
*/
function applyDynamicGlow(cardElement, colors) {
if (!cardElement || colors.length < 2) return;
const color1 = colors[0];
const color2 = colors[1];
// Add a small delay to make the effect feel more natural
setTimeout(() => {
// Create CSS custom properties for the dynamic colors
cardElement.style.setProperty('--glow-color-1', color1);
cardElement.style.setProperty('--glow-color-2', color2);
cardElement.classList.add('has-dynamic-glow');
console.log(`๐จ Applied dynamic glow: ${color1}, ${color2}`);
}, Math.random() * 200 + 100); // Random delay between 100-300ms
}
/**
* Utility function to escape HTML
*/
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
// --- Service Status and System Stats Functions ---
async function fetchAndUpdateServiceStatus() {
if (document.hidden) return; // Skip polling when tab is not visible
if (socketConnected) return; // WebSocket is pushing updates โ skip HTTP poll
try {
const response = await fetch('/status');
if (!response.ok) return;
const data = await response.json();
// Update service status indicators and text (dashboard)
updateServiceStatus('spotify', data.spotify);
updateServiceStatus('media-server', data.media_server);
updateServiceStatus('soulseek', data.soulseek);
// Update sidebar service status indicators
updateSidebarServiceStatus('spotify', data.spotify);
updateSidebarServiceStatus('media-server', data.media_server);
updateSidebarServiceStatus('soulseek', data.soulseek);
// Check for Spotify rate limit
if (data.spotify && data.spotify.rate_limited && data.spotify.rate_limit) {
handleSpotifyRateLimit(data.spotify.rate_limit);
} else if (_spotifyRateLimitShown) {
handleSpotifyRateLimit(null);
}
} catch (error) {
console.warn('Could not fetch service status:', error);
}
}
function updateServiceStatus(service, statusData) {
const indicator = document.getElementById(`${service}-status-indicator`);
const statusText = document.getElementById(`${service}-status-text`);
if (indicator && statusText) {
if (service === 'spotify' && (statusData.rate_limited || statusData.post_ban_cooldown)) {
indicator.className = 'service-card-indicator rate-limited';
const remaining = statusData.rate_limited
? formatRateLimitDuration(statusData.rate_limit?.remaining_seconds || 0)
: formatRateLimitDuration(statusData.post_ban_cooldown);
const phase = statusData.rate_limited ? 'paused' : 'recovering';
statusText.textContent = `Apple Music (Spotify ${phase} \u2014 ${remaining})`;
statusText.className = 'service-card-status-text rate-limited';
} else if (statusData.connected) {
indicator.className = 'service-card-indicator connected';
statusText.textContent = `Connected (${statusData.response_time}ms)`;
statusText.className = 'service-card-status-text connected';
} else {
indicator.className = 'service-card-indicator disconnected';
statusText.textContent = 'Disconnected';
statusText.className = 'service-card-status-text disconnected';
}
}
// Update music source title (Spotify or Apple Music) based on active source
if (service === 'spotify' && statusData.source) {
const musicSourceTitleElement = document.getElementById('music-source-title');
if (musicSourceTitleElement) {
const sourceName = statusData.source === 'itunes' ? 'Apple Music' : 'Spotify';
musicSourceTitleElement.textContent = sourceName;
// Update global variable for use in discovery modals
currentMusicSourceName = sourceName;
}
// Show/hide Spotify disconnect button based on connection state
const disconnectBtn = document.getElementById('spotify-disconnect-btn');
if (disconnectBtn) {
disconnectBtn.style.display = statusData.source === 'spotify' ? '' : 'none';
}
}
// Update download source title on dashboard card
if (service === 'soulseek' && statusData.source) {
const sourceNames = { soulseek: 'Soulseek', youtube: 'YouTube', tidal: 'Tidal', hybrid: 'Hybrid' };
const displayName = sourceNames[statusData.source] || 'Soulseek';
const titleEl = document.getElementById('download-source-title');
if (titleEl) titleEl.textContent = displayName;
}
}
function updateSidebarServiceStatus(service, statusData) {
const indicator = document.getElementById(`${service}-indicator`);
if (indicator) {
const dot = indicator.querySelector('.status-dot');
const nameElement = indicator.querySelector('.status-name');
if (dot) {
if (service === 'spotify' && (statusData.rate_limited || statusData.post_ban_cooldown)) {
dot.className = 'status-dot rate-limited';
dot.title = statusData.rate_limited
? `Spotify paused \u2014 ${formatRateLimitDuration(statusData.rate_limit?.remaining_seconds || 0)} remaining`
: `Spotify recovering \u2014 ${formatRateLimitDuration(statusData.post_ban_cooldown)} cooldown`;
} else if (statusData.connected) {
dot.className = 'status-dot connected';
dot.title = '';
} else {
dot.className = 'status-dot disconnected';
dot.title = '';
}
}
// Update media server name if it's the media server indicator
if (service === 'media-server' && statusData.type) {
const mediaServerNameElement = document.getElementById('media-server-name');
if (mediaServerNameElement) {
const serverName = statusData.type.charAt(0).toUpperCase() + statusData.type.slice(1);
mediaServerNameElement.textContent = serverName;
}
}
// Update music source name (Spotify or Apple Music) based on active source
if (service === 'spotify' && statusData.source) {
const musicSourceNameElement = document.getElementById('music-source-name');
if (musicSourceNameElement) {
const sourceName = statusData.source === 'itunes' ? 'Apple Music' : 'Spotify';
musicSourceNameElement.textContent = sourceName;
}
}
// Update download source name based on configured mode
if (service === 'soulseek' && statusData.source) {
const sourceNames = { soulseek: 'Soulseek', youtube: 'YouTube', tidal: 'Tidal', hybrid: 'Hybrid' };
const displayName = sourceNames[statusData.source] || 'Soulseek';
const sidebarName = document.getElementById('download-source-name');
if (sidebarName) sidebarName.textContent = displayName;
}
}
}
async function fetchAndUpdateSystemStats() {
if (socketConnected) return; // WebSocket handles this
if (document.hidden) return; // Skip polling when tab is not visible
try {
const response = await fetch('/api/system/stats');
if (!response.ok) return;
const data = await response.json();
// Update all stat cards
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');
} catch (error) {
console.warn('Could not fetch system stats:', error);
}
}
function updateStatCard(cardId, value, subtitle) {
const card = document.getElementById(cardId);
if (card) {
const valueElement = card.querySelector('.stat-card-value');
const subtitleElement = card.querySelector('.stat-card-subtitle');
if (valueElement) {
valueElement.textContent = value;
}
if (subtitleElement) {
subtitleElement.textContent = subtitle;
}
}
}
async function fetchAndUpdateActivityFeed() {
if (socketConnected) return; // WebSocket handles this
if (document.hidden) return; // Skip polling when tab is not visible
try {
const response = await fetch('/api/activity/feed');
if (!response.ok) {
console.warn('Activity feed response not ok:', response.status, response.statusText);
return;
}
const data = await response.json();
console.log('Activity feed data received:', data);
updateActivityFeed(data.activities || []);
} catch (error) {
console.warn('Could not fetch activity feed:', error);
}
}
// Cache last feed signature to avoid unnecessary DOM rebuilds (prevents blink)
let _lastActivityFeedSig = '';
function updateActivityFeed(activities) {
const feedContainer = document.getElementById('dashboard-activity-feed');
if (!feedContainer) return;
if (activities.length === 0) {
if (_lastActivityFeedSig === 'empty') return;
_lastActivityFeedSig = 'empty';
feedContainer.innerHTML = `
๐
System Started
Dashboard initialized successfully
Just now
`;
return;
}
const items = activities.slice(0, 5);
// Build signature from titles+subtitles to detect actual changes
const sig = items.map(a => a.title + a.subtitle).join('|');
const feedChanged = sig !== _lastActivityFeedSig;
_lastActivityFeedSig = sig;
if (!feedChanged) {
// Just update timestamps without rebuilding DOM
const timeEls = feedContainer.querySelectorAll('.activity-time');
items.forEach((activity, i) => {
if (timeEls[i]) timeEls[i].textContent = timeAgo(activity.time);
});
return;
}
// Full rebuild only when feed content actually changed
feedContainer.innerHTML = '';
items.forEach((activity, index) => {
const activityElement = document.createElement('div');
activityElement.className = 'activity-item';
activityElement.innerHTML = `
${escapeHtml(activity.icon)}
${escapeHtml(activity.title)}
${escapeHtml(activity.subtitle)}
${timeAgo(activity.time)}
`;
feedContainer.appendChild(activityElement);
if (index < items.length - 1) {
const separator = document.createElement('div');
separator.className = 'activity-separator';
feedContainer.appendChild(separator);
}
});
}
async function checkForActivityToasts() {
if (socketConnected) return; // WebSocket handles this (instant push)
if (document.hidden) return; // Skip polling when tab is not visible
try {
const response = await fetch('/api/activity/toasts');
if (!response.ok) return;
const data = await response.json();
const toasts = data.toasts || [];
toasts.forEach(activity => {
// Convert activity to toast type based on icon/title
let toastType = 'info';
if (activity.icon === 'โ
' || activity.title.includes('Complete')) {
toastType = 'success';
} else if (activity.icon === 'โ' || activity.title.includes('Failed') || activity.title.includes('Error')) {
toastType = 'error';
} else if (activity.icon === '๐ซ' || activity.title.includes('Cancelled')) {
toastType = 'warning';
}
// Show toast with activity info
showToast(`${activity.title}: ${activity.subtitle}`, toastType);
});
} catch (error) {
// Silently fail for toast checking to avoid spam
}
}
// --- Watchlist Functions ---
/**
* Toggle an artist's watchlist status
*/
async function toggleWatchlist(event, artistId, artistName) {
// Prevent event bubbling to parent card
event.stopPropagation();
const button = event.currentTarget;
const icon = button.querySelector('.watchlist-icon');
const text = button.querySelector('.watchlist-text');
// Show loading state
const originalText = text.textContent;
text.textContent = 'Loading...';
button.disabled = true;
try {
// Check current status
const checkResponse = await fetch('/api/watchlist/check', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ artist_id: artistId })
});
const checkData = await checkResponse.json();
if (!checkData.success) {
throw new Error(checkData.error || 'Failed to check watchlist status');
}
const isWatching = checkData.is_watching;
// Toggle watchlist status
const endpoint = isWatching ? '/api/watchlist/remove' : '/api/watchlist/add';
const payload = isWatching ?
{ artist_id: artistId } :
{ artist_id: artistId, artist_name: artistName };
const response = await fetch(endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
const data = await response.json();
if (!data.success) {
throw new Error(data.error || 'Failed to update watchlist');
}
// Update button appearance
const gearBtn = button.parentElement?.querySelector('.watchlist-settings-btn');
if (isWatching) {
// Was watching, now removed
icon.textContent = '๐๏ธ';
text.textContent = 'Add to Watchlist';
button.classList.remove('watching');
if (gearBtn) gearBtn.classList.add('hidden');
console.log(`โ Removed ${artistName} from watchlist`);
} else {
// Was not watching, now added
icon.textContent = '๐๏ธ';
text.textContent = 'Watching...';
button.classList.add('watching');
if (gearBtn) gearBtn.classList.remove('hidden');
console.log(`โ
Added ${artistName} to watchlist`);
}
// Update dashboard watchlist count
updateWatchlistButtonCount();
} catch (error) {
console.error('Error toggling watchlist:', error);
text.textContent = originalText;
// Show error feedback
const originalBackground = button.style.background;
button.style.background = 'rgba(255, 59, 48, 0.3)';
setTimeout(() => {
button.style.background = originalBackground;
}, 2000);
} finally {
button.disabled = false;
}
}
/**
* Update the watchlist button count on dashboard
*/
async function updateWatchlistButtonCount() {
if (document.hidden) return; // Skip polling when tab is not visible
if (socketConnected) return; // WebSocket is pushing updates โ skip HTTP poll
try {
const response = await fetch('/api/watchlist/count');
const data = await response.json();
if (data.success) {
const watchlistButton = document.getElementById('watchlist-button');
if (watchlistButton) {
// Format countdown for button tooltip (optional enhancement)
const countdownText = data.next_run_in_seconds ? formatCountdownTime(data.next_run_in_seconds) : '';
watchlistButton.textContent = `๐๏ธ Watchlist (${data.count})`;
if (countdownText) {
watchlistButton.title = `Next auto-scan in ${countdownText}`;
}
}
}
} catch (error) {
console.error('Error updating watchlist count:', error);
}
}
/**
* Check and update watchlist status for all visible artist cards
*/
async function updateArtistCardWatchlistStatus() {
const artistCards = document.querySelectorAll('.artist-card');
const artistIds = [];
for (const card of artistCards) {
const artistId = card.dataset.artistId;
if (artistId) artistIds.push(artistId);
}
if (!artistIds.length) return;
try {
const response = await fetch('/api/watchlist/check-batch', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ artist_ids: artistIds })
});
const data = await response.json();
if (data.success && data.results) {
for (const card of artistCards) {
const artistId = card.dataset.artistId;
if (!artistId) continue;
const button = card.querySelector('.watchlist-toggle-btn');
if (!button) continue;
const icon = button.querySelector('.watchlist-icon');
const text = button.querySelector('.watchlist-text');
const gearBtn = button.parentElement?.querySelector('.watchlist-settings-btn');
if (data.results[artistId]) {
if (icon) icon.textContent = '๐๏ธ';
if (text) text.textContent = 'Watching...';
button.classList.add('watching');
if (gearBtn) gearBtn.classList.remove('hidden');
} else {
if (icon) icon.textContent = '๐๏ธ';
if (text) text.textContent = 'Add to Watchlist';
button.classList.remove('watching');
if (gearBtn) gearBtn.classList.add('hidden');
}
}
}
} catch (error) {
console.error('Error batch checking watchlist status:', error);
}
}
/**
* Show watchlist modal
*/
async function showWatchlistModal() {
try {
// Check if watchlist has any artists
const countResponse = await fetch('/api/watchlist/count');
const countData = await countResponse.json();
if (!countData.success) {
console.error('Error getting watchlist count:', countData.error);
return;
}
if (countData.count === 0) {
// Show empty state message
alert('Your watchlist is empty!\n\nAdd artists to your watchlist from the Artists page to monitor them for new releases.');
return;
}
// Get watchlist artists
const artistsResponse = await fetch('/api/watchlist/artists');
const artistsData = await artistsResponse.json();
if (!artistsData.success) {
console.error('Error getting watchlist artists:', artistsData.error);
return;
}
// Create modal if it doesn't exist
let modal = document.getElementById('watchlist-modal');
if (!modal) {
modal = document.createElement('div');
modal.id = 'watchlist-modal';
modal.className = 'modal-overlay';
document.body.appendChild(modal);
}
// Get scan status and global config
const statusResponse = await fetch('/api/watchlist/scan/status');
const statusData = await statusResponse.json();
const scanStatus = statusData.success ? statusData.status : 'idle';
let globalOverrideActive = false;
try {
const globalConfigResponse = await fetch('/api/watchlist/global-config');
const globalConfigData = await globalConfigResponse.json();
globalOverrideActive = globalConfigData.success && globalConfigData.config.global_override_enabled;
} catch (e) {
console.debug('Could not fetch global config:', e);
}
// Format countdown timer
const nextRunSeconds = countData.next_run_in_seconds || 0;
const countdownText = formatCountdownTime(nextRunSeconds);
// Build modal content
modal.innerHTML = `
${scanStatus === 'scanning' ? 'Scanning...' : 'Scan for New Releases'}
Update Similar Artists
${globalOverrideActive ? 'Global Override ON' : 'Global Settings'}
${globalOverrideActive ? `
โ ๏ธ
Global override is active โ per-artist settings are being ignored during scans.
` : ''}
Select All
Remove Selected
${artistsData.artists.map(artist => {
const pills = [];
if (artist.include_albums) pills.push('
Albums ');
if (artist.include_eps) pills.push('
EPs ');
if (artist.include_singles) pills.push('
Singles ');
if (artist.include_live) pills.push('
Live ');
if (artist.include_remixes) pills.push('
Remixes ');
if (artist.include_acoustic) pills.push('
Acoustic ');
if (artist.include_compilations) pills.push('
Compilations ');
return `
${artist.image_url ? `
` : `
๐ค
`}
${escapeHtml(artist.artist_name)}
${formatRelativeScanTime(artist.last_scan_timestamp)}
${pills.length > 0 ? `
${pills.join('')}
` : ''}
`}).join('')}
`;
// Add event listeners for gear buttons
modal.querySelectorAll('.watchlist-card-gear').forEach(button => {
button.addEventListener('click', () => {
const artistId = button.getAttribute('data-artist-id');
const artistName = button.getAttribute('data-artist-name');
openWatchlistArtistConfigModal(artistId, artistName);
});
});
// Add click handlers to artist cards (except for gear button or checkbox)
modal.querySelectorAll('.watchlist-artist-card').forEach(item => {
item.addEventListener('click', (e) => {
if (e.target.closest('.watchlist-card-gear') || e.target.closest('.watchlist-card-checkbox')) {
return;
}
const artistId = item.getAttribute('data-artist-id');
const artistName = item.querySelector('.watchlist-card-name').textContent;
console.log(`๐ต Artist card clicked: ${artistName} (${artistId})`);
openWatchlistArtistDetailView(artistId, artistName);
});
});
// Show modal
modal.style.display = 'flex';
// Start countdown timer update interval
startWatchlistCountdownTimer(nextRunSeconds);
// Start polling for scan status if scanning
if (scanStatus === 'scanning') {
pollWatchlistScanStatus();
}
} catch (error) {
console.error('Error showing watchlist modal:', error);
}
}
function startWatchlistCountdownTimer(initialSeconds) {
// Clear any existing interval
if (watchlistCountdownInterval) {
clearInterval(watchlistCountdownInterval);
}
let remainingSeconds = initialSeconds;
watchlistCountdownInterval = setInterval(async () => {
remainingSeconds--;
if (remainingSeconds <= 0) {
// Timer expired, fetch fresh data
try {
const response = await fetch('/api/watchlist/count');
const data = await response.json();
remainingSeconds = data.next_run_in_seconds || 0;
const timerElement = document.getElementById('watchlist-next-auto-timer');
if (timerElement) {
const countdownText = formatCountdownTime(remainingSeconds);
timerElement.textContent = `Next Auto${countdownText ? ': ' + countdownText : ''}`;
}
} catch (error) {
console.debug('Error updating watchlist countdown:', error);
}
} else {
// Update the display
const timerElement = document.getElementById('watchlist-next-auto-timer');
if (timerElement) {
const countdownText = formatCountdownTime(remainingSeconds);
timerElement.textContent = `Next Auto${countdownText ? ': ' + countdownText : ''}`;
}
}
}, 1000); // Update every second
}
/**
* Close watchlist modal
*/
function closeWatchlistModal() {
// Stop countdown timer
if (watchlistCountdownInterval) {
clearInterval(watchlistCountdownInterval);
watchlistCountdownInterval = null;
}
const modal = document.getElementById('watchlist-modal');
if (modal) {
modal.style.display = 'none';
}
}
/**
* Populate the linked provider section in the watchlist config modal.
* Shows which Spotify/iTunes artist is linked and allows changing it.
*/
function _populateLinkedProviderSection(artistId, artistName, spotifyId, itunesId, artistInfo) {
const section = document.getElementById('watchlist-linked-provider-section');
const content = document.getElementById('watchlist-linked-provider-content');
if (!section || !content) return;
// Determine which providers are linked
const hasSpotify = !!spotifyId;
const hasItunes = !!itunesId;
if (!hasSpotify && !hasItunes) {
section.style.display = 'none';
return;
}
section.style.display = '';
// Build linked artist display โ show a card for each linked provider
// The artist info from the API is for the currently active provider
const linkedName = artistInfo?.name || artistName;
const linkedImage = artistInfo?.image_url || '';
const nameMatches = linkedName.toLowerCase().trim() === artistName.toLowerCase().trim();
let html = ``;
if (linkedImage) {
html += `
`;
} else {
html += `
๐ต
`;
}
html += `
`;
html += `
${escapeHtml(linkedName)}
`;
// Show provider badges
html += `
`;
if (hasSpotify) {
html += `Spotify `;
}
if (hasItunes) {
html += `iTunes `;
}
html += `
`;
// Show mismatch warning if linked name differs from watchlist name
if (!nameMatches) {
html += `
โ ๏ธ Name differs from watchlist entry "${escapeHtml(artistName)} "
`;
}
html += `
`; // close info
html += `
Change `;
html += `
`; // close card
// Search UI (hidden by default)
html += ``;
content.innerHTML = html;
// Wire up Change button
document.getElementById('watchlist-linked-change-btn').onclick = () => {
document.getElementById('watchlist-linked-search').style.display = '';
const input = document.getElementById('watchlist-linked-search-input');
input.focus();
input.select();
};
// Wire up search
const doSearch = () => _searchLinkedProviderArtists(artistId, artistName);
document.getElementById('watchlist-linked-search-go').onclick = doSearch;
document.getElementById('watchlist-linked-search-input').onkeydown = (e) => {
if (e.key === 'Enter') doSearch();
};
}
/**
* Search for artists to link to a watchlist entry.
*/
async function _searchLinkedProviderArtists(currentArtistId, watchlistName) {
const input = document.getElementById('watchlist-linked-search-input');
const resultsContainer = document.getElementById('watchlist-linked-search-results');
const query = input?.value?.trim();
if (!query || !resultsContainer) return;
resultsContainer.innerHTML = 'Searching...
';
try {
// Use match/search to find artists (returns genres, popularity, image)
const response = await fetch('/api/match/search', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ query: query, context: 'artist' })
});
const data = await response.json();
const results = data.results || [];
if (results.length === 0) {
resultsContainer.innerHTML = 'No artists found
';
return;
}
let html = '';
for (const r of results.slice(0, 8)) {
const a = r.artist || r;
const img = a.image_url || '';
const genres = (a.genres || []).slice(0, 2).join(', ');
const pop = a.popularity || 0;
html += `
${img ? `
` :
`
๐ต
`}
${escapeHtml(a.name)}
${genres ? escapeHtml(genres) + ' ยท ' : ''}Pop: ${pop}
Select
`;
}
resultsContainer.innerHTML = html;
// Wire up select buttons
resultsContainer.querySelectorAll('.watchlist-linked-search-result').forEach(el => {
el.querySelector('.watchlist-linked-select-btn').onclick = async (e) => {
e.stopPropagation();
const newId = el.dataset.id;
const newName = el.dataset.name;
await _linkProviderArtist(currentArtistId, newId, newName);
};
});
} catch (err) {
console.error('Error searching for linked artist:', err);
resultsContainer.innerHTML = 'Search error
';
}
}
/**
* Link a watchlist artist to a new provider artist.
*/
async function _linkProviderArtist(currentArtistId, newProviderId, newProviderName) {
// Determine provider type from ID format
const provider = /^\d+$/.test(newProviderId) ? 'itunes' : 'spotify';
try {
const response = await fetch(`/api/watchlist/artist/${currentArtistId}/link-provider`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ provider_id: newProviderId, provider: provider })
});
const data = await response.json();
if (!data.success) {
showToast(`Failed to link artist: ${data.error}`, 'error');
return;
}
showToast(`Linked to "${newProviderName}" on ${provider}`, 'success');
// Close and reopen the config modal with the new ID to refresh everything
closeWatchlistArtistConfigModal();
setTimeout(() => {
openWatchlistArtistConfigModal(newProviderId, newProviderName);
}, 300);
} catch (err) {
console.error('Error linking provider artist:', err);
showToast('Failed to link artist', 'error');
}
}
/**
* Open watchlist artist configuration modal
* @param {string} artistId - Spotify artist ID
* @param {string} artistName - Artist name
*/
async function openWatchlistArtistConfigModal(artistId, artistName) {
try {
console.log(`๐จ Opening config modal for artist: ${artistName} (${artistId})`);
// Fetch artist config and info
const response = await fetch(`/api/watchlist/artist/${artistId}/config`);
const data = await response.json();
if (!data.success) {
console.error('Error loading artist config:', data.error);
showToast(`Error loading artist configuration: ${data.error}`, 'error');
return;
}
const { config, artist, spotify_artist_id, itunes_artist_id, watchlist_name } = data;
// Populate linked provider section (use DB watchlist_name for mismatch comparison)
_populateLinkedProviderSection(artistId, watchlist_name || artistName, spotify_artist_id, itunes_artist_id, artist);
// Check if global override is active
let globalOverrideActive = false;
try {
const globalResponse = await fetch('/api/watchlist/global-config');
const globalData = await globalResponse.json();
globalOverrideActive = globalData.success && globalData.config.global_override_enabled;
} catch (e) {
console.debug('Could not check global config:', e);
}
// Generate hero section
const heroHTML = `
${artist.image_url ? `
` : ''}
${escapeHtml(artist.name)}
${formatNumber(artist.followers)}
Followers
${artist.popularity}/100
Popularity
${artist.genres && artist.genres.length > 0 ? `
${artist.genres.slice(0, 3).map(genre =>
`${escapeHtml(genre)} `
).join('')}
` : ''}
`;
// Populate hero section
const heroContainer = document.getElementById('watchlist-artist-config-hero');
if (heroContainer) {
heroContainer.innerHTML = heroHTML;
}
// Set checkbox states
document.getElementById('config-include-albums').checked = config.include_albums;
document.getElementById('config-include-eps').checked = config.include_eps;
document.getElementById('config-include-singles').checked = config.include_singles;
document.getElementById('config-include-live').checked = config.include_live || false;
document.getElementById('config-include-remixes').checked = config.include_remixes || false;
document.getElementById('config-include-acoustic').checked = config.include_acoustic || false;
document.getElementById('config-include-compilations').checked = config.include_compilations || false;
document.getElementById('config-include-instrumentals').checked = config.include_instrumentals || false;
// Show global override notice if active
const existingNotice = document.querySelector('.global-override-notice');
if (existingNotice) existingNotice.remove();
if (globalOverrideActive) {
const notice = document.createElement('div');
notice.className = 'global-override-notice watchlist-global-override-banner';
notice.innerHTML = 'โ ๏ธ Global override is active โ these per-artist settings are currently ignored during scans. ';
const configBody = document.querySelector('.watchlist-artist-config-body');
if (configBody) configBody.insertBefore(notice, configBody.firstChild);
}
// Store artist ID for saving
const modal = document.getElementById('watchlist-artist-config-modal');
if (modal) {
modal.setAttribute('data-artist-id', artistId);
}
// Show modal
const overlay = document.getElementById('watchlist-artist-config-modal-overlay');
if (overlay) {
overlay.classList.remove('hidden');
}
// Add save button handler
const saveBtn = document.getElementById('save-artist-config-btn');
if (saveBtn) {
// Remove old listeners
const newSaveBtn = saveBtn.cloneNode(true);
saveBtn.parentNode.replaceChild(newSaveBtn, saveBtn);
// Add new listener
newSaveBtn.addEventListener('click', () => saveWatchlistArtistConfig(artistId));
}
} catch (error) {
console.error('Error opening watchlist artist config modal:', error);
showToast(`Error: ${error.message}`, 'error');
}
}
/**
* Close watchlist artist configuration modal
*/
function closeWatchlistArtistConfigModal() {
const overlay = document.getElementById('watchlist-artist-config-modal-overlay');
if (overlay) {
overlay.classList.add('hidden');
}
// Clear hero content
const heroContainer = document.getElementById('watchlist-artist-config-hero');
if (heroContainer) {
heroContainer.innerHTML = '';
}
// Clear linked provider section
const linkedContent = document.getElementById('watchlist-linked-provider-content');
if (linkedContent) linkedContent.innerHTML = '';
const linkedSection = document.getElementById('watchlist-linked-provider-section');
if (linkedSection) linkedSection.style.display = 'none';
}
/**
* Open watchlist artist detail view (slides in from right)
*/
async function openWatchlistArtistDetailView(artistId, artistName) {
try {
const response = await fetch(`/api/watchlist/artist/${artistId}/config`);
const data = await response.json();
if (!data.success) {
showToast(`Error loading artist info: ${data.error}`, 'error');
return;
}
const { config, artist, recent_releases } = data;
// Remove existing overlay if any
const existing = document.querySelector('.watchlist-artist-detail-overlay');
if (existing) existing.remove();
const overlay = document.createElement('div');
overlay.className = 'watchlist-artist-detail-overlay';
// Build pills
const pills = [];
if (config.include_albums) pills.push('Albums ');
if (config.include_eps) pills.push('EPs ');
if (config.include_singles) pills.push('Singles ');
if (config.include_live) pills.push('Live ');
if (config.include_remixes) pills.push('Remixes ');
if (config.include_acoustic) pills.push('Acoustic ');
if (config.include_compilations) pills.push('Compilations ');
// Build scan info
const scanTimeText = config.last_scan_timestamp ? formatRelativeScanTime(config.last_scan_timestamp) : 'Never scanned';
const dateAddedText = config.date_added ? `Added ${new Date(config.date_added).toLocaleDateString()}` : '';
// Build metadata tags (style, mood, label)
const metaTags = [];
if (artist.style) metaTags.push(`${escapeHtml(artist.style)} `);
if (artist.mood) metaTags.push(`${escapeHtml(artist.mood)} `);
if (artist.label) metaTags.push(`${escapeHtml(artist.label)} `);
overlay.innerHTML = `
${artist.banner_url ? `
` : ''}
โ Back to Watchlist
${artist.image_url ? `
` : ''}
${escapeHtml(artist.name)}
${artist.followers || artist.popularity ? `
${artist.followers ? `
${formatNumber(artist.followers)}
Followers
` : ''}
${artist.popularity ? `
${artist.popularity}/100
Popularity
` : ''}
` : ''}
${artist.genres && artist.genres.length > 0 ? `
${artist.genres.map(g => `${escapeHtml(g)} `).join('')}
` : ''}
${artist.summary ? `
About
${escapeHtml(artist.summary)}
` : ''}
${metaTags.length > 0 ? `
Info
${metaTags.join('')}
` : ''}
${recent_releases && recent_releases.length > 0 ? `
Recent Releases
${recent_releases.map(r => `
${r.album_cover_url ? `
` : ''}
${escapeHtml(r.album_name)}
${r.release_date}${r.track_count ? ` ยท ${r.track_count} tracks` : ''}
`).join('')}
` : ''}
Watchlist
${scanTimeText}
${dateAddedText ? `ยท ${dateAddedText} ` : ''}
${pills.length > 0 ? pills.join('') : 'No release types enabled '}
Settings
Remove from Watchlist
`;
// Wire up event listeners (avoids inline onclick escaping issues)
overlay.querySelector('.watchlist-detail-back-btn').addEventListener('click', () => {
closeWatchlistArtistDetailView();
});
overlay.querySelector('.watchlist-detail-settings-action').addEventListener('click', () => {
// Remove overlay immediately so it doesn't block the config modal
const detailOverlay = document.querySelector('.watchlist-artist-detail-overlay');
if (detailOverlay) detailOverlay.remove();
openWatchlistArtistConfigModal(artistId, artistName);
});
overlay.querySelector('.watchlist-detail-remove-action').addEventListener('click', () => {
removeFromWatchlistModal(artistId, artistName);
});
// Append to the modal container
const container = document.querySelector('.watchlist-fullscreen');
if (container) {
container.appendChild(overlay);
// Trigger slide-in animation
requestAnimationFrame(() => overlay.classList.add('visible'));
}
} catch (error) {
console.error('Error opening artist detail view:', error);
showToast(`Error: ${error.message}`, 'error');
}
}
/**
* Close watchlist artist detail view (slides out)
*/
function closeWatchlistArtistDetailView() {
const overlay = document.querySelector('.watchlist-artist-detail-overlay');
if (overlay) {
overlay.classList.remove('visible');
overlay.addEventListener('transitionend', () => overlay.remove(), { once: true });
}
}
/**
* Open global watchlist settings modal
*/
async function openWatchlistGlobalSettingsModal() {
try {
const response = await fetch('/api/watchlist/global-config');
const data = await response.json();
if (!data.success) {
showToast(`Error loading global settings: ${data.error}`, 'error');
return;
}
const config = data.config;
// Populate checkboxes
document.getElementById('global-override-enabled').checked = config.global_override_enabled;
document.getElementById('global-include-albums').checked = config.include_albums;
document.getElementById('global-include-eps').checked = config.include_eps;
document.getElementById('global-include-singles').checked = config.include_singles;
document.getElementById('global-include-live').checked = config.include_live;
document.getElementById('global-include-remixes').checked = config.include_remixes;
document.getElementById('global-include-acoustic').checked = config.include_acoustic;
document.getElementById('global-include-compilations').checked = config.include_compilations;
document.getElementById('global-include-instrumentals').checked = config.include_instrumentals;
document.getElementById('global-exclude-terms').value = config.exclude_terms || '';
// Sync "Include Everything" checkbox
syncGlobalIncludeAllCheckbox();
// Update options visibility based on toggle state
toggleGlobalOverrideOptions();
// Update toggle label border
const toggleLabel = document.getElementById('global-override-toggle-label');
if (toggleLabel) {
toggleLabel.style.border = config.global_override_enabled
? '2px solid rgba(29, 185, 84, 0.5)'
: '2px solid rgba(255, 255, 255, 0.1)';
}
// Show modal
const overlay = document.getElementById('watchlist-global-config-modal-overlay');
if (overlay) overlay.classList.remove('hidden');
} catch (error) {
console.error('Error opening global watchlist settings:', error);
showToast(`Error: ${error.message}`, 'error');
}
}
/**
* Close global watchlist settings modal
*/
function closeWatchlistGlobalSettingsModal() {
const overlay = document.getElementById('watchlist-global-config-modal-overlay');
if (overlay) overlay.classList.add('hidden');
}
/**
* Toggle global override options visibility
*/
function toggleGlobalOverrideOptions() {
const enabled = document.getElementById('global-override-enabled').checked;
const options = document.getElementById('global-override-options');
if (options) {
options.style.opacity = enabled ? '1' : '0.4';
options.style.pointerEvents = enabled ? 'auto' : 'none';
}
// Update toggle label border
const toggleLabel = document.getElementById('global-override-toggle-label');
if (toggleLabel) {
toggleLabel.style.border = enabled
? '2px solid rgba(29, 185, 84, 0.5)'
: '2px solid rgba(255, 255, 255, 0.1)';
}
}
/**
* Toggle all global include checkboxes
*/
function toggleGlobalIncludeAll() {
const checked = document.getElementById('global-include-all').checked;
['global-include-albums', 'global-include-eps', 'global-include-singles',
'global-include-live', 'global-include-remixes', 'global-include-acoustic',
'global-include-compilations', 'global-include-instrumentals'].forEach(id => {
const el = document.getElementById(id);
if (el) el.checked = checked;
});
}
/**
* Sync the "Include Everything" checkbox based on individual checkbox states
*/
function syncGlobalIncludeAllCheckbox() {
const allIds = ['global-include-albums', 'global-include-eps', 'global-include-singles',
'global-include-live', 'global-include-remixes', 'global-include-acoustic',
'global-include-compilations', 'global-include-instrumentals'];
const allChecked = allIds.every(id => {
const el = document.getElementById(id);
return el && el.checked;
});
const includeAllEl = document.getElementById('global-include-all');
if (includeAllEl) includeAllEl.checked = allChecked;
}
/**
* Save global watchlist configuration
*/
async function saveWatchlistGlobalConfig() {
try {
const globalOverrideEnabled = document.getElementById('global-override-enabled').checked;
const includeAlbums = document.getElementById('global-include-albums').checked;
const includeEps = document.getElementById('global-include-eps').checked;
const includeSingles = document.getElementById('global-include-singles').checked;
const includeLive = document.getElementById('global-include-live').checked;
const includeRemixes = document.getElementById('global-include-remixes').checked;
const includeAcoustic = document.getElementById('global-include-acoustic').checked;
const includeCompilations = document.getElementById('global-include-compilations').checked;
const includeInstrumentals = document.getElementById('global-include-instrumentals').checked;
const excludeTerms = (document.getElementById('global-exclude-terms').value || '').trim();
if (globalOverrideEnabled && !includeAlbums && !includeEps && !includeSingles) {
showToast('Please select at least one release type', 'error');
return;
}
const saveBtn = document.getElementById('save-global-config-btn');
if (saveBtn) {
saveBtn.disabled = true;
saveBtn.textContent = 'Saving...';
}
const response = await fetch('/api/watchlist/global-config', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
global_override_enabled: globalOverrideEnabled,
include_albums: includeAlbums,
include_eps: includeEps,
include_singles: includeSingles,
include_live: includeLive,
include_remixes: includeRemixes,
include_acoustic: includeAcoustic,
include_compilations: includeCompilations,
include_instrumentals: includeInstrumentals,
exclude_terms: excludeTerms,
})
});
const data = await response.json();
if (data.success) {
showToast('Global watchlist settings saved', 'success');
closeWatchlistGlobalSettingsModal();
// Refresh the watchlist modal to update button and banner
const watchlistModal = document.getElementById('watchlist-modal');
if (watchlistModal && watchlistModal.style.display === 'flex') {
await showWatchlistModal();
}
} else {
showToast(`Error: ${data.error}`, 'error');
}
} catch (error) {
console.error('Error saving global config:', error);
showToast(`Error: ${error.message}`, 'error');
} finally {
const saveBtn = document.getElementById('save-global-config-btn');
if (saveBtn) {
saveBtn.disabled = false;
saveBtn.textContent = 'Save Global Settings';
}
}
}
/**
* Save watchlist artist configuration
* @param {string} artistId - Spotify artist ID
*/
async function saveWatchlistArtistConfig(artistId) {
try {
const includeAlbums = document.getElementById('config-include-albums').checked;
const includeEps = document.getElementById('config-include-eps').checked;
const includeSingles = document.getElementById('config-include-singles').checked;
const includeLive = document.getElementById('config-include-live').checked;
const includeRemixes = document.getElementById('config-include-remixes').checked;
const includeAcoustic = document.getElementById('config-include-acoustic').checked;
const includeCompilations = document.getElementById('config-include-compilations').checked;
const includeInstrumentals = document.getElementById('config-include-instrumentals').checked;
// Validate at least one release type is selected
if (!includeAlbums && !includeEps && !includeSingles) {
showToast('Please select at least one release type', 'error');
return;
}
// Disable save button
const saveBtn = document.getElementById('save-artist-config-btn');
if (saveBtn) {
saveBtn.disabled = true;
saveBtn.textContent = 'Saving...';
}
// Send update to backend
const response = await fetch(`/api/watchlist/artist/${artistId}/config`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
include_albums: includeAlbums,
include_eps: includeEps,
include_singles: includeSingles,
include_live: includeLive,
include_remixes: includeRemixes,
include_acoustic: includeAcoustic,
include_compilations: includeCompilations,
include_instrumentals: includeInstrumentals,
})
});
const data = await response.json();
if (data.success) {
showToast('Artist preferences saved successfully', 'success');
closeWatchlistArtistConfigModal();
// Refresh watchlist modal if it's open
const watchlistModal = document.getElementById('watchlist-modal');
if (watchlistModal && watchlistModal.style.display === 'flex') {
await showWatchlistModal();
}
} else {
showToast(`Error saving preferences: ${data.error}`, 'error');
}
} catch (error) {
console.error('Error saving watchlist artist config:', error);
showToast(`Error: ${error.message}`, 'error');
} finally {
// Re-enable save button
const saveBtn = document.getElementById('save-artist-config-btn');
if (saveBtn) {
saveBtn.disabled = false;
saveBtn.textContent = 'Save Preferences';
}
}
}
/**
* Format large numbers with commas
* @param {number} num - Number to format
* @returns {string} Formatted number
*/
function formatNumber(num) {
if (!num) return '0';
return num.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ",");
}
/**
* Format last scan timestamp as relative time
*/
function formatRelativeScanTime(isoString) {
if (!isoString) return 'Never scanned';
const diff = Date.now() - new Date(isoString).getTime();
const mins = Math.floor(diff / 60000);
if (mins < 1) return 'Scanned just now';
if (mins < 60) return `Scanned ${mins}m ago`;
const hrs = Math.floor(mins / 60);
if (hrs < 24) return `Scanned ${hrs}h ago`;
const days = Math.floor(hrs / 24);
if (days < 30) return `Scanned ${days}d ago`;
const months = Math.floor(days / 30);
return `Scanned ${months}mo ago`;
}
/**
* Filter watchlist artists based on search input
*/
function filterWatchlistArtists() {
const searchInput = document.getElementById('watchlist-search-input');
const artistsList = document.getElementById('watchlist-artists-list');
if (!searchInput || !artistsList) return;
const searchTerm = searchInput.value.toLowerCase().trim();
const artistItems = artistsList.querySelectorAll('.watchlist-artist-card');
artistItems.forEach(item => {
const artistName = item.getAttribute('data-artist-name');
if (!searchTerm || artistName.includes(searchTerm)) {
item.style.display = '';
} else {
item.style.display = 'none';
}
});
// Refresh batch bar in case visible selection changed
updateWatchlistBatchBar();
}
/**
* Start watchlist scan
*/
async function startWatchlistScan() {
try {
const button = document.getElementById('scan-watchlist-btn');
button.disabled = true;
button.textContent = 'Starting scan...';
button.classList.add('btn-processing');
const response = await fetch('/api/watchlist/scan', {
method: 'POST',
headers: { 'Content-Type': 'application/json' }
});
const data = await response.json();
if (!data.success) {
throw new Error(data.error || 'Failed to start scan');
}
button.textContent = 'Scanning...';
// Show scan status
const statusDiv = document.getElementById('watchlist-scan-status');
if (statusDiv) {
statusDiv.style.display = 'flex';
}
// Start polling for updates
pollWatchlistScanStatus();
} catch (error) {
console.error('Error starting watchlist scan:', error);
const button = document.getElementById('scan-watchlist-btn');
button.disabled = false;
button.textContent = 'Scan for New Releases';
button.classList.remove('btn-processing');
alert(`Error starting scan: ${error.message}`);
}
}
/**
* Poll watchlist scan status
*/
function handleWatchlistScanData(data) {
const button = document.getElementById('scan-watchlist-btn');
const liveActivity = document.getElementById('watchlist-live-activity');
// Update live visual activity display
if (liveActivity && data.status === 'scanning') {
liveActivity.style.display = 'flex';
// Update artist image and name
const artistImg = document.getElementById('watchlist-artist-img');
const artistName = document.getElementById('watchlist-artist-name');
if (artistImg && data.current_artist_image_url) {
artistImg.src = data.current_artist_image_url;
artistImg.style.display = 'block';
}
if (artistName) {
artistName.textContent = data.current_artist_name || 'Processing...';
}
// Update album image and name
const albumImg = document.getElementById('watchlist-album-img');
const albumName = document.getElementById('watchlist-album-name');
if (albumImg && data.current_album_image_url) {
albumImg.src = data.current_album_image_url;
albumImg.style.display = 'block';
} else if (albumImg) {
albumImg.style.display = 'none';
}
if (albumName) {
albumName.textContent = data.current_album || (data.current_phase === 'fetching_discography' ? 'Fetching releases...' : 'Processing...');
}
// Update current track
const trackName = document.getElementById('watchlist-track-name');
if (trackName) {
trackName.textContent = data.current_track_name || (data.current_phase === 'fetching_discography' ? 'Fetching releases...' : 'Processing...');
}
// Update wishlist additions feed
const additionsFeed = document.getElementById('watchlist-additions-feed');
if (additionsFeed) {
if (data.recent_wishlist_additions && data.recent_wishlist_additions.length > 0) {
additionsFeed.innerHTML = data.recent_wishlist_additions.map(item => `
${item.track_name}
${item.artist_name}
`).join('');
} else {
additionsFeed.innerHTML = 'No tracks added yet...
';
}
}
} else if (liveActivity && data.status !== 'scanning') {
liveActivity.style.display = 'none';
}
if (data.status === 'completed') {
if (button) {
button.disabled = false;
button.textContent = 'Scan for New Releases';
button.classList.remove('btn-processing');
}
// Hide live activity
if (liveActivity) {
liveActivity.style.display = 'none';
}
// Show completion message in status div
const statusDiv = document.getElementById('watchlist-scan-status');
if (statusDiv && data.summary) {
const newTracks = data.summary.new_tracks_found || 0;
const addedTracks = data.summary.tracks_added_to_wishlist || 0;
const totalArtists = data.summary.total_artists || 0;
const successfulScans = data.summary.successful_scans || 0;
let completionMessage = `Scan completed: ${successfulScans}/${totalArtists} artists scanned`;
if (newTracks > 0) {
completionMessage += `, found ${newTracks} new track${newTracks !== 1 ? 's' : ''}`;
if (addedTracks > 0) {
completionMessage += `, added ${addedTracks} to wishlist`;
}
} else {
completionMessage += ', no new tracks found';
}
// Update the scan status display with completion message and summary
statusDiv.innerHTML = `
${completionMessage}
Artists: ${totalArtists}
โข
New tracks: ${newTracks}
โข
Added to wishlist: ${addedTracks}
`;
}
// Update watchlist count
updateWatchlistButtonCount();
console.log('Watchlist scan completed:', data.summary);
} else if (data.status === 'error') {
if (button) {
button.disabled = false;
button.textContent = 'Scan for New Releases';
button.classList.remove('btn-processing');
}
// Hide live activity
if (liveActivity) {
liveActivity.style.display = 'none';
}
console.error('Watchlist scan error:', data.error);
}
}
async function pollWatchlistScanStatus() {
if (socketConnected) return; // Phase 5: WS handles scan updates
try {
const response = await fetch('/api/watchlist/scan/status');
const data = await response.json();
if (data.success) {
handleWatchlistScanData(data);
if (data.status === 'completed' || data.status === 'error') {
return; // Stop polling
}
}
// Continue polling if still scanning
if (data.success && data.status === 'scanning') {
setTimeout(pollWatchlistScanStatus, 2000); // Poll every 2 seconds
}
} catch (error) {
console.error('Error polling watchlist scan status:', error);
}
}
/**
* Update similar artists for discovery feature
*/
async function updateSimilarArtists() {
try {
const button = document.getElementById('update-similar-artists-btn');
const scanButton = document.getElementById('scan-watchlist-btn');
button.disabled = true;
button.textContent = 'Updating...';
button.classList.add('btn-processing');
if (scanButton) scanButton.disabled = true;
const response = await fetch('/api/watchlist/update-similar-artists', {
method: 'POST',
headers: { 'Content-Type': 'application/json' }
});
const data = await response.json();
if (!data.success) {
throw new Error(data.error || 'Failed to update similar artists');
}
showToast('Updating similar artists in background...', 'success');
// Poll for completion
pollSimilarArtistsUpdate();
} catch (error) {
console.error('Error updating similar artists:', error);
const button = document.getElementById('update-similar-artists-btn');
const scanButton = document.getElementById('scan-watchlist-btn');
button.disabled = false;
button.textContent = 'Update Similar Artists';
button.classList.remove('btn-processing');
if (scanButton) scanButton.disabled = false;
showToast(`Error: ${error.message}`, 'error');
}
}
/**
* Poll similar artists update status
*/
async function pollSimilarArtistsUpdate() {
try {
const response = await fetch('/api/watchlist/similar-artists-status');
const data = await response.json();
if (data.success) {
const button = document.getElementById('update-similar-artists-btn');
const scanButton = document.getElementById('scan-watchlist-btn');
if (data.status === 'completed') {
if (button) {
button.disabled = false;
button.textContent = 'Update Similar Artists';
button.classList.remove('btn-processing');
}
if (scanButton) scanButton.disabled = false;
showToast(`Updated similar artists for ${data.artists_processed || 0} artists!`, 'success');
return; // Stop polling
} else if (data.status === 'error') {
if (button) {
button.disabled = false;
button.textContent = 'Update Similar Artists';
button.classList.remove('btn-processing');
}
if (scanButton) scanButton.disabled = false;
showToast('Error updating similar artists', 'error');
return; // Stop polling
} else if (data.status === 'running') {
// Update button text with progress
if (button && data.current_artist) {
button.textContent = `Updating... (${data.artists_processed || 0}/${data.total_artists || 0})`;
}
}
}
// Continue polling if still running
if (data.success && data.status === 'running') {
setTimeout(pollSimilarArtistsUpdate, 1000); // Poll every 1 second
}
} catch (error) {
console.error('Error polling similar artists update:', error);
const button = document.getElementById('update-similar-artists-btn');
const scanButton = document.getElementById('scan-watchlist-btn');
if (button) {
button.disabled = false;
button.textContent = 'Update Similar Artists';
button.classList.remove('btn-processing');
}
if (scanButton) scanButton.disabled = false;
}
}
/**
* Remove artist from watchlist via modal
*/
async function removeFromWatchlistModal(artistId, artistName) {
try {
const response = await fetch('/api/watchlist/remove', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ artist_id: artistId })
});
const data = await response.json();
if (!data.success) {
throw new Error(data.error || 'Failed to remove from watchlist');
}
console.log(`โ Removed ${artistName} from watchlist`);
// Close detail view if open
closeWatchlistArtistDetailView();
// Refresh the modal
showWatchlistModal();
// Update button count
updateWatchlistButtonCount();
// Update any visible artist cards
updateArtistCardWatchlistStatus();
} catch (error) {
console.error('Error removing from watchlist:', error);
alert(`Error removing ${artistName} from watchlist: ${error.message}`);
}
}
/**
* Get visible checked checkboxes (not hidden by search filter)
*/
function getVisibleCheckedWatchlist() {
return Array.from(document.querySelectorAll('.watchlist-select-cb:checked')).filter(cb => {
const item = cb.closest('.watchlist-artist-card');
return item && item.style.display !== 'none';
});
}
/**
* Update the batch action bar based on checkbox selection
*/
function updateWatchlistBatchBar() {
const checked = getVisibleCheckedWatchlist();
const countEl = document.getElementById('watchlist-batch-count');
const removeBtn = document.getElementById('watchlist-batch-remove-btn');
const selectAllCb = document.getElementById('watchlist-select-all-cb');
if (checked.length > 0) {
countEl.textContent = `${checked.length} selected`;
removeBtn.style.display = '';
} else {
countEl.textContent = '';
removeBtn.style.display = 'none';
}
// Update select-all checkbox state
if (selectAllCb) {
const visible = Array.from(document.querySelectorAll('.watchlist-select-cb')).filter(cb => {
const card = cb.closest('.watchlist-artist-card');
return card && card.style.display !== 'none';
});
selectAllCb.checked = visible.length > 0 && checked.length === visible.length;
selectAllCb.indeterminate = checked.length > 0 && checked.length < visible.length;
}
}
function toggleWatchlistSelectAll(checked) {
const checkboxes = document.querySelectorAll('.watchlist-select-cb');
checkboxes.forEach(cb => {
const card = cb.closest('.watchlist-artist-card');
if (card && card.style.display !== 'none') {
cb.checked = checked;
}
});
updateWatchlistBatchBar();
}
/**
* Batch remove selected artists from watchlist
*/
async function batchRemoveFromWatchlist() {
const checked = getVisibleCheckedWatchlist();
if (checked.length === 0) return;
const count = checked.length;
if (!await showConfirmDialog({ title: 'Remove Artists', message: `Remove ${count} artist${count !== 1 ? 's' : ''} from your watchlist?`, confirmText: 'Remove', destructive: true })) return;
const artistIds = checked.map(cb => cb.getAttribute('data-artist-id'));
try {
const response = await fetch('/api/watchlist/remove-batch', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ artist_ids: artistIds })
});
const data = await response.json();
if (!data.success) {
throw new Error(data.error || 'Failed to remove artists');
}
console.log(`โ Batch removed ${data.removed} artists from watchlist`);
// Refresh the modal
showWatchlistModal();
// Update button count
updateWatchlistButtonCount();
// Update any visible artist cards
updateArtistCardWatchlistStatus();
} catch (error) {
console.error('Error batch removing from watchlist:', error);
alert(`Error removing artists: ${error.message}`);
}
}
// --- Metadata Updater Functions ---
// Global state for metadata update polling
let metadataUpdatePolling = false;
let metadataUpdateInterval = null;
/**
* Handle metadata update button click
*/
async function handleMetadataUpdateButtonClick() {
const button = document.getElementById('metadata-update-button');
const currentAction = button.textContent;
if (currentAction === 'Begin Update') {
// Get refresh interval from dropdown
const refreshSelect = document.getElementById('metadata-refresh-interval');
const refreshIntervalDays = refreshSelect.value !== undefined ? parseInt(refreshSelect.value) : 30;
try {
button.disabled = true;
button.textContent = 'Starting...';
const response = await fetch('/api/metadata/start', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ refresh_interval_days: refreshIntervalDays })
});
const data = await response.json();
if (!data.success) {
throw new Error(data.error || 'Failed to start metadata update');
}
showToast('Metadata update started!', 'success');
// Start polling for status updates
startMetadataUpdatePolling();
} catch (error) {
console.error('Error starting metadata update:', error);
button.disabled = false;
button.textContent = 'Begin Update';
showToast(`Error: ${error.message}`, 'error');
}
} else {
// Stop metadata update
try {
button.disabled = true;
button.textContent = 'Stopping...';
const response = await fetch('/api/metadata/stop', {
method: 'POST',
headers: { 'Content-Type': 'application/json' }
});
if (!response.ok) {
throw new Error('Failed to stop metadata update');
}
} catch (error) {
console.error('Error stopping metadata update:', error);
button.disabled = false;
button.textContent = 'Stop Update';
}
}
}
/**
* Start polling for metadata update status
*/
function startMetadataUpdatePolling() {
if (metadataUpdatePolling) return; // Already polling
metadataUpdatePolling = true;
metadataUpdateInterval = setInterval(checkMetadataUpdateStatus, 1000); // Poll every second
// Also check immediately
checkMetadataUpdateStatus();
}
/**
* Stop polling for metadata update status
*/
function stopMetadataUpdatePolling() {
metadataUpdatePolling = false;
if (metadataUpdateInterval) {
clearInterval(metadataUpdateInterval);
metadataUpdateInterval = null;
}
}
/**
* Check current metadata update status and update UI
*/
async function checkMetadataUpdateStatus() {
if (socketConnected) return; // WebSocket handles this
try {
const response = await fetch('/api/metadata/status');
const data = await response.json();
if (data.success && data.status) {
updateMetadataProgressUI(data.status);
// Stop polling if completed or error
if (data.status.status === 'completed' || data.status.status === 'error') {
stopMetadataUpdatePolling();
}
}
} catch (error) {
console.warn('Could not fetch metadata update status:', error);
}
}
function updateMetadataStatusFromData(data) {
if (!data.success || !data.status) return;
const prev = _lastToolStatus['metadata'];
_lastToolStatus['metadata'] = data.status.status;
if (prev !== undefined && data.status.status === prev && data.status.status !== 'running' && data.status.status !== 'stopping') return;
updateMetadataProgressUI(data.status);
if (data.status.status === 'completed' || data.status.status === 'error') {
stopMetadataUpdatePolling();
}
}
/**
* Update metadata progress UI elements
*/
function updateMetadataProgressUI(status) {
const button = document.getElementById('metadata-update-button');
const phaseLabel = document.getElementById('metadata-phase-label');
const progressLabel = document.getElementById('metadata-progress-label');
const progressBar = document.getElementById('metadata-progress-bar');
const refreshSelect = document.getElementById('metadata-refresh-interval');
if (!button || !phaseLabel || !progressLabel || !progressBar || !refreshSelect) return;
if (status.status === 'running') {
button.textContent = 'Stop Update';
button.disabled = false;
refreshSelect.disabled = true;
// Update current artist display
const currentArtist = status.current_artist || 'Processing...';
phaseLabel.textContent = `Current Artist: ${currentArtist}`;
// Update progress
const processed = status.processed || 0;
const total = status.total || 0;
const percentage = status.percentage || 0;
progressLabel.textContent = `${processed} / ${total} artists (${percentage.toFixed(1)}%)`;
progressBar.style.width = `${percentage}%`;
} else if (status.status === 'stopping') {
button.textContent = 'Stopping...';
button.disabled = true;
phaseLabel.textContent = 'Current Artist: Stopping...';
} else if (status.status === 'completed') {
button.textContent = 'Begin Update';
button.disabled = false;
refreshSelect.disabled = false;
phaseLabel.textContent = 'Current Artist: Completed';
const processed = status.processed || 0;
const successful = status.successful || 0;
const failed = status.failed || 0;
progressLabel.textContent = `Completed: ${processed} processed, ${successful} successful, ${failed} failed`;
progressBar.style.width = '100%';
showToast(`Metadata update completed: ${successful} artists updated, ${failed} failed`, 'success');
} else if (status.status === 'error') {
button.textContent = 'Begin Update';
button.disabled = false;
refreshSelect.disabled = false;
phaseLabel.textContent = 'Current Artist: Error occurred';
progressLabel.textContent = status.error || 'Unknown error';
progressBar.style.width = '0%';
} else {
// Idle state
button.textContent = 'Begin Update';
button.disabled = false;
refreshSelect.disabled = false;
phaseLabel.textContent = 'Current Artist: Not running';
progressLabel.textContent = '0 / 0 artists (0.0%)';
progressBar.style.width = '0%';
}
}
/**
* Check active media server and hide metadata updater if not Plex
*/
async function checkAndHideMetadataUpdaterForNonPlex() {
try {
const response = await fetch('/api/active-media-server');
const data = await response.json();
if (data.success) {
const metadataCard = document.getElementById('metadata-updater-card');
if (metadataCard) {
// Show metadata updater only for Plex and Jellyfin
if (data.active_server === 'plex' || data.active_server === 'jellyfin') {
metadataCard.style.display = 'flex';
console.log(`Metadata updater shown: ${data.active_server} is active server`);
// Update the header text to reflect the current server
const headerElement = metadataCard.querySelector('.card-header h3');
if (headerElement) {
const serverDisplayName = data.active_server.charAt(0).toUpperCase() + data.active_server.slice(1);
headerElement.textContent = `${serverDisplayName} Metadata Updater`;
}
// Update the description based on the server type
const descElement = metadataCard.querySelector('.metadata-updater-description');
if (descElement) {
if (data.active_server === 'jellyfin') {
descElement.textContent = 'Download and upload high-quality artist images from Spotify to your Jellyfin server for artists without photos.';
} else {
descElement.textContent = 'Download and upload high-quality artist images from Spotify to your Plex server for artists without photos.';
}
}
} else {
// Hide metadata updater for Navidrome
metadataCard.style.display = 'none';
console.log(`Metadata updater hidden: ${data.active_server} does not support image uploads`);
}
}
}
} catch (error) {
console.warn('Could not check active media server for metadata updater visibility:', error);
}
}
async function checkAndShowMediaScanForPlex() {
/**
* Show media scan tool only for Plex (Jellyfin/Navidrome auto-scan)
*/
try {
const response = await fetch('/api/active-media-server');
const data = await response.json();
if (data.success) {
const mediaScanCard = document.getElementById('media-scan-card');
if (mediaScanCard) {
// Show media scan tool only for Plex
if (data.active_server === 'plex') {
mediaScanCard.style.display = 'flex';
console.log('Media scan tool shown: Plex is active server');
} else {
// Hide for Jellyfin/Navidrome (they auto-scan)
mediaScanCard.style.display = 'none';
console.log(`Media scan tool hidden: ${data.active_server} auto-scans`);
}
}
}
} catch (error) {
console.warn('Could not check active media server for media scan visibility:', error);
}
}
async function handleMediaScanButtonClick() {
/**
* Trigger a manual Plex media library scan
*/
const button = document.getElementById('media-scan-button');
const phaseLabel = document.getElementById('media-scan-phase-label');
const progressBar = document.getElementById('media-scan-progress-bar');
const progressLabel = document.getElementById('media-scan-progress-label');
const statusValue = document.getElementById('media-scan-status');
if (!button) return;
try {
// Disable button and update UI
button.disabled = true;
phaseLabel.textContent = 'Requesting scan...';
progressBar.style.width = '30%';
progressLabel.textContent = 'Sending scan request to Plex';
statusValue.textContent = 'Scanning...';
statusValue.style.color = 'rgb(var(--accent-rgb))';
// Request scan (database update handled by system automation)
const response = await fetch('/api/scan/request', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
reason: 'Manual scan triggered from dashboard'
})
});
const result = await response.json();
if (result.success) {
// Get delay from API response (graceful fallback to 60 if not provided)
const delaySeconds = (result.scan_info && result.scan_info.delay_seconds) || 60;
let remainingSeconds = delaySeconds;
let countdownInterval = null;
let pollInterval = null;
// Update last scan time
const lastTimeEl = document.getElementById('media-scan-last-time');
if (lastTimeEl) {
const now = new Date();
lastTimeEl.textContent = now.toLocaleTimeString();
}
// Start countdown timer (visual feedback during delay)
phaseLabel.textContent = 'Scan scheduled...';
progressBar.style.width = '0%';
countdownInterval = setInterval(() => {
remainingSeconds--;
// Update progress bar (0% -> 100% over delay period)
const progress = ((delaySeconds - remainingSeconds) / delaySeconds) * 100;
progressBar.style.width = `${progress}%`;
// Update progress label with countdown
if (remainingSeconds > 0) {
progressLabel.textContent = `Starting scan in ${remainingSeconds}s...`;
} else {
progressLabel.textContent = 'Scan starting now...';
}
// When countdown reaches 0, start polling
if (remainingSeconds <= 0) {
clearInterval(countdownInterval);
// Transition to scanning phase
phaseLabel.textContent = 'Scan in progress...';
progressBar.style.width = '100%';
progressLabel.textContent = 'Media server is scanning library...';
showToast('๐ก Media scan started', 'success', 3000);
// Start polling for scan completion (5 minutes = 150 polls ร 2s)
let pollCount = 0;
const maxPolls = 150; // 5 minutes
pollInterval = setInterval(async () => {
if (socketConnected) return; // Phase 5: WS handles scan status
pollCount++;
if (pollCount > maxPolls) {
// Polling timeout after 5 minutes
clearInterval(pollInterval);
button.disabled = false;
phaseLabel.textContent = 'Scan completed';
progressBar.style.width = '0%';
progressLabel.textContent = 'Ready for next scan';
statusValue.textContent = 'Idle';
statusValue.style.color = '#b3b3b3';
showToast('โ
Media scan completed', 'success', 3000);
return;
}
try {
const statusResponse = await fetch('/api/scan/status');
const statusData = await statusResponse.json();
if (statusData.success && statusData.status) {
const status = statusData.status;
// Update status display
if (status.is_scanning) {
phaseLabel.textContent = 'Media server scanning...';
progressLabel.textContent = status.progress_message || 'Scan in progress';
} else if (status.status === 'idle') {
// Scan completed
clearInterval(pollInterval);
button.disabled = false;
phaseLabel.textContent = 'Scan completed successfully';
progressBar.style.width = '0%';
progressLabel.textContent = 'Ready for next scan';
statusValue.textContent = 'Idle';
statusValue.style.color = '#b3b3b3';
showToast('โ
Media scan completed', 'success', 3000);
}
}
} catch (pollError) {
console.debug('Scan status poll error:', pollError);
}
}, 2000); // Poll every 2 seconds
}
}, 1000); // Update countdown every second
} else {
// Error occurred
showToast(`โ Scan request failed: ${result.error}`, 'error', 5000);
button.disabled = false;
phaseLabel.textContent = 'Scan failed';
progressBar.style.width = '0%';
progressLabel.textContent = result.error || 'Unknown error';
statusValue.textContent = 'Error';
statusValue.style.color = '#f44336';
}
} catch (error) {
console.error('Error requesting media scan:', error);
showToast('โ Failed to request media scan', 'error', 3000);
button.disabled = false;
phaseLabel.textContent = 'Error';
progressBar.style.width = '0%';
progressLabel.textContent = error.message;
statusValue.textContent = 'Error';
statusValue.style.color = '#f44336';
}
}
/**
* Check for ongoing metadata update and restore state on page load
*/
async function checkAndRestoreMetadataUpdateState() {
try {
const response = await fetch('/api/metadata/status');
const data = await response.json();
if (data.success && data.status) {
const status = data.status;
// If metadata update is running, restore the UI state and start polling
if (status.status === 'running') {
console.log('Found ongoing metadata update, restoring state...');
updateMetadataProgressUI(status);
startMetadataUpdatePolling();
} else if (status.status === 'completed' || status.status === 'error') {
// Show final state but don't start polling
updateMetadataProgressUI(status);
}
}
} catch (error) {
console.warn('Could not check metadata update state on page load:', error);
}
}
// --- Live Log Viewer Functions ---
// Global state for log polling
let logPolling = false;
let logInterval = null;
let lastLogCount = 0;
/**
* Initialize the live log viewer for sync page
*/
function initializeLiveLogViewer() {
const logArea = document.getElementById('sync-log-area');
if (!logArea) return;
// Set initial content
logArea.value = 'Loading activity feed...';
// Start log polling
startLogPolling();
// Initial load
loadLogs();
}
/**
* Start polling for logs
*/
function startLogPolling() {
if (logPolling) return; // Already polling
logPolling = true;
logInterval = setInterval(loadLogs, 3000); // Poll every 3 seconds
console.log('๐ Started activity feed polling for sync page');
}
/**
* Stop polling for logs
*/
function stopLogPolling() {
logPolling = false;
if (logInterval) {
clearInterval(logInterval);
logInterval = null;
console.log('๐ Stopped log polling');
}
}
/**
* Load and display activity feed as logs
*/
async function loadLogs() {
if (socketConnected) return; // WebSocket handles this
try {
const response = await fetch('/api/logs');
const data = await response.json();
updateLogsFromData(data);
} catch (error) {
console.warn('Could not load activity logs for sync page:', error);
const logArea = document.getElementById('sync-log-area');
if (logArea && (logArea.value === 'Loading logs...' || logArea.value === '')) {
logArea.value = 'Error loading activity feed. Check console for details.';
}
}
}
function updateLogsFromData(data) {
if (!data.logs || !Array.isArray(data.logs)) return;
const logArea = document.getElementById('sync-log-area');
if (!logArea) return;
const logText = data.logs.join('\n');
// Store current scroll state
const wasAtTop = logArea.scrollTop <= 10;
const wasUserScrolled = logArea.scrollTop < logArea.scrollHeight - logArea.clientHeight - 10;
// Update content only if it has changed
if (logArea.value !== logText) {
logArea.value = logText;
// Smart scrolling: stay at top for new entries, preserve user position if scrolled
if (wasAtTop || !wasUserScrolled) {
logArea.scrollTop = 0; // Stay at top since newest entries are now at top
}
}
}
/**
* Stop log polling when leaving sync page
*/
function cleanupSyncPageLogs() {
stopLogPolling();
}
// --- Global Cleanup on Page Unload ---
// Note: Automatic wishlist processing now runs server-side and continues even when browser is closed
// ===============================
// LIBRARY PAGE FUNCTIONALITY
// ===============================
// Library page state
const libraryPageState = {
isInitialized: false,
currentSearch: "",
currentLetter: "all",
currentPage: 1,
limit: 75,
debounceTimer: null,
watchlistFilter: "all"
};
function initializeLibraryPage() {
console.log("๐ง Initializing Library page...");
try {
// Initialize search functionality
initializeLibrarySearch();
// Initialize watchlist filter
initializeWatchlistFilter();
// Initialize alphabet selector
initializeAlphabetSelector();
// Initialize pagination
initializeLibraryPagination();
// Load initial data
loadLibraryArtists();
// Show download bubbles if any exist
showLibraryDownloadsSection();
libraryPageState.isInitialized = true;
console.log("โ
Library page initialized successfully");
} catch (error) {
console.error("โ Error initializing Library page:", error);
showToast("Failed to initialize Library page", "error");
}
}
function initializeLibrarySearch() {
const searchInput = document.getElementById("library-search-input");
if (!searchInput) return;
searchInput.addEventListener("input", (e) => {
const query = e.target.value.trim();
// Clear existing debounce timer
if (libraryPageState.debounceTimer) {
clearTimeout(libraryPageState.debounceTimer);
}
// Debounce search requests
libraryPageState.debounceTimer = setTimeout(() => {
libraryPageState.currentSearch = query;
libraryPageState.currentPage = 1; // Reset to first page
loadLibraryArtists();
}, 300);
});
// Clear search on Escape key
searchInput.addEventListener("keydown", (e) => {
if (e.key === "Escape") {
searchInput.value = "";
libraryPageState.currentSearch = "";
libraryPageState.currentPage = 1;
loadLibraryArtists();
}
});
}
function initializeWatchlistFilter() {
const filterButtons = document.querySelectorAll(".watchlist-filter-btn");
const watchAllBtn = document.getElementById("library-watchlist-all-btn");
filterButtons.forEach(button => {
button.addEventListener("click", () => {
const filter = button.getAttribute("data-filter");
// Update active state
filterButtons.forEach(btn => btn.classList.remove("active"));
button.classList.add("active");
// Show/hide "Watch All Unwatched" button
if (watchAllBtn) {
if (filter === "unwatched") {
watchAllBtn.classList.remove("hidden");
} else {
watchAllBtn.classList.add("hidden");
}
}
// Update state and reload
libraryPageState.watchlistFilter = filter;
libraryPageState.currentPage = 1;
loadLibraryArtists();
});
});
}
function initializeAlphabetSelector() {
const alphabetButtons = document.querySelectorAll(".alphabet-btn");
alphabetButtons.forEach(button => {
button.addEventListener("click", () => {
const letter = button.getAttribute("data-letter");
// Update active state
alphabetButtons.forEach(btn => btn.classList.remove("active"));
button.classList.add("active");
// Update state and load data
libraryPageState.currentLetter = letter;
libraryPageState.currentPage = 1; // Reset to first page
loadLibraryArtists();
});
});
}
function initializeLibraryPagination() {
const prevBtn = document.getElementById("prev-page-btn");
const nextBtn = document.getElementById("next-page-btn");
if (prevBtn) {
prevBtn.addEventListener("click", () => {
if (libraryPageState.currentPage > 1) {
libraryPageState.currentPage--;
loadLibraryArtists();
}
});
}
if (nextBtn) {
nextBtn.addEventListener("click", () => {
libraryPageState.currentPage++;
loadLibraryArtists();
});
}
}
async function loadLibraryArtists() {
try {
// Show loading state
showLibraryLoading(true);
// Build query parameters
const params = new URLSearchParams({
search: libraryPageState.currentSearch,
letter: libraryPageState.currentLetter,
page: libraryPageState.currentPage,
limit: libraryPageState.limit,
watchlist: libraryPageState.watchlistFilter
});
// Fetch artists from API
const response = await fetch(`/api/library/artists?${params}`);
const data = await response.json();
if (!data.success) {
throw new Error(data.error || "Failed to load artists");
}
// Update UI with artists
displayLibraryArtists(data.artists);
updateLibraryPagination(data.pagination);
updateLibraryStats(data.pagination.total_count);
// Hide loading state
showLibraryLoading(false);
// Show empty state if no artists
if (data.artists.length === 0) {
showLibraryEmpty(true);
} else {
showLibraryEmpty(false);
}
} catch (error) {
console.error("โ Error loading library artists:", error);
showToast("Failed to load artists", "error");
showLibraryLoading(false);
showLibraryEmpty(true);
}
}
function displayLibraryArtists(artists) {
const grid = document.getElementById("library-artists-grid");
if (!grid) return;
// Clear existing content
grid.innerHTML = "";
// Create artist cards
artists.forEach(artist => {
const card = createLibraryArtistCard(artist);
grid.appendChild(card);
});
}
function createLibraryArtistCard(artist) {
const card = document.createElement("div");
card.className = "library-artist-card";
card.setAttribute("data-artist-id", artist.id);
// Add relative positioning for icon and smooth transition
card.style.position = 'relative';
card.style.transition = 'transform 0.2s, box-shadow 0.2s';
// Add source badges stacked on top-right
const badgeSources = [];
if (artist.spotify_artist_id) {
badgeSources.push({ cls: 'spotify-card-icon', logo: SPOTIFY_LOGO_URL, fallback: 'SP', title: 'View on Spotify', url: `https://open.spotify.com/artist/${artist.spotify_artist_id}` });
}
if (artist.musicbrainz_id) {
badgeSources.push({ cls: 'mb-card-icon', logo: MUSICBRAINZ_LOGO_URL, fallback: 'MB', title: 'View on MusicBrainz', url: `https://musicbrainz.org/artist/${artist.musicbrainz_id}` });
}
if (artist.deezer_id) {
badgeSources.push({ cls: 'deezer-card-icon', logo: DEEZER_LOGO_URL, fallback: 'Dz', title: 'View on Deezer', url: `https://www.deezer.com/artist/${artist.deezer_id}` });
}
if (artist.audiodb_id) {
const adbSlug = artist.name ? artist.name.replace(/\s+/g, '-').replace(/[^a-zA-Z0-9-]/g, '') : '';
badgeSources.push({ cls: 'audiodb-card-icon', logo: getAudioDBLogoURL(), fallback: 'ADB', title: 'View on TheAudioDB', url: `https://www.theaudiodb.com/artist/${artist.audiodb_id}-${adbSlug}` });
}
if (artist.itunes_artist_id) {
badgeSources.push({ cls: 'itunes-card-icon', logo: ITUNES_LOGO_URL, fallback: 'IT', title: 'View on Apple Music', url: `https://music.apple.com/artist/${artist.itunes_artist_id}` });
}
if (artist.lastfm_url) {
badgeSources.push({ cls: 'lastfm-card-icon', logo: LASTFM_LOGO_URL, fallback: 'LFM', title: 'View on Last.fm', url: artist.lastfm_url });
}
if (artist.genius_url) {
badgeSources.push({ cls: 'genius-card-icon', logo: GENIUS_LOGO_URL, fallback: 'GEN', title: 'View on Genius', url: artist.genius_url });
}
if (artist.tidal_id) {
badgeSources.push({ cls: 'tidal-card-icon', logo: TIDAL_LOGO_URL, fallback: 'TD', title: 'View on Tidal', url: `https://tidal.com/browse/artist/${artist.tidal_id}` });
}
if (artist.qobuz_id) {
badgeSources.push({ cls: 'qobuz-card-icon', logo: QOBUZ_LOGO_URL, fallback: 'Qz', title: 'View on Qobuz', url: `https://www.qobuz.com/artist/${artist.qobuz_id}` });
}
// Add watchlist indicator โ only if artist has a usable ID for the active source
const hasActiveSourceId = currentMusicSourceName === 'Apple Music'
? (artist.itunes_artist_id || artist.spotify_artist_id)
: (artist.spotify_artist_id || artist.itunes_artist_id);
if (artist.is_watched) {
badgeSources.push({ cls: 'watch-card-icon watched', logo: null, fallback: '๐๏ธ', fallbackExpanded: 'Watching', title: 'On your watchlist', url: null, isWatch: true });
} else if (hasActiveSourceId) {
badgeSources.push({ cls: 'watch-card-icon', logo: null, fallback: '๐๏ธ', fallbackExpanded: 'Watch', title: 'Add to Watchlist', url: null, isWatch: true, unwatched: true });
}
if (badgeSources.length > 0) {
const badgeContainer = document.createElement('div');
badgeContainer.className = 'card-badge-container';
// Separate service badges from watch badge
const serviceBadges = badgeSources.filter(s => !s.isWatch);
const watchBadge = badgeSources.find(s => s.isWatch);
const maxPerColumn = 6;
const needsOverflow = serviceBadges.length > maxPerColumn;
// Helper to create a badge icon element
const createBadgeIcon = (source) => {
const icon = document.createElement('div');
icon.className = `${source.cls} source-card-icon`;
icon.title = source.title;
if (source.logo) {
const img = document.createElement('img');
img.src = source.logo;
img.style.cssText = 'width: 16px; height: auto; display: block;';
img.onerror = () => { icon.textContent = source.fallback; };
icon.appendChild(img);
} else if (source.fallbackExpanded) {
icon.innerHTML = `${source.fallback} ${source.fallbackExpanded} `;
} else {
icon.textContent = source.fallback;
}
if (source.isWatch && source.unwatched) {
icon.style.opacity = '0.4';
icon.onclick = (e) => {
e.stopPropagation();
toggleLibraryCardWatchlist(icon, artist);
};
} else if (source.url) {
icon.onclick = (e) => {
e.stopPropagation();
window.open(source.url, '_blank');
};
}
return icon;
};
if (needsOverflow) {
// Overflow column (left) โ watch badge first, then extra service badges
const overflowCol = document.createElement('div');
overflowCol.className = 'badge-overflow-column';
if (watchBadge) {
overflowCol.appendChild(createBadgeIcon(watchBadge));
}
serviceBadges.slice(maxPerColumn).forEach(source => {
overflowCol.appendChild(createBadgeIcon(source));
});
badgeContainer.appendChild(overflowCol);
// Primary column (right) โ first 6 service badges
const primaryCol = document.createElement('div');
primaryCol.className = 'badge-primary-column';
serviceBadges.slice(0, maxPerColumn).forEach(source => {
primaryCol.appendChild(createBadgeIcon(source));
});
badgeContainer.appendChild(primaryCol);
} else {
// Single column โ service badges + watch badge last
serviceBadges.forEach(source => {
badgeContainer.appendChild(createBadgeIcon(source));
});
if (watchBadge) {
badgeContainer.appendChild(createBadgeIcon(watchBadge));
}
}
card.appendChild(badgeContainer);
}
// Create image element
const imageContainer = document.createElement("div");
imageContainer.className = "library-artist-image";
if (artist.image_url && artist.image_url.trim() !== "") {
const img = document.createElement("img");
img.src = artist.image_url;
img.alt = artist.name;
img.loading = 'lazy';
img.onerror = () => {
console.log(`Failed to load image for ${artist.name}: ${img.src}`);
// Try Deezer fallback before emoji
if (artist.deezer_id && !img.dataset.triedDeezer) {
img.dataset.triedDeezer = 'true';
img.src = `https://api.deezer.com/artist/${artist.deezer_id}/image?size=big`;
} else {
imageContainer.innerHTML = `๐ต
`;
}
};
img.onload = () => {
console.log(`Successfully loaded image for ${artist.name}: ${artist.image_url}`);
};
imageContainer.appendChild(img);
} else {
console.log(`No image URL for ${artist.name}: '${artist.image_url}'`);
imageContainer.innerHTML = `๐ต
`;
}
// Create info section
const info = document.createElement("div");
info.className = "library-artist-info";
const name = document.createElement("h3");
name.className = "library-artist-name";
name.textContent = artist.name;
name.title = artist.name; // For tooltip on long names
const stats = document.createElement("div");
stats.className = "library-artist-stats";
if (artist.track_count > 0) {
const trackStat = document.createElement("span");
trackStat.className = "library-artist-stat";
trackStat.textContent = `${artist.track_count} track${artist.track_count !== 1 ? "s" : ""}`;
stats.appendChild(trackStat);
}
info.appendChild(name);
info.appendChild(stats);
// Assemble card
card.appendChild(imageContainer);
card.appendChild(info);
// Add click handler to navigate to artist detail page
card.addEventListener("click", () => {
console.log(`๐ต Opening artist detail for: ${artist.name} (ID: ${artist.id})`);
navigateToArtistDetail(artist.id, artist.name);
});
return card;
}
function updateLibraryPagination(pagination) {
const prevBtn = document.getElementById("prev-page-btn");
const nextBtn = document.getElementById("next-page-btn");
const pageInfo = document.getElementById("page-info");
const paginationContainer = document.getElementById("library-pagination");
if (!paginationContainer) return;
// Update button states
if (prevBtn) {
prevBtn.disabled = !pagination.has_prev;
}
if (nextBtn) {
nextBtn.disabled = !pagination.has_next;
}
// Update page info
if (pageInfo) {
pageInfo.textContent = `Page ${pagination.page} of ${pagination.total_pages}`;
}
// Show/hide pagination based on total pages
if (pagination.total_pages > 1) {
paginationContainer.classList.remove("hidden");
} else {
paginationContainer.classList.add("hidden");
}
}
function updateLibraryStats(totalCount) {
const countElement = document.getElementById("library-artist-count");
if (countElement) {
countElement.textContent = totalCount;
}
}
function showLibraryLoading(show) {
const loadingElement = document.getElementById("library-loading");
if (loadingElement) {
if (show) {
loadingElement.classList.remove("hidden");
} else {
loadingElement.classList.add("hidden");
}
}
}
function showLibraryEmpty(show) {
const emptyElement = document.getElementById("library-empty");
if (emptyElement) {
if (show) {
emptyElement.classList.remove("hidden");
} else {
emptyElement.classList.add("hidden");
}
}
}
async function addAllUnwatchedToWatchlist(btn) {
if (btn.classList.contains('adding') || btn.classList.contains('all-added')) return;
btn.classList.add('adding');
const textSpan = btn.querySelector('.watchlist-all-text');
const originalText = textSpan.textContent;
textSpan.textContent = 'Adding...';
try {
const response = await fetch('/api/library/watchlist-all-unwatched', {
method: 'POST',
headers: { 'Content-Type': 'application/json' }
});
const data = await response.json();
if (data.success) {
btn.classList.remove('adding');
btn.classList.add('all-added');
let resultText = `Added ${data.added}`;
if (data.skipped_no_id > 0) {
resultText += ` (${data.skipped_no_id} unmatched)`;
}
textSpan.textContent = resultText;
if (data.added > 0) {
showToast(data.message, 'success');
// Reload the library to reflect changes
setTimeout(() => {
loadLibraryArtists();
}, 1500);
} else if (data.skipped_no_id > 0) {
showToast(`No artists could be added โ ${data.skipped_no_id} don't have matching IDs yet. Background workers will match them over time.`, 'warning');
} else {
showToast('No unwatched artists found', 'info');
}
// Reset button after a delay
setTimeout(() => {
btn.classList.remove('all-added');
textSpan.textContent = originalText;
}, 5000);
} else {
throw new Error(data.error || 'Failed to add artists');
}
} catch (error) {
console.error('Error bulk adding unwatched artists:', error);
btn.classList.remove('adding');
textSpan.textContent = originalText;
showToast('Failed to add artists to watchlist', 'error');
}
}
async function toggleLibraryCardWatchlist(btn, artist) {
if (btn.disabled) return;
btn.disabled = true;
// Support both badge-style (.watch-icon-label) and button-style (.watchlist-text)
const label = btn.querySelector('.watch-icon-label') || btn.querySelector('.watchlist-text');
const isWatching = btn.classList.contains('watched') || btn.classList.contains('watching');
if (label) label.textContent = '...';
try {
// Use the ID matching the active metadata source
const artistId = currentMusicSourceName === 'Apple Music'
? (artist.itunes_artist_id || artist.spotify_artist_id)
: (artist.spotify_artist_id || artist.itunes_artist_id);
if (!artistId) throw new Error('No iTunes or Spotify ID available for this artist');
if (isWatching) {
const response = await fetch('/api/watchlist/remove', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ artist_id: artistId })
});
const data = await response.json();
if (!data.success) throw new Error(data.error);
btn.classList.remove('watched', 'watching');
btn.style.opacity = '0.4';
btn.title = 'Add to Watchlist';
if (label) label.textContent = 'Watch';
showToast(`Removed ${artist.name} from watchlist`, 'success');
} else {
const response = await fetch('/api/watchlist/add', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ artist_id: artistId, artist_name: artist.name })
});
const data = await response.json();
if (!data.success) throw new Error(data.error);
btn.classList.add('watched');
btn.style.opacity = '';
btn.title = 'Remove from Watchlist';
if (label) label.textContent = 'Watching';
showToast(`Added ${artist.name} to watchlist`, 'success');
}
if (typeof updateWatchlistCount === 'function') {
updateWatchlistCount();
}
} catch (error) {
console.error('Error toggling library card watchlist:', error);
if (label) label.textContent = isWatching ? 'Watching' : 'Watch';
showToast(`Error: ${error.message}`, 'error');
} finally {
btn.disabled = false;
}
}
// ===============================================
// Artist Detail Page Functions
// ===============================================
// Artist detail page state
let artistDetailPageState = {
isInitialized: false,
currentArtistId: null,
currentArtistName: null,
enhancedView: false,
enhancedData: null,
expandedAlbums: new Set(),
selectedTracks: new Set(),
editingCell: null,
enhancedTrackSort: {}
};
// Discography filter state
let discographyFilterState = {
categories: { albums: true, eps: true, singles: true },
content: { live: true, compilations: true, featured: true },
ownership: 'all' // 'all', 'owned', 'missing'
};
function navigateToArtistDetail(artistId, artistName) {
console.log(`๐ต Navigating to artist detail: ${artistName} (ID: ${artistId})`);
// Abort any in-progress completion stream
if (artistDetailPageState.completionController) {
artistDetailPageState.completionController.abort();
artistDetailPageState.completionController = null;
}
// Cancel any active inline edit and close manual match modal before resetting state
cancelInlineEdit();
const existingMatchOverlay = document.getElementById('enhanced-manual-match-overlay');
if (existingMatchOverlay) existingMatchOverlay.remove();
// Store current artist info and reset enhanced view state
artistDetailPageState.currentArtistId = artistId;
artistDetailPageState.currentArtistName = artistName;
artistDetailPageState.enhancedData = null;
artistDetailPageState.expandedAlbums = new Set();
artistDetailPageState.selectedTracks = new Set();
artistDetailPageState.enhancedTrackSort = {};
artistDetailPageState.enhancedView = false;
// Reset enhanced view toggle to standard
const toggleBtns = document.querySelectorAll('.enhanced-view-toggle-btn');
toggleBtns.forEach(btn => {
btn.classList.toggle('active', btn.getAttribute('data-view') === 'standard');
});
const enhancedContainer = document.getElementById('enhanced-view-container');
if (enhancedContainer) enhancedContainer.classList.add('hidden');
const standardSections = document.querySelector('.discography-sections');
if (standardSections) standardSections.classList.remove('hidden');
// Restore standard view filter groups
const filterGroups = document.querySelectorAll('#discography-filters .filter-group');
filterGroups.forEach(group => {
const label = group.querySelector('.filter-label');
if (label && label.textContent !== 'View') group.style.display = '';
});
const dividers = document.querySelectorAll('#discography-filters .filter-divider');
dividers.forEach(d => d.style.display = '');
// Hide bulk bar
const bulkBar = document.getElementById('enhanced-bulk-bar');
if (bulkBar) bulkBar.classList.remove('visible');
// Navigate to artist detail page
navigateToPage('artist-detail');
// Initialize if needed and load data
if (!artistDetailPageState.isInitialized) {
initializeArtistDetailPage();
}
// Load artist data
loadArtistDetailData(artistId, artistName);
}
function initializeArtistDetailPage() {
console.log("๐ง Initializing Artist Detail page...");
// Initialize back button
const backBtn = document.getElementById("artist-detail-back-btn");
if (backBtn) {
backBtn.addEventListener("click", () => {
console.log("๐ Returning to Library page");
// Abort any in-progress completion stream
if (artistDetailPageState.completionController) {
artistDetailPageState.completionController.abort();
artistDetailPageState.completionController = null;
}
// Clear artist detail state so we go back to the list view
artistDetailPageState.currentArtistId = null;
artistDetailPageState.currentArtistName = null;
navigateToPage('library');
});
}
// Initialize retry button
const retryBtn = document.getElementById("artist-detail-retry-btn");
if (retryBtn) {
retryBtn.addEventListener("click", () => {
if (artistDetailPageState.currentArtistId && artistDetailPageState.currentArtistName) {
loadArtistDetailData(artistDetailPageState.currentArtistId, artistDetailPageState.currentArtistName);
}
});
}
// Initialize discography filter buttons
initializeDiscographyFilters();
artistDetailPageState.isInitialized = true;
console.log("โ
Artist Detail page initialized successfully");
}
async function loadArtistDetailData(artistId, artistName) {
console.log(`๐ Loading artist detail data for: ${artistName} (ID: ${artistId})`);
// Reset discography filters to defaults
resetDiscographyFilters();
// Show loading state and hide all content
showArtistDetailLoading(true);
showArtistDetailError(false);
showArtistDetailMain(false);
showArtistDetailHero(false);
// Don't update header until data loads to avoid showing stale data
try {
// Call API to get artist discography data
const response = await fetch(`/api/artist-detail/${artistId}`);
if (!response.ok) {
throw new Error(`Failed to load artist data: ${response.statusText}`);
}
const data = await response.json();
if (!data.success) {
throw new Error(data.error || 'Failed to load artist data');
}
console.log(`โ
Loaded artist detail data:`, data);
// Hide loading and show all content
showArtistDetailLoading(false);
showArtistDetailMain(true);
showArtistDetailHero(true);
console.log(`๐จ Main content visibility:`, document.getElementById('artist-detail-main'));
console.log(`๐จ Albums section:`, document.getElementById('albums-section'));
// Populate the page with data (which updates the hero section and sets textContent)
populateArtistDetailPage(data);
// Update header with artist name and MusicBrainz link LAST to avoid overwrite
updateArtistDetailPageHeaderWithData(data.artist);
// Start streaming ownership checks if we have Spotify discography with checking state
if (data.discography && data.discography.albums) {
const hasChecking = [...(data.discography.albums || []), ...(data.discography.eps || []), ...(data.discography.singles || [])]
.some(r => r.owned === null);
if (hasChecking) {
// Store discography for stream updates
artistDetailPageState.currentDiscography = data.discography;
checkLibraryCompletion(data.artist.name, data.discography);
}
}
// Check if artist has tracks eligible for quality enhancement
checkArtistEnhanceEligibility(artistId);
} catch (error) {
console.error(`โ Error loading artist detail data:`, error);
// Show error state (keep hero section hidden)
showArtistDetailLoading(false);
showArtistDetailError(true, error.message);
showArtistDetailHero(false);
showToast(`Failed to load artist details: ${error.message}`, "error");
}
}
function updateArtistDetailPageHeader(artistName) {
// Update header title
const headerTitle = document.getElementById("artist-detail-name");
if (headerTitle) {
headerTitle.textContent = artistName;
}
// Update main artist name
const mainTitle = document.getElementById("artist-info-name");
if (mainTitle) {
mainTitle.textContent = artistName;
}
}
function updateArtistDetailPageHeaderWithData(artist) {
// Target the visible header element
const mainTitle = document.getElementById("artist-detail-name");
if (mainTitle) {
mainTitle.textContent = artist.name;
// Remove existing source links (to prevent duplicates)
mainTitle.querySelectorAll('.source-link-btn').forEach(el => el.remove());
const adbSlug = artist.name ? artist.name.replace(/\s+/g, '-').replace(/[^a-zA-Z0-9-]/g, '') : '';
const sources = [
{ id: artist.spotify_artist_id, url: `https://open.spotify.com/artist/${artist.spotify_artist_id}`, logo: SPOTIFY_LOGO_URL, label: 'Spotify' },
{ id: artist.itunes_artist_id, url: `https://music.apple.com/artist/${artist.itunes_artist_id}`, logo: ITUNES_LOGO_URL, label: 'Apple Music' },
{ id: artist.musicbrainz_id, url: `https://musicbrainz.org/artist/${artist.musicbrainz_id}`, logo: MUSICBRAINZ_LOGO_URL, label: 'MusicBrainz' },
{ id: artist.deezer_id, url: `https://www.deezer.com/artist/${artist.deezer_id}`, logo: DEEZER_LOGO_URL, label: 'Deezer' },
{ id: artist.audiodb_id, url: `https://www.theaudiodb.com/artist/${artist.audiodb_id}-${adbSlug}`, logo: getAudioDBLogoURL(), label: 'TheAudioDB' },
{ id: artist.lastfm_url, url: artist.lastfm_url, logo: LASTFM_LOGO_URL, label: 'Last.fm' },
{ id: artist.genius_url, url: artist.genius_url, logo: GENIUS_LOGO_URL, label: 'Genius' },
{ id: artist.tidal_id, url: `https://tidal.com/browse/artist/${artist.tidal_id}`, logo: TIDAL_LOGO_URL, label: 'Tidal' },
{ id: artist.qobuz_id, url: `https://www.qobuz.com/artist/${artist.qobuz_id}`, logo: QOBUZ_LOGO_URL, label: 'Qobuz' },
];
sources.forEach(source => {
if (!source.id) return;
const link = document.createElement('a');
link.className = 'source-link-btn';
link.href = source.url;
link.target = '_blank';
link.title = `View on ${source.label}`;
if (source.logo) {
const img = document.createElement('img');
img.src = source.logo;
img.onerror = () => { img.style.display = 'none'; };
link.appendChild(img);
}
const span = document.createElement('span');
span.textContent = source.label;
link.appendChild(span);
mainTitle.appendChild(link);
});
}
}
function populateArtistDetailPage(data) {
const artist = data.artist;
const discography = data.discography;
console.log(`๐จ Populating artist detail page for: ${artist.name}`);
console.log(`๐ Discography data:`, discography);
console.log(`๐ Albums:`, discography.albums);
console.log(`๐ EPs:`, discography.eps);
console.log(`๐ Singles:`, discography.singles);
// Update hero section with image, name, and stats
updateArtistHeroSection(artist, discography);
// Update genres (if element exists)
updateArtistGenres(artist.genres);
// Update summary stats (if element exists)
updateArtistSummaryStats(discography);
// Populate discography sections
populateDiscographySections(discography);
// Initialize library watchlist button if it exists (for library page)
const libraryWatchlistBtn = document.getElementById('library-artist-watchlist-btn');
if (libraryWatchlistBtn && data.spotify_artist && data.spotify_artist.spotify_artist_id) {
initializeLibraryWatchlistButton(data.spotify_artist.spotify_artist_id, data.spotify_artist.spotify_artist_name);
}
}
function updateArtistDetailImage(imageUrl, artistName) {
const imageElement = document.getElementById("artist-detail-image");
const fallbackElement = document.getElementById("artist-image-fallback");
if (imageUrl && imageUrl.trim() !== "") {
imageElement.src = imageUrl;
imageElement.alt = artistName;
imageElement.classList.remove("hidden");
fallbackElement.classList.add("hidden");
imageElement.onerror = () => {
console.log(`Failed to load artist image for ${artistName}: ${imageUrl}`);
// Replace with fallback on error
imageElement.classList.add("hidden");
fallbackElement.classList.remove("hidden");
};
imageElement.onload = () => {
console.log(`Successfully loaded artist image for ${artistName}: ${imageUrl}`);
};
} else {
console.log(`No image URL for ${artistName}: '${imageUrl}'`);
imageElement.classList.add("hidden");
fallbackElement.classList.remove("hidden");
}
}
function updateArtistGenres(genres) {
const genresContainer = document.getElementById("artist-genres");
if (!genresContainer) return;
genresContainer.innerHTML = "";
// Clear any previous artist format tags (they arrive later via streaming)
const oldFormats = genresContainer.parentElement?.querySelector('.artist-formats');
if (oldFormats) oldFormats.remove();
if (genres && genres.length > 0) {
genres.forEach(genre => {
const genreTag = document.createElement("span");
genreTag.className = "genre-tag";
genreTag.textContent = genre;
genresContainer.appendChild(genreTag);
});
}
}
function updateArtistSummaryStats(discography) {
const allReleases = [...discography.albums, ...discography.eps, ...discography.singles];
const hasChecking = allReleases.some(r => r.owned === null);
const ownedAlbums = discography.albums.filter(album => album.owned === true).length;
const missingAlbums = discography.albums.filter(album => album.owned === false).length;
const totalAlbums = discography.albums.length;
const completionPercentage = totalAlbums > 0 ? Math.round((ownedAlbums / totalAlbums) * 100) : 0;
// Update owned albums count
const ownedElement = document.getElementById("owned-albums-count");
if (ownedElement) {
ownedElement.textContent = hasChecking ? '...' : ownedAlbums;
}
// Update missing albums count
const missingElement = document.getElementById("missing-albums-count");
if (missingElement) {
missingElement.textContent = hasChecking ? '...' : missingAlbums;
}
// Update completion percentage
const completionElement = document.getElementById("completion-percentage");
if (completionElement) {
completionElement.textContent = hasChecking ? 'Checking...' : `${completionPercentage}%`;
}
}
function updateArtistHeaderStats(albumCount, trackCount) {
// This function is deprecated - now using updateArtistHeroSection
console.log("๐ Using new hero section instead of old header stats");
}
function updateArtistHeroSection(artist, discography) {
console.log("๐ผ๏ธ Updating artist hero section");
// Update artist image with detailed debugging
const imageElement = document.getElementById("artist-detail-image");
const fallbackElement = document.getElementById("artist-detail-image-fallback");
console.log(`๐ผ๏ธ Debug Artist image info:`);
console.log(` - URL: '${artist.image_url}'`);
console.log(` - Type: ${typeof artist.image_url}`);
console.log(` - Full artist object:`, artist);
console.log(` - Image element:`, imageElement);
console.log(` - Fallback element:`, fallbackElement);
if (artist.image_url && artist.image_url.trim() !== "" && artist.image_url !== "null") {
console.log(`โ
Setting image src to: ${artist.image_url}`);
imageElement.src = artist.image_url;
imageElement.alt = artist.name;
imageElement.style.display = "block";
if (fallbackElement) {
fallbackElement.style.display = "none";
}
imageElement.onload = () => {
console.log(`โ
Successfully loaded artist image: ${artist.image_url}`);
};
imageElement.onerror = () => {
console.error(`โ Failed to load artist image: ${artist.image_url}`);
// Try Deezer fallback before emoji
if (artist.deezer_id && !imageElement.dataset.triedDeezer) {
imageElement.dataset.triedDeezer = 'true';
imageElement.src = `https://api.deezer.com/artist/${artist.deezer_id}/image?size=big`;
} else {
imageElement.style.display = "none";
if (fallbackElement) {
fallbackElement.style.display = "flex";
}
}
};
} else {
console.log(`๐ผ๏ธ No valid image URL - showing fallback for ${artist.name}`);
imageElement.style.display = "none";
if (fallbackElement) {
fallbackElement.style.display = "flex";
}
}
// Update artist name
const nameElement = document.getElementById("artist-detail-name");
if (nameElement) {
nameElement.textContent = artist.name;
}
// Calculate and update stats for each category
updateCategoryStats('albums', discography.albums);
updateCategoryStats('eps', discography.eps);
updateCategoryStats('singles', discography.singles);
}
function updateCategoryStats(category, releases) {
const hasChecking = releases.some(r => r.owned === null);
const owned = releases.filter(r => r.owned === true).length;
const missing = releases.filter(r => r.owned === false).length;
const total = releases.length;
const completion = total > 0 ? Math.round((owned / total) * 100) : 100;
// Update stats text
const statsElement = document.getElementById(`${category}-stats`);
if (statsElement) {
if (hasChecking) {
statsElement.textContent = `Checking...`;
} else {
statsElement.textContent = `${owned} owned, ${missing} missing`;
}
}
// Update completion bar
const fillElement = document.getElementById(`${category}-completion-fill`);
if (fillElement) {
if (hasChecking) {
fillElement.style.width = '100%';
fillElement.classList.add('checking');
} else {
fillElement.style.width = `${completion}%`;
fillElement.classList.remove('checking');
}
}
// Update completion text
const textElement = document.getElementById(`${category}-completion-text`);
if (textElement) {
if (hasChecking) {
textElement.textContent = `Checking...`;
} else {
textElement.textContent = `${completion}%`;
}
}
}
function populateDiscographySections(discography) {
// Populate albums
populateReleaseSection('albums', discography.albums);
// Populate EPs
populateReleaseSection('eps', discography.eps);
// Populate singles
populateReleaseSection('singles', discography.singles);
// Apply any active filters after populating
applyDiscographyFilters();
}
function populateReleaseSection(sectionType, releases) {
const gridId = `${sectionType}-grid`;
const ownedCountId = `${sectionType}-owned-count`;
const missingCountId = `${sectionType}-missing-count`;
const grid = document.getElementById(gridId);
if (!grid) return;
// Clear existing content
grid.innerHTML = "";
const hasChecking = releases.some(r => r.owned === null);
const ownedCount = releases.filter(release => release.owned === true).length;
const missingCount = releases.filter(release => release.owned === false).length;
// Update section stats
const ownedElement = document.getElementById(ownedCountId);
const missingElement = document.getElementById(missingCountId);
if (ownedElement) {
ownedElement.textContent = hasChecking ? 'Checking...' : `${ownedCount} owned`;
}
if (missingElement) {
missingElement.textContent = hasChecking ? '' : `${missingCount} missing`;
}
// Create release cards
releases.forEach((release, index) => {
const card = createReleaseCard(release);
grid.appendChild(card);
});
console.log(`๐ Populated ${sectionType} section: ${ownedCount} owned, ${missingCount} missing`);
console.log(`๐ Grid element:`, grid);
console.log(`๐ Grid children count:`, grid.children.length);
}
function createReleaseCard(release) {
const card = document.createElement("div");
const isChecking = release.owned === null;
card.className = `release-card${isChecking ? " checking" : (release.owned ? "" : " missing")}`;
card.setAttribute("data-release-id", release.id || "");
card.setAttribute("data-spotify-id", release.spotify_id || "");
// Store mutable reference so stream updates propagate to click handler
card._releaseData = release;
// Tag card for content-type filtering
const titleLower = (release.title || '').toLowerCase();
const livePattern = /\b(live)\b|\(live[^)]*\)|\[live[^]]*\]/i;
const compilationPattern = /\b(greatest hits|best of|collection|anthology|essential)\b/i;
const featuredPattern = /\(?\bfeat\.?\s|\bft\.?\s|\bfeaturing\b/i;
const isLive = livePattern.test(release.title || '') || (release.album_type === 'compilation' && livePattern.test(release.title || ''));
const isCompilation = (release.album_type === 'compilation') || compilationPattern.test(release.title || '');
const isFeatured = featuredPattern.test(release.title || '');
card.setAttribute("data-is-live", isLive ? "true" : "false");
card.setAttribute("data-is-compilation", isCompilation ? "true" : "false");
card.setAttribute("data-is-featured", isFeatured ? "true" : "false");
// Add MusicBrainz icon if available
let mbIcon = null;
if (release.musicbrainz_release_id) {
mbIcon = document.createElement("div");
mbIcon.className = "mb-card-icon";
mbIcon.title = "View on MusicBrainz";
mbIcon.innerHTML = ` `;
mbIcon.onclick = (e) => {
e.stopPropagation();
window.open(`https://musicbrainz.org/release/${release.musicbrainz_release_id}`, '_blank');
};
}
// Create image
const imageContainer = document.createElement("div");
if (release.image_url && release.image_url.trim() !== "") {
const img = document.createElement("img");
img.src = release.image_url;
img.alt = release.title;
img.className = "release-image";
img.loading = 'lazy';
img.onerror = () => {
imageContainer.innerHTML = `๐ฟ
`;
};
imageContainer.appendChild(img);
} else {
imageContainer.innerHTML = `๐ฟ
`;
}
// Create title
const title = document.createElement("h4");
title.className = "release-title";
title.textContent = release.title;
title.title = release.title;
// Create year - extract from release_date (Spotify format) or fall back to year field
const year = document.createElement("div");
year.className = "release-year";
let yearText = "Unknown Year";
// DEBUG: Log the release data to see what we're working with (remove this after testing)
// console.log(`๐ DEBUG: Release "${release.title}" data:`, {
// title: release.title,
// owned: release.owned,
// year: release.year,
// release_date: release.release_date,
// track_completion: release.track_completion
// });
// First try to extract year from release_date (Spotify format: "YYYY-MM-DD")
if (release.release_date) {
try {
// Extract year directly from string to avoid timezone issues
const yearMatch = release.release_date.match(/^(\d{4})/);
if (yearMatch) {
const releaseYear = parseInt(yearMatch[1]);
if (releaseYear && !isNaN(releaseYear) && releaseYear > 1900 && releaseYear <= new Date().getFullYear() + 1) {
yearText = releaseYear.toString();
}
} else {
// Fallback to Date parsing if format is different
const releaseYear = new Date(release.release_date).getFullYear();
if (releaseYear && !isNaN(releaseYear) && releaseYear > 1900 && releaseYear <= new Date().getFullYear() + 1) {
yearText = releaseYear.toString();
}
}
} catch (e) {
console.warn('Error parsing release_date:', release.release_date, e);
}
}
// Fallback to direct year field if release_date parsing failed
if (yearText === "Unknown Year" && release.year) {
yearText = release.year.toString();
}
year.textContent = yearText;
// Create completion info
const completion = document.createElement("div");
completion.className = "release-completion";
const completionText = document.createElement("span");
const completionBar = document.createElement("div");
completionBar.className = "completion-bar";
const completionFill = document.createElement("div");
completionFill.className = "completion-fill";
if (release.owned === null || release.track_completion === 'checking') {
// Checking state - ownership not yet resolved
completionText.textContent = "Checking...";
completionText.className = "completion-text checking";
completionFill.className += " checking";
completionFill.style.width = "100%";
} else if (release.owned) {
// Handle new detailed track completion object
if (release.track_completion && typeof release.track_completion === 'object') {
const completion = release.track_completion;
const percentage = completion.percentage || 100;
const ownedTracks = completion.owned_tracks || 0;
const totalTracks = completion.total_tracks || 0;
const missingTracks = completion.missing_tracks || 0;
completionFill.style.width = `${percentage}%`;
if (missingTracks === 0) {
completionText.textContent = `Complete (${ownedTracks})`;
completionText.className = "completion-text complete";
completionFill.className += " complete";
} else {
completionText.textContent = `${ownedTracks}/${totalTracks} tracks`;
completionText.className = "completion-text partial";
completionFill.className += " partial";
// Add missing tracks indicator
completionText.title = `Missing ${missingTracks} track${missingTracks !== 1 ? 's' : ''}`;
}
} else {
// Fallback for legacy simple percentage
const percentage = release.track_completion || 100;
completionFill.style.width = `${percentage}%`;
if (percentage === 100) {
completionText.textContent = "Complete";
completionText.className = "completion-text complete";
completionFill.className += " complete";
} else {
completionText.textContent = `${percentage}%`;
completionText.className = "completion-text partial";
completionFill.className += " partial";
}
}
} else {
completionText.textContent = "Missing";
completionText.className = "completion-text missing";
completionFill.className += " missing";
completionFill.style.width = "0%";
}
completionBar.appendChild(completionFill);
completion.appendChild(completionText);
completion.appendChild(completionBar);
// Assemble card
card.appendChild(imageContainer);
card.appendChild(title);
card.appendChild(year);
card.appendChild(completion);
// Add MusicBrainz icon LAST to ensure it's on top
if (release.musicbrainz_release_id && mbIcon) { // Check if mbIcon was created
card.appendChild(mbIcon);
}
// Add click handler for release card (uses card._releaseData for mutable reference)
card.addEventListener("click", async () => {
const rel = card._releaseData;
console.log(`Clicked on release: ${rel.title} (Owned: ${rel.owned})`);
// Still checking - ignore click
if (rel.owned === null) {
showToast(`Still checking ownership for ${rel.title}...`, "info");
return;
}
showLoadingOverlay('Loading album...');
// For missing or incomplete releases, open wishlist modal
try {
// Convert release object to album format expected by our function
const albumData = {
id: rel.spotify_id || rel.id,
name: rel.title,
image_url: rel.image_url,
release_date: rel.year ? `${rel.year}-01-01` : '',
album_type: rel.album_type || rel.type || 'album',
total_tracks: (rel.track_completion && typeof rel.track_completion === 'object')
? rel.track_completion.total_tracks : (rel.track_count || 1)
};
// Get current artist from artist detail page state
const currentArtist = artistDetailPageState.currentArtistName ? {
id: artistDetailPageState.currentArtistId,
name: artistDetailPageState.currentArtistName,
image_url: getArtistImageFromPage() || '' // Get artist image from page
} : null;
if (!currentArtist) {
console.error('โ No current artist found for release click');
showToast('Error: No artist information available', 'error');
return;
}
// Load tracks for the album (pass name/artist for Hydrabase support)
const _aat2 = new URLSearchParams({ name: albumData.name || '', artist: currentArtist.name || '' });
const response = await fetch(`/api/artist/${currentArtist.id}/album/${albumData.id}/tracks?${_aat2}`);
if (!response.ok) {
throw new Error(`Failed to load album tracks: ${response.status}`);
}
const data = await response.json();
if (!data.success || !data.tracks || data.tracks.length === 0) {
throw new Error('No tracks found for this release');
}
// Use the actual album type from release data
const albumType = rel.album_type || rel.type || 'album';
// Open the Add to Wishlist modal immediately (no waiting for ownership check)
hideLoadingOverlay();
await openAddToWishlistModal(albumData, currentArtist, data.tracks, albumType);
// Always lazy-load track ownership + metadata (non-blocking)
lazyLoadTrackOwnership(currentArtist.name, data.tracks, card);
} catch (error) {
hideLoadingOverlay();
console.error('โ Error handling release click:', error);
showToast(`Error opening wishlist modal: ${error.message}`, 'error');
}
});
return card;
}
/**
* Helper function to get artist image from the current artist detail page
*/
function getArtistImageFromPage() {
try {
// Try to get from artist detail image element
const artistDetailImage = document.getElementById('artist-detail-image');
if (artistDetailImage && artistDetailImage.src && artistDetailImage.src !== window.location.href) {
return artistDetailImage.src;
}
// Try to get from artist hero image
const artistImage = document.getElementById('artist-image');
if (artistImage) {
const bgImage = window.getComputedStyle(artistImage).backgroundImage;
if (bgImage && bgImage !== 'none') {
// Extract URL from CSS background-image
const urlMatch = bgImage.match(/url\(["']?(.*?)["']?\)/);
if (urlMatch && urlMatch[1]) {
return urlMatch[1];
}
}
}
return null;
} catch (error) {
console.warn('Error getting artist image from page:', error);
return null;
}
}
// ================================================================================================
// LIBRARY COMPLETION STREAMING - Two-phase lazy-load pattern
// ================================================================================================
async function checkLibraryCompletion(artistName, discography) {
// Abort any in-progress check
if (artistDetailPageState.completionController) {
artistDetailPageState.completionController.abort();
}
artistDetailPageState.completionController = new AbortController();
const payload = {
artist_name: artistName,
albums: discography.albums || [],
eps: discography.eps || [],
singles: discography.singles || []
};
try {
const response = await fetch('/api/library/completion-stream', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
signal: artistDetailPageState.completionController.signal
});
if (!response.ok) {
console.error(`โ Completion stream failed: ${response.status}`);
return;
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
let ownedCounts = { albums: 0, eps: 0, singles: 0 };
let totalCounts = { albums: 0, eps: 0, singles: 0 };
const artistFormatSet = new Set();
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop(); // Keep incomplete line in buffer
for (const line of lines) {
if (!line.startsWith('data: ')) continue;
try {
const eventData = JSON.parse(line.slice(6));
if (eventData.type === 'completion') {
updateLibraryReleaseCard(eventData);
totalCounts[eventData.category]++;
if (eventData.status !== 'missing' && eventData.status !== 'error') {
ownedCounts[eventData.category]++;
// Accumulate formats for artist-level summary
if (eventData.formats) {
eventData.formats.forEach(f => artistFormatSet.add(f));
}
}
// Update stats incrementally
updateCategoryStatsFromStream(
eventData.category,
ownedCounts[eventData.category],
totalCounts[eventData.category] - ownedCounts[eventData.category]
);
} else if (eventData.type === 'complete') {
console.log(`โ
Library completion stream done: ${eventData.processed_count} items`);
// Final stats recalculation
recalculateSummaryStats(artistFormatSet);
}
} catch (parseError) {
console.warn('Error parsing SSE event:', parseError, line);
}
}
}
} catch (error) {
if (error.name === 'AbortError') {
console.log('๐ Library completion stream aborted (navigation)');
} else {
console.error('โ Error in library completion stream:', error);
}
}
}
function updateLibraryReleaseCard(data) {
const card = document.querySelector(`[data-spotify-id="${data.spotify_id}"]`);
if (!card) return;
const isOwned = data.status !== 'missing' && data.status !== 'error';
// Update card class
card.classList.remove('checking', 'missing');
if (!isOwned) {
card.classList.add('missing');
}
// If backend says "completed" (>=90%), trust it โ Spotify metadata track counts
// can be wrong (e.g. total_tracks=15 but API only returns 14 actual tracks)
const isComplete = data.status === 'completed';
const effectiveMissing = isComplete ? 0 : (data.expected_tracks - data.owned_tracks);
// Update the mutable release data on the card
if (card._releaseData) {
card._releaseData.owned = isOwned;
if (isOwned && data.expected_tracks > 0) {
card._releaseData.track_completion = {
owned_tracks: data.owned_tracks,
total_tracks: isComplete ? data.owned_tracks : data.expected_tracks,
percentage: isComplete ? 100 : data.completion_percentage,
missing_tracks: effectiveMissing
};
} else if (isOwned) {
card._releaseData.track_completion = {
owned_tracks: data.owned_tracks,
total_tracks: data.owned_tracks,
percentage: 100,
missing_tracks: 0
};
} else {
card._releaseData.track_completion = 0;
}
}
// Update completion text element in-place
const completionText = card.querySelector('.completion-text');
if (completionText) {
completionText.classList.remove('checking', 'complete', 'partial', 'missing');
if (isOwned) {
if (effectiveMissing <= 0) {
completionText.textContent = `Complete (${data.owned_tracks})`;
completionText.className = 'completion-text complete';
} else {
completionText.textContent = `${data.owned_tracks}/${data.expected_tracks} tracks`;
completionText.className = 'completion-text partial';
completionText.title = `Missing ${effectiveMissing} track${effectiveMissing !== 1 ? 's' : ''}`;
}
} else {
completionText.textContent = 'Missing';
completionText.className = 'completion-text missing';
}
}
// Update completion fill bar in-place
const completionFill = card.querySelector('.completion-fill');
if (completionFill) {
completionFill.classList.remove('checking', 'complete', 'partial', 'missing');
if (isOwned) {
const pct = isComplete ? 100 : (data.completion_percentage || 100);
completionFill.style.width = `${pct}%`;
completionFill.classList.add(effectiveMissing <= 0 ? 'complete' : 'partial');
} else {
completionFill.style.width = '0%';
completionFill.classList.add('missing');
}
}
// Display format tags on owned releases
if (isOwned && data.formats && data.formats.length > 0) {
// Store formats on release data for modal use
if (card._releaseData) {
card._releaseData.formats = data.formats;
}
// Remove any existing format tags
const existingFormats = card.querySelector('.release-formats');
if (existingFormats) existingFormats.remove();
const formatsDiv = document.createElement('div');
formatsDiv.className = 'release-formats';
formatsDiv.innerHTML = data.formats.map(f => `${f} `).join('');
card.appendChild(formatsDiv);
}
// Re-apply filters so newly resolved cards respect active filters
applyDiscographyFilters();
}
function updateCategoryStatsFromStream(category, ownedCount, missingCount) {
const statsElement = document.getElementById(`${category}-stats`);
if (statsElement) {
statsElement.textContent = `${ownedCount} owned, ${missingCount} missing`;
}
const total = ownedCount + missingCount;
const completion = total > 0 ? Math.round((ownedCount / total) * 100) : 100;
const fillElement = document.getElementById(`${category}-completion-fill`);
if (fillElement) {
fillElement.classList.remove('checking');
fillElement.style.width = `${completion}%`;
}
const textElement = document.getElementById(`${category}-completion-text`);
if (textElement) {
textElement.textContent = `${completion}%`;
}
// Update section owned/missing counts
const ownedElement = document.getElementById(`${category}-owned-count`);
if (ownedElement) {
ownedElement.textContent = `${ownedCount} owned`;
}
const missingElement = document.getElementById(`${category}-missing-count`);
if (missingElement) {
missingElement.textContent = `${missingCount} missing`;
}
}
function recalculateSummaryStats(artistFormatSet) {
const disc = artistDetailPageState.currentDiscography;
if (!disc) return;
// Recalculate from the live card data
const categories = ['albums', 'eps', 'singles'];
for (const cat of categories) {
const grid = document.getElementById(`${cat}-grid`);
if (!grid) continue;
let owned = 0, missing = 0;
grid.querySelectorAll('.release-card').forEach(card => {
if (card._releaseData) {
if (card._releaseData.owned === true) owned++;
else if (card._releaseData.owned === false) missing++;
}
});
updateCategoryStatsFromStream(cat, owned, missing);
}
// Update summary stats (albums only, matches original behavior)
const albumGrid = document.getElementById('albums-grid');
if (albumGrid) {
let ownedAlbums = 0, missingAlbums = 0;
albumGrid.querySelectorAll('.release-card').forEach(card => {
if (card._releaseData) {
if (card._releaseData.owned === true) ownedAlbums++;
else if (card._releaseData.owned === false) missingAlbums++;
}
});
const total = ownedAlbums + missingAlbums;
const pct = total > 0 ? Math.round((ownedAlbums / total) * 100) : 0;
const ownedEl = document.getElementById("owned-albums-count");
if (ownedEl) ownedEl.textContent = ownedAlbums;
const missingEl = document.getElementById("missing-albums-count");
if (missingEl) missingEl.textContent = missingAlbums;
const completionEl = document.getElementById("completion-percentage");
if (completionEl) completionEl.textContent = `${pct}%`;
}
// Display artist-level format summary
if (artistFormatSet && artistFormatSet.size > 0) {
const heroInfo = document.querySelector('.artist-hero-section .artist-info');
if (heroInfo) {
// Remove any existing artist format tag
const existing = heroInfo.querySelector('.artist-formats');
if (existing) existing.remove();
const formatsDiv = document.createElement('div');
formatsDiv.className = 'artist-formats';
formatsDiv.innerHTML = [...artistFormatSet].sort()
.map(f => `${f} `)
.join('');
// Insert after genres container
const genresContainer = heroInfo.querySelector('.artist-genres-container');
if (genresContainer && genresContainer.nextSibling) {
heroInfo.insertBefore(formatsDiv, genresContainer.nextSibling);
} else {
heroInfo.appendChild(formatsDiv);
}
}
}
}
// ===============================================
// Discography Filter Functions
// ===============================================
function initializeDiscographyFilters() {
const container = document.getElementById('discography-filters');
if (!container) return;
container.addEventListener('click', (e) => {
const btn = e.target.closest('.discography-filter-btn');
if (!btn) return;
const filterType = btn.dataset.filter;
const value = btn.dataset.value;
if (filterType === 'category') {
// Multi-toggle: toggle this category on/off
btn.classList.toggle('active');
discographyFilterState.categories[value] = btn.classList.contains('active');
} else if (filterType === 'content') {
// Multi-toggle: toggle this content type on/off
btn.classList.toggle('active');
discographyFilterState.content[value] = btn.classList.contains('active');
} else if (filterType === 'ownership') {
// Single-select: deactivate siblings, activate this one
container.querySelectorAll('[data-filter="ownership"]').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
discographyFilterState.ownership = value;
}
applyDiscographyFilters();
});
}
function resetDiscographyFilters() {
discographyFilterState.categories = { albums: true, eps: true, singles: true };
discographyFilterState.content = { live: true, compilations: true, featured: true };
discographyFilterState.ownership = 'all';
// Reset button visual states
const container = document.getElementById('discography-filters');
if (!container) return;
container.querySelectorAll('.discography-filter-btn').forEach(btn => {
const filterType = btn.dataset.filter;
const value = btn.dataset.value;
if (filterType === 'ownership') {
btn.classList.toggle('active', value === 'all');
} else {
btn.classList.add('active');
}
});
}
function applyDiscographyFilters() {
const categories = ['albums', 'eps', 'singles'];
for (const cat of categories) {
const section = document.getElementById(`${cat}-section`);
if (!section) continue;
// Category toggle โ hide entire section
if (!discographyFilterState.categories[cat]) {
section.style.display = 'none';
continue;
}
section.style.display = '';
// Filter individual cards within the section
const grid = document.getElementById(`${cat}-grid`);
if (!grid) continue;
let visibleOwned = 0;
let visibleMissing = 0;
let visibleCount = 0;
grid.querySelectorAll('.release-card').forEach(card => {
let hidden = false;
// Content filters
if (!discographyFilterState.content.live && card.getAttribute('data-is-live') === 'true') {
hidden = true;
}
if (!discographyFilterState.content.compilations && card.getAttribute('data-is-compilation') === 'true') {
hidden = true;
}
if (!discographyFilterState.content.featured && card.getAttribute('data-is-featured') === 'true') {
hidden = true;
}
// Ownership filter (only apply if card is not still checking)
if (!hidden && discographyFilterState.ownership !== 'all' && card._releaseData) {
const owned = card._releaseData.owned;
if (owned !== null) { // Don't hide cards still being checked
if (discographyFilterState.ownership === 'owned' && !owned) hidden = true;
if (discographyFilterState.ownership === 'missing' && owned) hidden = true;
}
}
card.style.display = hidden ? 'none' : '';
// Count visible cards for stats
if (!hidden && card._releaseData) {
visibleCount++;
if (card._releaseData.owned === true) visibleOwned++;
else if (card._releaseData.owned === false) visibleMissing++;
}
});
// Update section stats to reflect filtered view
const ownedEl = document.getElementById(`${cat}-owned-count`);
const missingEl = document.getElementById(`${cat}-missing-count`);
if (ownedEl) ownedEl.textContent = `${visibleOwned} owned`;
if (missingEl) missingEl.textContent = `${visibleMissing} missing`;
// Hide section entirely if all cards are hidden
section.style.display = visibleCount === 0 ? 'none' : '';
}
}
// ==================== Enhanced Library Management View ====================
function isEnhancedAdmin() {
return currentProfile && currentProfile.is_admin;
}
function toggleEnhancedView(enabled) {
const standardSections = document.querySelector('.discography-sections');
const enhancedContainer = document.getElementById('enhanced-view-container');
const toggleBtns = document.querySelectorAll('.enhanced-view-toggle-btn');
if (!standardSections || !enhancedContainer) return;
artistDetailPageState.enhancedView = enabled;
// Update toggle button states
toggleBtns.forEach(btn => {
const view = btn.getAttribute('data-view');
btn.classList.toggle('active', (view === 'enhanced') === enabled);
});
// Hide/show standard filter groups (not relevant in enhanced view)
const filterGroups = document.querySelectorAll('#discography-filters .filter-group');
filterGroups.forEach(group => {
const label = group.querySelector('.filter-label');
if (label && label.textContent !== 'View') {
group.style.display = enabled ? 'none' : '';
}
});
const dividers = document.querySelectorAll('#discography-filters .filter-divider');
dividers.forEach((d, i) => {
if (i < dividers.length - 1) d.style.display = enabled ? 'none' : '';
});
if (enabled) {
standardSections.classList.add('hidden');
enhancedContainer.classList.remove('hidden');
if (!artistDetailPageState.enhancedData) {
loadEnhancedViewData(artistDetailPageState.currentArtistId);
} else {
renderEnhancedView();
}
} else {
standardSections.classList.remove('hidden');
enhancedContainer.classList.add('hidden');
const bulkBar = document.getElementById('enhanced-bulk-bar');
if (bulkBar) bulkBar.classList.remove('visible');
}
}
async function loadEnhancedViewData(artistId) {
const container = document.getElementById('enhanced-view-container');
if (!container) return;
container.innerHTML = 'Loading library data...
';
try {
const response = await fetch(`/api/library/artist/${artistId}/enhanced`);
const data = await response.json();
if (!data.success) throw new Error(data.error || 'Failed to load enhanced data');
artistDetailPageState.enhancedData = data;
artistDetailPageState.expandedAlbums = new Set();
artistDetailPageState.selectedTracks = new Set();
artistDetailPageState.enhancedTrackSort = {};
artistDetailPageState.serverType = data.server_type || null;
_tagPreviewServerType = data.server_type || null;
_rebuildAlbumMap();
renderEnhancedView();
} catch (error) {
console.error('Error loading enhanced view data:', error);
container.innerHTML = `Failed to load: ${escapeHtml(error.message)}
`;
}
}
function renderEnhancedView() {
const container = document.getElementById('enhanced-view-container');
const data = artistDetailPageState.enhancedData;
if (!container || !data) return;
container.innerHTML = '';
// Artist metadata card (visual + editable)
container.appendChild(renderArtistMetaPanel(data.artist));
// Library stats summary bar
container.appendChild(renderEnhancedStatsBar(data));
// Group albums by type
const grouped = { album: [], ep: [], single: [] };
(data.albums || []).forEach(album => {
const type = (album.record_type || 'album').toLowerCase();
if (grouped[type]) grouped[type].push(album);
else grouped[type] = [album];
});
const sectionLabels = { album: 'Albums', ep: 'EPs', single: 'Singles' };
for (const [type, label] of Object.entries(sectionLabels)) {
const albums = grouped[type] || [];
if (albums.length === 0) continue;
container.appendChild(renderEnhancedSection(type, label, albums));
}
}
function renderEnhancedStatsBar(data) {
const bar = document.createElement('div');
bar.className = 'enhanced-stats-bar';
const albums = data.albums || [];
const totalAlbums = albums.filter(a => (a.record_type || 'album') === 'album').length;
const totalEps = albums.filter(a => a.record_type === 'ep').length;
const totalSingles = albums.filter(a => a.record_type === 'single').length;
const totalTracks = albums.reduce((s, a) => s + (a.tracks ? a.tracks.length : 0), 0);
// Calculate total duration
let totalDurationMs = 0;
albums.forEach(a => (a.tracks || []).forEach(t => { totalDurationMs += (t.duration || 0); }));
const totalHours = Math.floor(totalDurationMs / 3600000);
const totalMins = Math.floor((totalDurationMs % 3600000) / 60000);
// Calculate format breakdown
const formatCounts = {};
albums.forEach(a => (a.tracks || []).forEach(t => {
const fmt = extractFormat(t.file_path);
if (fmt !== '-') formatCounts[fmt] = (formatCounts[fmt] || 0) + 1;
}));
const statsItems = [
{ value: totalAlbums, label: 'Albums', icon: '💿' },
{ value: totalEps, label: 'EPs', icon: '📀' },
{ value: totalSingles, label: 'Singles', icon: '♪' },
{ value: totalTracks, label: 'Tracks', icon: '🎵' },
{ value: totalHours > 0 ? `${totalHours}h ${totalMins}m` : `${totalMins}m`, label: 'Duration', icon: '⏲' },
];
let statsHtml = statsItems.map(s =>
`
${s.value}
${s.label}
`
).join('');
// Format badges
const formatBadges = Object.entries(formatCounts)
.sort((a, b) => b[1] - a[1])
.map(([fmt, count]) => {
const cls = fmt === 'FLAC' ? 'flac' : (fmt === 'MP3' ? 'mp3' : 'other');
return `${fmt} (${count}) `;
}).join('');
bar.innerHTML = `
${statsHtml}
${formatBadges}
`;
return bar;
}
function renderArtistMetaPanel(artist) {
const panel = document.createElement('div');
panel.className = 'enhanced-artist-meta';
panel.id = 'enhanced-artist-meta';
// Build using DOM to avoid innerHTML escaping issues
const header = document.createElement('div');
header.className = 'enhanced-artist-meta-header';
// Left side: artist image + name display
const headerLeft = document.createElement('div');
headerLeft.className = 'enhanced-artist-meta-header-left';
if (artist.thumb_url) {
const img = document.createElement('img');
img.className = 'enhanced-artist-meta-image';
img.src = artist.thumb_url;
img.alt = artist.name || '';
img.onerror = function() { this.style.display = 'none'; };
headerLeft.appendChild(img);
}
const headerInfo = document.createElement('div');
headerInfo.className = 'enhanced-artist-meta-info';
const artistTitle = document.createElement('div');
artistTitle.className = 'enhanced-artist-meta-name';
artistTitle.textContent = artist.name || 'Unknown Artist';
headerInfo.appendChild(artistTitle);
// ID badges row (clickable links)
const idBadges = document.createElement('div');
idBadges.className = 'enhanced-artist-id-badges';
const idSources = [
{ key: 'spotify_artist_id', label: 'Spotify', svc: 'spotify' },
{ key: 'musicbrainz_id', label: 'MusicBrainz', svc: 'musicbrainz' },
{ key: 'deezer_id', label: 'Deezer', svc: 'deezer' },
{ key: 'audiodb_id', label: 'AudioDB', svc: 'audiodb' },
{ key: 'itunes_artist_id', label: 'iTunes', svc: 'itunes' },
{ key: 'lastfm_url', label: 'Last.fm', svc: 'lastfm' },
{ key: 'genius_url', label: 'Genius', svc: 'genius' },
{ key: 'tidal_id', label: 'Tidal', svc: 'tidal' },
{ key: 'qobuz_id', label: 'Qobuz', svc: 'qobuz' },
];
idSources.forEach(src => {
if (artist[src.key]) {
idBadges.appendChild(makeClickableBadge(src.svc, 'artist', artist[src.key], src.label));
}
});
if (artist.server_source) {
const srcBadge = document.createElement('span');
srcBadge.className = 'enhanced-id-badge server';
srcBadge.textContent = artist.server_source;
idBadges.appendChild(srcBadge);
}
headerInfo.appendChild(idBadges);
headerLeft.appendChild(headerInfo);
header.appendChild(headerLeft);
// Right side: admin actions
const headerRight = document.createElement('div');
headerRight.className = 'enhanced-artist-meta-actions';
if (isEnhancedAdmin()) {
const editToggle = document.createElement('button');
editToggle.className = 'enhanced-meta-edit-toggle';
editToggle.textContent = 'Edit Metadata';
editToggle.onclick = () => {
const form = document.getElementById('enhanced-artist-meta-form');
if (form) {
const isVisible = !form.classList.contains('hidden');
form.classList.toggle('hidden');
editToggle.textContent = isVisible ? 'Edit Metadata' : 'Hide Editor';
editToggle.classList.toggle('active', !isVisible);
}
};
headerRight.appendChild(editToggle);
// Enrich dropdown button
const enrichWrap = document.createElement('div');
enrichWrap.className = 'enhanced-enrich-wrap';
const enrichBtn = document.createElement('button');
enrichBtn.className = 'enhanced-enrich-btn';
enrichBtn.textContent = 'Enrich โพ';
enrichBtn.onclick = (e) => {
e.stopPropagation();
enrichMenu.classList.toggle('visible');
};
enrichWrap.appendChild(enrichBtn);
const enrichMenu = document.createElement('div');
enrichMenu.className = 'enhanced-enrich-menu';
const services = [
{ id: 'spotify', label: 'Spotify', icon: '๐ข' },
{ id: 'musicbrainz', label: 'MusicBrainz', icon: '๐ ' },
{ id: 'deezer', label: 'Deezer', icon: '๐ฃ' },
{ id: 'audiodb', label: 'AudioDB', icon: '๐ต' },
{ id: 'itunes', label: 'iTunes', icon: '๐ด' },
{ id: 'lastfm', label: 'Last.fm', icon: 'โช' },
{ id: 'genius', label: 'Genius', icon: '๐ก' },
{ id: 'tidal', label: 'Tidal', icon: 'โฌ' },
{ id: 'qobuz', label: 'Qobuz', icon: '๐ท' },
];
services.forEach(svc => {
const item = document.createElement('div');
item.className = 'enhanced-enrich-menu-item';
item.textContent = `${svc.icon} ${svc.label}`;
item.onclick = (e) => {
e.stopPropagation();
enrichMenu.classList.remove('visible');
runEnrichment('artist', artist.id, svc.id, artist.name, '', artist.id);
};
enrichMenu.appendChild(item);
});
enrichWrap.appendChild(enrichMenu);
headerRight.appendChild(enrichWrap);
}
header.appendChild(headerRight);
panel.appendChild(header);
// Match status row (clickable to rematch)
const statusRow = document.createElement('div');
statusRow.className = 'enhanced-match-status-row';
const statusServices = [
{ key: 'spotify_match_status', label: 'Spotify', attempted: 'spotify_last_attempted', svc: 'spotify' },
{ key: 'musicbrainz_match_status', label: 'MusicBrainz', attempted: 'musicbrainz_last_attempted', svc: 'musicbrainz' },
{ key: 'deezer_match_status', label: 'Deezer', attempted: 'deezer_last_attempted', svc: 'deezer' },
{ key: 'audiodb_match_status', label: 'AudioDB', attempted: 'audiodb_last_attempted', svc: 'audiodb' },
{ key: 'itunes_match_status', label: 'iTunes', attempted: 'itunes_last_attempted', svc: 'itunes' },
{ key: 'lastfm_match_status', label: 'Last.fm', attempted: 'lastfm_last_attempted', svc: 'lastfm' },
{ key: 'genius_match_status', label: 'Genius', attempted: 'genius_last_attempted', svc: 'genius' },
{ key: 'tidal_match_status', label: 'Tidal', attempted: 'tidal_last_attempted', svc: 'tidal' },
{ key: 'qobuz_match_status', label: 'Qobuz', attempted: 'qobuz_last_attempted', svc: 'qobuz' },
];
statusServices.forEach(s => {
const status = artist[s.key];
const attempted = artist[s.attempted];
const chip = document.createElement('span');
chip.className = `enhanced-match-chip clickable ${status === 'matched' ? 'matched' : (status === 'not_found' ? 'not-found' : 'pending')}`;
chip.textContent = `${s.label}: ${status || 'pending'}`;
const tipParts = [];
if (attempted) tipParts.push(`Last: ${new Date(attempted).toLocaleString()}`);
tipParts.push('Click to rematch');
chip.title = tipParts.join(' ยท ');
chip.onclick = () => openManualMatchModal('artist', artist.id, s.svc, artist.name, artist.id);
statusRow.appendChild(chip);
});
panel.appendChild(statusRow);
// Collapsible edit form (hidden by default)
const form = document.createElement('div');
form.className = 'enhanced-artist-meta-form hidden';
form.id = 'enhanced-artist-meta-form';
const editableFields = [
{ key: 'name', label: 'Artist Name', type: 'text' },
{ key: 'genres', label: 'Genres (comma separated)', type: 'text', isArray: true },
{ key: 'label', label: 'Label', type: 'text' },
{ key: 'style', label: 'Style', type: 'text' },
{ key: 'mood', label: 'Mood', type: 'text' },
{ key: 'summary', label: 'Summary / Bio', type: 'textarea', wide: true },
];
const grid = document.createElement('div');
grid.className = 'enhanced-artist-meta-grid';
editableFields.forEach(f => {
const fieldDiv = document.createElement('div');
fieldDiv.className = 'enhanced-meta-field' + (f.wide ? ' wide' : '');
const label = document.createElement('label');
label.className = 'enhanced-meta-field-label';
label.textContent = f.label;
fieldDiv.appendChild(label);
const val = f.isArray
? (Array.isArray(artist[f.key]) ? artist[f.key].join(', ') : (artist[f.key] || ''))
: (artist[f.key] || '');
if (f.type === 'textarea') {
const ta = document.createElement('textarea');
ta.className = 'enhanced-meta-field-input';
ta.dataset.field = f.key;
ta.placeholder = f.label + '...';
ta.textContent = val;
fieldDiv.appendChild(ta);
} else {
const inp = document.createElement('input');
inp.type = 'text';
inp.className = 'enhanced-meta-field-input';
inp.dataset.field = f.key;
inp.value = val;
inp.placeholder = f.label + '...';
fieldDiv.appendChild(inp);
}
grid.appendChild(fieldDiv);
});
form.appendChild(grid);
// Save/revert buttons
const formActions = document.createElement('div');
formActions.className = 'enhanced-artist-form-actions';
const revertBtn = document.createElement('button');
revertBtn.className = 'enhanced-meta-cancel-btn';
revertBtn.textContent = 'Revert';
revertBtn.onclick = () => revertArtistMetadata();
const saveBtn = document.createElement('button');
saveBtn.className = 'enhanced-meta-save-btn';
saveBtn.textContent = 'Save Changes';
saveBtn.onclick = () => saveArtistMetadata();
formActions.appendChild(revertBtn);
formActions.appendChild(saveBtn);
form.appendChild(formActions);
panel.appendChild(form);
return panel;
}
function renderEnhancedSection(type, label, albums) {
const section = document.createElement('div');
section.className = 'enhanced-section';
const totalTracks = albums.reduce((sum, a) => sum + (a.tracks ? a.tracks.length : 0), 0);
const sectionHeader = document.createElement('div');
sectionHeader.className = 'enhanced-section-header';
sectionHeader.innerHTML = `
${label}
${albums.length} release${albums.length !== 1 ? 's' : ''} · ${totalTracks} tracks
`;
section.appendChild(sectionHeader);
const grid = document.createElement('div');
grid.className = 'enhanced-album-grid';
albums.forEach(album => {
const wrapper = document.createElement('div');
wrapper.className = 'enhanced-album-wrapper';
wrapper.id = `enhanced-album-wrapper-${album.id}`;
const isExpanded = artistDetailPageState.expandedAlbums.has(album.id);
if (isExpanded) wrapper.classList.add('expanded');
wrapper.appendChild(renderAlbumRow(album, type));
const tracksPanel = document.createElement('div');
tracksPanel.className = 'enhanced-tracks-panel';
tracksPanel.id = `enhanced-tracks-panel-${album.id}`;
if (isExpanded) tracksPanel.classList.add('visible');
const inner = document.createElement('div');
inner.className = 'enhanced-tracks-panel-inner';
if (isExpanded) {
inner.dataset.rendered = 'true';
inner.appendChild(renderExpandedAlbumHeader(album));
inner.appendChild(renderAlbumMetaRow(album));
inner.appendChild(renderTrackTable(album));
}
tracksPanel.appendChild(inner);
wrapper.appendChild(tracksPanel);
grid.appendChild(wrapper);
});
section.appendChild(grid);
return section;
}
function renderAlbumRow(album, type) {
const row = document.createElement('div');
row.className = 'enhanced-album-row';
row.id = `enhanced-album-row-${album.id}`;
if (artistDetailPageState.expandedAlbums.has(album.id)) row.classList.add('expanded');
const trackCount = album.tracks ? album.tracks.length : 0;
const typeClass = (type || 'album').toLowerCase();
// Total duration for this album
let albumDurMs = 0;
(album.tracks || []).forEach(t => { albumDurMs += (t.duration || 0); });
const albumDur = formatDurationMs(albumDurMs);
// Format breakdown for this album
const fmts = {};
(album.tracks || []).forEach(t => {
const f = extractFormat(t.file_path);
if (f !== '-') fmts[f] = (fmts[f] || 0) + 1;
});
const primaryFormat = Object.keys(fmts).sort((a, b) => fmts[b] - fmts[a])[0] || '';
// Build with DOM for safety
const expandIcon = document.createElement('span');
expandIcon.className = 'enhanced-album-expand-icon';
expandIcon.innerHTML = '▶';
row.appendChild(expandIcon);
// Album art - larger, prominent
const artWrap = document.createElement('div');
artWrap.className = 'enhanced-album-art-wrap';
if (album.thumb_url) {
const img = document.createElement('img');
img.className = 'enhanced-album-thumb';
img.src = album.thumb_url;
img.alt = '';
img.loading = 'lazy';
img.onerror = function() {
const fallback = document.createElement('div');
fallback.className = 'enhanced-album-thumb-fallback';
fallback.innerHTML = '🎵';
this.replaceWith(fallback);
};
artWrap.appendChild(img);
} else {
const fallback = document.createElement('div');
fallback.className = 'enhanced-album-thumb-fallback';
fallback.innerHTML = '🎵';
artWrap.appendChild(fallback);
}
row.appendChild(artWrap);
// Info block (title + meta line)
const infoBlock = document.createElement('div');
infoBlock.className = 'enhanced-album-info-block';
const titleEl = document.createElement('span');
titleEl.className = 'enhanced-album-title';
titleEl.textContent = album.title || 'Unknown';
titleEl.title = album.title || '';
infoBlock.appendChild(titleEl);
const metaLine = document.createElement('span');
metaLine.className = 'enhanced-album-meta-line';
const metaParts = [];
if (album.year) metaParts.push(String(album.year));
metaParts.push(`${trackCount} track${trackCount !== 1 ? 's' : ''}`);
if (albumDur !== '-') metaParts.push(albumDur);
if (album.label) metaParts.push(album.label);
metaLine.textContent = metaParts.join(' \u00B7 ');
infoBlock.appendChild(metaLine);
row.appendChild(infoBlock);
// Type badge
const badge = document.createElement('span');
badge.className = `enhanced-album-type-badge ${typeClass}`;
badge.textContent = type;
row.appendChild(badge);
// Format badge inline
if (primaryFormat) {
const fmtBadge = document.createElement('span');
const fmtClass = primaryFormat === 'FLAC' ? 'flac' : (primaryFormat === 'MP3' ? 'mp3' : 'other');
fmtBadge.className = `enhanced-format-badge ${fmtClass}`;
fmtBadge.textContent = primaryFormat;
row.appendChild(fmtBadge);
}
row.addEventListener('click', () => toggleAlbumExpand(album.id));
return row;
}
function toggleAlbumExpand(albumId) {
const row = document.getElementById(`enhanced-album-row-${albumId}`);
const panel = document.getElementById(`enhanced-tracks-panel-${albumId}`);
const wrapper = document.getElementById(`enhanced-album-wrapper-${albumId}`);
if (!row || !panel) return;
const isExpanded = artistDetailPageState.expandedAlbums.has(albumId);
if (isExpanded) {
artistDetailPageState.expandedAlbums.delete(albumId);
row.classList.remove('expanded');
panel.classList.remove('visible');
if (wrapper) wrapper.classList.remove('expanded');
} else {
artistDetailPageState.expandedAlbums.add(albumId);
row.classList.add('expanded');
panel.classList.add('visible');
if (wrapper) wrapper.classList.add('expanded');
// Lazy render
const inner = panel.querySelector('.enhanced-tracks-panel-inner');
if (inner && !inner.dataset.rendered) {
const album = findEnhancedAlbum(albumId);
if (album) {
inner.innerHTML = '';
inner.appendChild(renderExpandedAlbumHeader(album));
inner.appendChild(renderAlbumMetaRow(album));
inner.appendChild(renderTrackTable(album));
inner.dataset.rendered = 'true';
}
}
}
}
function findEnhancedAlbum(albumId) {
// Use cached map for O(1) lookups instead of O(n) array scan
if (artistDetailPageState._albumMap) {
return artistDetailPageState._albumMap.get(String(albumId)) || null;
}
const data = artistDetailPageState.enhancedData;
if (!data || !data.albums) return null;
return data.albums.find(a => String(a.id) === String(albumId));
}
function _rebuildAlbumMap() {
const data = artistDetailPageState.enhancedData;
if (!data || !data.albums) { artistDetailPageState._albumMap = null; return; }
const map = new Map();
data.albums.forEach(a => map.set(String(a.id), a));
artistDetailPageState._albumMap = map;
}
function renderExpandedAlbumHeader(album) {
const header = document.createElement('div');
header.className = 'enhanced-expanded-header';
// Large album art
if (album.thumb_url) {
const img = document.createElement('img');
img.className = 'enhanced-expanded-art';
img.src = album.thumb_url;
img.alt = album.title || '';
img.onerror = function() { this.style.display = 'none'; };
header.appendChild(img);
}
const info = document.createElement('div');
info.className = 'enhanced-expanded-info';
const title = document.createElement('div');
title.className = 'enhanced-expanded-title';
title.textContent = album.title || 'Unknown';
info.appendChild(title);
const meta = document.createElement('div');
meta.className = 'enhanced-expanded-meta';
const details = [];
if (album.year) details.push(String(album.year));
const trackCount = album.tracks ? album.tracks.length : 0;
details.push(`${trackCount} track${trackCount !== 1 ? 's' : ''}`);
let durMs = 0;
(album.tracks || []).forEach(t => { durMs += (t.duration || 0); });
if (durMs > 0) details.push(formatDurationMs(durMs));
if (album.label) details.push(album.label);
if (album.record_type) details.push(album.record_type.toUpperCase());
meta.textContent = details.join(' \u00B7 ');
info.appendChild(meta);
// Genre tags
const genres = Array.isArray(album.genres) ? album.genres : [];
if (genres.length > 0) {
const genreRow = document.createElement('div');
genreRow.className = 'enhanced-expanded-genres';
genres.forEach(g => {
const tag = document.createElement('span');
tag.className = 'enhanced-genre-tag';
tag.textContent = g;
genreRow.appendChild(tag);
});
info.appendChild(genreRow);
}
// External ID badges (clickable links)
const ids = document.createElement('div');
ids.className = 'enhanced-expanded-ids';
const idFields = [
{ key: 'spotify_album_id', label: 'Spotify', svc: 'spotify' },
{ key: 'musicbrainz_release_id', label: 'MusicBrainz', svc: 'musicbrainz' },
{ key: 'deezer_id', label: 'Deezer', svc: 'deezer' },
{ key: 'audiodb_id', label: 'AudioDB', svc: 'audiodb' },
{ key: 'itunes_album_id', label: 'iTunes', svc: 'itunes' },
{ key: 'lastfm_url', label: 'Last.fm', svc: 'lastfm' },
];
idFields.forEach(f => {
if (album[f.key]) {
ids.appendChild(makeClickableBadge(f.svc, 'album', album[f.key], f.label));
}
});
if (ids.children.length > 0) info.appendChild(ids);
// Resolve artist name for enrichment calls
const artistName = artistDetailPageState.enhancedData ? artistDetailPageState.enhancedData.artist.name : '';
// Match status chips (clickable to rematch)
const statusRow = document.createElement('div');
statusRow.className = 'enhanced-match-status-row compact';
const statusSvcs = [
{ key: 'spotify_match_status', label: 'Spotify', attempted: 'spotify_last_attempted', svc: 'spotify' },
{ key: 'musicbrainz_match_status', label: 'MB', attempted: 'musicbrainz_last_attempted', svc: 'musicbrainz' },
{ key: 'deezer_match_status', label: 'Deezer', attempted: 'deezer_last_attempted', svc: 'deezer' },
{ key: 'audiodb_match_status', label: 'AudioDB', attempted: 'audiodb_last_attempted', svc: 'audiodb' },
{ key: 'itunes_match_status', label: 'iTunes', attempted: 'itunes_last_attempted', svc: 'itunes' },
{ key: 'lastfm_match_status', label: 'Last.fm', attempted: 'lastfm_last_attempted', svc: 'lastfm' },
];
statusSvcs.forEach(s => {
const status = album[s.key];
const attempted = album[s.attempted];
const chip = document.createElement('span');
chip.className = `enhanced-match-chip clickable ${status === 'matched' ? 'matched' : (status === 'not_found' ? 'not-found' : 'pending')}`;
chip.textContent = `${s.label}: ${status || 'โ'}`;
const tipParts = [];
if (attempted) tipParts.push(`Last: ${new Date(attempted).toLocaleString()}`);
tipParts.push('Click to rematch');
chip.title = tipParts.join(' ยท ');
chip.onclick = (e) => {
e.stopPropagation();
const aId = artistDetailPageState.enhancedData ? artistDetailPageState.enhancedData.artist.id : '';
openManualMatchModal('album', album.id, s.svc, album.title || '', aId);
};
statusRow.appendChild(chip);
});
info.appendChild(statusRow);
// Action buttons row
const enrichRow = document.createElement('div');
enrichRow.className = 'enhanced-expanded-actions';
if (isEnhancedAdmin()) {
const albumEnrichWrap = document.createElement('div');
albumEnrichWrap.className = 'enhanced-enrich-wrap';
const albumEnrichBtn = document.createElement('button');
albumEnrichBtn.className = 'enhanced-enrich-btn small';
albumEnrichBtn.textContent = 'Enrich Album โพ';
albumEnrichBtn.onclick = (e) => { e.stopPropagation(); albumEnrichMenu.classList.toggle('visible'); };
albumEnrichWrap.appendChild(albumEnrichBtn);
const albumEnrichMenu = document.createElement('div');
albumEnrichMenu.className = 'enhanced-enrich-menu';
[
{ id: 'spotify', label: 'Spotify', icon: '๐ข' },
{ id: 'musicbrainz', label: 'MusicBrainz', icon: '๐ ' },
{ id: 'deezer', label: 'Deezer', icon: '๐ฃ' },
{ id: 'audiodb', label: 'AudioDB', icon: '๐ต' },
{ id: 'itunes', label: 'iTunes', icon: '๐ด' },
{ id: 'lastfm', label: 'Last.fm', icon: 'โช' },
].forEach(svc => {
const item = document.createElement('div');
item.className = 'enhanced-enrich-menu-item';
item.textContent = `${svc.icon} ${svc.label}`;
item.onclick = (e) => {
e.stopPropagation();
albumEnrichMenu.classList.remove('visible');
const aId = artistDetailPageState.enhancedData ? artistDetailPageState.enhancedData.artist.id : '';
runEnrichment('album', album.id, svc.id, album.title || '', artistName, aId);
};
albumEnrichMenu.appendChild(item);
});
albumEnrichWrap.appendChild(albumEnrichMenu);
enrichRow.appendChild(albumEnrichWrap);
const writeTagsBtn = document.createElement('button');
writeTagsBtn.className = 'enhanced-write-tags-album-btn';
writeTagsBtn.innerHTML = '✎ Write All Tags';
writeTagsBtn.title = 'Write DB metadata to file tags for all tracks in this album';
writeTagsBtn.onclick = (e) => { e.stopPropagation(); writeAlbumTags(album.id); };
enrichRow.appendChild(writeTagsBtn);
const reorganizeBtn = document.createElement('button');
reorganizeBtn.className = 'enhanced-reorganize-album-btn';
reorganizeBtn.innerHTML = '📁 Reorganize';
reorganizeBtn.title = 'Reorganize album files using a custom path template';
reorganizeBtn.onclick = (e) => { e.stopPropagation(); showReorganizeModal(album.id); };
enrichRow.appendChild(reorganizeBtn);
const deleteAlbumBtn = document.createElement('button');
deleteAlbumBtn.className = 'enhanced-delete-album-btn';
deleteAlbumBtn.textContent = 'Delete Album';
deleteAlbumBtn.onclick = (e) => { e.stopPropagation(); deleteLibraryAlbum(album.id); };
enrichRow.appendChild(deleteAlbumBtn);
}
// Report Issue button (available to all users)
const reportBtn = document.createElement('button');
reportBtn.className = 'enhanced-report-issue-btn';
reportBtn.innerHTML = '⚑ Report Issue';
reportBtn.title = 'Report a problem with this album';
reportBtn.onclick = (e) => {
e.stopPropagation();
const aName = artistDetailPageState.enhancedData ? artistDetailPageState.enhancedData.artist.name : '';
showReportIssueModal('album', album.id, album.title || '', aName);
};
enrichRow.appendChild(reportBtn);
info.appendChild(enrichRow);
header.appendChild(info);
return header;
}
function renderAlbumMetaRow(album) {
const row = document.createElement('div');
row.className = 'enhanced-album-meta-row';
row.id = `enhanced-album-meta-${album.id}`;
const fields = [
{ key: 'title', label: 'Title', value: album.title || '' },
{ key: 'year', label: 'Year', value: album.year || '', type: 'number' },
{ key: 'genres', label: 'Genres', value: Array.isArray(album.genres) ? album.genres.join(', ') : (album.genres || '') },
{ key: 'label', label: 'Label', value: album.label || '' },
{ key: 'style', label: 'Style', value: album.style || '' },
{ key: 'mood', label: 'Mood', value: album.mood || '' },
{ key: 'record_type', label: 'Type', value: album.record_type || 'album' },
{ key: 'explicit', label: 'Explicit', value: album.explicit ? '1' : '0' },
];
const admin = isEnhancedAdmin();
fields.forEach(f => {
const fieldDiv = document.createElement('div');
fieldDiv.className = 'enhanced-album-meta-field';
const label = document.createElement('label');
label.className = 'enhanced-album-meta-label';
label.textContent = f.label;
fieldDiv.appendChild(label);
if (admin) {
const input = document.createElement('input');
input.className = 'enhanced-album-meta-input';
input.type = f.type || 'text';
input.dataset.albumId = album.id;
input.dataset.field = f.key;
input.value = String(f.value);
input.addEventListener('click', e => e.stopPropagation());
fieldDiv.appendChild(input);
} else {
const span = document.createElement('span');
span.className = 'enhanced-album-meta-value';
span.textContent = String(f.value) || 'โ';
fieldDiv.appendChild(span);
}
row.appendChild(fieldDiv);
});
if (admin) {
const saveDiv = document.createElement('div');
saveDiv.className = 'enhanced-album-meta-field';
const spacer = document.createElement('label');
spacer.className = 'enhanced-album-meta-label';
spacer.innerHTML = ' ';
saveDiv.appendChild(spacer);
const saveBtn = document.createElement('button');
saveBtn.className = 'enhanced-album-save-btn';
saveBtn.textContent = 'Save Album';
saveBtn.onclick = (e) => { e.stopPropagation(); saveAlbumMetadata(album.id); };
saveDiv.appendChild(saveBtn);
row.appendChild(saveDiv);
}
return row;
}
function _buildTrackRow(track, album, admin) {
const tr = document.createElement('tr');
tr.dataset.trackId = track.id;
tr.dataset.albumId = album.id;
if (artistDetailPageState.selectedTracks.has(String(track.id))) tr.classList.add('selected');
// Checkbox (admin only)
if (admin) {
const cbTd = document.createElement('td');
const cb = document.createElement('input');
cb.type = 'checkbox';
cb.className = 'enhanced-track-checkbox';
cb.checked = artistDetailPageState.selectedTracks.has(String(track.id));
cbTd.appendChild(cb);
tr.appendChild(cbTd);
}
// Play button
const playTd = document.createElement('td');
playTd.className = 'col-play';
const playBtn = document.createElement('button');
playBtn.className = 'enhanced-play-btn';
playBtn.innerHTML = '▶';
playBtn.title = track.file_path ? 'Play track' : 'No file available';
if (!track.file_path) playBtn.disabled = true;
playTd.appendChild(playBtn);
tr.appendChild(playTd);
// Track number
const numTd = document.createElement('td');
numTd.className = 'col-num' + (admin ? ' editable' : '');
numTd.textContent = track.track_number || '-';
tr.appendChild(numTd);
// Disc number
const discTd = document.createElement('td');
discTd.className = 'col-disc';
discTd.textContent = track.disc_number || '-';
tr.appendChild(discTd);
// Title
const titleTd = document.createElement('td');
titleTd.className = 'col-title' + (admin ? ' editable' : '');
titleTd.textContent = track.title || 'Unknown';
tr.appendChild(titleTd);
// Duration
const durTd = document.createElement('td');
durTd.className = 'col-duration';
durTd.textContent = formatDurationMs(track.duration);
tr.appendChild(durTd);
// Format
const fmtTd = document.createElement('td');
fmtTd.className = 'col-format';
const format = extractFormat(track.file_path);
const fmtSpan = document.createElement('span');
const fmtClass = format === 'FLAC' ? 'flac' : (format === 'MP3' ? 'mp3' : 'other');
fmtSpan.className = `enhanced-format-badge ${fmtClass}`;
fmtSpan.textContent = format;
fmtTd.appendChild(fmtSpan);
tr.appendChild(fmtTd);
// Bitrate
const brTd = document.createElement('td');
brTd.className = 'col-bitrate';
const brSpan = document.createElement('span');
const brClass = (track.bitrate || 0) >= 320 ? 'high' : ((track.bitrate || 0) >= 192 ? 'medium' : 'low');
brSpan.className = `enhanced-bitrate ${brClass}`;
brSpan.textContent = track.bitrate ? track.bitrate + ' kbps' : '-';
brTd.appendChild(brSpan);
tr.appendChild(brTd);
// BPM
const bpmTd = document.createElement('td');
bpmTd.className = 'col-bpm' + (admin ? ' editable' : '');
bpmTd.textContent = track.bpm || '-';
tr.appendChild(bpmTd);
// File path
const pathTd = document.createElement('td');
pathTd.className = 'col-path';
const filePath = track.file_path || '-';
const fileName = filePath !== '-' ? filePath.split(/[\\/]/).pop() : '-';
pathTd.textContent = fileName;
pathTd.title = filePath;
tr.appendChild(pathTd);
// Match status chips
const matchTd = document.createElement('td');
matchTd.className = 'col-match';
const matchCell = document.createElement('div');
matchCell.className = 'enhanced-track-match-cell';
const trackServices = [
{ svc: 'spotify', col: 'spotify_track_id', label: 'SP' },
{ svc: 'musicbrainz', col: 'musicbrainz_recording_id', label: 'MB' },
{ svc: 'deezer', col: 'deezer_id', label: 'Dz' },
{ svc: 'audiodb', col: 'audiodb_id', label: 'ADB' },
{ svc: 'itunes', col: 'itunes_track_id', label: 'iT' },
{ svc: 'lastfm', col: 'lastfm_url', label: 'LFM' },
{ svc: 'genius', col: 'genius_id', label: 'Gen' },
];
trackServices.forEach(s => {
const hasId = !!track[s.col];
const chip = document.createElement('span');
chip.className = 'enhanced-track-match-chip' + (hasId ? ' matched' : ' not-found');
chip.textContent = s.label;
chip.title = hasId ? `${s.svc}: ${track[s.col]}` : `${s.svc}: no match`;
chip.dataset.service = s.svc;
matchCell.appendChild(chip);
});
matchTd.appendChild(matchCell);
tr.appendChild(matchTd);
// Add to Queue button
const queueTd = document.createElement('td');
queueTd.className = 'col-queue';
if (track.file_path) {
const queueBtn = document.createElement('button');
queueBtn.className = 'enhanced-queue-btn';
queueBtn.innerHTML = '+';
queueBtn.title = 'Add to queue';
queueTd.appendChild(queueBtn);
}
tr.appendChild(queueTd);
if (admin) {
// Write Tags button (admin only)
const tagTd = document.createElement('td');
tagTd.className = 'col-writetag';
if (track.file_path) {
const tagBtn = document.createElement('button');
tagBtn.className = 'enhanced-write-tag-btn';
tagBtn.innerHTML = '✎';
tagBtn.title = 'Write tags to file';
tagTd.appendChild(tagBtn);
}
tr.appendChild(tagTd);
// Delete button (admin only)
const delTd = document.createElement('td');
delTd.className = 'col-delete';
const delBtn = document.createElement('button');
delBtn.className = 'enhanced-delete-btn';
delBtn.innerHTML = '✕';
delBtn.title = 'Delete track from library';
delTd.appendChild(delBtn);
tr.appendChild(delTd);
} else {
// Report Issue button per track (non-admin)
const reportTd = document.createElement('td');
reportTd.className = 'col-report';
const reportBtn = document.createElement('button');
reportBtn.className = 'enhanced-track-report-btn';
reportBtn.innerHTML = '⚑';
reportBtn.title = 'Report issue with this track';
reportTd.appendChild(reportBtn);
tr.appendChild(reportTd);
}
return tr;
}
function _getTrackDataFromRow(tr) {
const trackId = tr.dataset.trackId;
const albumId = tr.dataset.albumId;
const album = findEnhancedAlbum(albumId);
if (!album) return null;
const track = (album.tracks || []).find(t => String(t.id) === String(trackId));
return track ? { track, album, trackId, albumId } : null;
}
function _attachTableDelegation(table, album) {
// Single click handler for the entire table โ replaces 12-16 per-row handlers
const admin = isEnhancedAdmin();
table.addEventListener('click', (e) => {
const target = e.target;
const tr = target.closest('tr[data-track-id]');
// Header checkbox (select all)
if (target.closest('thead') && target.classList.contains('enhanced-track-checkbox')) {
toggleSelectAllTracks(album.id, target.checked);
return;
}
// Sort header click
const th = target.closest('th[data-sort-field]');
if (th) {
cancelInlineEdit();
const sortField = th.dataset.sortField;
const current = artistDetailPageState.enhancedTrackSort[album.id];
const ascending = current && current.field === sortField ? !current.ascending : true;
artistDetailPageState.enhancedTrackSort[album.id] = { field: sortField, ascending };
sortEnhancedTracks(album, sortField, ascending);
_rebuildTbody(table, album);
// Update header sort indicators
table.querySelectorAll('th[data-sort-field]').forEach(h => {
const sf = h.dataset.sortField;
const baseLabel = h.dataset.label || '';
const sort = artistDetailPageState.enhancedTrackSort[album.id];
h.textContent = sort && sort.field === sf ? baseLabel + (sort.ascending ? ' \u25B2' : ' \u25BC') : baseLabel;
});
return;
}
if (!tr) return;
const info = _getTrackDataFromRow(tr);
if (!info) return;
const { track, trackId } = info;
// Checkbox
if (target.classList.contains('enhanced-track-checkbox')) {
toggleTrackSelection(String(trackId));
return;
}
// Play button
if (target.closest('.enhanced-play-btn')) {
e.stopPropagation();
if (track.file_path) {
const artistName = artistDetailPageState.enhancedData ? artistDetailPageState.enhancedData.artist.name : '';
playLibraryTrack(track, album.title || '', artistName);
}
return;
}
// Inline editable cells (admin)
if (admin) {
const cell = target.closest('td.editable');
if (cell) {
e.stopPropagation();
if (cell.classList.contains('col-num')) {
startInlineEdit(cell, 'track', track.id, 'track_number', track.track_number || '');
} else if (cell.classList.contains('col-title')) {
startInlineEdit(cell, 'track', track.id, 'title', track.title || '');
} else if (cell.classList.contains('col-bpm')) {
startInlineEdit(cell, 'track', track.id, 'bpm', track.bpm || '');
}
return;
}
}
// Match chip click (admin โ open manual match modal)
if (admin) {
const chip = target.closest('.enhanced-track-match-chip');
if (chip) {
e.stopPropagation();
const svc = chip.dataset.service;
const aId = artistDetailPageState.enhancedData ? artistDetailPageState.enhancedData.artist.id : null;
openManualMatchModal('track', track.id, svc, track.title || '', aId);
return;
}
}
// Queue button
if (target.closest('.enhanced-queue-btn')) {
e.stopPropagation();
if (track.file_path) {
const artistName = artistDetailPageState.enhancedData ? artistDetailPageState.enhancedData.artist.name : '';
let albumArt = album.thumb_url || null;
if (!albumArt && artistDetailPageState.enhancedData) {
albumArt = artistDetailPageState.enhancedData.artist?.thumb_url;
}
addToQueue({
title: track.title || 'Unknown Track',
artist: artistName || 'Unknown Artist',
album: album.title || 'Unknown Album',
file_path: track.file_path,
filename: track.file_path,
is_library: true,
image_url: albumArt,
id: track.id,
artist_id: artistDetailPageState.enhancedData ? artistDetailPageState.enhancedData.artist.id : null,
album_id: album.id,
bitrate: track.bitrate,
sample_rate: track.sample_rate
});
}
return;
}
// Write tags button (admin)
if (target.closest('.enhanced-write-tag-btn')) {
e.stopPropagation();
showTagPreview(track.id);
return;
}
// Delete button (admin)
if (target.closest('.enhanced-delete-btn')) {
e.stopPropagation();
deleteLibraryTrack(track.id, album.id);
return;
}
// Report button (non-admin)
if (target.closest('.enhanced-track-report-btn')) {
e.stopPropagation();
const artistName = artistDetailPageState.enhancedData ? artistDetailPageState.enhancedData.artist.name : '';
showReportIssueModal('track', track.id, track.title || 'Unknown', artistName, album.title || '');
return;
}
});
}
function _rebuildTbody(table, album) {
// Replace only the tbody โ keeps thead and event delegation intact
const admin = isEnhancedAdmin();
const oldTbody = table.querySelector('tbody');
const newTbody = document.createElement('tbody');
(album.tracks || []).forEach(track => {
newTbody.appendChild(_buildTrackRow(track, album, admin));
});
if (oldTbody) table.replaceChild(newTbody, oldTbody);
else table.appendChild(newTbody);
}
function renderTrackTable(album) {
const wrapper = document.createElement('div');
const tracks = album.tracks || [];
// Re-apply stored sort order if any
const activeSort = artistDetailPageState.enhancedTrackSort[album.id];
if (activeSort) {
sortEnhancedTracks(album, activeSort.field, activeSort.ascending);
}
if (tracks.length === 0) {
wrapper.innerHTML = 'No tracks in database
';
return wrapper;
}
const table = document.createElement('table');
table.className = 'enhanced-track-table';
table.dataset.albumId = album.id;
const admin = isEnhancedAdmin();
// Clear stale selections for non-admin to prevent ghost state
if (!admin) {
artistDetailPageState.selectedTracks.clear();
}
// Header
const thead = document.createElement('thead');
const headRow = document.createElement('tr');
if (admin) {
const selectAllTh = document.createElement('th');
const selectAllCb = document.createElement('input');
selectAllCb.type = 'checkbox';
selectAllCb.className = 'enhanced-track-checkbox';
selectAllTh.appendChild(selectAllCb);
headRow.appendChild(selectAllTh);
}
const columns = [
{ label: '', cls: 'col-play' },
{ label: '#', cls: 'col-num', sortField: 'track_number' },
{ label: 'Disc', cls: 'col-disc', sortField: 'disc_number' },
{ label: 'Title', cls: 'col-title', sortField: 'title' },
{ label: 'Duration', cls: 'col-duration', sortField: 'duration' },
{ label: 'Format', cls: 'col-format', sortField: 'format' },
{ label: 'Bitrate', cls: 'col-bitrate', sortField: 'bitrate' },
{ label: 'BPM', cls: 'col-bpm', sortField: 'bpm' },
{ label: 'File', cls: 'col-path' },
{ label: 'Match', cls: 'col-match' },
{ label: '', cls: 'col-queue' },
...(admin ? [
{ label: '', cls: 'col-writetag' },
{ label: '', cls: 'col-delete' },
] : [
{ label: '', cls: 'col-report' },
]),
];
const currentSort = artistDetailPageState.enhancedTrackSort[album.id];
columns.forEach(col => {
const th = document.createElement('th');
th.className = col.cls;
if (col.sortField) {
let headerText = col.label;
if (currentSort && currentSort.field === col.sortField) {
headerText += currentSort.ascending ? ' \u25B2' : ' \u25BC';
}
th.textContent = headerText;
th.style.cursor = 'pointer';
th.dataset.sortField = col.sortField;
th.dataset.label = col.label;
} else {
th.textContent = col.label;
}
headRow.appendChild(th);
});
thead.appendChild(headRow);
table.appendChild(thead);
// Body
const tbody = document.createElement('tbody');
tracks.forEach(track => {
tbody.appendChild(_buildTrackRow(track, album, admin));
});
table.appendChild(tbody);
// Single delegated event listener for the whole table
_attachTableDelegation(table, album);
wrapper.appendChild(table);
return wrapper;
}
function sortEnhancedTracks(album, field, ascending) {
const tracks = album.tracks || [];
tracks.sort((a, b) => {
let valA, valB;
if (field === 'format') {
valA = extractFormat(a.file_path);
valB = extractFormat(b.file_path);
} else {
valA = a[field];
valB = b[field];
}
if (valA == null) return 1;
if (valB == null) return -1;
if (['track_number', 'disc_number', 'bpm', 'bitrate', 'duration'].includes(field)) {
return ascending ? (Number(valA) - Number(valB)) : (Number(valB) - Number(valA));
}
valA = String(valA).toLowerCase();
valB = String(valB).toLowerCase();
return ascending ? valA.localeCompare(valB) : valB.localeCompare(valA);
});
}
async function deleteLibraryTrack(trackId, albumId) {
cancelInlineEdit();
if (!await showConfirmDialog({ title: 'Delete Track', message: 'Delete this track from the library? (File on disk is not affected)', confirmText: 'Delete', destructive: true })) return;
try {
const response = await fetch(`/api/library/track/${trackId}`, { method: 'DELETE' });
const result = await response.json();
if (!result.success) throw new Error(result.error);
showToast('Track deleted from library', 'success');
if (artistDetailPageState.enhancedData) {
const albums = artistDetailPageState.enhancedData.albums || [];
const album = albums.find(a => a.id === albumId);
if (album) {
album.tracks = (album.tracks || []).filter(t => t.id !== trackId);
}
}
artistDetailPageState.selectedTracks.delete(String(trackId));
renderEnhancedView();
} catch (error) {
showToast(`Delete failed: ${error.message}`, 'error');
}
}
async function deleteLibraryAlbum(albumId) {
if (!await showConfirmDialog({ title: 'Delete Album', message: 'Delete this album and all its tracks from the library? (Files on disk are not affected)', confirmText: 'Delete', destructive: true })) return;
try {
const response = await fetch(`/api/library/album/${albumId}`, { method: 'DELETE' });
const result = await response.json();
if (!result.success) throw new Error(result.error);
showToast(`Album deleted (${result.tracks_deleted || 0} tracks removed)`, 'success');
if (artistDetailPageState.enhancedData) {
const album = (artistDetailPageState.enhancedData.albums || []).find(a => a.id === albumId);
if (album && album.tracks) {
album.tracks.forEach(t => artistDetailPageState.selectedTracks.delete(String(t.id)));
}
artistDetailPageState.enhancedData.albums = (artistDetailPageState.enhancedData.albums || []).filter(a => a.id !== albumId);
_rebuildAlbumMap();
}
artistDetailPageState.expandedAlbums.delete(albumId);
delete artistDetailPageState.enhancedTrackSort[albumId];
renderEnhancedView();
} catch (error) {
showToast(`Delete failed: ${error.message}`, 'error');
}
}
function extractFormat(filePath) {
if (!filePath) return '-';
const ext = filePath.split('.').pop().toLowerCase();
const formatMap = { mp3: 'MP3', flac: 'FLAC', m4a: 'AAC', ogg: 'OGG', opus: 'OPUS', wav: 'WAV', wma: 'WMA', aac: 'AAC' };
return formatMap[ext] || ext.toUpperCase();
}
function formatDurationMs(ms) {
if (!ms) return '-';
const totalSeconds = Math.floor(ms / 1000);
const minutes = Math.floor(totalSeconds / 60);
const seconds = totalSeconds % 60;
return `${minutes}:${seconds.toString().padStart(2, '0')}`;
}
function getServiceUrl(service, entityType, id) {
if (!id) return null;
const urls = {
spotify: {
artist: `https://open.spotify.com/artist/${id}`,
album: `https://open.spotify.com/album/${id}`,
track: `https://open.spotify.com/track/${id}`,
},
musicbrainz: {
artist: `https://musicbrainz.org/artist/${id}`,
album: `https://musicbrainz.org/release/${id}`,
track: `https://musicbrainz.org/recording/${id}`,
},
deezer: {
artist: `https://www.deezer.com/artist/${id}`,
album: `https://www.deezer.com/album/${id}`,
track: `https://www.deezer.com/track/${id}`,
},
audiodb: {
artist: `https://www.theaudiodb.com/artist/${id}`,
album: `https://www.theaudiodb.com/album/${id}`,
track: `https://www.theaudiodb.com/track/${id}`,
},
itunes: {
artist: `https://music.apple.com/artist/${id}`,
album: `https://music.apple.com/album/${id}`,
track: `https://music.apple.com/song/${id}`,
},
lastfm: {
artist: id, // lastfm_url is already a full URL
album: id,
track: id,
},
genius: {
artist: id, // genius_url is already a full URL
track: id, // genius_url on tracks is already a full URL
},
tidal: {
artist: `https://tidal.com/browse/artist/${id}`,
album: `https://tidal.com/browse/album/${id}`,
track: `https://tidal.com/browse/track/${id}`,
},
qobuz: {
artist: `https://www.qobuz.com/artist/${id}`,
album: `https://www.qobuz.com/album/${id}`,
track: `https://www.qobuz.com/track/${id}`,
},
};
return urls[service] && urls[service][entityType] || null;
}
function makeClickableBadge(service, entityType, id, label) {
const url = getServiceUrl(service, entityType, id);
if (url) {
const a = document.createElement('a');
a.className = `enhanced-id-badge ${service === 'musicbrainz' ? 'mb' : service}`;
a.href = url;
a.target = '_blank';
a.rel = 'noopener noreferrer';
a.textContent = label;
a.title = `${label}: ${id} (click to open)`;
a.onclick = (e) => e.stopPropagation();
return a;
}
const span = document.createElement('span');
span.className = `enhanced-id-badge ${service === 'musicbrainz' ? 'mb' : service}`;
span.textContent = label;
span.title = `${label}: ${id}`;
return span;
}
// ---- Inline Editing ----
function startInlineEdit(cell, type, id, field, currentValue) {
if (cell.querySelector('.enhanced-inline-input')) return;
cancelInlineEdit();
const isNumeric = ['track_number', 'bpm'].includes(field);
const originalContent = cell.innerHTML;
cell.dataset.originalContent = originalContent;
const input = document.createElement('input');
input.type = isNumeric ? 'number' : 'text';
input.className = 'enhanced-inline-input' + (isNumeric ? ' num' : '');
input.value = currentValue || '';
if (field === 'bpm') input.step = '0.1';
if (field === 'track_number') { input.min = '1'; input.step = '1'; }
cell.innerHTML = '';
cell.appendChild(input);
input.focus();
input.select();
artistDetailPageState.editingCell = { cell, type, id, field, originalContent };
input.addEventListener('click', e => e.stopPropagation());
input.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
e.preventDefault();
saveInlineEdit(type, id, field, input.value);
} else if (e.key === 'Escape') {
cancelInlineEdit();
}
e.stopPropagation();
});
input.addEventListener('blur', () => {
setTimeout(() => {
if (artistDetailPageState.editingCell && artistDetailPageState.editingCell.cell === cell) {
saveInlineEdit(type, id, field, input.value);
}
}, 150);
});
}
async function saveInlineEdit(type, id, field, newValue) {
const editInfo = artistDetailPageState.editingCell;
if (!editInfo) return;
artistDetailPageState.editingCell = null;
let parsedValue = newValue;
if (field === 'track_number') parsedValue = parseInt(newValue) || null;
else if (field === 'bpm') parsedValue = parseFloat(newValue) || null;
else if (field === 'explicit') parsedValue = parseInt(newValue) || 0;
const url = type === 'track' ? `/api/library/track/${id}` : `/api/library/album/${id}`;
try {
const response = await fetch(url, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ [field]: parsedValue })
});
const result = await response.json();
if (!result.success) throw new Error(result.error);
const displayValue = parsedValue !== null && parsedValue !== '' ? String(parsedValue) : '-';
editInfo.cell.textContent = displayValue;
updateLocalEnhancedData(type, id, field, parsedValue);
showToast(`Updated ${field}`, 'success');
} catch (error) {
console.error('Failed to save inline edit:', error);
editInfo.cell.innerHTML = editInfo.originalContent;
showToast(`Failed to update: ${error.message}`, 'error');
}
}
function cancelInlineEdit() {
const editInfo = artistDetailPageState.editingCell;
if (!editInfo) return;
editInfo.cell.innerHTML = editInfo.originalContent;
artistDetailPageState.editingCell = null;
}
function updateLocalEnhancedData(type, id, field, value) {
const data = artistDetailPageState.enhancedData;
if (!data) return;
if (type === 'track') {
for (const album of data.albums) {
const track = (album.tracks || []).find(t => String(t.id) === String(id));
if (track) { track[field] = value; break; }
}
} else if (type === 'album') {
const album = data.albums.find(a => String(a.id) === String(id));
if (album) album[field] = value;
} else if (type === 'artist') {
data.artist[field] = value;
}
}
// ---- Track Selection & Bulk Operations ----
function toggleTrackSelection(trackId) {
trackId = String(trackId);
if (artistDetailPageState.selectedTracks.has(trackId)) {
artistDetailPageState.selectedTracks.delete(trackId);
} else {
artistDetailPageState.selectedTracks.add(trackId);
}
const row = document.querySelector(`tr[data-track-id="${trackId}"]`);
if (row) row.classList.toggle('selected', artistDetailPageState.selectedTracks.has(trackId));
updateBulkBar();
}
function toggleSelectAllTracks(albumId, checked) {
const album = findEnhancedAlbum(albumId);
if (!album || !album.tracks) return;
// Batch update state
album.tracks.forEach(track => {
const tid = String(track.id);
if (checked) artistDetailPageState.selectedTracks.add(tid);
else artistDetailPageState.selectedTracks.delete(tid);
});
// Scoped DOM query โ only search within this album's panel, not entire document
const panel = document.getElementById(`enhanced-tracks-panel-${albumId}`);
if (panel) {
panel.querySelectorAll('tr[data-track-id]').forEach(row => {
row.classList.toggle('selected', checked);
const cb = row.querySelector('.enhanced-track-checkbox');
if (cb) cb.checked = checked;
});
}
updateBulkBar();
}
function clearTrackSelection() {
// Scoped batch clear โ query the container once instead of per-track
const container = document.getElementById('enhanced-view-container');
if (container) {
container.querySelectorAll('tr[data-track-id].selected').forEach(row => {
row.classList.remove('selected');
const cb = row.querySelector('.enhanced-track-checkbox');
if (cb) cb.checked = false;
});
container.querySelectorAll('.enhanced-track-table thead .enhanced-track-checkbox').forEach(cb => cb.checked = false);
}
artistDetailPageState.selectedTracks.clear();
updateBulkBar();
}
function updateBulkBar() {
const bar = document.getElementById('enhanced-bulk-bar');
const count = document.getElementById('enhanced-bulk-count');
if (!bar || !count) return;
if (!isEnhancedAdmin()) {
bar.classList.remove('visible');
return;
}
const n = artistDetailPageState.selectedTracks.size;
count.textContent = n;
bar.classList.toggle('visible', n > 0);
}
function showBulkEditModal() {
const overlay = document.getElementById('enhanced-bulk-edit-overlay');
const body = document.getElementById('enhanced-bulk-modal-body');
const title = document.getElementById('enhanced-bulk-modal-title');
if (!overlay || !body) return;
const count = artistDetailPageState.selectedTracks.size;
title.textContent = `Batch Edit ${count} Track${count !== 1 ? 's' : ''}`;
body.innerHTML = `
Track Number (leave blank to skip)
BPM (leave blank to skip)
Style (leave blank to skip)
Mood (leave blank to skip)
Explicit
-- No change --
No
Yes
`;
overlay.classList.remove('hidden');
}
function closeBulkEditModal() {
const overlay = document.getElementById('enhanced-bulk-edit-overlay');
if (overlay) overlay.classList.add('hidden');
}
async function executeBulkEdit() {
const trackIds = Array.from(artistDetailPageState.selectedTracks);
if (trackIds.length === 0) return;
const updates = {};
const trackNum = document.getElementById('bulk-edit-track-number');
const bpm = document.getElementById('bulk-edit-bpm');
const style = document.getElementById('bulk-edit-style');
const mood = document.getElementById('bulk-edit-mood');
const explicit = document.getElementById('bulk-edit-explicit');
if (trackNum && trackNum.value !== '') updates.track_number = parseInt(trackNum.value);
if (bpm && bpm.value !== '') updates.bpm = parseFloat(bpm.value);
if (style && style.value !== '') updates.style = style.value;
if (mood && mood.value !== '') updates.mood = mood.value;
if (explicit && explicit.value !== '') updates.explicit = parseInt(explicit.value);
if (Object.keys(updates).length === 0) {
showToast('No changes to apply', 'error');
return;
}
try {
const response = await fetch('/api/library/tracks/batch', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ track_ids: trackIds, updates })
});
const result = await response.json();
if (!result.success) throw new Error(result.error);
showToast(`Updated ${result.updated_count} tracks`, 'success');
closeBulkEditModal();
for (const [field, val] of Object.entries(updates)) {
trackIds.forEach(tid => updateLocalEnhancedData('track', tid, field, val));
}
reRenderExpandedPanels();
clearTrackSelection();
} catch (error) {
console.error('Bulk edit failed:', error);
showToast(`Bulk edit failed: ${error.message}`, 'error');
}
}
// ---- Save Artist / Album Metadata ----
async function saveArtistMetadata() {
const form = document.getElementById('enhanced-artist-meta-form');
if (!form) return;
const inputs = form.querySelectorAll('.enhanced-meta-field-input');
const updates = {};
const original = artistDetailPageState.enhancedData.artist;
inputs.forEach(input => {
const field = input.dataset.field;
if (!field) return;
let value = (input.tagName === 'TEXTAREA' ? input.value : input.value).trim();
let origVal = original[field];
if (field === 'genres') {
const newGenres = value ? value.split(',').map(g => g.trim()).filter(Boolean) : [];
const origGenres = Array.isArray(origVal) ? origVal : [];
if (JSON.stringify(newGenres) !== JSON.stringify(origGenres)) updates[field] = newGenres;
} else {
if ((value || '') !== (origVal || '')) updates[field] = value || null;
}
});
if (Object.keys(updates).length === 0) {
showToast('No changes to save', 'error');
return;
}
try {
const response = await fetch(`/api/library/artist/${original.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(updates)
});
const result = await response.json();
if (!result.success) throw new Error(result.error);
for (const [field, value] of Object.entries(updates)) {
artistDetailPageState.enhancedData.artist[field] = value;
}
// Update the display name in the header
if (updates.name) {
const nameEl = document.querySelector('.enhanced-artist-meta-name');
if (nameEl) nameEl.textContent = updates.name;
}
showToast(`Artist metadata saved (${(result.updated_fields || []).join(', ')})`, 'success');
} catch (error) {
console.error('Failed to save artist metadata:', error);
showToast(`Failed to save: ${error.message}`, 'error');
}
}
function revertArtistMetadata() {
const data = artistDetailPageState.enhancedData;
if (!data) return;
const panel = document.getElementById('enhanced-artist-meta');
if (!panel) return;
const parent = panel.parentNode;
const newPanel = renderArtistMetaPanel(data.artist);
parent.replaceChild(newPanel, panel);
showToast('Reverted to saved values', 'success');
}
async function saveAlbumMetadata(albumId) {
const metaRow = document.getElementById(`enhanced-album-meta-${albumId}`);
if (!metaRow) return;
const album = findEnhancedAlbum(albumId);
if (!album) return;
const inputs = metaRow.querySelectorAll('.enhanced-album-meta-input');
const updates = {};
inputs.forEach(input => {
const field = input.dataset.field;
if (!field) return;
let value = input.value.trim();
if (field === 'genres') {
const newGenres = value ? value.split(',').map(g => g.trim()).filter(Boolean) : [];
const origGenres = Array.isArray(album.genres) ? album.genres : [];
if (JSON.stringify(newGenres) !== JSON.stringify(origGenres)) updates[field] = newGenres;
} else if (field === 'year' || field === 'explicit' || field === 'track_count') {
const numVal = value !== '' ? parseInt(value) : null;
if (numVal !== (album[field] || null)) updates[field] = numVal;
} else {
if ((value || '') !== (album[field] || '')) updates[field] = value || null;
}
});
if (Object.keys(updates).length === 0) {
showToast('No album changes to save', 'error');
return;
}
try {
const response = await fetch(`/api/library/album/${albumId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(updates)
});
const result = await response.json();
if (!result.success) throw new Error(result.error);
for (const [field, value] of Object.entries(updates)) {
album[field] = value;
}
// Update album row display
const albumRow = document.getElementById(`enhanced-album-row-${albumId}`);
if (albumRow) {
if (updates.title) {
const titleEl = albumRow.querySelector('.enhanced-album-title');
if (titleEl) { titleEl.textContent = updates.title; titleEl.title = updates.title; }
}
if (updates.year !== undefined) {
const yearEl = albumRow.querySelector('.enhanced-album-year');
if (yearEl) yearEl.textContent = updates.year || '-';
}
}
showToast(`Album metadata saved (${(result.updated_fields || []).join(', ')})`, 'success');
} catch (error) {
console.error('Failed to save album metadata:', error);
showToast(`Failed to save: ${error.message}`, 'error');
}
}
function reRenderExpandedPanels() {
artistDetailPageState.expandedAlbums.forEach(albumId => {
const panel = document.getElementById(`enhanced-tracks-panel-${albumId}`);
if (!panel) return;
const inner = panel.querySelector('.enhanced-tracks-panel-inner');
if (!inner) return;
const album = findEnhancedAlbum(albumId);
if (album) {
inner.innerHTML = '';
inner.appendChild(renderExpandedAlbumHeader(album));
inner.appendChild(renderAlbumMetaRow(album));
inner.appendChild(renderTrackTable(album));
}
});
}
// ---- Manual Match Modal ----
function openManualMatchModal(entityType, entityId, service, defaultQuery, artistId) {
// Remove existing modal if any
const existing = document.getElementById('enhanced-manual-match-overlay');
if (existing) existing.remove();
const serviceLabels = {
spotify: 'Spotify', musicbrainz: 'MusicBrainz', deezer: 'Deezer',
audiodb: 'AudioDB', itunes: 'iTunes', lastfm: 'Last.fm', genius: 'Genius'
};
const overlay = document.createElement('div');
overlay.id = 'enhanced-manual-match-overlay';
overlay.className = 'modal-overlay';
overlay.onclick = (e) => { if (e.target === overlay) overlay.remove(); };
const modal = document.createElement('div');
modal.className = 'enhanced-manual-match-modal';
// Header
const header = document.createElement('div');
header.className = 'enhanced-bulk-modal-header';
const title = document.createElement('h3');
title.textContent = `Match ${entityType} on ${serviceLabels[service] || service}`;
header.appendChild(title);
const closeBtn = document.createElement('button');
closeBtn.className = 'enhanced-bulk-modal-close';
closeBtn.innerHTML = '×';
closeBtn.onclick = () => overlay.remove();
header.appendChild(closeBtn);
modal.appendChild(header);
// Search bar
const searchRow = document.createElement('div');
searchRow.className = 'enhanced-match-search-row';
const searchInput = document.createElement('input');
searchInput.type = 'text';
searchInput.className = 'enhanced-match-search-input';
searchInput.placeholder = `Search ${serviceLabels[service] || service}...`;
searchInput.value = defaultQuery;
searchRow.appendChild(searchInput);
const searchBtn = document.createElement('button');
searchBtn.className = 'enhanced-enrich-btn';
searchBtn.textContent = 'Search';
searchBtn.onclick = () => doManualMatchSearch(service, entityType, searchInput.value, resultsContainer, entityId, artistId);
searchRow.appendChild(searchBtn);
modal.appendChild(searchRow);
// Handle Enter key
searchInput.onkeydown = (e) => {
if (e.key === 'Enter') searchBtn.click();
};
// Results container
const resultsContainer = document.createElement('div');
resultsContainer.className = 'enhanced-match-results';
resultsContainer.innerHTML = 'Press Search or Enter to find matches
';
modal.appendChild(resultsContainer);
overlay.appendChild(modal);
document.body.appendChild(overlay);
// Auto-search on open
searchInput.focus();
searchBtn.click();
}
async function doManualMatchSearch(service, entityType, query, container, entityId, artistId) {
if (!query.trim()) {
container.innerHTML = 'Enter a search term
';
return;
}
container.innerHTML = 'Searching...
';
try {
const response = await fetch('/api/library/search-service', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ service, entity_type: entityType, query: query.trim() })
});
const data = await response.json();
if (!data.success) throw new Error(data.error);
const results = data.results || [];
container.innerHTML = '';
if (results.length === 0) {
container.innerHTML = 'No results found. Try a different search.
';
return;
}
results.forEach(result => {
const row = document.createElement('div');
row.className = 'enhanced-match-result-row';
if (result.image) {
const img = document.createElement('img');
img.className = 'enhanced-match-result-img';
img.src = result.image;
img.alt = '';
img.onerror = function() { this.style.display = 'none'; };
row.appendChild(img);
} else {
const placeholder = document.createElement('div');
placeholder.className = 'enhanced-match-result-img-placeholder';
placeholder.innerHTML = '🎵';
row.appendChild(placeholder);
}
const info = document.createElement('div');
info.className = 'enhanced-match-result-info';
const name = document.createElement('div');
name.className = 'enhanced-match-result-name';
name.textContent = result.name || 'Unknown';
info.appendChild(name);
if (result.extra) {
const extra = document.createElement('div');
extra.className = 'enhanced-match-result-extra';
extra.textContent = result.extra;
info.appendChild(extra);
}
const idLine = document.createElement('div');
idLine.className = 'enhanced-match-result-id';
idLine.textContent = `ID: ${result.id}`;
info.appendChild(idLine);
row.appendChild(info);
const matchBtn = document.createElement('button');
matchBtn.className = 'enhanced-meta-save-btn';
matchBtn.textContent = 'Match';
matchBtn.onclick = () => applyManualMatch(entityType, entityId, service, result.id, artistId);
row.appendChild(matchBtn);
container.appendChild(row);
});
} catch (error) {
container.innerHTML = `Error: ${escapeHtml(error.message)}
`;
}
}
async function applyManualMatch(entityType, entityId, service, serviceId, artistId) {
try {
showToast(`Matching ${entityType} to ${service}...`, 'info');
const response = await fetch('/api/library/manual-match', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
entity_type: entityType,
entity_id: entityId,
service: service,
service_id: serviceId,
artist_id: artistId
})
});
const result = await response.json();
if (!result.success) throw new Error(result.error);
showToast(`Manually matched to ${service} ID: ${serviceId}`, 'success');
// Close modal
const overlay = document.getElementById('enhanced-manual-match-overlay');
if (overlay) overlay.remove();
// Update view with fresh data
if (result.updated_data && result.updated_data.success) {
artistDetailPageState.enhancedData = result.updated_data;
_rebuildAlbumMap();
renderEnhancedView();
} else if (artistDetailPageState.currentArtistId) {
await loadEnhancedViewData(artistDetailPageState.currentArtistId);
}
} catch (error) {
showToast(`Match failed: ${error.message}`, 'error');
}
}
// ---- Enrichment ----
let _enrichmentInFlight = false;
async function runEnrichment(entityType, entityId, service, name, artistName, artistId) {
if (_enrichmentInFlight) {
showToast('An enrichment is already in progress', 'error');
return;
}
_enrichmentInFlight = true;
// Add loading class to all match chips for this service
const chipPrefixes = {
'spotify': ['spotify', 'sp'],
'musicbrainz': ['musicbrainz', 'mb'],
'deezer': ['deezer', 'dz'],
'audiodb': ['audiodb', 'adb'],
'itunes': ['itunes', 'it'],
'lastfm': ['last.fm', 'lfm'],
'genius': ['genius', 'gen'],
};
const prefixes = chipPrefixes[service] || [service];
document.querySelectorAll('.enhanced-match-chip').forEach(chip => {
const chipText = chip.textContent.toLowerCase();
if (prefixes.some(p => chipText.startsWith(p))) {
chip.classList.add('loading');
}
});
showToast(`Enriching ${entityType} from ${service}...`, 'info');
try {
const response = await fetch('/api/library/enrich', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
entity_type: entityType,
entity_id: entityId,
service: service,
name: name,
artist_name: artistName,
artist_id: artistId
})
});
const result = await response.json();
if (response.status === 429) {
showToast(result.error || 'Another enrichment is in progress', 'error');
return;
}
if (!result.success) {
throw new Error(result.error || 'Enrichment failed');
}
// Show per-service results
const results = result.results || {};
const successes = Object.entries(results).filter(([, r]) => r.success).map(([s]) => s);
const failures = Object.entries(results).filter(([, r]) => !r.success).map(([s, r]) => `${s}: ${r.error}`);
if (successes.length > 0) {
showToast(`Enriched from: ${successes.join(', ')}`, 'success');
}
if (failures.length > 0) {
showToast(`Failed: ${failures.join('; ')}`, 'error');
}
// Update local data with fresh response and re-render (preserves expanded state)
if (result.updated_data && result.updated_data.success) {
artistDetailPageState.enhancedData = result.updated_data;
_rebuildAlbumMap();
renderEnhancedView();
} else if (artistDetailPageState.currentArtistId) {
await loadEnhancedViewData(artistDetailPageState.currentArtistId);
}
} catch (error) {
console.error('Enrichment error:', error);
showToast(`Enrichment error: ${error.message}`, 'error');
} finally {
_enrichmentInFlight = false;
document.querySelectorAll('.enhanced-match-chip.loading').forEach(c => c.classList.remove('loading'));
}
}
// Close enrich dropdowns when clicking outside (early bail when enhanced view isn't active)
document.addEventListener('click', (e) => {
if (!artistDetailPageState.enhancedView) return;
if (!e.target.closest('.enhanced-enrich-wrap')) {
document.querySelectorAll('.enhanced-enrich-menu.visible').forEach(m => m.classList.remove('visible'));
}
});
// ---- Write Tags to File ----
let _tagPreviewTrackId = null;
let _tagPreviewServerType = null;
async function showTagPreview(trackId) {
_tagPreviewTrackId = trackId;
_tagPreviewServerType = null;
const overlay = document.getElementById('tag-preview-overlay');
const body = document.getElementById('tag-preview-body');
const title = document.getElementById('tag-preview-title');
if (!overlay || !body) return;
title.textContent = 'Write Tags to File';
body.innerHTML = 'Loading tag comparison...
';
overlay.classList.remove('hidden');
// Hide sync checkbox until we know server type
const syncLabel = document.getElementById('tag-preview-sync-label');
if (syncLabel) syncLabel.classList.add('hidden');
try {
const response = await fetch(`/api/library/track/${trackId}/tag-preview`);
const result = await response.json();
if (!result.success) {
body.innerHTML = `${escapeHtml(result.error)}
`;
return;
}
const diff = result.diff || [];
const hasChanges = result.has_changes;
// Show server sync checkbox if a server is connected (not navidrome โ it auto-detects)
_tagPreviewServerType = result.server_type || null;
if (syncLabel && _tagPreviewServerType && _tagPreviewServerType !== 'navidrome') {
const syncText = document.getElementById('tag-preview-sync-text');
if (syncText) syncText.textContent = `Sync to ${_tagPreviewServerType === 'plex' ? 'Plex' : 'Jellyfin'}`;
syncLabel.classList.remove('hidden');
}
let html = '';
html += 'Field Current File Tag DB Value ';
html += ' ';
diff.forEach(d => {
const rowClass = d.changed ? 'tag-diff-changed' : 'tag-diff-same';
const arrow = d.changed ? '→ ' : '✓ ';
html += ``;
html += `${d.field} `;
html += `${escapeHtml(d.file_value) || 'empty '} `;
html += `${arrow} `;
html += `${escapeHtml(d.db_value) || 'empty '} `;
html += ' ';
});
html += '
';
if (!hasChanges) {
html += 'File tags already match DB metadata
';
}
body.innerHTML = html;
const writeBtn = document.getElementById('tag-preview-write-btn');
if (writeBtn) {
writeBtn.disabled = !hasChanges && !document.getElementById('tag-preview-embed-cover')?.checked;
}
} catch (error) {
body.innerHTML = `Failed to load preview: ${escapeHtml(error.message)}
`;
}
}
function closeTagPreviewModal() {
const overlay = document.getElementById('tag-preview-overlay');
if (overlay) overlay.classList.add('hidden');
_tagPreviewTrackId = null;
}
async function executeWriteTags() {
if (!_tagPreviewTrackId) return;
const writeBtn = document.getElementById('tag-preview-write-btn');
if (writeBtn) {
writeBtn.disabled = true;
writeBtn.textContent = 'Writing...';
}
const embedCover = document.getElementById('tag-preview-embed-cover')?.checked ?? true;
const syncToServer = document.getElementById('tag-preview-sync-server')?.checked && _tagPreviewServerType && _tagPreviewServerType !== 'navidrome';
try {
const response = await fetch(`/api/library/track/${_tagPreviewTrackId}/write-tags`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ embed_cover: embedCover, sync_to_server: syncToServer })
});
const result = await response.json();
if (!result.success) throw new Error(result.error);
const fieldCount = (result.written_fields || []).length;
let msg = `Tags written successfully (${fieldCount} fields)`;
if (result.server_sync) {
const ss = result.server_sync;
if (ss.synced > 0) msg += ` โ synced to ${_tagPreviewServerType === 'plex' ? 'Plex' : 'Jellyfin'}`;
else if (ss.failed > 0) msg += ` โ server sync failed`;
}
showToast(msg, 'success');
closeTagPreviewModal();
} catch (error) {
showToast(`Failed to write tags: ${error.message}`, 'error');
} finally {
if (writeBtn) {
writeBtn.disabled = false;
writeBtn.textContent = 'Write Tags';
}
}
}
async function writeAlbumTags(albumId) {
const album = findEnhancedAlbum(albumId);
if (!album) return;
const tracks = (album.tracks || []).filter(t => t.file_path);
if (tracks.length === 0) {
showToast('No tracks with files in this album', 'error');
return;
}
await showBatchTagPreview(tracks.map(t => t.id), album.title);
}
async function batchWriteTagsSelected() {
const trackIds = Array.from(artistDetailPageState.selectedTracks);
if (trackIds.length === 0) return;
await showBatchTagPreview(trackIds, null);
}
async function showBatchTagPreview(trackIds, albumTitle) {
const overlay = document.getElementById('batch-tag-preview-overlay');
const body = document.getElementById('batch-tag-preview-body');
const titleEl = document.getElementById('batch-tag-preview-title');
const summary = document.getElementById('batch-tag-preview-summary');
const writeBtn = document.getElementById('batch-tag-preview-write-btn');
if (!overlay || !body) return;
titleEl.textContent = albumTitle ? `Write Tags โ ${albumTitle}` : `Write Tags โ ${trackIds.length} Tracks`;
body.innerHTML = 'Loading tag previews...
';
summary.innerHTML = '';
writeBtn.disabled = true;
overlay.classList.remove('hidden');
// Hide sync checkbox until we know server type
const syncLabel = document.getElementById('batch-tag-preview-sync-label');
if (syncLabel) syncLabel.classList.add('hidden');
try {
const response = await fetch('/api/library/tracks/tag-preview-batch', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ track_ids: trackIds })
});
const result = await response.json();
if (!result.success) {
body.innerHTML = `${escapeHtml(result.error)}
`;
return;
}
const tracks = result.tracks || [];
const serverType = result.server_type || null;
// Show sync checkbox if server connected
if (syncLabel && serverType && serverType !== 'navidrome') {
const syncText = document.getElementById('batch-tag-preview-sync-text');
if (syncText) syncText.textContent = `Sync to ${serverType === 'plex' ? 'Plex' : 'Jellyfin'}`;
syncLabel.classList.remove('hidden');
}
// Categorize tracks
const withChanges = tracks.filter(t => t.has_changes);
const noChanges = tracks.filter(t => !t.error && !t.has_changes);
const errors = tracks.filter(t => t.error);
// Summary bar
let summaryHtml = '';
if (withChanges.length > 0) summaryHtml += `${withChanges.length} with changes `;
if (noChanges.length > 0) summaryHtml += `${noChanges.length} unchanged `;
if (errors.length > 0) summaryHtml += `${errors.length} unavailable `;
summaryHtml += '
';
summary.innerHTML = summaryHtml;
// Build track accordion
let html = '';
// Tracks with changes (expanded by default)
withChanges.forEach(track => {
html += _renderBatchTrackDiff(track, true);
});
// Errors
errors.forEach(track => {
html += ``;
html += `
`;
});
// Unchanged tracks (collapsed)
if (noChanges.length > 0) {
html += ``;
html += ``;
html += `
`;
noChanges.forEach(track => {
html += `
`;
html += `${track.track_number || 'โ'} `;
html += `${escapeHtml(track.title)} `;
html += `โ Tags match `;
html += `
`;
});
html += `
`;
}
if (withChanges.length === 0 && errors.length === 0) {
html += 'All file tags already match DB metadata
';
}
body.innerHTML = html;
// Store state for write action
overlay._batchTrackIds = trackIds;
overlay._batchServerType = serverType;
writeBtn.disabled = withChanges.length === 0;
} catch (error) {
body.innerHTML = `Failed to load previews: ${escapeHtml(error.message)}
`;
}
}
function _renderBatchTrackDiff(track, expanded) {
let html = ``;
html += ``;
html += `
`;
html += '
';
html += 'Field Current File New Value ';
html += ' ';
(track.diff || []).forEach(d => {
if (!d.changed) return; // Only show changed fields in batch view
html += ``;
html += `${d.field} `;
html += `${escapeHtml(d.file_value) || 'empty '} `;
html += `→ `;
html += `${escapeHtml(d.db_value) || 'empty '} `;
html += ' ';
});
html += '
';
return html;
}
function closeBatchTagPreviewModal() {
const overlay = document.getElementById('batch-tag-preview-overlay');
if (overlay) {
overlay.classList.add('hidden');
overlay._batchTrackIds = null;
overlay._batchServerType = null;
}
}
async function executeBatchWriteTags() {
const overlay = document.getElementById('batch-tag-preview-overlay');
const trackIds = overlay?._batchTrackIds;
if (!trackIds || trackIds.length === 0) return;
const writeBtn = document.getElementById('batch-tag-preview-write-btn');
if (writeBtn) {
writeBtn.disabled = true;
writeBtn.textContent = 'Writing...';
}
const embedCover = document.getElementById('batch-tag-preview-embed-cover')?.checked ?? true;
const serverType = overlay._batchServerType;
const syncToServer = document.getElementById('batch-tag-preview-sync-server')?.checked && serverType && serverType !== 'navidrome';
closeBatchTagPreviewModal();
await _startBatchWriteTags(trackIds, embedCover, syncToServer);
if (writeBtn) {
writeBtn.disabled = false;
writeBtn.textContent = 'Write Tags';
}
}
async function _startBatchWriteTags(trackIds, embedCover, syncToServer = false) {
try {
const response = await fetch('/api/library/tracks/write-tags-batch', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ track_ids: trackIds, embed_cover: embedCover, sync_to_server: syncToServer })
});
const result = await response.json();
if (!result.success) throw new Error(result.error);
showToast(`Writing tags for ${trackIds.length} tracks...`, 'info');
_pollBatchWriteTagsStatus();
} catch (error) {
showToast(`Failed to start tag write: ${error.message}`, 'error');
}
}
let _batchWriteTagsPollTimer = null;
function _pollBatchWriteTagsStatus() {
if (_batchWriteTagsPollTimer) clearTimeout(_batchWriteTagsPollTimer);
async function poll() {
try {
const response = await fetch('/api/library/tracks/write-tags-batch/status');
const state = await response.json();
if (state.status === 'running') {
if (state.sync_phase === 'syncing') {
const serverName = state.sync_server === 'plex' ? 'Plex' : state.sync_server === 'jellyfin' ? 'Jellyfin' : state.sync_server;
showToast(`Syncing to ${serverName}...`, 'info');
} else {
const pct = state.total > 0 ? Math.round(state.processed / state.total * 100) : 0;
showToast(`Writing tags: ${state.processed}/${state.total} (${pct}%) โ ${state.current_track}`, 'info');
}
_batchWriteTagsPollTimer = setTimeout(poll, 1000);
} else if (state.status === 'done') {
let msg = `Tags written: ${state.written} succeeded, ${state.failed} failed`;
if (state.sync_phase === 'done') {
const serverName = state.sync_server === 'plex' ? 'Plex' : state.sync_server === 'jellyfin' ? 'Jellyfin' : state.sync_server;
if (state.sync_synced > 0 && state.sync_failed === 0) {
msg += ` โ synced to ${serverName}`;
} else if (state.sync_failed > 0) {
msg += ` โ ${serverName} sync: ${state.sync_synced} synced, ${state.sync_failed} failed`;
}
}
// Surface the first error reason so users can diagnose (e.g. "File not found")
if (state.failed > 0 && state.errors && state.errors.length > 0) {
const firstErr = state.errors[0].error || 'Unknown error';
msg += ` (${firstErr})`;
}
showToast(msg, state.failed > 0 || state.sync_failed > 0 ? 'warning' : 'success');
_batchWriteTagsPollTimer = null;
}
} catch (error) {
console.error('Poll write-tags status failed:', error);
_batchWriteTagsPollTimer = null;
}
}
_batchWriteTagsPollTimer = setTimeout(poll, 800);
}
// โโ Reorganize Album Files โโ
let _reorganizeAlbumId = null;
let _reorganizePollTimer = null;
async function showReorganizeModal(albumId) {
_reorganizeAlbumId = albumId;
const overlay = document.getElementById('reorganize-overlay');
const body = document.getElementById('reorganize-modal-body');
const title = document.getElementById('reorganize-modal-title');
const applyBtn = document.getElementById('reorganize-apply-btn');
if (!overlay || !body) return;
// Find album data from enhanced view state
let albumData = null;
let artistName = '';
if (artistDetailPageState.enhancedData) {
artistName = artistDetailPageState.enhancedData.artist.name || '';
const allAlbums = artistDetailPageState.enhancedData.albums || [];
albumData = allAlbums.find(a => String(a.id) === String(albumId));
}
title.textContent = `Reorganize: ${albumData ? albumData.title : 'Album'}`;
if (applyBtn) applyBtn.disabled = true;
// Build modal content
const variables = [
{ var: '$artist', desc: 'Track artist', example: artistName || 'Artist' },
{ var: '$albumartist', desc: 'Album artist', example: artistName || 'Album Artist' },
{ var: '$artistletter', desc: 'First letter of artist', example: (artistName || 'A')[0].toUpperCase() },
{ var: '$album', desc: 'Album title', example: albumData ? albumData.title : 'Album' },
{ var: '$albumtype', desc: 'Album/EP/Single', example: 'Album' },
{ var: '$title', desc: 'Track title', example: 'Track Name' },
{ var: '$track', desc: 'Track number (zero-padded)', example: '01' },
{ var: '$disc', desc: 'Disc number (filename only)', example: '01' },
{ var: '$year', desc: 'Release year', example: albumData && albumData.year ? String(albumData.year) : '2024' },
{ var: '$quality', desc: 'Audio quality (filename only)', example: 'FLAC 16bit/44kHz' },
];
let html = '';
// Template input
html += '
';
html += '
Path Template ';
html += '
Use / to separate folders. The last segment becomes the filename.
';
html += '
';
html += '
';
// Variables reference
html += '
';
html += '
Available Variables ';
html += '
';
variables.forEach(v => {
html += `
`;
html += `${v.var}${v.desc} `;
html += '
';
});
html += '
';
// Preview area
html += '
';
html += '';
html += '
';
html += '
Click "Generate Preview" to see how files will be reorganized.
';
html += '
';
html += '
';
body.innerHTML = html;
overlay.classList.remove('hidden');
// Wire up live preview on enter key
setTimeout(() => {
const input = document.getElementById('reorganize-template-input');
if (input) {
input.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
e.preventDefault();
loadReorganizePreview();
}
});
input.focus();
}
}, 50);
}
function insertReorganizeVar(varName) {
const input = document.getElementById('reorganize-template-input');
if (!input) return;
const start = input.selectionStart;
const end = input.selectionEnd;
const val = input.value;
input.value = val.substring(0, start) + varName + val.substring(end);
input.focus();
const newPos = start + varName.length;
input.setSelectionRange(newPos, newPos);
}
function closeReorganizeModal() {
const overlay = document.getElementById('reorganize-overlay');
if (overlay) overlay.classList.add('hidden');
_reorganizeAlbumId = null;
}
async function loadReorganizePreview() {
const template = document.getElementById('reorganize-template-input')?.value?.trim();
const previewBody = document.getElementById('reorganize-preview-body');
const applyBtn = document.getElementById('reorganize-apply-btn');
if (!template || !previewBody || !_reorganizeAlbumId) return;
if (applyBtn) applyBtn.disabled = true;
previewBody.innerHTML = 'Loading preview...
';
try {
const response = await fetch(`/api/library/album/${_reorganizeAlbumId}/reorganize/preview`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ template })
});
const result = await response.json();
if (!result.success) {
previewBody.innerHTML = `${escapeHtml(result.error || 'Preview failed')}
`;
return;
}
const tracks = result.tracks || [];
if (tracks.length === 0) {
previewBody.innerHTML = 'No tracks found.
';
return;
}
let hasChanges = false;
let hasCollisions = false;
let html = '';
html += '# Title Current Path New Path ';
html += ' ';
tracks.forEach(t => {
const unchanged = t.unchanged;
const noFile = !t.file_exists;
const collision = t.collision;
if (!unchanged && t.file_exists) hasChanges = true;
if (collision) hasCollisions = true;
const rowClass = collision ? 'reorganize-row-collision' : noFile ? 'reorganize-row-missing' : unchanged ? 'reorganize-row-unchanged' : 'reorganize-row-changed';
html += ``;
html += `${t.track_number || ''} `;
html += `${escapeHtml(t.title)} `;
html += `${noFile ? 'File not found ' : escapeHtml(t.current_path)} `;
html += `${collision ? '!!' : unchanged ? '=' : noFile ? '' : 'โ'} `;
html += `${noFile ? '' : escapeHtml(t.new_path)}${collision ? ' (collision) ' : ''} `;
html += ' ';
});
html += '
';
const changedCount = tracks.filter(t => !t.unchanged && t.file_exists && !t.collision).length;
const skippedCount = tracks.filter(t => t.unchanged).length;
const missingCount = tracks.filter(t => !t.file_exists).length;
const collisionCount = tracks.filter(t => t.collision).length;
let summary = ``;
if (changedCount > 0) summary += `${changedCount} will move `;
if (skippedCount > 0) summary += `${skippedCount} unchanged `;
if (missingCount > 0) summary += `${missingCount} missing `;
if (collisionCount > 0) summary += `${collisionCount} collision${collisionCount !== 1 ? 's' : ''} โ add $track or $disc to fix `;
summary += '
';
previewBody.innerHTML = summary + html;
// Block apply if collisions exist
if (applyBtn) applyBtn.disabled = !hasChanges || hasCollisions;
} catch (error) {
previewBody.innerHTML = `Error: ${escapeHtml(error.message)}
`;
}
}
async function executeReorganize() {
const template = document.getElementById('reorganize-template-input')?.value?.trim();
if (!template || !_reorganizeAlbumId) return;
const applyBtn = document.getElementById('reorganize-apply-btn');
if (applyBtn) {
applyBtn.disabled = true;
applyBtn.textContent = 'Reorganizing...';
}
try {
const response = await fetch(`/api/library/album/${_reorganizeAlbumId}/reorganize`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ template })
});
const result = await response.json();
if (!result.success) throw new Error(result.error);
closeReorganizeModal();
showToast(`Reorganizing ${result.total} tracks...`, 'info');
_pollReorganizeStatus();
} catch (error) {
showToast(`Reorganize failed: ${error.message}`, 'error');
if (applyBtn) {
applyBtn.disabled = false;
applyBtn.textContent = 'Apply';
}
}
}
function _pollReorganizeStatus() {
if (_reorganizePollTimer) clearTimeout(_reorganizePollTimer);
async function poll() {
try {
const response = await fetch('/api/library/album/reorganize/status');
const state = await response.json();
if (state.status === 'running') {
const pct = state.total > 0 ? Math.round(state.processed / state.total * 100) : 0;
showToast(`Reorganizing: ${state.processed}/${state.total} (${pct}%) โ ${state.current_track}`, 'info');
_reorganizePollTimer = setTimeout(poll, 800);
} else if (state.status === 'done') {
let msg = `Reorganized: ${state.moved} moved`;
if (state.skipped > 0) msg += `, ${state.skipped} skipped`;
if (state.failed > 0) msg += `, ${state.failed} failed`;
if (state.failed > 0 && state.errors && state.errors.length > 0) {
msg += ` (${state.errors[0].error})`;
}
showToast(msg, state.failed > 0 ? 'warning' : 'success');
_reorganizePollTimer = null;
// Refresh the enhanced view to show updated paths
if (artistDetailPageState.currentArtistId && artistDetailPageState.enhancedView) {
loadEnhancedViewData(artistDetailPageState.currentArtistId);
}
}
} catch (error) {
console.error('Poll reorganize status failed:', error);
_reorganizePollTimer = null;
}
}
_reorganizePollTimer = setTimeout(poll, 600);
}
async function playLibraryTrack(track, albumTitle, artistName) {
if (!track.file_path) {
showToast('No file available for this track', 'error');
return;
}
try {
// Stop any current playback first
if (audioPlayer && !audioPlayer.paused) {
audioPlayer.pause();
}
// Get album art from enhanced data if available
let albumArt = null;
if (artistDetailPageState.enhancedData) {
const albums = artistDetailPageState.enhancedData.albums || [];
for (const a of albums) {
if ((a.tracks || []).some(t => t.id === track.id)) {
albumArt = a.thumb_url;
break;
}
}
if (!albumArt) albumArt = artistDetailPageState.enhancedData.artist?.thumb_url;
}
// Set track info in the media player UI
setTrackInfo({
title: track.title || 'Unknown Track',
artist: artistName || 'Unknown Artist',
album: albumTitle || 'Unknown Album',
filename: track.file_path,
is_library: true,
image_url: albumArt,
id: track.id,
artist_id: track.artist_id,
album_id: track.album_id,
bitrate: track.bitrate,
sample_rate: track.sample_rate
});
// Show loading state
showLoadingAnimation();
const loadingText = document.querySelector('.loading-text');
if (loadingText) {
loadingText.textContent = 'Loading library track...';
}
// POST to library play endpoint
const response = await fetch('/api/library/play', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
file_path: track.file_path,
title: track.title || '',
artist: artistName || '',
album: albumTitle || ''
})
});
const result = await response.json();
if (!result.success) {
throw new Error(result.error || 'Failed to start library playback');
}
// Re-apply repeat-one loop property
if (audioPlayer) audioPlayer.loop = (npRepeatMode === 'one');
// Stream state is already "ready" โ start audio playback directly
await startAudioPlayback();
} catch (error) {
console.error('Library playback error:', error);
showToast(`Playback error: ${error.message}`, 'error');
hideLoadingAnimation();
clearTrack();
}
}
// ==================== End Enhanced Library Management View ====================
// UI state management functions
function showArtistDetailLoading(show) {
const loadingElement = document.getElementById("artist-detail-loading");
if (loadingElement) {
if (show) {
loadingElement.classList.remove("hidden");
} else {
loadingElement.classList.add("hidden");
}
}
}
function showArtistDetailError(show, message = "") {
const errorElement = document.getElementById("artist-detail-error");
const errorMessageElement = document.getElementById("artist-detail-error-message");
if (errorElement) {
if (show) {
errorElement.classList.remove("hidden");
if (errorMessageElement && message) {
errorMessageElement.textContent = message;
}
} else {
errorElement.classList.add("hidden");
}
}
}
function showArtistDetailMain(show) {
const mainElement = document.getElementById("artist-detail-main");
if (mainElement) {
if (show) {
mainElement.classList.remove("hidden");
} else {
mainElement.classList.add("hidden");
}
}
}
function showArtistDetailHero(show) {
const heroElement = document.getElementById("artist-hero-section");
if (heroElement) {
if (show) {
heroElement.classList.remove("hidden");
} else {
heroElement.classList.add("hidden");
}
}
}
/**
* Initialize the library page watchlist button
*/
async function initializeLibraryWatchlistButton(artistId, artistName) {
const button = document.getElementById('library-artist-watchlist-btn');
if (!button) return;
console.log(`๐ง Initializing library watchlist button for: ${artistName} (${artistId})`);
// Reset button state
button.disabled = false;
button.classList.remove('watching');
// Set up click handler
button.onclick = (e) => toggleLibraryWatchlist(e, artistId, artistName);
// Check and update current status
await updateLibraryWatchlistButtonStatus(artistId);
}
/**
* Toggle watchlist status for library page
*/
async function toggleLibraryWatchlist(event, artistId, artistName) {
event.preventDefault();
const button = document.getElementById('library-artist-watchlist-btn');
const icon = button.querySelector('.watchlist-icon');
const text = button.querySelector('.watchlist-text');
// Show loading state
const originalText = text.textContent;
text.textContent = 'Loading...';
button.disabled = true;
try {
// Check current status
const checkResponse = await fetch('/api/watchlist/check', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ artist_id: artistId })
});
const checkData = await checkResponse.json();
if (!checkData.success) {
throw new Error(checkData.error || 'Failed to check watchlist status');
}
const isWatching = checkData.is_watching;
// Toggle watchlist status
const endpoint = isWatching ? '/api/watchlist/remove' : '/api/watchlist/add';
const payload = isWatching ?
{ artist_id: artistId } :
{ artist_id: artistId, artist_name: artistName };
const response = await fetch(endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
const data = await response.json();
if (!data.success) {
throw new Error(data.error || 'Failed to update watchlist');
}
// Update button state based on new status
if (isWatching) {
// Was watching, now removed
icon.textContent = '๐๏ธ';
text.textContent = 'Add to Watchlist';
button.classList.remove('watching');
console.log(`โ Removed ${artistName} from watchlist`);
} else {
// Was not watching, now added
icon.textContent = '๐๏ธ';
text.textContent = 'Watching...';
button.classList.add('watching');
console.log(`โ
Added ${artistName} to watchlist`);
}
// Update dashboard watchlist count if function exists
if (typeof updateWatchlistCount === 'function') {
updateWatchlistCount();
}
showToast(data.message, 'success');
} catch (error) {
console.error('Error toggling library watchlist:', error);
// Restore button state
text.textContent = originalText;
showToast(`Error: ${error.message}`, 'error');
} finally {
button.disabled = false;
}
}
/**
* Update library watchlist button status based on current state
*/
async function updateLibraryWatchlistButtonStatus(artistId) {
const button = document.getElementById('library-artist-watchlist-btn');
if (!button) return;
try {
const response = await fetch('/api/watchlist/check', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ artist_id: artistId })
});
const data = await response.json();
if (data.success) {
const icon = button.querySelector('.watchlist-icon');
const text = button.querySelector('.watchlist-text');
if (data.is_watching) {
icon.textContent = '๐๏ธ';
text.textContent = 'Watching...';
button.classList.add('watching');
} else {
icon.textContent = '๐๏ธ';
text.textContent = 'Add to Watchlist';
button.classList.remove('watching');
}
}
} catch (error) {
console.warn('Failed to check library watchlist status:', error);
}
}
// =================================
// BEATPORT REBUILD SLIDER FUNCTIONALITY
// =================================
let beatportRebuildSliderState = {
currentSlide: 0,
totalSlides: 4,
autoPlayInterval: null,
autoPlayDelay: 5000
};
/**
* Initialize the beatport rebuild slider functionality
*/
function initializeBeatportRebuildSlider() {
console.log('๐ Initializing beatport rebuild slider...');
const slider = document.getElementById('beatport-rebuild-slider');
if (!slider) {
console.warn('Beatport rebuild slider not found');
return;
}
// Check if already initialized to prevent duplicate event listeners
if (slider.dataset.initialized === 'true') {
console.log('Beatport rebuild slider already initialized, skipping...');
startBeatportRebuildSliderAutoPlay(); // Just restart autoplay
return;
}
// Mark as initialized
slider.dataset.initialized = 'true';
// Load real Beatport data first
loadBeatportHeroTracks();
console.log('โ
Beatport rebuild slider initialized successfully');
}
/**
* Load real Beatport hero tracks and populate the slider
*/
async function loadBeatportHeroTracks() {
console.log('๐ฏ Loading real Beatport hero tracks...');
try {
const response = await fetch('/api/beatport/hero-tracks');
const data = await response.json();
if (data.success && data.tracks && data.tracks.length > 0) {
console.log(`โ
Loaded ${data.tracks.length} Beatport tracks`);
populateBeatportSlider(data.tracks);
} else {
console.warn('โ No tracks received from Beatport API, using placeholder data');
setupBeatportSliderWithPlaceholders();
}
} catch (error) {
console.error('โ Error loading Beatport tracks:', error);
setupBeatportSliderWithPlaceholders();
}
}
/**
* Populate the slider with real Beatport track data
*/
function populateBeatportSlider(tracks) {
const sliderTrack = document.getElementById('beatport-rebuild-slider-track');
const indicatorsContainer = document.querySelector('.beatport-rebuild-slider-indicators');
if (!sliderTrack || !indicatorsContainer) {
console.warn('Slider elements not found');
return;
}
// Clear existing content
sliderTrack.innerHTML = '';
indicatorsContainer.innerHTML = '';
// Update state
beatportRebuildSliderState.totalSlides = tracks.length;
beatportRebuildSliderState.currentSlide = 0;
// Generate slides HTML
tracks.forEach((track, index) => {
const slideHtml = `
${track.title}
${track.artist}
New on Beatport
`;
sliderTrack.insertAdjacentHTML('beforeend', slideHtml);
// Add indicator
const indicatorHtml = ` `;
indicatorsContainer.insertAdjacentHTML('beforeend', indicatorHtml);
});
// Now set up all the functionality
setupBeatportSliderFunctionality();
// Add individual click handlers for each slide (like top 10 releases pattern)
setupHeroSliderIndividualClickHandlers(tracks);
console.log(`โ
Populated slider with ${tracks.length} real Beatport tracks`);
}
/**
* Set up individual click handlers for hero slider slides (like top 10 releases)
*/
function setupHeroSliderIndividualClickHandlers(tracks) {
const slides = document.querySelectorAll('.beatport-rebuild-slide[data-url]');
slides.forEach((slide, index) => {
const releaseUrl = slide.getAttribute('data-url');
if (releaseUrl && releaseUrl !== '#' && releaseUrl !== '') {
// Create release data object from the track data (similar to top 10 releases)
const track = tracks[index];
if (track) {
const releaseData = {
url: releaseUrl,
title: track.title || 'Unknown Title',
artist: track.artist || 'Unknown Artist',
label: track.label || 'Unknown Label',
image_url: track.image_url || ''
};
// Add click handler that mimics the top 10 releases behavior
slide.addEventListener('click', (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(`๐ฏ Hero slider slide clicked: ${releaseData.title} by ${releaseData.artist}`);
handleBeatportReleaseCardClick(slide, releaseData);
});
slide.style.cursor = 'pointer';
}
}
});
console.log(`โ
Set up individual click handlers for ${slides.length} hero slider slides`);
}
/**
* Set up placeholder data if API fails
*/
function setupBeatportSliderWithPlaceholders() {
console.log('๐ Setting up slider with placeholder data...');
// The HTML already has placeholder slides, just set up functionality
setupBeatportSliderFunctionality();
}
/**
* Set up all slider functionality after content is loaded
*/
function setupBeatportSliderFunctionality() {
// Set up navigation buttons
setupBeatportRebuildSliderNavigation();
// Set up indicators
setupBeatportRebuildSliderIndicators();
// Start auto-play
startBeatportRebuildSliderAutoPlay();
// Set up pause on hover
setupBeatportRebuildSliderHoverPause();
}
/**
* Set up navigation button functionality
*/
function setupBeatportRebuildSliderNavigation() {
const prevBtn = document.getElementById('beatport-rebuild-prev-btn');
const nextBtn = document.getElementById('beatport-rebuild-next-btn');
if (prevBtn) {
prevBtn.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
console.log('Previous button clicked, current slide:', beatportRebuildSliderState.currentSlide);
goToBeatportRebuildSlide(beatportRebuildSliderState.currentSlide - 1);
resetBeatportRebuildSliderAutoPlay();
});
}
if (nextBtn) {
nextBtn.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
console.log('Next button clicked, current slide:', beatportRebuildSliderState.currentSlide);
goToBeatportRebuildSlide(beatportRebuildSliderState.currentSlide + 1);
resetBeatportRebuildSliderAutoPlay();
});
}
}
/**
* Set up indicator functionality
*/
function setupBeatportRebuildSliderIndicators() {
const indicators = document.querySelectorAll('.beatport-rebuild-indicator');
indicators.forEach((indicator, index) => {
indicator.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
goToBeatportRebuildSlide(index);
resetBeatportRebuildSliderAutoPlay();
});
});
}
/**
* Navigate to a specific slide
*/
function goToBeatportRebuildSlide(slideIndex) {
console.log('goToBeatportRebuildSlide called with:', slideIndex, 'current:', beatportRebuildSliderState.currentSlide);
// Wrap around if out of bounds
if (slideIndex < 0) {
slideIndex = beatportRebuildSliderState.totalSlides - 1;
} else if (slideIndex >= beatportRebuildSliderState.totalSlides) {
slideIndex = 0;
}
console.log('After wrapping, slideIndex:', slideIndex);
// Update current slide
beatportRebuildSliderState.currentSlide = slideIndex;
// Update slide visibility
const slides = document.querySelectorAll('.beatport-rebuild-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-rebuild-indicator');
indicators.forEach((indicator, index) => {
indicator.classList.toggle('active', index === slideIndex);
});
console.log('Slide updated to:', beatportRebuildSliderState.currentSlide);
}
/**
* Start auto-play functionality
*/
function startBeatportRebuildSliderAutoPlay() {
if (beatportRebuildSliderState.autoPlayInterval) {
clearInterval(beatportRebuildSliderState.autoPlayInterval);
}
beatportRebuildSliderState.autoPlayInterval = setInterval(() => {
goToBeatportRebuildSlide(beatportRebuildSliderState.currentSlide + 1);
}, beatportRebuildSliderState.autoPlayDelay);
}
/**
* Reset auto-play timer
*/
function resetBeatportRebuildSliderAutoPlay() {
startBeatportRebuildSliderAutoPlay();
}
/**
* Set up hover pause functionality
*/
function setupBeatportRebuildSliderHoverPause() {
const sliderContainer = document.querySelector('.beatport-rebuild-slider-container');
if (sliderContainer) {
sliderContainer.addEventListener('mouseenter', () => {
if (beatportRebuildSliderState.autoPlayInterval) {
clearInterval(beatportRebuildSliderState.autoPlayInterval);
}
});
sliderContainer.addEventListener('mouseleave', () => {
startBeatportRebuildSliderAutoPlay();
});
}
}
/**
* Clean up beatport rebuild slider when switching away
*/
function cleanupBeatportRebuildSlider() {
if (beatportRebuildSliderState.autoPlayInterval) {
clearInterval(beatportRebuildSliderState.autoPlayInterval);
beatportRebuildSliderState.autoPlayInterval = null;
}
}
// ===================================
// BEATPORT NEW RELEASES SLIDER
// ===================================
// State management for new releases slider (copied from hero slider)
let beatportReleasesSliderState = {
currentSlide: 0,
totalSlides: 0,
autoPlayInterval: null,
autoPlayDelay: 8000,
isInitialized: false
};
/**
* Initialize the beatport new releases slider functionality (based on hero slider)
*/
function initializeBeatportReleasesSlider() {
console.log('๐ Initializing beatport new releases slider...');
const slider = document.getElementById('beatport-releases-slider');
if (!slider) {
console.warn('Beatport releases slider not found');
return;
}
// Prevent double initialization
if (slider.dataset.initialized === 'true') {
console.log('Releases slider already initialized');
return;
}
const sliderTrack = document.getElementById('beatport-releases-slider-track');
const indicatorsContainer = document.getElementById('beatport-releases-slider-indicators');
if (!sliderTrack || !indicatorsContainer) {
console.warn('Releases slider elements not found');
return;
}
// Load data and initialize
loadBeatportNewReleases().then(success => {
if (success) {
setupBeatportReleasesSliderNavigation();
setupBeatportReleasesSliderIndicators();
setupBeatportReleasesSliderHoverPause();
startBeatportReleasesSliderAutoPlay();
slider.dataset.initialized = 'true';
beatportReleasesSliderState.isInitialized = true;
console.log('โ
New releases slider initialized successfully');
}
});
}
/**
* Load new releases data from API
*/
async function loadBeatportNewReleases() {
try {
console.log('๐ก Fetching new releases data...');
const response = await fetch('/api/beatport/new-releases');
const data = await response.json();
if (data.success && data.releases && data.releases.length > 0) {
console.log(`๐ Loaded ${data.releases.length} releases`);
populateBeatportReleasesSlider(data.releases);
return true;
} else {
console.error('Failed to load releases:', data.error || 'No releases found');
showBeatportReleasesError(data.error || 'No releases available');
return false;
}
} catch (error) {
console.error('Error loading new releases:', error);
showBeatportReleasesError('Failed to load releases');
return false;
}
}
/**
* Populate the releases slider with data (based on hero slider)
*/
function populateBeatportReleasesSlider(releases) {
const sliderTrack = document.getElementById('beatport-releases-slider-track');
const indicatorsContainer = document.getElementById('beatport-releases-slider-indicators');
if (!sliderTrack || !indicatorsContainer) return;
// Calculate slides needed (10 cards per slide)
const cardsPerSlide = 10;
const totalSlides = Math.ceil(releases.length / cardsPerSlide);
// Clear existing content
sliderTrack.innerHTML = '';
indicatorsContainer.innerHTML = '';
// Update state
beatportReleasesSliderState.totalSlides = totalSlides;
beatportReleasesSliderState.currentSlide = 0;
console.log(`๐ฏ Creating ${totalSlides} slides with ${cardsPerSlide} cards each`);
// Generate slides HTML (similar to hero slider)
for (let slideIndex = 0; slideIndex < totalSlides; slideIndex++) {
const startIndex = slideIndex * cardsPerSlide;
const endIndex = Math.min(startIndex + cardsPerSlide, releases.length);
const slideReleases = releases.slice(startIndex, endIndex);
// Create grid HTML for this slide
let gridHtml = '';
for (let i = 0; i < cardsPerSlide; i++) {
if (i < slideReleases.length) {
const release = slideReleases[i];
gridHtml += `
${release.image_url ? `
` : ''}
${release.title}
${release.artist}
${release.label}
`;
} else {
// Placeholder card
gridHtml += `
More Releases
Coming Soon
Beatport
`;
}
}
const slideHtml = `
`;
sliderTrack.innerHTML += slideHtml;
// Create indicator
const indicatorHtml = ` `;
indicatorsContainer.innerHTML += indicatorHtml;
}
console.log(`โ
Created ${totalSlides} slides for releases slider`);
// Add click handlers for individual release discovery (matching Top 10 Releases pattern)
const releaseCards = sliderTrack.querySelectorAll('.beatport-release-card[data-url]:not(.beatport-release-placeholder)');
releaseCards.forEach((card) => {
const releaseUrl = card.getAttribute('data-url');
if (releaseUrl && releaseUrl !== '#') {
// Find the corresponding release data
const releaseData = releases.find(release => release.url === releaseUrl);
if (releaseData) {
card.addEventListener('click', () => handleBeatportReleaseCardClick(card, releaseData));
card.style.cursor = 'pointer';
}
}
});
}
/**
* Set up navigation functionality (copied from hero slider)
*/
function setupBeatportReleasesSliderNavigation() {
const prevBtn = document.getElementById('beatport-releases-prev-btn');
const nextBtn = document.getElementById('beatport-releases-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 releases button clicked, current slide:', beatportReleasesSliderState.currentSlide);
goToBeatportReleasesSlide(beatportReleasesSliderState.currentSlide - 1);
resetBeatportReleasesSliderAutoPlay();
});
}
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 releases button clicked, current slide:', beatportReleasesSliderState.currentSlide);
goToBeatportReleasesSlide(beatportReleasesSliderState.currentSlide + 1);
resetBeatportReleasesSliderAutoPlay();
});
}
}
/**
* Set up indicator functionality (copied from hero slider)
*/
function setupBeatportReleasesSliderIndicators() {
const indicators = document.querySelectorAll('.beatport-releases-indicator');
indicators.forEach((indicator, index) => {
indicator.addEventListener('click', () => {
goToBeatportReleasesSlide(index);
resetBeatportReleasesSliderAutoPlay();
});
});
}
/**
* Navigate to a specific slide (copied from hero slider)
*/
function goToBeatportReleasesSlide(slideIndex) {
console.log('goToBeatportReleasesSlide called with:', slideIndex, 'current:', beatportReleasesSliderState.currentSlide);
// Wrap around if out of bounds
if (slideIndex < 0) {
slideIndex = beatportReleasesSliderState.totalSlides - 1;
} else if (slideIndex >= beatportReleasesSliderState.totalSlides) {
slideIndex = 0;
}
console.log('After wrapping, slideIndex:', slideIndex);
// Update current slide
beatportReleasesSliderState.currentSlide = slideIndex;
// Update slide visibility
const slides = document.querySelectorAll('.beatport-releases-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-releases-indicator');
indicators.forEach((indicator, index) => {
indicator.classList.toggle('active', index === slideIndex);
});
console.log('Releases slide updated to:', beatportReleasesSliderState.currentSlide);
}
/**
* Start auto-play functionality (copied from hero slider)
*/
function startBeatportReleasesSliderAutoPlay() {
if (beatportReleasesSliderState.autoPlayInterval) {
clearInterval(beatportReleasesSliderState.autoPlayInterval);
}
beatportReleasesSliderState.autoPlayInterval = setInterval(() => {
goToBeatportReleasesSlide(beatportReleasesSliderState.currentSlide + 1);
}, beatportReleasesSliderState.autoPlayDelay);
}
/**
* Reset auto-play timer (copied from hero slider)
*/
function resetBeatportReleasesSliderAutoPlay() {
startBeatportReleasesSliderAutoPlay();
}
/**
* Set up hover pause functionality (copied from hero slider)
*/
function setupBeatportReleasesSliderHoverPause() {
const sliderContainer = document.querySelector('.beatport-releases-slider-container');
if (sliderContainer) {
sliderContainer.addEventListener('mouseenter', () => {
if (beatportReleasesSliderState.autoPlayInterval) {
clearInterval(beatportReleasesSliderState.autoPlayInterval);
beatportReleasesSliderState.autoPlayInterval = null;
}
});
sliderContainer.addEventListener('mouseleave', () => {
startBeatportReleasesSliderAutoPlay();
});
}
}
/**
* Show error state
*/
function showBeatportReleasesError(errorMessage) {
const sliderTrack = document.getElementById('beatport-releases-slider-track');
if (!sliderTrack) return;
sliderTrack.innerHTML = `
โ Error Loading Releases
${errorMessage}
`;
}
/**
* Clean up releases slider when switching away (copied from hero slider)
*/
function cleanupBeatportReleasesSlider() {
if (beatportReleasesSliderState.autoPlayInterval) {
clearInterval(beatportReleasesSliderState.autoPlayInterval);
beatportReleasesSliderState.autoPlayInterval = null;
}
}
// ===================================
// BEATPORT HYPE PICKS SLIDER
// ===================================
// Hype Picks Slider State
let beatportHypePicksSliderState = {
currentSlide: 0,
totalSlides: 0,
autoPlayInterval: null,
autoPlayDelay: 4000,
isInitialized: false
};
/**
* Initialize the beatport hype picks slider functionality (based on releases slider)
*/
function initializeBeatportHypePicksSlider() {
console.log('๐ฅ Initializing beatport hype picks slider...');
const slider = document.getElementById('beatport-hype-picks-slider');
if (!slider) {
console.warn('Beatport hype picks slider not found');
return;
}
// Check if already initialized
if (beatportHypePicksSliderState.isInitialized) {
console.log('Beatport hype picks slider already initialized, skipping...');
startBeatportHypePicksSliderAutoPlay(); // Just restart autoplay
return;
}
// Mark as initialized
beatportHypePicksSliderState.isInitialized = true;
// Reset state
beatportHypePicksSliderState.currentSlide = 0;
beatportHypePicksSliderState.totalSlides = 0;
// Load data and initialize
loadBeatportHypePicks().then(success => {
if (success) {
setupBeatportHypePicksSliderNavigation();
setupBeatportHypePicksSliderIndicators();
setupBeatportHypePicksSliderHoverPause();
startBeatportHypePicksSliderAutoPlay();
}
});
console.log('โ
Beatport hype picks slider initialized successfully');
}
/**
* Load hype picks data from API
*/
async function loadBeatportHypePicks() {
try {
console.log('๐ฅ Fetching hype picks data...');
const response = await fetch('/api/beatport/hype-picks');
const data = await response.json();
if (data.success && data.releases && data.releases.length > 0) {
console.log(`๐ฅ Loaded ${data.releases.length} hype picks releases`);
populateBeatportHypePicksSlider(data.releases);
return true;
} else {
console.error('Failed to load hype picks:', data.error || 'No hype picks found');
showBeatportHypePicksError(data.error || 'No hype picks available');
return false;
}
} catch (error) {
console.error('Error loading hype picks:', error);
showBeatportHypePicksError('Failed to load hype picks');
return false;
}
}
/**
* Populate the hype picks slider with data (based on releases slider)
*/
function populateBeatportHypePicksSlider(releases) {
const sliderTrack = document.getElementById('beatport-hype-picks-slider-track');
const indicatorsContainer = document.getElementById('beatport-hype-picks-slider-indicators');
if (!sliderTrack || !indicatorsContainer) return;
// Clear existing content
sliderTrack.innerHTML = '';
indicatorsContainer.innerHTML = '';
// Group releases into slides (10 releases per slide in 5x2 grid)
const releasesPerSlide = 10;
const slides = [];
for (let i = 0; i < releases.length; i += releasesPerSlide) {
slides.push(releases.slice(i, i + releasesPerSlide));
}
console.log(`๐ฅ Hype Picks: Got ${releases.length} releases, creating ${slides.length} slides`);
beatportHypePicksSliderState.totalSlides = slides.length;
beatportHypePicksSliderState.currentSlide = 0;
// Create slides
slides.forEach((slideReleases, slideIndex) => {
const slideHtml = `
${slideReleases.map(release => createBeatportHypePickCard(release)).join('')}
${slideReleases.length < releasesPerSlide ?
Array(releasesPerSlide - slideReleases.length).fill(0).map(() =>
`
`
).join('') : ''
}
`;
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 = `
`;
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 = `
`;
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 = `
`;
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 = `
`;
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 = `
`;
releases.forEach((release, index) => {
releasesHtml += `
${release.rank || index + 1}
${release.image_url ?
`
` :
'
๐ฟ
'
}
${release.title || 'Unknown Title'}
${release.artist || 'Unknown Artist'}
${release.label || 'Unknown Label'}
`;
});
releasesHtml += '
';
container.innerHTML = releasesHtml;
// Set background images for cards
const cards = container.querySelectorAll('.beatport-releases-top10-card[data-bg-image]');
cards.forEach(card => {
const bgImage = card.getAttribute('data-bg-image');
if (bgImage) {
// Transform image URL from 95x95 to 500x500 for higher quality background
const highResImage = bgImage.replace('/image_size/95x95/', '/image_size/500x500/');
card.style.backgroundImage = `linear-gradient(rgba(0,0,0,0.7), rgba(0,0,0,0.8)), url('${highResImage}')`;
card.style.backgroundSize = 'cover';
card.style.backgroundPosition = 'center';
}
});
// Add click handlers for individual release discovery
const releaseCards = container.querySelectorAll('.beatport-releases-top10-card[data-url]');
releaseCards.forEach((card, index) => {
card.addEventListener('click', () => handleBeatportReleaseCardClick(card, releases[index]));
card.style.cursor = 'pointer';
});
}
/**
* Show error message for top 10 releases
*/
function showTop10ReleasesError(errorMessage) {
const container = document.getElementById('beatport-releases-top10-list');
const errorHtml = `
โ Error Loading Releases
${errorMessage}
`;
if (container) container.innerHTML = errorHtml;
}
/**
* Handle click on individual Top 10 Release card - create discovery process for single release
*/
async function handleBeatportReleaseCardClick(cardElement, release) {
console.log(`๐ฟ Individual release card clicked: ${release.title} by ${release.artist}`);
if (!release.url || release.url === '#') {
showToast('No release URL available', 'error');
return;
}
try {
// Create unique identifiers for this release
const releaseHash = `release_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
const chartName = `${release.title} - ${release.artist}`;
showToast(`Loading ${release.title}...`, 'info');
showLoadingOverlay(`Getting tracks from ${release.title}...`);
// Check if we already have a card for this release
const existingState = Object.values(beatportChartStates).find(state =>
state.chart &&
state.chart.name === chartName &&
state.chart.chart_type === 'individual_release'
);
if (existingState) {
console.log(`๐ Found existing card for ${release.title}, opening existing modal`);
hideLoadingOverlay();
handleBeatportCardClick(existingState.chart.hash);
return;
}
// Get track data from this single release
console.log(`๐ต Fetching tracks from release: ${release.url}`);
const response = await fetch('/api/beatport/scrape-releases', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
release_urls: [release.url],
source_name: `Top 10 Release: ${release.title}`
})
});
const data = await response.json();
if (!data.success || !data.tracks || data.tracks.length === 0) {
throw new Error('No tracks found in this release');
}
console.log(`โ
Successfully fetched ${data.tracks.length} tracks from ${release.title}`);
// Transform to standard chart format (following the exact pattern from handleRebuildChartClick)
const chartData = {
hash: releaseHash,
name: chartName,
chart_type: 'individual_release',
track_count: data.tracks.length,
tracks: data.tracks.map(track => ({
name: cleanTrackText(track.title || 'Unknown Title'),
artists: [cleanTrackText(track.artist || 'Unknown Artist')],
album: chartName,
duration_ms: 0,
external_urls: { beatport: track.url || '' },
source: 'beatport',
// Include release metadata
release_title: release.title,
release_artist: release.artist,
release_label: release.label,
release_image: release.image_url
}))
};
// Create Beatport playlist card (following the exact pattern)
addBeatportCardToContainer(chartData);
// Automatically open discovery modal (following the exact pattern)
hideLoadingOverlay();
handleBeatportCardClick(releaseHash);
console.log(`โ
Created individual release card and opened discovery modal for ${release.title}`);
} catch (error) {
console.error(`โ Error handling release click for ${release.title}:`, error);
hideLoadingOverlay();
showToast(`Error loading ${release.title}: ${error.message}`, 'error');
}
}
/**
* Handle click on individual chart card - create discovery process for chart tracks
*/
async function handleBeatportChartCardClick(cardElement, chart) {
console.log(`๐ Individual chart card clicked: ${chart.name} by ${chart.creator}`);
if (!chart.url || chart.url === '') {
showToast('No chart URL available', 'error');
return;
}
try {
// Create unique identifiers for this chart
const chartHash = `chart_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
const chartName = `${chart.name} - ${chart.creator}`;
showToast(`Loading ${chart.name}...`, 'info');
showLoadingOverlay(`Getting tracks from ${chart.name}...`);
// Check if we already have a card for this chart
const existingState = Object.values(beatportChartStates).find(state =>
state.chart &&
state.chart.name === chartName &&
state.chart.chart_type === 'individual_chart'
);
if (existingState) {
console.log(`๐ Found existing card for ${chart.name}, opening existing modal`);
hideLoadingOverlay();
handleBeatportCardClick(existingState.chart.hash);
return;
}
// Get track data from this chart URL (charts contain multiple tracks)
console.log(`๐ Fetching tracks from chart: ${chart.url}`);
const response = await fetch('/api/beatport/chart/extract', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
chart_url: chart.url,
chart_name: `Featured Chart: ${chart.name}`,
limit: 100
})
});
const data = await response.json();
if (!data.success || !data.tracks || data.tracks.length === 0) {
throw new Error('No tracks found in this chart');
}
console.log(`โ
Successfully fetched ${data.tracks.length} tracks from ${chart.name}`);
// Transform to standard chart format (following the exact pattern)
const chartData = {
hash: chartHash,
name: chartName,
chart_type: 'individual_chart',
track_count: data.tracks.length,
tracks: data.tracks.map(track => ({
name: cleanTrackText(track.title || 'Unknown Title'),
artists: [cleanTrackText(track.artist || 'Unknown Artist')],
album: chartName,
duration_ms: 0,
external_urls: { beatport: track.url || '' },
source: 'beatport',
// Include chart metadata
chart_name: chart.name,
chart_creator: chart.creator,
chart_image: chart.image
}))
};
// Create Beatport playlist card (following the exact pattern)
addBeatportCardToContainer(chartData);
// Automatically open discovery modal (following the exact pattern)
hideLoadingOverlay();
handleBeatportCardClick(chartHash);
console.log(`โ
Created individual chart card and opened discovery modal for ${chart.name}`);
} catch (error) {
console.error(`โ Error handling chart click for ${chart.name}:`, error);
hideLoadingOverlay();
showToast(`Error loading ${chart.name}: ${error.message}`, 'error');
}
}
/**
* Handle click on individual DJ chart card - create discovery process for DJ chart tracks
*/
async function handleBeatportDJChartCardClick(cardElement, chart) {
console.log(`๐ง Individual DJ chart card clicked: ${chart.name} by ${chart.creator}`);
if (!chart.url || chart.url === '') {
showToast('No DJ chart URL available', 'error');
return;
}
try {
// Create unique identifiers for this DJ chart
const chartHash = `dj_chart_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
const chartName = `${chart.name} - ${chart.creator}`;
showToast(`Loading ${chart.name}...`, 'info');
showLoadingOverlay(`Getting tracks from ${chart.name}...`);
// Check if we already have a card for this DJ chart
const existingState = Object.values(beatportChartStates).find(state =>
state.chart &&
state.chart.name === chartName &&
state.chart.chart_type === 'individual_dj_chart'
);
if (existingState) {
console.log(`๐ Found existing card for ${chart.name}, opening existing modal`);
hideLoadingOverlay();
handleBeatportCardClick(existingState.chart.hash);
return;
}
// Get track data from this DJ chart URL
console.log(`๐ง Fetching tracks from DJ chart: ${chart.url}`);
const response = await fetch('/api/beatport/chart/extract', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
chart_url: chart.url,
chart_name: `DJ Chart: ${chart.name}`,
limit: 100
})
});
const data = await response.json();
if (!data.success || !data.tracks || data.tracks.length === 0) {
throw new Error('No tracks found in this DJ chart');
}
console.log(`โ
Successfully fetched ${data.tracks.length} tracks from ${chart.name}`);
// Transform to standard chart format (following the exact pattern)
const chartData = {
hash: chartHash,
name: chartName,
chart_type: 'individual_dj_chart',
track_count: data.tracks.length,
tracks: data.tracks.map(track => ({
name: cleanTrackText(track.title || 'Unknown Title'),
artists: [cleanTrackText(track.artist || 'Unknown Artist')],
album: chartName,
duration_ms: 0,
external_urls: { beatport: track.url || '' },
source: 'beatport',
// Include DJ chart metadata
chart_name: chart.name,
chart_creator: chart.creator,
chart_image: chart.image
}))
};
// Create Beatport playlist card (following the exact pattern)
addBeatportCardToContainer(chartData);
// Automatically open discovery modal (following the exact pattern)
hideLoadingOverlay();
handleBeatportCardClick(chartHash);
console.log(`โ
Created individual DJ chart card and opened discovery modal for ${chart.name}`);
} catch (error) {
console.error(`โ Error handling DJ chart click for ${chart.name}:`, error);
hideLoadingOverlay();
showToast(`Error loading ${chart.name}: ${error.message}`, 'error');
}
}
/**
* Handle click on Beatport Top 100 button - create discovery process for top 100 tracks
*/
async function handleBeatportTop100Click() {
console.log('๐ฏ Beatport Top 100 button clicked');
try {
// Create unique identifiers for this chart
const chartHash = `beatport_top100_${Date.now()}`;
const chartName = 'Beatport Top 100';
showToast('Loading Beatport Top 100...', 'info');
showLoadingOverlay('Getting Beatport Top 100 tracks...');
// Check if we already have a card for Beatport Top 100
const existingState = Object.values(beatportChartStates).find(state =>
state.chart &&
state.chart.name === chartName &&
state.chart.chart_type === 'beatport_top100'
);
if (existingState) {
console.log('๐ Found existing Beatport Top 100 card, opening existing modal');
hideLoadingOverlay();
handleBeatportCardClick(existingState.chart.hash);
return;
}
// Get track data from Beatport Top 100 API
console.log('๐ฏ Fetching tracks from Beatport Top 100');
const response = await fetch('/api/beatport/top-100', {
method: 'GET'
});
const data = await response.json();
if (!data.success || !data.tracks || data.tracks.length === 0) {
throw new Error('No tracks found in Beatport Top 100');
}
console.log(`โ
Successfully fetched ${data.tracks.length} tracks from Beatport Top 100`);
// Transform to standard chart format (following the exact pattern)
const chartData = {
hash: chartHash,
name: chartName,
chart_type: 'beatport_top100',
track_count: data.tracks.length,
tracks: data.tracks.map(track => ({
name: cleanTrackText(track.title || 'Unknown Title'),
artists: [cleanTrackText(track.artist || 'Unknown Artist')],
album: chartName,
duration_ms: 0,
external_urls: { beatport: track.url || '' },
source: 'beatport',
// Include position info if available
position: track.position || track.rank
}))
};
// Create Beatport playlist card (following the exact pattern)
addBeatportCardToContainer(chartData);
// Automatically open discovery modal (following the exact pattern)
hideLoadingOverlay();
handleBeatportCardClick(chartHash);
console.log('โ
Created Beatport Top 100 card and opened discovery modal');
} catch (error) {
console.error('โ Error handling Beatport Top 100 click:', error);
hideLoadingOverlay();
showToast(`Error loading Beatport Top 100: ${error.message}`, 'error');
}
}
/**
* Handle click on Hype Top 100 button - create discovery process for hype top 100 tracks
*/
async function handleHypeTop100Click() {
console.log('๐ฅ Hype Top 100 button clicked');
try {
// Create unique identifiers for this chart
const chartHash = `hype_top100_${Date.now()}`;
const chartName = 'Hype Top 100';
showToast('Loading Hype Top 100...', 'info');
showLoadingOverlay('Getting Hype Top 100 tracks...');
// Check if we already have a card for Hype Top 100
const existingState = Object.values(beatportChartStates).find(state =>
state.chart &&
state.chart.name === chartName &&
state.chart.chart_type === 'hype_top100'
);
if (existingState) {
console.log('๐ Found existing Hype Top 100 card, opening existing modal');
hideLoadingOverlay();
handleBeatportCardClick(existingState.chart.hash);
return;
}
// Get track data from Hype Top 100 API
console.log('๐ฅ Fetching tracks from Hype Top 100');
const response = await fetch('/api/beatport/hype-top-100', {
method: 'GET'
});
const data = await response.json();
if (!data.success || !data.tracks || data.tracks.length === 0) {
throw new Error('No tracks found in Hype Top 100');
}
console.log(`โ
Successfully fetched ${data.tracks.length} tracks from Hype Top 100`);
// Transform to standard chart format (following the exact pattern)
const chartData = {
hash: chartHash,
name: chartName,
chart_type: 'hype_top100',
track_count: data.tracks.length,
tracks: data.tracks.map(track => ({
name: cleanTrackText(track.title || 'Unknown Title'),
artists: [cleanTrackText(track.artist || 'Unknown Artist')],
album: chartName,
duration_ms: 0,
external_urls: { beatport: track.url || '' },
source: 'beatport',
// Include position info if available
position: track.position || track.rank
}))
};
// Create Beatport playlist card (following the exact pattern)
addBeatportCardToContainer(chartData);
// Automatically open discovery modal (following the exact pattern)
hideLoadingOverlay();
handleBeatportCardClick(chartHash);
console.log('โ
Created Hype Top 100 card and opened discovery modal');
} catch (error) {
console.error('โ Error handling Hype Top 100 click:', error);
hideLoadingOverlay();
showToast(`Error loading Hype Top 100: ${error.message}`, 'error');
}
}
// ================================= //
// GENRE BROWSER MODAL FUNCTIONS //
// ================================= //
// Cache for genre browser data to avoid re-loading
let genreBrowserCache = {
genres: null,
imagesLoaded: false,
lastLoaded: null,
imageLoadingActive: false,
imageWorkers: null
};
function initializeGenreBrowserModal() {
console.log('๐ต Initializing Genre Browser Modal...');
// Browse by Genre button click handler
const browseByGenreBtn = document.getElementById('browse-by-genre-btn');
if (browseByGenreBtn) {
browseByGenreBtn.addEventListener('click', () => {
console.log('๐ต Browse by Genre button clicked');
openGenreBrowserModal();
});
}
// Modal close button handler
const modalCloseBtn = document.getElementById('genre-browser-modal-close');
if (modalCloseBtn) {
modalCloseBtn.addEventListener('click', closeGenreBrowserModal);
}
// Click outside modal to close
const modalOverlay = document.getElementById('genre-browser-modal');
if (modalOverlay) {
modalOverlay.addEventListener('click', (e) => {
if (e.target === modalOverlay) {
closeGenreBrowserModal();
}
});
}
// Search functionality
const searchInput = document.getElementById('genre-browser-search');
if (searchInput) {
searchInput.addEventListener('input', (e) => {
filterGenreBrowserCards(e.target.value);
});
}
// ESC key to close modal
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && isGenreBrowserModalOpen()) {
closeGenreBrowserModal();
}
});
console.log('โ
Genre Browser Modal initialized');
}
function openGenreBrowserModal() {
console.log('๐ต Opening Genre Browser Modal...');
const modal = document.getElementById('genre-browser-modal');
if (modal) {
modal.classList.add('active');
document.body.style.overflow = 'hidden'; // Prevent background scrolling
// Check cache before loading genres
if (genreBrowserCache.genres && genreBrowserCache.genres.length > 0) {
console.log('๐พ Using cached genres data');
displayCachedGenres();
} else {
console.log('๐ No cached data, loading genres...');
loadGenreBrowserGenres();
}
console.log('โ
Genre Browser Modal opened');
}
}
function closeGenreBrowserModal() {
console.log('๐ต Closing Genre Browser Modal...');
const modal = document.getElementById('genre-browser-modal');
if (modal) {
modal.classList.remove('active');
document.body.style.overflow = ''; // Restore scrolling
// Clear search input but keep the genre data cached
const searchInput = document.getElementById('genre-browser-search');
if (searchInput) {
searchInput.value = '';
// Also reset the display filter to show all genres
filterGenreBrowserCards('');
}
// Pause image loading workers if they're running
if (genreBrowserCache.imageLoadingActive) {
console.log('โธ๏ธ Pausing image loading workers...');
genreBrowserCache.imageLoadingActive = false;
}
console.log('โ
Genre Browser Modal closed (data preserved in cache)');
}
}
function isGenreBrowserModalOpen() {
const modal = document.getElementById('genre-browser-modal');
return modal && modal.classList.contains('active');
}
async function loadGenreBrowserGenres() {
console.log('๐ Loading genres for Genre Browser Modal...');
const genresGrid = document.getElementById('genre-browser-genres-grid');
if (!genresGrid) {
console.error('โ Genre browser grid not found');
return;
}
// Show loading state
genresGrid.innerHTML = `
๐ Discovering current Beatport genres...
`;
try {
// First, fetch genres quickly without images
console.log('๐ Fetching genres without images for fast loading...');
const fastResponse = await fetch('/api/beatport/genres');
if (!fastResponse.ok) {
throw new Error(`API returned ${fastResponse.status}: ${fastResponse.statusText}`);
}
const fastData = await fastResponse.json();
const genres = fastData.genres || [];
if (genres.length === 0) {
genresGrid.innerHTML = `
โ ๏ธ No genres available
๐ Retry
`;
return;
}
// Filter out unwanted genres (section titles, etc.)
const filteredGenres = genres.filter(genre => {
const name = genre.name.toLowerCase().trim();
const unwantedGenres = [
'open format',
'electronic',
'genres',
'browse',
'charts',
'new releases',
'trending',
'featured',
'popular'
];
const isUnwanted = unwantedGenres.includes(name);
if (isUnwanted) {
console.log(`๐ซ Filtered out unwanted genre: "${genre.name}"`);
}
return !isUnwanted;
});
console.log(`๐ Filtered genres: ${genres.length} โ ${filteredGenres.length} (removed ${genres.length - filteredGenres.length} unwanted)`);
// Generate genre cards dynamically (without images first)
const genreCardsHTML = filteredGenres.map(genre => `
๐ต
${genre.name}
Top 10 & Top 100 Charts
`).join('');
genresGrid.innerHTML = genreCardsHTML;
// Add click event listeners to genre cards
addGenreBrowserCardClickListeners();
// Cache the filtered genres data
genreBrowserCache.genres = filteredGenres;
genreBrowserCache.lastLoaded = new Date();
genreBrowserCache.imagesLoaded = false;
console.log(`โ
Loaded ${filteredGenres.length} Beatport genres for modal (fast mode)`);
console.log(`๐พ Cached ${filteredGenres.length} genres for future use`);
showToast(`Loaded ${filteredGenres.length} genres for browsing`, 'success');
// Now fetch images progressively in the background
if (filteredGenres.length > 5) {
console.log('๐ผ๏ธ Loading genre images progressively for modal...');
loadGenreBrowserImagesProgressively(filteredGenres);
}
} catch (error) {
console.error('โ Error loading genres for modal:', error);
genresGrid.innerHTML = `
โ Failed to load genres: ${error.message}
๐ Retry
`;
showToast(`Error loading genres: ${error.message}`, 'error');
}
}
function displayCachedGenres() {
console.log('๐พ Displaying cached genres...');
const genresGrid = document.getElementById('genre-browser-genres-grid');
if (!genresGrid) {
console.error('โ Genre browser grid not found');
return;
}
const genres = genreBrowserCache.genres;
if (!genres || genres.length === 0) {
console.error('โ No cached genres available');
return;
}
// Generate genre cards from cached data
const genreCardsHTML = genres.map(genre => `
๐ต
${genre.name}
Top 10 & Top 100 Charts
`).join('');
genresGrid.innerHTML = genreCardsHTML;
// Add click event listeners to genre cards
addGenreBrowserCardClickListeners();
console.log(`โ
Displayed ${genres.length} cached genres instantly`);
// Handle image loading based on current state
if (genreBrowserCache.imagesLoaded) {
console.log('๐ผ๏ธ Images already loaded, restoring them...');
restoreCachedImages(genres);
} else if (!genreBrowserCache.imageLoadingActive && genres.length > 5) {
// Resume or start image loading
const cachedCount = genres.filter(g => g.imageUrl).length;
if (cachedCount > 0) {
console.log(`๐ Resuming image loading (${cachedCount}/${genres.length} already cached)...`);
restoreCachedImages(genres); // Show already cached images
} else {
console.log('๐ผ๏ธ Starting fresh image loading for cached genres...');
}
loadGenreBrowserImagesProgressively(genres);
} else {
console.log('๐ท Image loading in progress, showing cached images...');
restoreCachedImages(genres);
}
}
function restoreCachedImages(genres) {
// Restore images that were already loaded in previous sessions
genres.forEach(genre => {
if (genre.imageUrl) {
const genreCard = document.querySelector(
`.genre-browser-card[data-genre-slug="${genre.slug}"][data-genre-id="${genre.id}"]`
);
if (genreCard) {
const imageElement = genreCard.querySelector('.genre-browser-card-image');
if (imageElement) {
imageElement.innerHTML = ` `;
genreCard.classList.remove('genre-browser-card-fallback');
}
}
}
});
}
async function loadGenreBrowserImagesProgressively(genres) {
// Load genre images with 2 concurrent workers for faster loading
// Only process genres that don't already have cached images
const imageQueue = genres.filter(genre => !genre.imageUrl);
let imagesLoaded = 0;
const maxWorkers = 2;
// Mark loading as active
genreBrowserCache.imageLoadingActive = true;
console.log(`๐ผ๏ธ Starting progressive image loading for modal with ${maxWorkers} workers for ${imageQueue.length} remaining genres (${genres.length - imageQueue.length} already cached)`);
// If all images are already cached, mark as complete
if (imageQueue.length === 0) {
console.log('โ
All images already cached, marking as complete');
genreBrowserCache.imagesLoaded = true;
genreBrowserCache.imageLoadingActive = false;
return;
}
// Function to process a single image
async function processImage(genre) {
try {
// Fetch individual genre image from backend
const response = await fetch(`/api/beatport/genre-image/${genre.slug}/${genre.id}`);
if (response.ok) {
const data = await response.json();
if (data.success && data.image_url) {
// Cache the image URL in the genre object
genre.imageUrl = data.image_url;
// Find the genre card in the modal
const genreCard = document.querySelector(
`.genre-browser-card[data-genre-slug="${genre.slug}"][data-genre-id="${genre.id}"]`
);
if (genreCard) {
const imageElement = genreCard.querySelector('.genre-browser-card-image');
if (imageElement) {
// Replace the fallback emoji with the actual image
imageElement.innerHTML = ` `;
genreCard.classList.remove('genre-browser-card-fallback');
console.log(`โ
Loaded and cached image for ${genre.name} in modal`);
}
}
}
}
imagesLoaded++;
console.log(`๐ท Progress: ${imagesLoaded}/${genres.length} images loaded for modal`);
} catch (error) {
console.log(`โ ๏ธ Could not load image for ${genre.name} in modal: ${error.message}`);
imagesLoaded++;
}
}
// Worker function to process images from the queue
async function worker() {
while (imageQueue.length > 0 && genreBrowserCache.imageLoadingActive) {
const genre = imageQueue.shift();
if (genre) {
await processImage(genre);
// Small delay to prevent overwhelming the server
await new Promise(resolve => setTimeout(resolve, 100));
}
// Check if we should pause
if (!genreBrowserCache.imageLoadingActive) {
console.log('โธ๏ธ Worker paused - modal closed');
break;
}
}
}
// Start the workers
const workers = [];
for (let i = 0; i < maxWorkers; i++) {
workers.push(worker());
}
// Wait for all workers to complete
await Promise.all(workers);
// Check if loading was completed or paused
if (genreBrowserCache.imageLoadingActive) {
// Completed successfully
genreBrowserCache.imagesLoaded = true;
genreBrowserCache.imageLoadingActive = false;
console.log(`๐ Completed loading all genre images for modal (${imagesLoaded}/${genres.length})`);
console.log(`๐พ Marked images as loaded in cache`);
} else {
// Was paused
console.log(`โธ๏ธ Image loading paused (${imagesLoaded}/${genres.length} completed)`);
console.log(`๐พ Partial progress saved in cache`);
}
}
function filterGenreBrowserCards(searchTerm) {
const genreCards = document.querySelectorAll('.genre-browser-card');
const searchLower = searchTerm.toLowerCase();
genreCards.forEach(card => {
const genreName = card.dataset.genreName?.toLowerCase() || '';
const shouldShow = genreName.includes(searchLower);
card.style.display = shouldShow ? 'block' : 'none';
});
console.log(`๐ Filtered genre cards with search term: "${searchTerm}"`);
}
// === GENRE BROWSER CARD CLICK HANDLERS ===
function addGenreBrowserCardClickListeners() {
const genreCards = document.querySelectorAll('.genre-browser-card');
genreCards.forEach(card => {
card.addEventListener('click', () => {
const genreSlug = card.dataset.genreSlug;
const genreId = card.dataset.genreId;
const genreName = card.dataset.genreName;
console.log(`๐ต Genre card clicked: ${genreName} (${genreSlug})`);
handleGenreBrowserCardClick(genreSlug, genreId, genreName);
});
});
console.log(`๐ Added click listeners to ${genreCards.length} genre browser cards`);
}
async function handleGenreBrowserCardClick(genreSlug, genreId, genreName) {
console.log(`๐ Loading hero slider for ${genreName}...`);
try {
// Show the genre page view
showGenrePageView(genreSlug, genreId, genreName);
// Load the hero slider data
// Load hero slider, Top 10 lists, and Top 10 releases in parallel
await Promise.all([
loadGenreHeroSlider(genreSlug, genreId, genreName),
loadGenreTop10Lists(genreSlug, genreId, genreName),
loadGenreTop10Releases(genreSlug, genreId, genreName)
]);
} catch (error) {
console.error(`โ Error loading genre page for ${genreName}:`, error);
showToast(`Error loading ${genreName}: ${error.message}`, 'error');
// Return to genre list on error
showGenreListView();
}
}
function showGenrePageView(genreSlug, genreId, genreName) {
console.log(`๐ฏ Showing genre page view for ${genreName}`);
// CRITICAL: Stop all other slider auto-play to prevent conflicts
if (typeof beatportRebuildSliderState !== 'undefined' && beatportRebuildSliderState.autoPlayInterval) {
clearInterval(beatportRebuildSliderState.autoPlayInterval);
console.log('๐ Stopped main slider auto-play to prevent conflicts');
}
const modal = document.getElementById('genre-browser-modal');
if (!modal) return;
// Hide genre list elements
const searchSection = modal.querySelector('.genre-browser-search-section');
const genresSection = modal.querySelector('.genre-browser-genres-section');
if (searchSection) searchSection.style.display = 'none';
if (genresSection) genresSection.style.display = 'none';
// Create or show genre page content
let genrePageContent = modal.querySelector('.genre-page-content');
if (!genrePageContent) {
genrePageContent = document.createElement('div');
genrePageContent.className = 'genre-page-content';
genrePageContent.innerHTML = `
๐ Loading hero releases...
๐ต Loading Top 10 lists...
๐ฟ Loading Top 10 releases...
`;
modal.querySelector('.genre-browser-modal-content').appendChild(genrePageContent);
// Add back button listener
const backButton = genrePageContent.querySelector('#genre-back-button');
if (backButton) {
backButton.addEventListener('click', showGenreListView);
}
// Add genre top 100 button listener
const genreTop100Button = genrePageContent.querySelector('#genre-top100-btn');
if (genreTop100Button) {
genreTop100Button.addEventListener('click', () => {
handleGenreTop100Click(genreSlug, genreId, genreName);
});
}
}
// Update title and show genre page
const titleElement = genrePageContent.querySelector('.genre-page-title');
if (titleElement) titleElement.textContent = genreName;
genrePageContent.style.display = 'block';
// Store current genre info for potential back navigation
genrePageContent.dataset.genreSlug = genreSlug;
genrePageContent.dataset.genreId = genreId;
genrePageContent.dataset.genreName = genreName;
}
function showGenreListView() {
console.log(`๐ Returning to genre list view`);
// Clean up genre hero slider
if (window.genreHeroSliderState && window.genreHeroSliderState.autoPlayInterval) {
clearInterval(window.genreHeroSliderState.autoPlayInterval);
console.log('๐งน Cleaned up genre hero slider auto-play');
}
// CRITICAL: Restart main slider auto-play
if (typeof beatportRebuildSliderState !== 'undefined' && !beatportRebuildSliderState.autoPlayInterval) {
if (typeof startBeatportRebuildSliderAutoPlay === 'function') {
startBeatportRebuildSliderAutoPlay();
console.log('๐ Restarted main slider auto-play');
}
}
const modal = document.getElementById('genre-browser-modal');
if (!modal) return;
// Show genre list elements
const searchSection = modal.querySelector('.genre-browser-search-section');
const genresSection = modal.querySelector('.genre-browser-genres-section');
const genrePageContent = modal.querySelector('.genre-page-content');
if (searchSection) searchSection.style.display = 'block';
if (genresSection) genresSection.style.display = 'block';
if (genrePageContent) genrePageContent.style.display = 'none';
}
async function loadGenreHeroSlider(genreSlug, genreId, genreName) {
console.log(`๐ Loading hero slider data for ${genreName}...`);
const container = document.getElementById('genre-hero-slider-container');
if (!container) return;
try {
// Show loading state
container.innerHTML = `
๐ Loading ${genreName} hero releases...
`;
// Fetch hero slider data from API
const response = await fetch(`/api/beatport/genre/${genreSlug}/${genreId}/hero`);
if (!response.ok) {
throw new Error(`API returned ${response.status}: ${response.statusText}`);
}
const data = await response.json();
if (!data.success || !data.releases || data.releases.length === 0) {
throw new Error(data.message || 'No hero releases found');
}
console.log(`โ
Loaded ${data.count} hero releases for ${genreName} (cached: ${data.cached})`);
// Create hero slider HTML
const heroSliderHTML = createGenreHeroSliderHTML(data.releases, genreName);
container.innerHTML = heroSliderHTML;
// Add click handlers to individual releases (for future download functionality)
addGenreHeroReleaseClickHandlers(data.releases);
showToast(`Loaded ${data.count} ${genreName} releases`, 'success');
} catch (error) {
console.error(`โ Error loading hero slider for ${genreName}:`, error);
container.innerHTML = `
โ Failed to load ${genreName} releases
${error.message}
๐ Retry
`;
throw error;
}
}
function createGenreHeroSliderHTML(releases, genreName) {
const slidesHTML = releases.map((release, index) => {
// Convert relative URL to absolute URL
const absoluteUrl = release.url.startsWith('http')
? release.url
: `https://www.beatport.com${release.url}`;
return `
${release.title}
${release.artists_string}
${release.label || genreName + ' Hero Release'}
`;
}).join('');
const indicatorsHTML = releases.map((_, index) => `
`).join('');
return `
${slidesHTML}
โน
โบ
${indicatorsHTML}
`;
}
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 = `
`;
// 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 += `
`;
// Add Hype Top 10 tracks (same classes as main page)
hype_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 += `
`;
}
// No else block - completely hide hype section when no hype tracks available
html += `
`;
return html;
}
/**
* Add container-level click handlers for genre Top 10 lists (exact parity with main page)
*/
function addGenreTop10ClickHandlers() {
console.log('๐ Adding container-level click handlers for genre Top 10 lists...');
// Add container-level click handler for Beatport Top 10 (exact match to main page)
const beatportContainer = document.getElementById('genre-beatport-top10-list');
if (beatportContainer) {
beatportContainer.addEventListener('click', () => {
console.log('๐ต Genre Beatport Top 10 container clicked');
handleGenreBeatportTop10Click();
});
console.log('โ
Added Beatport Top 10 container click handler');
}
// Add container-level click handler for Hype Top 10 (exact match to main page)
const hypeContainer = document.getElementById('genre-beatport-hype10-list');
if (hypeContainer) {
hypeContainer.addEventListener('click', () => {
console.log('๐ฅ Genre Hype Top 10 container clicked');
handleGenreHypeTop10Click();
});
console.log('โ
Added Hype Top 10 container click handler');
}
console.log(`โ
Set up container-level click handlers for genre Top 10 lists`);
}
/**
* Handle genre Beatport Top 10 container click (exact parity with main page)
*/
async function handleGenreBeatportTop10Click() {
console.log('๐ต Handling Genre Beatport Top 10 click');
// Get the actual genre name from the page title
const genreName = document.querySelector('.genre-page-title')?.textContent?.trim() || 'Genre';
// Use actual genre name in chart title
await handleGenreChartClick('genre_beatport_top10', `${genreName} Beatport Top 10`, 'genre_beatport_top10');
}
/**
* Handle genre Hype Top 10 container click (exact parity with main page)
*/
async function handleGenreHypeTop10Click() {
console.log('๐ฅ Handling Genre Hype Top 10 click');
// Get the actual genre name from the page title
const genreName = document.querySelector('.genre-page-title')?.textContent?.trim() || 'Genre';
// Use actual genre name in chart title
await handleGenreChartClick('genre_hype_top10', `${genreName} Hype Top 10`, 'genre_hype_top10');
}
/**
* Handle genre chart click (based on main page handleRebuildChartClick)
*/
async function handleGenreChartClick(trackDataKey, chartName, chartType) {
try {
// Create chart hash (following main page pattern)
const chartHash = `${chartType}_${Date.now()}`;
// Check if we already have an existing state (following main page pattern)
const existingState = Object.values(beatportChartStates).find(state =>
state.chart && state.chart.name === chartName && state.chart.chart_type === chartType
);
if (existingState) {
console.log(`๐ Found existing ${chartName} card, opening existing modal`);
// Use existing card click handler (following main page pattern)
handleBeatportCardClick(existingState.chart.hash);
return;
}
// Extract track data from DOM cards (exact same pattern as main page)
const trackData = await getGenrePageTrackData(trackDataKey);
if (!trackData || trackData.length === 0) {
throw new Error(`No track data found for ${chartName}`);
}
// Transform DOM data to Browse Charts format EXACTLY like main page
const chartData = {
hash: chartHash,
name: chartName,
chart_type: chartType,
track_count: trackData.length,
tracks: trackData.map(track => ({
name: cleanTrackText(track.title || 'Unknown Title'),
artists: [cleanTrackText(track.artist || 'Unknown Artist')],
album: chartName,
duration_ms: 0,
external_urls: { beatport: track.url || '' },
source: 'beatport'
}))
};
// Follow main page pattern EXACTLY:
// 1. Add card to container (creates playlist card)
console.log(`๐ Creating Beatport playlist card for: ${chartData.name}`);
addBeatportCardToContainer(chartData);
// 2. Automatically open discovery modal (like when you click a card in fresh state)
handleBeatportCardClick(chartHash);
console.log(`โ
Created ${chartName} card and opened discovery modal`);
} catch (error) {
console.error(`โ Error handling ${chartName} click:`, error);
showToast(`Error loading ${chartName}: ${error.message}`, 'error');
}
}
/**
* Extract track data from genre page DOM (based on main page getRebuildPageTrackData)
*/
async function getGenrePageTrackData(trackDataKey) {
console.log(`๐ Extracting ${trackDataKey} data from genre page DOM`);
let containerSelector, cardSelector;
if (trackDataKey === 'genre_beatport_top10') {
containerSelector = '#genre-beatport-top10-list';
cardSelector = '.beatport-top10-card[data-url]';
} else if (trackDataKey === 'genre_hype_top10') {
containerSelector = '#genre-beatport-hype10-list';
cardSelector = '.beatport-hype10-card[data-url]';
} else {
throw new Error(`Unknown track data key: ${trackDataKey}`);
}
const container = document.querySelector(containerSelector);
if (!container) {
throw new Error(`Container ${containerSelector} not found`);
}
const trackCards = container.querySelectorAll(cardSelector);
if (trackCards.length === 0) {
throw new Error(`No track cards found in ${containerSelector}`);
}
// Extract track data from DOM cards (exact same pattern as main page)
const tracks = Array.from(trackCards).map(card => {
const title = card.querySelector('.beatport-top10-card-title, .beatport-hype10-card-title')?.textContent?.trim() || 'Unknown Title';
const artist = card.querySelector('.beatport-top10-card-artist, .beatport-hype10-card-artist')?.textContent?.trim() || 'Unknown Artist';
const label = card.querySelector('.beatport-top10-card-label, .beatport-hype10-card-label')?.textContent?.trim() || 'Unknown Label';
const url = card.getAttribute('data-url') || '';
const rank = card.querySelector('.beatport-top10-card-rank, .beatport-hype10-card-rank')?.textContent?.trim() || '';
return {
title: title,
artist: artist,
label: label,
url: url,
rank: rank
};
});
console.log(`๐ Extracted ${tracks.length} tracks from ${containerSelector}`);
return tracks;
}
/**
* Handle genre-specific Top 100 button click - create discovery process for genre top 100 tracks
*/
async function handleGenreTop100Click(genreSlug, genreId, genreName) {
console.log(`๐ฏ Genre Top 100 button clicked for ${genreName}`);
try {
// Create unique identifiers for this chart
const chartHash = `${genreSlug}_top100_${Date.now()}`;
const chartName = `${genreName} Top 100`;
showToast(`Loading ${genreName} Top 100...`, 'info');
showLoadingOverlay(`Getting ${genreName} Top 100 tracks...`);
// Check if we already have a card for this genre's Top 100
const existingState = Object.values(beatportChartStates).find(state =>
state.chart &&
state.chart.name === chartName &&
state.chart.chart_type === 'genre_top100'
);
if (existingState) {
console.log(`๐ Found existing ${genreName} Top 100 card, opening existing modal`);
hideLoadingOverlay();
handleBeatportCardClick(existingState.chart.hash);
return;
}
// Construct the genre top 100 URL: genre URL + /top-100
const genreTop100Url = `https://www.beatport.com/genre/${genreSlug}/${genreId}/top-100`;
console.log(`๐ฏ Fetching tracks from ${genreTop100Url}`);
// Get track data from genre top 100 page
const response = await fetch('/api/beatport/scrape-releases', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
release_urls: [genreTop100Url],
source_name: chartName
})
});
const data = await response.json();
if (!data.success || !data.tracks || data.tracks.length === 0) {
throw new Error(`No tracks found in ${genreName} Top 100`);
}
console.log(`โ
Successfully fetched ${data.tracks.length} tracks from ${genreName} Top 100`);
// Transform to standard chart format (following the exact pattern)
const chartData = {
hash: chartHash,
name: chartName,
chart_type: 'genre_top100',
track_count: data.tracks.length,
tracks: data.tracks.map(track => ({
name: cleanTrackText(track.title || 'Unknown Title'),
artists: [cleanTrackText(track.artist || 'Unknown Artist')],
album: chartName,
duration_ms: 0,
external_urls: { beatport: track.url || '' },
source: 'beatport',
// Include genre metadata
genre_slug: genreSlug,
genre_id: genreId,
genre_name: genreName,
position: track.position || track.rank
}))
};
// Create Beatport playlist card (following the exact pattern)
addBeatportCardToContainer(chartData);
// Automatically open discovery modal (following the exact pattern)
hideLoadingOverlay();
handleBeatportCardClick(chartHash);
console.log(`โ
Created ${genreName} Top 100 card and opened discovery modal`);
} catch (error) {
console.error(`โ Error handling ${genreName} Top 100 click:`, error);
hideLoadingOverlay();
showToast(`Error loading ${genreName} Top 100: ${error.message}`, 'error');
}
}
/**
* Load Top 10 releases for a specific genre
*/
async function loadGenreTop10Releases(genreSlug, genreId, genreName) {
console.log(`๐ฟ Loading Top 10 releases for ${genreName}...`);
const container = document.getElementById('genre-top10-releases-container');
if (!container) {
console.error('โ Genre Top 10 releases container not found');
return;
}
try {
const response = await fetch(`/api/beatport/genre/${genreSlug}/${genreId}/top-10-releases`);
const data = await response.json();
if (!data.success) {
throw new Error(data.error || 'Failed to load Top 10 releases');
}
console.log(`๐ฟ Loaded ${data.releases.length} Top 10 releases for ${genreName}`);
createGenreTop10ReleasesHTML(data.releases, genreName);
} catch (error) {
console.error(`โ Error loading Top 10 releases for ${genreName}:`, error);
showGenreTop10ReleasesError(error.message || 'Failed to load Top 10 releases');
}
}
/**
* Create HTML for genre Top 10 releases section (exact parity with main page)
*/
function createGenreTop10ReleasesHTML(releases, genreName) {
const container = document.getElementById('genre-top10-releases-container');
if (!container || !releases || releases.length === 0) return;
// Create section with unique ID but exact same structure as main page
const sectionHtml = `
${createGenreTop10ReleasesCardsHTML(releases)}
`;
container.innerHTML = sectionHtml;
// Add background images and click handlers
addGenreTop10ReleasesInteractivity(releases);
}
/**
* Create release cards HTML for genre Top 10 releases
*/
function createGenreTop10ReleasesCardsHTML(releases) {
let cardsHtml = '';
releases.forEach((release, index) => {
cardsHtml += `
${release.rank || index + 1}
${release.image_url ?
`
` :
'
๐ฟ
'
}
${release.title || 'Unknown Title'}
${release.artist || 'Unknown Artist'}
${release.label || 'Unknown Label'}
`;
});
cardsHtml += '
';
return cardsHtml;
}
/**
* Add interactivity to genre Top 10 releases cards
*/
function addGenreTop10ReleasesInteractivity(releases) {
const container = document.getElementById('genre-beatport-releases-top10-list');
if (!container) return;
// Set background images for cards
const cards = container.querySelectorAll('.beatport-releases-top10-card[data-bg-image]');
cards.forEach(card => {
const bgImage = card.getAttribute('data-bg-image');
if (bgImage) {
// Transform image URL from 95x95 to 500x500 for higher quality background
const highResImage = bgImage.replace('/image_size/95x95/', '/image_size/500x500/');
card.style.backgroundImage = `linear-gradient(rgba(0,0,0,0.7), rgba(0,0,0,0.8)), url('${highResImage}')`;
card.style.backgroundSize = 'cover';
card.style.backgroundPosition = 'center';
}
});
// Add click handlers for individual release discovery (exact same pattern as main page)
const releaseCards = container.querySelectorAll('.beatport-releases-top10-card[data-url]');
releaseCards.forEach((card, index) => {
card.addEventListener('click', () => handleGenreReleaseCardClick(card, releases[index]));
card.style.cursor = 'pointer';
});
}
/**
* Handle click on individual genre Top 10 Release card (exact parity with main page)
*/
async function handleGenreReleaseCardClick(cardElement, release) {
console.log(`๐ฟ Individual genre release card clicked: ${release.title} by ${release.artist}`);
if (!release.url || release.url === '#') {
showToast('No release URL available', 'error');
return;
}
try {
// Create unique identifiers for this release
const releaseHash = `genre_release_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
const chartName = `${release.title} - ${release.artist}`;
showToast(`Loading ${release.title}...`, 'info');
showLoadingOverlay(`Getting tracks from ${release.title}...`);
// Check if we already have a card for this release
const existingState = Object.values(beatportChartStates).find(state =>
state.chart &&
state.chart.name === chartName &&
state.chart.chart_type === 'individual_release'
);
if (existingState) {
console.log(`๐ Found existing card for ${release.title}, opening existing modal`);
hideLoadingOverlay();
handleBeatportCardClick(existingState.chart.hash);
return;
}
// Get track data from this single release (exact same API call as main page)
console.log(`๐ต Fetching tracks from release: ${release.url}`);
const response = await fetch('/api/beatport/scrape-releases', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
release_urls: [release.url],
source_name: `Genre Top 10 Release: ${release.title}`
})
});
const data = await response.json();
if (!data.success || !data.tracks || data.tracks.length === 0) {
throw new Error('No tracks found in this release');
}
console.log(`โ
Successfully fetched ${data.tracks.length} tracks from ${release.title}`);
// Transform to standard chart format (exact same pattern as main page)
const chartData = {
hash: releaseHash,
name: chartName,
chart_type: 'individual_release',
track_count: data.tracks.length,
tracks: data.tracks.map(track => ({
name: cleanTrackText(track.title || 'Unknown Title'),
artists: [cleanTrackText(track.artist || 'Unknown Artist')],
album: chartName,
duration_ms: 0,
external_urls: { beatport: track.url || '' },
source: 'beatport',
// Include release metadata
release_title: release.title,
release_artist: release.artist,
release_label: release.label,
release_image: release.image_url
}))
};
// Create Beatport playlist card (exact same pattern as main page)
addBeatportCardToContainer(chartData);
// Automatically open discovery modal (exact same pattern as main page)
hideLoadingOverlay();
handleBeatportCardClick(releaseHash);
console.log(`โ
Created individual release card and opened discovery modal for ${release.title}`);
} catch (error) {
console.error(`โ Error handling release click for ${release.title}:`, error);
hideLoadingOverlay();
showToast(`Error loading ${release.title}: ${error.message}`, 'error');
}
}
/**
* Show error message for genre Top 10 releases
*/
function showGenreTop10ReleasesError(errorMessage) {
const container = document.getElementById('genre-top10-releases-container');
const errorHtml = `
โ Error Loading Releases
${errorMessage}
`;
if (container) container.innerHTML = errorHtml;
}
// Initialize the Genre Browser Modal when the page loads
document.addEventListener('DOMContentLoaded', () => {
initializeGenreBrowserModal();
});
// ============ Plex Music Library Selection ============
async function loadPlexMusicLibraries() {
try {
const response = await fetch('/api/plex/music-libraries');
const data = await response.json();
if (data.success && data.libraries && data.libraries.length > 0) {
const selector = document.getElementById('plex-music-library');
const container = document.getElementById('plex-library-selector-container');
// Clear existing options
selector.innerHTML = '';
// Add options for each library
data.libraries.forEach(library => {
const option = document.createElement('option');
option.value = library.title;
option.textContent = library.title;
// Mark the currently selected library
if (library.title === data.current || library.title === data.selected) {
option.selected = true;
}
selector.appendChild(option);
});
// Show the container
container.style.display = 'block';
} else {
// Hide if no libraries found or not connected
document.getElementById('plex-library-selector-container').style.display = 'none';
}
} catch (error) {
console.error('Error loading Plex music libraries:', error);
document.getElementById('plex-library-selector-container').style.display = 'none';
}
}
async function selectPlexLibrary() {
const selector = document.getElementById('plex-music-library');
const selectedLibrary = selector.value;
if (!selectedLibrary) return;
try {
const response = await fetch('/api/plex/select-music-library', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
library_name: selectedLibrary
})
});
const data = await response.json();
if (data.success) {
console.log(`Plex music library switched to: ${selectedLibrary}`);
} else {
console.error('Failed to switch library:', data.error);
alert(`Failed to switch library: ${data.error}`);
}
} catch (error) {
console.error('Error selecting Plex library:', error);
alert('Error selecting library. Please try again.');
}
}
// ============ Jellyfin User Selection ============
async function loadJellyfinUsers() {
try {
const response = await fetch('/api/jellyfin/users');
const data = await response.json();
if (data.success && data.users && data.users.length > 0) {
const selector = document.getElementById('jellyfin-user');
const container = document.getElementById('jellyfin-user-selector-container');
// Clear existing options
selector.innerHTML = '';
// Add options for each user
data.users.forEach(user => {
const option = document.createElement('option');
option.value = user.name;
option.textContent = user.name;
// Mark the currently selected user
if (user.name === data.current || user.name === data.selected) {
option.selected = true;
}
selector.appendChild(option);
});
// Show the container
container.style.display = 'block';
} else {
// Hide if no users found or not connected
document.getElementById('jellyfin-user-selector-container').style.display = 'none';
}
} catch (error) {
console.error('Error loading Jellyfin users:', error);
document.getElementById('jellyfin-user-selector-container').style.display = 'none';
}
}
async function selectJellyfinUser() {
const selector = document.getElementById('jellyfin-user');
const selectedUser = selector.value;
if (!selectedUser) return;
try {
const response = await fetch('/api/jellyfin/select-user', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
username: selectedUser
})
});
const data = await response.json();
if (data.success) {
console.log(`Jellyfin user switched to: ${selectedUser}`);
// Refresh library dropdown for the new user
loadJellyfinMusicLibraries();
} else {
console.error('Failed to switch user:', data.error);
alert(`Failed to switch user: ${data.error}`);
}
} catch (error) {
console.error('Error selecting Jellyfin user:', error);
alert('Error selecting user. Please try again.');
}
}
// ============ Jellyfin Music Library Selection ============
async function loadJellyfinMusicLibraries() {
try {
const response = await fetch('/api/jellyfin/music-libraries');
const data = await response.json();
if (data.success && data.libraries && data.libraries.length > 0) {
const selector = document.getElementById('jellyfin-music-library');
const container = document.getElementById('jellyfin-library-selector-container');
// Clear existing options
selector.innerHTML = '';
// Add options for each library
data.libraries.forEach(library => {
const option = document.createElement('option');
option.value = library.title;
option.textContent = library.title;
// Mark the currently selected library
if (library.title === data.current || library.title === data.selected) {
option.selected = true;
}
selector.appendChild(option);
});
// Show the container
container.style.display = 'block';
} else {
// Hide if no libraries found or not connected
document.getElementById('jellyfin-library-selector-container').style.display = 'none';
}
} catch (error) {
console.error('Error loading Jellyfin music libraries:', error);
document.getElementById('jellyfin-library-selector-container').style.display = 'none';
}
}
async function selectJellyfinLibrary() {
const selector = document.getElementById('jellyfin-music-library');
const selectedLibrary = selector.value;
if (!selectedLibrary) return;
try {
const response = await fetch('/api/jellyfin/select-music-library', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
library_name: selectedLibrary
})
});
const data = await response.json();
if (data.success) {
console.log(`Jellyfin music library switched to: ${selectedLibrary}`);
} else {
console.error('Failed to switch library:', data.error);
alert(`Failed to switch library: ${data.error}`);
}
} catch (error) {
console.error('Error selecting Jellyfin library:', error);
alert('Error selecting library. Please try again.');
}
}
// ============ Navidrome Music Folder Selection ============
async function loadNavidromeMusicFolders() {
try {
const response = await fetch('/api/navidrome/music-folders');
const data = await response.json();
if (data.success && data.folders && data.folders.length > 0) {
const selector = document.getElementById('navidrome-music-folder');
const container = document.getElementById('navidrome-folder-selector-container');
selector.innerHTML = 'All Libraries ';
data.folders.forEach(folder => {
const option = document.createElement('option');
option.value = folder.title;
option.textContent = folder.title;
if (folder.title === data.current || folder.title === data.selected) {
option.selected = true;
}
selector.appendChild(option);
});
container.style.display = 'block';
} else {
document.getElementById('navidrome-folder-selector-container').style.display = 'none';
}
} catch (error) {
console.error('Error loading Navidrome music folders:', error);
document.getElementById('navidrome-folder-selector-container').style.display = 'none';
}
}
async function selectNavidromeMusicFolder() {
const selector = document.getElementById('navidrome-music-folder');
const selectedFolder = selector.value;
try {
const response = await fetch('/api/navidrome/select-music-folder', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ folder_name: selectedFolder })
});
const data = await response.json();
if (data.success) {
showToast(data.message, 'success');
} else {
console.error('Failed to set music folder:', data.error);
showToast(`Failed to set music folder: ${data.error}`, 'error', 'set-media');
}
} catch (error) {
console.error('Error selecting Navidrome music folder:', error);
showToast('Error selecting music folder. Please try again.', 'error', 'set-media');
}
}
// ============================================
// == DISCOVER PAGE ==
// ============================================
let discoverHeroIndex = 0;
let discoverHeroArtists = [];
let discoverHeroInterval = null;
let discoverPageInitialized = false;
// Store discover playlist tracks for download/sync functionality
let discoverReleaseRadarTracks = [];
let discoverWeeklyTracks = [];
let discoverRecentAlbums = [];
let discoverSeasonalAlbums = [];
let discoverSeasonalTracks = [];
let currentSeasonKey = null;
// Personalized playlists storage
let personalizedRecentlyAdded = [];
let personalizedTopTracks = [];
let personalizedForgottenFavorites = [];
let personalizedPopularPicks = [];
let personalizedHiddenGems = [];
let personalizedDailyMixes = [];
let personalizedDiscoveryShuffle = [];
let personalizedFamiliarFavorites = [];
let buildPlaylistSelectedArtists = [];
async function loadDiscoverPage() {
console.log('Loading discover page...');
// Load all sections
await Promise.all([
loadDiscoverHero(),
loadSpotifyLibrarySection(),
loadDiscoverRecentReleases(),
loadSeasonalContent(), // Seasonal discovery
loadPersonalizedRecentlyAdded(), // NEW: Recently added from library
// loadPersonalizedDailyMixes(), // NEW: Daily Mix playlists (HIDDEN)
loadDiscoverReleaseRadar(),
loadDiscoverWeekly(),
loadPersonalizedPopularPicks(), // NEW: Popular picks from discovery pool
loadPersonalizedHiddenGems(), // NEW: Hidden gems from discovery pool
loadPersonalizedTopTracks(), // NEW: Your top tracks
loadPersonalizedForgottenFavorites(), // NEW: Forgotten favorites
loadDiscoveryShuffle(), // NEW: Discovery Shuffle
loadFamiliarFavorites(), // NEW: Familiar Favorites
initializeListenBrainzTabs(), // ListenBrainz playlists (tabbed)
loadDecadeBrowserTabs(), // Time Machine (tabbed by decade)
loadGenreBrowserTabs(), // Browse by Genre (tabbed by genre)
loadListenBrainzPlaylistsFromBackend() // Load ListenBrainz playlist states for persistence
]);
// Check for active syncs after page load
checkForActiveDiscoverSyncs();
}
async function checkForActiveDiscoverSyncs() {
// Check if Fresh Tape sync is active
try {
const releaseRadarResponse = await fetch('/api/sync/status/discover_release_radar');
if (releaseRadarResponse.ok) {
const data = await releaseRadarResponse.json();
if (data.status === 'syncing' || data.status === 'starting') {
console.log('๐ Resuming Fresh Tape sync polling after page refresh');
// Show status display
const statusDisplay = document.getElementById('release-radar-sync-status');
if (statusDisplay) {
statusDisplay.style.display = 'block';
}
// Disable button
const syncButton = document.getElementById('release-radar-sync-btn');
if (syncButton) {
syncButton.disabled = true;
syncButton.style.opacity = '0.5';
syncButton.style.cursor = 'not-allowed';
}
// Resume polling
startDiscoverSyncPolling('release_radar', 'discover_release_radar');
}
}
} catch (error) {
// Sync not active, ignore
}
// Check if The Archives sync is active
try {
const discoveryWeeklyResponse = await fetch('/api/sync/status/discover_discovery_weekly');
if (discoveryWeeklyResponse.ok) {
const data = await discoveryWeeklyResponse.json();
if (data.status === 'syncing' || data.status === 'starting') {
console.log('๐ Resuming The Archives sync polling after page refresh');
// Show status display
const statusDisplay = document.getElementById('discovery-weekly-sync-status');
if (statusDisplay) {
statusDisplay.style.display = 'block';
}
// Disable button
const syncButton = document.getElementById('discovery-weekly-sync-btn');
if (syncButton) {
syncButton.disabled = true;
syncButton.style.opacity = '0.5';
syncButton.style.cursor = 'not-allowed';
}
// Resume polling
startDiscoverSyncPolling('discovery_weekly', 'discover_discovery_weekly');
}
}
} catch (error) {
// Sync not active, ignore
}
// Check if Seasonal Playlist sync is active
try {
const seasonalResponse = await fetch('/api/sync/status/discover_seasonal_playlist');
if (seasonalResponse.ok) {
const data = await seasonalResponse.json();
if (data.status === 'syncing' || data.status === 'starting') {
console.log('๐ Resuming Seasonal Playlist sync polling after page refresh');
const statusDisplay = document.getElementById('seasonal-playlist-sync-status');
if (statusDisplay) {
statusDisplay.style.display = 'block';
}
const syncButton = document.getElementById('seasonal-playlist-sync-btn');
if (syncButton) {
syncButton.disabled = true;
syncButton.style.opacity = '0.5';
syncButton.style.cursor = 'not-allowed';
}
startDiscoverSyncPolling('seasonal_playlist', 'discover_seasonal_playlist');
}
}
} catch (error) {
// Sync not active, ignore
}
}
async function loadDiscoverHero() {
try {
const response = await fetch('/api/discover/hero');
if (!response.ok) {
console.error('Failed to fetch discover hero');
return;
}
const data = await response.json();
if (!data.success || !data.artists || data.artists.length === 0) {
console.log('No hero artists available');
showDiscoverHeroEmpty();
return;
}
discoverHeroArtists = data.artists;
discoverHeroIndex = 0;
// Display first artist
displayDiscoverHeroArtist(discoverHeroArtists[0]);
// Start slideshow (change every 8 seconds)
if (discoverHeroInterval) {
clearInterval(discoverHeroInterval);
}
if (discoverHeroArtists.length > 1) {
discoverHeroInterval = setInterval(() => {
discoverHeroIndex = (discoverHeroIndex + 1) % discoverHeroArtists.length;
displayDiscoverHeroArtist(discoverHeroArtists[discoverHeroIndex]);
}, 8000);
}
// Check if all hero artists are already watched
checkAllHeroWatchlistStatus();
} catch (error) {
console.error('Error loading discover hero:', error);
showDiscoverHeroEmpty();
}
}
function displayDiscoverHeroArtist(artist) {
const titleEl = document.getElementById('discover-hero-title');
const subtitleEl = document.getElementById('discover-hero-subtitle');
const metaEl = document.getElementById('discover-hero-meta');
const imageEl = document.getElementById('discover-hero-image');
const bgEl = document.getElementById('discover-hero-bg');
if (titleEl) {
titleEl.textContent = artist.artist_name;
}
if (subtitleEl) {
// Show recommendation context based on occurrence count
let subtitle = '';
if (artist.occurrence_count > 1) {
subtitle = `Similar to ${artist.occurrence_count} artists in your watchlist`;
} else {
subtitle = 'Similar to an artist in your watchlist';
}
subtitleEl.textContent = subtitle;
}
// Build metadata section with popularity and genres
if (metaEl) {
let metaHTML = '';
// Add popularity indicator
if (artist.popularity !== undefined && artist.popularity > 0) {
const popularityClass = artist.popularity >= 80 ? 'high' :
artist.popularity >= 50 ? 'medium' : 'low';
metaHTML += `
โญ
${artist.popularity}/100
Popularity
`;
}
// Add genre tags
if (artist.genres && artist.genres.length > 0) {
metaHTML += '
';
artist.genres.slice(0, 3).forEach(genre => {
metaHTML += `${genre} `;
});
metaHTML += '
';
}
metaHTML += '
';
metaEl.innerHTML = metaHTML;
}
if (imageEl && artist.image_url) {
imageEl.innerHTML = ` `;
} else if (imageEl) {
imageEl.innerHTML = '๐ง
';
}
if (bgEl && artist.image_url) {
bgEl.style.backgroundImage = `url('${artist.image_url}')`;
bgEl.style.backgroundSize = 'cover';
bgEl.style.backgroundPosition = 'center';
}
// Store artist ID for both buttons and update watchlist state
// Use artist_id which is set by the backend to the appropriate ID for the active source
const addBtn = document.getElementById('discover-hero-add');
const discographyBtn = document.getElementById('discover-hero-discography');
const artistId = artist.artist_id || artist.spotify_artist_id || artist.itunes_artist_id;
if (addBtn && artistId) {
addBtn.setAttribute('data-artist-id', artistId);
addBtn.setAttribute('data-artist-name', artist.artist_name);
// Also store both IDs for cross-source operations
if (artist.spotify_artist_id) addBtn.setAttribute('data-spotify-id', artist.spotify_artist_id);
if (artist.itunes_artist_id) addBtn.setAttribute('data-itunes-id', artist.itunes_artist_id);
// Check if this artist is already in watchlist and update button appearance
checkAndUpdateDiscoverHeroWatchlistButton(artistId);
}
if (discographyBtn && artistId) {
discographyBtn.setAttribute('data-artist-id', artistId);
discographyBtn.setAttribute('data-artist-name', artist.artist_name);
// Also store both IDs for cross-source operations
if (artist.spotify_artist_id) discographyBtn.setAttribute('data-spotify-id', artist.spotify_artist_id);
if (artist.itunes_artist_id) discographyBtn.setAttribute('data-itunes-id', artist.itunes_artist_id);
}
// Update slideshow indicators
updateDiscoverHeroIndicators();
}
async function checkAndUpdateDiscoverHeroWatchlistButton(artistId) {
try {
const response = await fetch('/api/watchlist/check', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ artist_id: artistId })
});
const data = await response.json();
if (!data.success) return;
const addBtn = document.getElementById('discover-hero-add');
if (!addBtn) return;
const icon = addBtn.querySelector('.watchlist-icon');
const text = addBtn.querySelector('.watchlist-text');
if (data.is_watching) {
// Artist is in watchlist
if (icon) icon.textContent = '๐๏ธ';
if (text) text.textContent = 'Watching...';
addBtn.classList.add('watching');
} else {
// Artist not in watchlist
if (icon) icon.textContent = '๐๏ธ';
if (text) text.textContent = 'Add to Watchlist';
addBtn.classList.remove('watching');
}
} catch (error) {
console.error('Error checking watchlist status for hero:', error);
}
}
function toggleDiscoverHeroWatchlist(event) {
event.stopPropagation();
const button = document.getElementById('discover-hero-add');
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 on discover hero button');
return;
}
// Call the existing toggleWatchlist function
toggleWatchlist(event, artistId, artistName);
}
async function watchAllHeroArtists(btn) {
if (!discoverHeroArtists || discoverHeroArtists.length === 0) return;
if (btn.classList.contains('all-watched')) return;
const textEl = btn.querySelector('.watch-all-text');
const originalText = textEl ? textEl.textContent : '';
// Loading state
btn.disabled = true;
if (textEl) textEl.textContent = 'Adding...';
try {
const artists = discoverHeroArtists.map(a => ({
artist_id: a.artist_id,
artist_name: a.artist_name
}));
const response = await fetch('/api/watchlist/add-batch', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ artists })
});
const data = await response.json();
if (data.success) {
if (textEl) textEl.textContent = 'All Watched';
btn.classList.add('all-watched');
btn.disabled = true;
// Sync the per-slide watchlist button for current artist
const currentArtist = discoverHeroArtists[discoverHeroIndex];
if (currentArtist) {
checkAndUpdateDiscoverHeroWatchlistButton(currentArtist.artist_id);
}
// Update watchlist count badge
if (typeof updateWatchlistButtonCount === 'function') {
updateWatchlistButtonCount();
}
} else {
if (textEl) textEl.textContent = originalText;
btn.disabled = false;
}
} catch (error) {
console.error('Error watching all hero artists:', error);
if (textEl) textEl.textContent = originalText;
btn.disabled = false;
}
}
// Cache for recommended artists data so reopening is instant
let _recommendedArtistsCache = null;
async function openRecommendedArtistsModal() {
let modal = document.getElementById('recommended-artists-modal');
if (!modal) {
modal = document.createElement('div');
modal.id = 'recommended-artists-modal';
modal.className = 'modal-overlay';
document.body.appendChild(modal);
modal.addEventListener('click', function(e) {
if (e.target === modal) closeRecommendedArtistsModal();
});
}
// If cached, render instantly and refresh watchlist statuses
if (_recommendedArtistsCache) {
modal.style.display = 'flex';
renderRecommendedArtistsModal(modal, _recommendedArtistsCache);
checkRecommendedWatchlistStatuses(_recommendedArtistsCache);
return;
}
// Show loading
modal.innerHTML = `
Loading recommended artists...
`;
modal.style.display = 'flex';
try {
// Phase 1: Fetch basic data (instant โ no API enrichment)
const response = await fetch('/api/discover/similar-artists');
const data = await response.json();
if (!data.success || !data.artists || data.artists.length === 0) {
modal.querySelector('.playlist-modal-body').innerHTML = `
No recommended artists yet.
Run a watchlist scan to generate recommendations.
`;
modal.querySelector('.playlist-track-count').textContent = '0 artists';
return;
}
// Render cards immediately with fallback images
_recommendedArtistsCache = data.artists;
renderRecommendedArtistsModal(modal, data.artists);
// Phase 2: Enrich with images/genres progressively in batches of 50
// Skip artists that already have cached metadata from the initial response
const source = data.source || 'spotify';
const idKey = source === 'spotify' ? 'spotify_artist_id' : 'itunes_artist_id';
const allIds = data.artists
.filter(a => !a.image_url) // Only enrich artists without cached images
.map(a => a[idKey]).filter(Boolean);
for (let i = 0; i < allIds.length; i += 50) {
const batchIds = allIds.slice(i, i + 50);
try {
const enrichResp = await fetch('/api/discover/similar-artists/enrich', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ artist_ids: batchIds, source })
});
const enrichData = await enrichResp.json();
if (enrichData.success && enrichData.artists) {
// Update cards and cache as each batch arrives
for (const [aid, info] of Object.entries(enrichData.artists)) {
// Update the card in DOM
const card = modal.querySelector(`.recommended-artist-card[data-artist-id="${aid}"]`);
if (card && info.image_url) {
const imgContainer = card.querySelector('.recommended-card-image');
if (imgContainer) {
imgContainer.innerHTML = ` `;
}
}
if (card && info.genres && info.genres.length > 0) {
const genresContainer = card.querySelector('.recommended-card-genres');
if (genresContainer) {
genresContainer.innerHTML = info.genres.map(g =>
`${escapeHtml(g)} `
).join('');
} else {
const infoDiv = card.querySelector('.recommended-card-info');
if (infoDiv) {
const genreDiv = document.createElement('div');
genreDiv.className = 'recommended-card-genres';
genreDiv.innerHTML = info.genres.map(g =>
`${escapeHtml(g)} `
).join('');
infoDiv.appendChild(genreDiv);
}
}
}
// Update cache
const cached = _recommendedArtistsCache.find(a => a.artist_id === aid || a.spotify_artist_id === aid || a.itunes_artist_id === aid);
if (cached) {
if (info.image_url) cached.image_url = info.image_url;
if (info.genres) cached.genres = info.genres;
if (info.artist_name) cached.artist_name = info.artist_name;
}
}
}
} catch (enrichErr) {
console.error('Error enriching batch:', enrichErr);
}
}
// Phase 3: Check watchlist statuses
checkRecommendedWatchlistStatuses(data.artists);
} catch (error) {
console.error('Error loading recommended artists:', error);
modal.querySelector('.playlist-modal-body').innerHTML = `
Error loading recommended artists.
`;
}
}
function renderRecommendedArtistsModal(modal, artists) {
modal.innerHTML = `
${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 `
Add to Watchlist
${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 = '';
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 += `
${album.album_name}
${album.artist_name}
${album.release_date}
`;
});
carousel.innerHTML = html;
} catch (error) {
console.error('Error loading recent releases:', error);
const carousel = document.getElementById('recent-releases-carousel');
if (carousel) {
carousel.innerHTML = 'Failed to load recent releases
';
}
}
}
// ===============================
// SPOTIFY LIBRARY SECTION
// ===============================
let spotifyLibraryAlbums = [];
let spotifyLibraryPage = 0;
let spotifyLibraryTotal = 0;
const SPOTIFY_LIBRARY_PAGE_SIZE = 48;
let _spotifyLibrarySearchTimeout = null;
function debouncedSpotifyLibrarySearch() {
clearTimeout(_spotifyLibrarySearchTimeout);
_spotifyLibrarySearchTimeout = setTimeout(() => {
spotifyLibraryPage = 0;
loadSpotifyLibraryAlbums();
}, 400);
}
async function loadSpotifyLibrarySection() {
try {
const section = document.getElementById('spotify-library-section');
if (!section) return;
const response = await fetch(`/api/discover/spotify-library?offset=0&limit=${SPOTIFY_LIBRARY_PAGE_SIZE}`);
if (!response.ok) throw new Error('Failed to fetch');
const data = await response.json();
if (!data.success || !data.albums || data.albums.length === 0) {
section.style.display = 'none';
return;
}
section.style.display = '';
spotifyLibraryAlbums = data.albums;
spotifyLibraryTotal = data.total;
spotifyLibraryPage = 0;
// Update subtitle with stats
const subtitle = document.getElementById('spotify-library-subtitle');
if (subtitle && data.stats) {
const s = data.stats;
subtitle.textContent = `${s.total} albums \u00B7 ${s.owned} owned \u00B7 ${s.missing} missing`;
}
// Show download missing button if there are missing albums
const dlBtn = document.getElementById('spotify-library-download-missing-btn');
if (dlBtn && data.stats && data.stats.missing > 0) {
dlBtn.style.display = '';
}
// Show filters
const filters = document.getElementById('spotify-library-filters');
if (filters) filters.style.display = '';
renderSpotifyLibraryGrid(data.albums);
renderSpotifyLibraryPagination(data.total, 0);
} catch (error) {
console.error('Error loading Spotify library section:', error);
const section = document.getElementById('spotify-library-section');
if (section) section.style.display = 'none';
}
}
async function loadSpotifyLibraryAlbums() {
const grid = document.getElementById('spotify-library-grid');
if (!grid) return;
grid.innerHTML = '';
try {
const search = (document.getElementById('spotify-library-search')?.value || '').trim();
const status = document.getElementById('spotify-library-status-filter')?.value || 'all';
const sort = document.getElementById('spotify-library-sort')?.value || 'date_saved';
const offset = spotifyLibraryPage * SPOTIFY_LIBRARY_PAGE_SIZE;
const params = new URLSearchParams({
offset, limit: SPOTIFY_LIBRARY_PAGE_SIZE, sort, sort_dir: 'desc', status
});
if (search) params.set('search', search);
const response = await fetch(`/api/discover/spotify-library?${params}`);
const data = await response.json();
if (!data.success) throw new Error(data.error);
spotifyLibraryAlbums = data.albums;
spotifyLibraryTotal = data.total;
// Update subtitle
const subtitle = document.getElementById('spotify-library-subtitle');
if (subtitle && data.stats) {
const s = data.stats;
subtitle.textContent = `${s.total} albums \u00B7 ${s.owned} owned \u00B7 ${s.missing} missing`;
}
renderSpotifyLibraryGrid(data.albums);
renderSpotifyLibraryPagination(data.total, offset);
} catch (error) {
console.error('Error loading Spotify library albums:', error);
grid.innerHTML = '';
}
}
function renderSpotifyLibraryGrid(albums) {
const grid = document.getElementById('spotify-library-grid');
if (!grid) return;
if (!albums || albums.length === 0) {
grid.innerHTML = '';
return;
}
let html = '';
albums.forEach((album, index) => {
const coverUrl = album.image_url || '/static/placeholder-album.png';
const year = album.release_date ? album.release_date.substring(0, 4) : '';
const badgeClass = album.in_library ? 'owned' : 'missing';
const badgeIcon = album.in_library ? '\u2713' : '\u2193';
const trackInfo = album.total_tracks ? `${album.total_tracks} tracks` : '';
const meta = [year, trackInfo].filter(Boolean).join(' \u00B7 ');
html += `
${badgeIcon}
${album.album_name}
${album.artist_name}
${meta}
`;
});
grid.innerHTML = html;
}
function renderSpotifyLibraryPagination(total, offset) {
const container = document.getElementById('spotify-library-pagination');
if (!container) return;
if (total <= SPOTIFY_LIBRARY_PAGE_SIZE) {
container.style.display = 'none';
return;
}
container.style.display = '';
const totalPages = Math.ceil(total / SPOTIFY_LIBRARY_PAGE_SIZE);
const currentPage = Math.floor(offset / SPOTIFY_LIBRARY_PAGE_SIZE) + 1;
const showEnd = Math.min(offset + SPOTIFY_LIBRARY_PAGE_SIZE, total);
container.innerHTML = `
← Previous
${offset + 1}\u2013${showEnd} of ${total}
= totalPages ? 'disabled' : ''}>Next →
`;
}
function spotifyLibraryPrevPage() {
if (spotifyLibraryPage > 0) {
spotifyLibraryPage--;
loadSpotifyLibraryAlbums();
}
}
function spotifyLibraryNextPage() {
const totalPages = Math.ceil(spotifyLibraryTotal / SPOTIFY_LIBRARY_PAGE_SIZE);
if (spotifyLibraryPage < totalPages - 1) {
spotifyLibraryPage++;
loadSpotifyLibraryAlbums();
}
}
async function openSpotifyLibraryAlbumDownload(index) {
const album = spotifyLibraryAlbums[index];
if (!album) {
showToast('Album data not found', 'error');
return;
}
console.log(`\u{1F4E5} Opening download modal for Spotify library album: ${album.album_name}`);
showLoadingOverlay(`Loading tracks for ${album.album_name}...`);
try {
const _params = new URLSearchParams({ name: album.album_name || '', artist: album.artist_name || '' });
const response = await fetch(`/api/discover/album/spotify/${album.spotify_album_id}?${_params}`);
if (!response.ok) throw new Error('Failed to fetch album tracks');
const albumData = await response.json();
if (!albumData.tracks || albumData.tracks.length === 0) {
throw new Error('No tracks found in album');
}
const spotifyTracks = albumData.tracks.map(track => {
let artists = track.artists || albumData.artists || [{ name: album.artist_name }];
if (Array.isArray(artists)) {
artists = artists.map(a => a.name || a);
}
return {
id: track.id,
name: track.name,
artists: artists,
album: {
id: albumData.id,
name: albumData.name,
album_type: albumData.album_type || 'album',
total_tracks: albumData.total_tracks || 0,
release_date: albumData.release_date || '',
images: albumData.images || []
},
duration_ms: track.duration_ms || 0,
track_number: track.track_number || 0
};
});
const virtualPlaylistId = `spotify_library_${album.spotify_album_id}`;
const artistContext = {
id: album.artist_id,
name: album.artist_name,
source: 'spotify'
};
const albumContext = {
id: albumData.id,
name: albumData.name,
album_type: albumData.album_type || 'album',
total_tracks: albumData.total_tracks || 0,
release_date: albumData.release_date || '',
images: albumData.images || []
};
await openDownloadMissingModalForYouTube(virtualPlaylistId, albumData.name, spotifyTracks, artistContext, albumContext);
hideLoadingOverlay();
} catch (error) {
console.error('Error opening Spotify library album download:', error);
showToast(`Failed to load album: ${error.message}`, 'error');
hideLoadingOverlay();
}
}
async function refreshSpotifyLibraryCache() {
try {
showToast('Refreshing Spotify library...', 'info');
const response = await fetch('/api/discover/spotify-library/refresh', { method: 'POST' });
const data = await response.json();
if (data.success) {
showToast('Spotify library refresh started โ will update shortly', 'success');
// Reload after a delay to let the sync run
setTimeout(() => loadSpotifyLibrarySection(), 10000);
} else {
showToast(`Error: ${data.error}`, 'error');
}
} catch (error) {
showToast(`Error: ${error.message}`, 'error');
}
}
async function downloadMissingSpotifyLibraryAlbums() {
// Fetch all missing albums (no pagination limit)
try {
const response = await fetch('/api/discover/spotify-library?status=missing&limit=500&offset=0');
const data = await response.json();
if (!data.success || !data.albums || data.albums.length === 0) {
showToast('No missing albums to download', 'info');
return;
}
const missing = data.albums.filter(a => !a.in_library);
if (missing.length === 0) {
showToast('All albums are already in your library!', 'success');
return;
}
if (!confirm(`Download ${missing.length} missing album${missing.length > 1 ? 's' : ''} from your Spotify library?`)) {
return;
}
showToast(`Starting download for ${missing.length} albums...`, 'info');
// Download one at a time to avoid overwhelming the system
for (let i = 0; i < missing.length; i++) {
const album = missing[i];
try {
showToast(`Queuing ${i + 1}/${missing.length}: ${album.album_name}`, 'info');
const _params = new URLSearchParams({ name: album.album_name || '', artist: album.artist_name || '' });
const response = await fetch(`/api/discover/album/spotify/${album.spotify_album_id}?${_params}`);
if (!response.ok) continue;
const albumData = await response.json();
if (!albumData.tracks || albumData.tracks.length === 0) continue;
const spotifyTracks = albumData.tracks.map(track => {
let artists = track.artists || albumData.artists || [{ name: album.artist_name }];
if (Array.isArray(artists)) artists = artists.map(a => a.name || a);
return {
id: track.id,
name: track.name,
artists: artists,
album: {
id: albumData.id,
name: albumData.name,
album_type: albumData.album_type || 'album',
total_tracks: albumData.total_tracks || 0,
release_date: albumData.release_date || '',
images: albumData.images || []
},
duration_ms: track.duration_ms || 0,
track_number: track.track_number || 0
};
});
const virtualPlaylistId = `spotify_library_${album.spotify_album_id}`;
await openDownloadMissingModalForYouTube(virtualPlaylistId, albumData.name, spotifyTracks, {
id: album.artist_id, name: album.artist_name, source: 'spotify'
}, {
id: albumData.id, name: albumData.name, album_type: albumData.album_type || 'album',
total_tracks: albumData.total_tracks || 0, release_date: albumData.release_date || '',
images: albumData.images || []
});
} catch (err) {
console.error(`Error downloading album ${album.album_name}:`, err);
}
}
} catch (error) {
console.error('Error downloading missing Spotify library albums:', error);
showToast(`Error: ${error.message}`, 'error');
}
}
async function loadDiscoverReleaseRadar() {
try {
const playlistContainer = document.getElementById('release-radar-playlist');
if (!playlistContainer) return;
playlistContainer.innerHTML = '';
const response = await fetch('/api/discover/release-radar');
if (!response.ok) {
throw new Error('Failed to fetch release radar');
}
const data = await response.json();
if (!data.success || !data.tracks || data.tracks.length === 0) {
playlistContainer.innerHTML = 'No new releases available
';
return;
}
// Store tracks for download/sync functionality
discoverReleaseRadarTracks = data.tracks;
// Build compact playlist HTML
let html = '';
data.tracks.forEach((track, index) => {
const coverUrl = track.album_cover_url || '/static/placeholder-album.png';
const durationMin = Math.floor(track.duration_ms / 60000);
const durationSec = Math.floor((track.duration_ms % 60000) / 1000);
const duration = `${durationMin}:${durationSec.toString().padStart(2, '0')}`;
html += `
${index + 1}
${track.track_name}
${track.artist_name}
${track.album_name}
${duration}
`;
});
html += '
';
playlistContainer.innerHTML = html;
} catch (error) {
console.error('Error loading release radar:', error);
const playlistContainer = document.getElementById('release-radar-playlist');
if (playlistContainer) {
playlistContainer.innerHTML = 'Failed to load release radar
';
}
}
}
async function loadDiscoverWeekly() {
try {
const playlistContainer = document.getElementById('discovery-weekly-playlist');
if (!playlistContainer) return;
playlistContainer.innerHTML = '
Curating your discovery playlist...
';
const response = await fetch('/api/discover/weekly');
if (!response.ok) {
throw new Error('Failed to fetch discovery weekly');
}
const data = await response.json();
if (!data.success || !data.tracks || data.tracks.length === 0) {
playlistContainer.innerHTML = '';
return;
}
// Store tracks for download/sync functionality
discoverWeeklyTracks = data.tracks;
// Build compact playlist HTML
let html = '';
data.tracks.forEach((track, index) => {
const coverUrl = track.album_cover_url || '/static/placeholder-album.png';
const durationMin = Math.floor(track.duration_ms / 60000);
const durationSec = Math.floor((track.duration_ms % 60000) / 1000);
const duration = `${durationMin}:${durationSec.toString().padStart(2, '0')}`;
html += `
${index + 1}
${track.track_name}
${track.artist_name}
${track.album_name}
${duration}
`;
});
html += '
';
playlistContainer.innerHTML = html;
} catch (error) {
console.error('Error loading discovery weekly:', error);
const playlistContainer = document.getElementById('discovery-weekly-playlist');
if (playlistContainer) {
playlistContainer.innerHTML = 'Failed to load discovery weekly
';
}
}
}
// ===============================
// DECADE BROWSER
// ===============================
let selectedDecade = null;
let decadeTracks = [];
async function loadDecadeBrowser() {
try {
const carousel = document.getElementById('decade-browser-carousel');
if (!carousel) return;
// Fetch available decades from backend
const response = await fetch('/api/discover/decades/available');
if (!response.ok) {
throw new Error('Failed to fetch available decades');
}
const data = await response.json();
if (!data.success || !data.decades || data.decades.length === 0) {
carousel.innerHTML = 'No decade content available yet. Run a watchlist scan to populate your discovery pool!
';
return;
}
// Build decade cards matching Recent Releases style
let html = '';
data.decades.forEach(decade => {
const icon = getDecadeIcon(decade.year);
const label = `${decade.year}s`;
html += `
${label}
${decade.track_count} tracks
Classics
`;
});
carousel.innerHTML = html;
} catch (error) {
console.error('Error loading decade browser:', error);
const carousel = document.getElementById('decade-browser-carousel');
if (carousel) {
carousel.innerHTML = '';
}
}
}
function getDecadeIcon(year) {
const icons = {
1950: '๐บ',
1960: '๐ธ',
1970: '๐บ',
1980: '๐ป',
1990: '๐ฟ',
2000: '๐ฑ',
2010: '๐ง',
2020: '๐'
};
return icons[year] || '๐ต';
}
async function openDecadePlaylist(decade) {
try {
showLoadingOverlay(`Loading ${decade}s playlist...`);
const response = await fetch(`/api/discover/decade/${decade}`);
if (!response.ok) {
throw new Error('Failed to fetch decade playlist');
}
const data = await response.json();
if (!data.success || !data.tracks || data.tracks.length === 0) {
const message = data.message || `No tracks found for the ${decade}s`;
showToast(message, 'info');
hideLoadingOverlay();
return;
}
selectedDecade = decade;
decadeTracks = data.tracks;
// Open download modal
const playlistName = `${decade}s Classics`;
const virtualPlaylistId = `decade_${decade}`;
await openDownloadMissingModalForYouTube(virtualPlaylistId, playlistName, data.tracks);
hideLoadingOverlay();
} catch (error) {
console.error(`Error opening ${decade}s playlist:`, error);
showToast(`Failed to load ${decade}s playlist`, 'error');
hideLoadingOverlay();
}
}
// ===============================
// GENRE BROWSER
// ===============================
let selectedGenre = null;
let genreTracks = [];
async function loadGenreBrowser() {
try {
const carousel = document.getElementById('genre-browser-carousel');
if (!carousel) return;
// Fetch available genres from backend
const response = await fetch('/api/discover/genres/available');
if (!response.ok) {
throw new Error('Failed to fetch available genres');
}
const data = await response.json();
if (!data.success || !data.genres || data.genres.length === 0) {
carousel.innerHTML = 'No genre content available yet. Run a watchlist scan to populate your discovery pool!
';
return;
}
// Build genre cards matching Recent Releases style
let html = '';
data.genres.forEach(genre => {
const icon = getGenreIcon(genre.name);
const displayName = capitalizeGenre(genre.name);
html += `
${displayName}
${genre.track_count} tracks
Curated
`;
});
carousel.innerHTML = html;
} catch (error) {
console.error('Error loading genre browser:', error);
const carousel = document.getElementById('genre-browser-carousel');
if (carousel) {
carousel.innerHTML = '';
}
}
}
function getGenreIcon(genreName) {
const genre = genreName.toLowerCase();
// Parent genre exact matches (consolidated categories)
if (genre === 'electronic/dance') return '๐น';
if (genre === 'hip hop/rap') return '๐ค';
if (genre === 'rock') return '๐ธ';
if (genre === 'pop') return '๐ต';
if (genre === 'r&b/soul') return '๐๏ธ';
if (genre === 'jazz') return '๐บ';
if (genre === 'classical') return '๐ป';
if (genre === 'metal') return '๐ค';
if (genre === 'country') return '๐ช';
if (genre === 'folk/indie') return '๐ง';
if (genre === 'latin') return '๐';
if (genre === 'reggae/dancehall') return '๐ด';
if (genre === 'world') return '๐';
if (genre === 'alternative') return '๐ญ';
if (genre === 'blues') return '๐ธ';
if (genre === 'funk/disco') return '๐บ';
// Fallback: partial matching for specific genres
if (genre.includes('house') || genre.includes('techno') || genre.includes('edm') ||
genre.includes('electro') || genre.includes('trance') || genre.includes('electronic')) {
return '๐น';
}
if (genre.includes('hip hop') || genre.includes('rap') || genre.includes('trap')) {
return '๐ค';
}
if (genre.includes('rock') || genre.includes('punk')) {
return '๐ธ';
}
if (genre.includes('metal')) {
return '๐ค';
}
if (genre.includes('jazz') || genre.includes('blues')) {
return '๐บ';
}
if (genre.includes('pop')) {
return '๐ต';
}
if (genre.includes('r&b') || genre.includes('soul')) {
return '๐๏ธ';
}
if (genre.includes('country') || genre.includes('folk')) {
return '๐ช';
}
if (genre.includes('classical') || genre.includes('orchestra')) {
return '๐ป';
}
if (genre.includes('indie') || genre.includes('alternative')) {
return '๐ง';
}
if (genre.includes('latin') || genre.includes('reggaeton') || genre.includes('salsa')) {
return '๐';
}
if (genre.includes('reggae') || genre.includes('dancehall')) {
return '๐ด';
}
if (genre.includes('funk') || genre.includes('disco')) {
return '๐บ';
}
// Default
return '๐ถ';
}
function capitalizeGenre(genre) {
// Capitalize each word in genre, handling both spaces and slashes
return genre.split(/(\s|\/)/g)
.map(part => {
if (part === ' ' || part === '/') return part;
return part.charAt(0).toUpperCase() + part.slice(1);
})
.join('');
}
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
async function openGenrePlaylist(genre) {
try {
showLoadingOverlay(`Loading ${capitalizeGenre(genre)} playlist...`);
const response = await fetch(`/api/discover/genre/${encodeURIComponent(genre)}`);
if (!response.ok) {
throw new Error('Failed to fetch genre playlist');
}
const data = await response.json();
if (!data.success || !data.tracks || data.tracks.length === 0) {
const message = data.message || `No tracks found for ${genre}`;
showToast(message, 'info');
hideLoadingOverlay();
return;
}
selectedGenre = genre;
genreTracks = data.tracks;
// Open download modal
const playlistName = `${capitalizeGenre(genre)} Mix`;
const virtualPlaylistId = `genre_${genre.replace(/\s+/g, '_')}`;
await openDownloadMissingModalForYouTube(virtualPlaylistId, playlistName, data.tracks);
hideLoadingOverlay();
} catch (error) {
console.error(`Error opening ${genre} playlist:`, error);
showToast(`Failed to load ${genre} playlist`, 'error');
hideLoadingOverlay();
}
}
// ===============================
// TIME MACHINE (TABBED BY DECADE)
// ===============================
let decadeTracksCache = {}; // Store tracks for each decade
let activeDecade = null;
async function loadDecadeBrowserTabs() {
try {
const tabsContainer = document.getElementById('decade-tabs');
const contentsContainer = document.getElementById('decade-tab-contents');
if (!tabsContainer || !contentsContainer) return;
// Fetch available decades from backend
const response = await fetch('/api/discover/decades/available');
if (!response.ok) {
throw new Error('Failed to fetch available decades');
}
const data = await response.json();
if (!data.success || !data.decades || data.decades.length === 0) {
tabsContainer.innerHTML = 'No decade content available yet. Run a watchlist scan to populate your discovery pool!
';
return;
}
// Build decade tabs
let tabsHTML = '';
let contentsHTML = '';
data.decades.forEach((decade, index) => {
const isActive = index === 0;
const icon = getDecadeIcon(decade.year);
const tabId = `decade-${decade.year}`;
// Tab button
tabsHTML += `
${icon} ${decade.year}s
`;
// Tab content
contentsHTML += `
โณ
Syncing to media server...
โ 0
โณ 0
โ 0
(0 %)
Loading ${decade.year}s tracks...
`;
});
tabsContainer.innerHTML = tabsHTML;
contentsContainer.innerHTML = contentsHTML;
// Load first decade's tracks
if (data.decades.length > 0) {
await loadDecadeTracks(data.decades[0].year);
}
} catch (error) {
console.error('Error loading decade browser tabs:', error);
const tabsContainer = document.getElementById('decade-tabs');
if (tabsContainer) {
tabsContainer.innerHTML = '';
}
}
}
function switchDecadeTab(decade) {
// Update tab buttons
const tabs = document.querySelectorAll('.decade-tab');
tabs.forEach(tab => {
if (parseInt(tab.getAttribute('data-decade')) === decade) {
tab.classList.add('active');
} else {
tab.classList.remove('active');
}
});
// Update tab content
const tabContents = document.querySelectorAll('.decade-tab-content');
tabContents.forEach(content => {
if (content.id === `decade-${decade}-content`) {
content.classList.add('active');
} else {
content.classList.remove('active');
}
});
// Load tracks if not already loaded
if (!decadeTracksCache[decade]) {
loadDecadeTracks(decade);
}
}
async function loadDecadeTracks(decade) {
try {
const playlistContainer = document.getElementById(`decade-${decade}-playlist`);
if (!playlistContainer) return;
const response = await fetch(`/api/discover/decade/${decade}`);
if (!response.ok) {
throw new Error('Failed to fetch decade playlist');
}
const data = await response.json();
if (!data.success || !data.tracks || data.tracks.length === 0) {
playlistContainer.innerHTML = 'No tracks found for the ' + decade + 's
';
return;
}
// Store tracks in cache
decadeTracksCache[decade] = data.tracks;
activeDecade = decade;
// Build compact playlist HTML
let html = '';
data.tracks.forEach((track, index) => {
// Extract track data from track_data_json if available
let trackData = track;
if (track.track_data_json) {
trackData = track.track_data_json;
}
// Get track properties with fallbacks
const trackName = trackData.name || trackData.track_name || track.track_name || 'Unknown Track';
const artistName = trackData.artists?.[0]?.name || trackData.artists?.[0] || trackData.artist_name || track.artist_name || 'Unknown Artist';
const albumName = trackData.album?.name || trackData.album_name || track.album_name || 'Unknown Album';
const coverUrl = trackData.album?.images?.[0]?.url || track.album_cover_url || '/static/placeholder-album.png';
const durationMs = trackData.duration_ms || track.duration_ms || 0;
const durationMin = Math.floor(durationMs / 60000);
const durationSec = Math.floor((durationMs % 60000) / 1000);
const duration = `${durationMin}:${durationSec.toString().padStart(2, '0')}`;
html += `
${index + 1}
${trackName}
${artistName}
${albumName}
${duration}
`;
});
html += '
';
playlistContainer.innerHTML = html;
} catch (error) {
console.error('Error loading decade tracks:', error);
const playlistContainer = document.getElementById(`decade-${decade}-playlist`);
if (playlistContainer) {
playlistContainer.innerHTML = 'Failed to load decade tracks
';
}
}
}
async function startDecadeSync(decade) {
const tracks = decadeTracksCache[decade];
if (!tracks || tracks.length === 0) {
showToast('No tracks available for this decade', 'warning');
return;
}
// Convert to format expected by sync API
const spotifyTracks = tracks.map(track => {
// Extract track data from track_data_json if available
let trackData = track;
if (track.track_data_json) {
trackData = track.track_data_json;
}
// Build properly formatted Spotify track object
let spotifyTrack = {
id: trackData.id || track.spotify_track_id,
name: trackData.name || trackData.track_name || track.track_name,
artists: trackData.artists || [{ name: trackData.artist_name || track.artist_name }],
album: trackData.album || {
name: trackData.album_name || track.album_name,
images: trackData.album?.images || (track.album_cover_url ? [{ url: track.album_cover_url }] : [])
},
duration_ms: trackData.duration_ms || track.duration_ms || 0
};
// Normalize artists to array of strings for sync compatibility
if (spotifyTrack.artists && Array.isArray(spotifyTrack.artists)) {
spotifyTrack.artists = spotifyTrack.artists.map(a => a.name || a);
}
return spotifyTrack;
});
const virtualPlaylistId = `discover_decade_${decade}`;
playlistTrackCache[virtualPlaylistId] = spotifyTracks;
const virtualPlaylist = {
id: virtualPlaylistId,
name: `${decade}s Classics`,
track_count: spotifyTracks.length
};
if (!spotifyPlaylists.find(p => p.id === virtualPlaylistId)) {
spotifyPlaylists.push(virtualPlaylist);
}
// Show sync status display
const statusDisplay = document.getElementById(`decade-${decade}-sync-status`);
if (statusDisplay) statusDisplay.style.display = 'block';
// Disable sync button
const syncButton = document.getElementById(`decade-${decade}-sync-btn`);
if (syncButton) {
syncButton.disabled = true;
syncButton.style.opacity = '0.5';
syncButton.style.cursor = 'not-allowed';
}
// Start sync
await startPlaylistSync(virtualPlaylistId);
// Start polling
startDecadeSyncPolling(decade, virtualPlaylistId);
}
function startDecadeSyncPolling(decade, virtualPlaylistId) {
const pollerId = `decade_${decade}`;
if (discoverSyncPollers[pollerId]) {
clearInterval(discoverSyncPollers[pollerId]);
}
// Phase 5: Subscribe via WebSocket
if (socketConnected) {
socket.emit('sync:subscribe', { playlist_ids: [virtualPlaylistId] });
_syncProgressCallbacks[virtualPlaylistId] = (data) => {
const progress = data.progress || {};
const total = progress.total_tracks || 0;
const matched = progress.matched_tracks || 0;
const failed = progress.failed_tracks || 0;
const processed = matched + failed;
const pending = total - processed;
const pct = total > 0 ? Math.round((processed / total) * 100) : 0;
const el = (id) => document.getElementById(id);
if (el(`decade-${decade}-sync-completed`)) el(`decade-${decade}-sync-completed`).textContent = matched;
if (el(`decade-${decade}-sync-pending`)) el(`decade-${decade}-sync-pending`).textContent = pending;
if (el(`decade-${decade}-sync-failed`)) el(`decade-${decade}-sync-failed`).textContent = failed;
if (el(`decade-${decade}-sync-percentage`)) el(`decade-${decade}-sync-percentage`).textContent = pct;
if (data.status === 'finished') {
if (discoverSyncPollers[pollerId]) { clearInterval(discoverSyncPollers[pollerId]); delete discoverSyncPollers[pollerId]; }
socket.emit('sync:unsubscribe', { playlist_ids: [virtualPlaylistId] });
delete _syncProgressCallbacks[virtualPlaylistId];
const syncButton = el(`decade-${decade}-sync-btn`);
if (syncButton) { syncButton.disabled = false; syncButton.style.opacity = '1'; syncButton.style.cursor = 'pointer'; }
showToast(`${decade}s Classics sync complete!`, 'success');
setTimeout(() => { const sd = el(`decade-${decade}-sync-status`); if (sd) sd.style.display = 'none'; }, 3000);
}
};
}
discoverSyncPollers[pollerId] = setInterval(async () => {
if (socketConnected) return; // Phase 5: WS handles updates
try {
const response = await fetch(`/api/sync/status/${virtualPlaylistId}`);
if (!response.ok) return;
const data = await response.json();
const progress = data.progress || {};
const completedEl = document.getElementById(`decade-${decade}-sync-completed`);
const pendingEl = document.getElementById(`decade-${decade}-sync-pending`);
const failedEl = document.getElementById(`decade-${decade}-sync-failed`);
const percentageEl = document.getElementById(`decade-${decade}-sync-percentage`);
const total = progress.total_tracks || 0;
const matched = progress.matched_tracks || 0;
const failed = progress.failed_tracks || 0;
const processed = matched + failed;
const pending = total - processed;
const completionPercentage = total > 0 ? Math.round((processed / total) * 100) : 0;
if (completedEl) completedEl.textContent = matched;
if (pendingEl) pendingEl.textContent = pending;
if (failedEl) failedEl.textContent = failed;
if (percentageEl) percentageEl.textContent = completionPercentage;
if (data.status === 'finished') {
clearInterval(discoverSyncPollers[pollerId]);
delete discoverSyncPollers[pollerId];
const syncButton = document.getElementById(`decade-${decade}-sync-btn`);
if (syncButton) {
syncButton.disabled = false;
syncButton.style.opacity = '1';
syncButton.style.cursor = 'pointer';
}
showToast(`${decade}s Classics sync complete!`, 'success');
setTimeout(() => {
const statusDisplay = document.getElementById(`decade-${decade}-sync-status`);
if (statusDisplay) statusDisplay.style.display = 'none';
}, 3000);
}
} catch (error) {
console.error(`Error polling sync status for decade ${decade}:`, error);
}
}, 500);
}
async function openDownloadModalForDecade(decade) {
const tracks = decadeTracksCache[decade];
if (!tracks || tracks.length === 0) {
showToast('No tracks available for this decade', 'warning');
return;
}
// Convert to format expected by download modal
const spotifyTracks = tracks.map(track => {
// Extract track data from track_data_json if available
let trackData = track;
if (track.track_data_json) {
trackData = track.track_data_json;
}
// Build properly formatted Spotify track object
let spotifyTrack = {
id: trackData.id || track.spotify_track_id,
name: trackData.name || trackData.track_name || track.track_name,
artists: trackData.artists || [{ name: trackData.artist_name || track.artist_name }],
album: trackData.album || {
name: trackData.album_name || track.album_name,
images: trackData.album?.images || (track.album_cover_url ? [{ url: track.album_cover_url }] : [])
},
duration_ms: trackData.duration_ms || track.duration_ms || 0
};
return spotifyTrack;
});
const playlistName = `${decade}s Classics`;
const virtualPlaylistId = `decade_${decade}`;
await openDownloadMissingModalForYouTube(virtualPlaylistId, playlistName, spotifyTracks);
}
// ===============================
// BROWSE BY GENRE (TABBED BY GENRE)
// ===============================
let genreTracksCache = {}; // Store tracks for each genre
let activeGenre = null;
let availableGenres = [];
async function loadGenreBrowserTabs() {
try {
const tabsContainer = document.getElementById('genre-tabs');
const contentsContainer = document.getElementById('genre-tab-contents');
if (!tabsContainer || !contentsContainer) return;
// Fetch available genres from backend
const response = await fetch('/api/discover/genres/available');
if (!response.ok) {
throw new Error('Failed to fetch available genres');
}
const data = await response.json();
if (!data.success || !data.genres || data.genres.length === 0) {
tabsContainer.innerHTML = 'No genre content available yet. Run a watchlist scan to populate your discovery pool!
';
return;
}
availableGenres = data.genres;
// Build genre tabs (limit to first 8-10 to avoid overcrowding)
const displayGenres = data.genres.slice(0, 10);
let tabsHTML = '';
let contentsHTML = '';
displayGenres.forEach((genre, index) => {
const isActive = index === 0;
const icon = getGenreIcon(genre.name);
const genreName = genre.name;
const genreId = genreName.replace(/\s+/g, '-').replace(/[^a-zA-Z0-9-]/g, '');
const tabId = `genre-${genreId}`;
// Tab button
tabsHTML += `
${icon} ${capitalizeGenre(genreName)}
`;
// Tab content
contentsHTML += `
โณ
Syncing to media server...
โ 0
โณ 0
โ 0
(0 %)
Loading ${capitalizeGenre(genreName)} tracks...
`;
});
tabsContainer.innerHTML = tabsHTML;
contentsContainer.innerHTML = contentsHTML;
// Load first genre's tracks
if (displayGenres.length > 0) {
await loadGenreTracks(displayGenres[0].name);
}
} catch (error) {
console.error('Error loading genre browser tabs:', error);
const tabsContainer = document.getElementById('genre-tabs');
if (tabsContainer) {
tabsContainer.innerHTML = '';
}
}
}
function switchGenreTab(genreName) {
// Update tab buttons
const tabs = document.querySelectorAll('.genre-tab');
tabs.forEach(tab => {
if (tab.getAttribute('data-genre') === genreName) {
tab.classList.add('active');
} else {
tab.classList.remove('active');
}
});
// Update tab content
const tabContents = document.querySelectorAll('.genre-tab-content');
tabContents.forEach(content => {
if (content.getAttribute('data-genre') === genreName) {
content.classList.add('active');
} else {
content.classList.remove('active');
}
});
// Load tracks if not already loaded
if (!genreTracksCache[genreName]) {
loadGenreTracks(genreName);
}
}
async function loadGenreTracks(genreName) {
try {
const genreId = genreName.replace(/\s+/g, '-').replace(/[^a-zA-Z0-9-]/g, '');
const playlistContainer = document.getElementById(`genre-${genreId}-playlist`);
if (!playlistContainer) return;
const response = await fetch(`/api/discover/genre/${encodeURIComponent(genreName)}`);
if (!response.ok) {
throw new Error('Failed to fetch genre playlist');
}
const data = await response.json();
if (!data.success || !data.tracks || data.tracks.length === 0) {
playlistContainer.innerHTML = `No tracks found for ${capitalizeGenre(genreName)}
`;
return;
}
// Store tracks in cache
genreTracksCache[genreName] = data.tracks;
activeGenre = genreName;
// Build compact playlist HTML
let html = '';
data.tracks.forEach((track, index) => {
// Extract track data from track_data_json if available
let trackData = track;
if (track.track_data_json) {
trackData = track.track_data_json;
}
// Get track properties with fallbacks
const trackName = trackData.name || trackData.track_name || track.track_name || 'Unknown Track';
const artistName = trackData.artists?.[0]?.name || trackData.artists?.[0] || trackData.artist_name || track.artist_name || 'Unknown Artist';
const albumName = trackData.album?.name || trackData.album_name || track.album_name || 'Unknown Album';
const coverUrl = trackData.album?.images?.[0]?.url || track.album_cover_url || '/static/placeholder-album.png';
const durationMs = trackData.duration_ms || track.duration_ms || 0;
const durationMin = Math.floor(durationMs / 60000);
const durationSec = Math.floor((durationMs % 60000) / 1000);
const duration = `${durationMin}:${durationSec.toString().padStart(2, '0')}`;
html += `
${index + 1}
${trackName}
${artistName}
${albumName}
${duration}
`;
});
html += '
';
playlistContainer.innerHTML = html;
} catch (error) {
console.error('Error loading genre tracks:', error);
const genreId = genreName.replace(/\s+/g, '-').replace(/[^a-zA-Z0-9-]/g, '');
const playlistContainer = document.getElementById(`genre-${genreId}-playlist`);
if (playlistContainer) {
playlistContainer.innerHTML = 'Failed to load genre tracks
';
}
}
}
async function startGenreSync(genreName) {
const tracks = genreTracksCache[genreName];
if (!tracks || tracks.length === 0) {
showToast('No tracks available for this genre', 'warning');
return;
}
const genreId = genreName.replace(/\s+/g, '-').replace(/[^a-zA-Z0-9-]/g, '');
// Convert to format expected by sync API
const spotifyTracks = tracks.map(track => {
// Extract track data from track_data_json if available
let trackData = track;
if (track.track_data_json) {
trackData = track.track_data_json;
}
// Build properly formatted Spotify track object
let spotifyTrack = {
id: trackData.id || track.spotify_track_id,
name: trackData.name || trackData.track_name || track.track_name,
artists: trackData.artists || [{ name: trackData.artist_name || track.artist_name }],
album: trackData.album || {
name: trackData.album_name || track.album_name,
images: trackData.album?.images || (track.album_cover_url ? [{ url: track.album_cover_url }] : [])
},
duration_ms: trackData.duration_ms || track.duration_ms || 0
};
// Normalize artists to array of strings for sync compatibility
if (spotifyTrack.artists && Array.isArray(spotifyTrack.artists)) {
spotifyTrack.artists = spotifyTrack.artists.map(a => a.name || a);
}
return spotifyTrack;
});
const virtualPlaylistId = `discover_genre_${genreName.replace(/\s+/g, '_')}`;
playlistTrackCache[virtualPlaylistId] = spotifyTracks;
const virtualPlaylist = {
id: virtualPlaylistId,
name: `${capitalizeGenre(genreName)} Mix`,
track_count: spotifyTracks.length
};
if (!spotifyPlaylists.find(p => p.id === virtualPlaylistId)) {
spotifyPlaylists.push(virtualPlaylist);
}
// Show sync status display
const statusDisplay = document.getElementById(`genre-${genreId}-sync-status`);
if (statusDisplay) statusDisplay.style.display = 'block';
// Disable sync button
const syncButton = document.getElementById(`genre-${genreId}-sync-btn`);
if (syncButton) {
syncButton.disabled = true;
syncButton.style.opacity = '0.5';
syncButton.style.cursor = 'not-allowed';
}
// Start sync
await startPlaylistSync(virtualPlaylistId);
// Start polling
startGenreSyncPolling(genreName, genreId, virtualPlaylistId);
}
function startGenreSyncPolling(genreName, genreId, virtualPlaylistId) {
const pollerId = `genre_${genreId}`;
if (discoverSyncPollers[pollerId]) {
clearInterval(discoverSyncPollers[pollerId]);
}
// Phase 5: Subscribe via WebSocket
if (socketConnected) {
socket.emit('sync:subscribe', { playlist_ids: [virtualPlaylistId] });
_syncProgressCallbacks[virtualPlaylistId] = (data) => {
const progress = data.progress || {};
const total = progress.total_tracks || 0;
const matched = progress.matched_tracks || 0;
const failed = progress.failed_tracks || 0;
const processed = matched + failed;
const pending = total - processed;
const pct = total > 0 ? Math.round((processed / total) * 100) : 0;
const el = (id) => document.getElementById(id);
if (el(`genre-${genreId}-sync-completed`)) el(`genre-${genreId}-sync-completed`).textContent = matched;
if (el(`genre-${genreId}-sync-pending`)) el(`genre-${genreId}-sync-pending`).textContent = pending;
if (el(`genre-${genreId}-sync-failed`)) el(`genre-${genreId}-sync-failed`).textContent = failed;
if (el(`genre-${genreId}-sync-percentage`)) el(`genre-${genreId}-sync-percentage`).textContent = pct;
if (data.status === 'finished') {
if (discoverSyncPollers[pollerId]) { clearInterval(discoverSyncPollers[pollerId]); delete discoverSyncPollers[pollerId]; }
socket.emit('sync:unsubscribe', { playlist_ids: [virtualPlaylistId] });
delete _syncProgressCallbacks[virtualPlaylistId];
const syncButton = el(`genre-${genreId}-sync-btn`);
if (syncButton) { syncButton.disabled = false; syncButton.style.opacity = '1'; syncButton.style.cursor = 'pointer'; }
showToast(`${capitalizeGenre(genreName)} Mix sync complete!`, 'success');
setTimeout(() => { const sd = el(`genre-${genreId}-sync-status`); if (sd) sd.style.display = 'none'; }, 3000);
}
};
}
discoverSyncPollers[pollerId] = setInterval(async () => {
if (socketConnected) return; // Phase 5: WS handles updates
try {
const response = await fetch(`/api/sync/status/${virtualPlaylistId}`);
if (!response.ok) return;
const data = await response.json();
const progress = data.progress || {};
const completedEl = document.getElementById(`genre-${genreId}-sync-completed`);
const pendingEl = document.getElementById(`genre-${genreId}-sync-pending`);
const failedEl = document.getElementById(`genre-${genreId}-sync-failed`);
const percentageEl = document.getElementById(`genre-${genreId}-sync-percentage`);
const total = progress.total_tracks || 0;
const matched = progress.matched_tracks || 0;
const failed = progress.failed_tracks || 0;
const processed = matched + failed;
const pending = total - processed;
const completionPercentage = total > 0 ? Math.round((processed / total) * 100) : 0;
if (completedEl) completedEl.textContent = matched;
if (pendingEl) pendingEl.textContent = pending;
if (failedEl) failedEl.textContent = failed;
if (percentageEl) percentageEl.textContent = completionPercentage;
if (data.status === 'finished') {
clearInterval(discoverSyncPollers[pollerId]);
delete discoverSyncPollers[pollerId];
const syncButton = document.getElementById(`genre-${genreId}-sync-btn`);
if (syncButton) {
syncButton.disabled = false;
syncButton.style.opacity = '1';
syncButton.style.cursor = 'pointer';
}
showToast(`${capitalizeGenre(genreName)} Mix sync complete!`, 'success');
setTimeout(() => {
const statusDisplay = document.getElementById(`genre-${genreId}-sync-status`);
if (statusDisplay) statusDisplay.style.display = 'none';
}, 3000);
}
} catch (error) {
console.error(`Error polling sync status for genre ${genreName}:`, error);
}
}, 500);
}
async function openDownloadModalForGenre(genreName) {
const tracks = genreTracksCache[genreName];
if (!tracks || tracks.length === 0) {
showToast('No tracks available for this genre', 'warning');
return;
}
// Convert to format expected by download modal
const spotifyTracks = tracks.map(track => {
// Extract track data from track_data_json if available
let trackData = track;
if (track.track_data_json) {
trackData = track.track_data_json;
}
// Build properly formatted Spotify track object
let spotifyTrack = {
id: trackData.id || track.spotify_track_id,
name: trackData.name || trackData.track_name || track.track_name,
artists: trackData.artists || [{ name: trackData.artist_name || track.artist_name }],
album: trackData.album || {
name: trackData.album_name || track.album_name,
images: trackData.album?.images || (track.album_cover_url ? [{ url: track.album_cover_url }] : [])
},
duration_ms: trackData.duration_ms || track.duration_ms || 0
};
return spotifyTrack;
});
const playlistName = `${capitalizeGenre(genreName)} Mix`;
const virtualPlaylistId = `genre_${genreName.replace(/\s+/g, '_')}`;
await openDownloadMissingModalForYouTube(virtualPlaylistId, playlistName, spotifyTracks);
}
// ===============================
// LISTENBRAINZ PLAYLISTS
// ===============================
let listenbrainzPlaylistsCache = {}; // Store playlists by type
let listenbrainzTracksCache = {}; // Store tracks for each playlist
let activeListenBrainzTab = 'recommendations'; // Track active tab
let activeListenBrainzSubTab = null; // Track active sub-tab within recommendations
async function initializeListenBrainzTabs() {
try {
console.log('๐ง Initializing ListenBrainz tabs...');
// Fetch all playlists types
const [createdForRes, userPlaylistsRes, collaborativeRes] = await Promise.all([
fetch('/api/discover/listenbrainz/created-for'),
fetch('/api/discover/listenbrainz/user-playlists'),
fetch('/api/discover/listenbrainz/collaborative')
]);
console.log('๐ก API Responses:', {
createdFor: createdForRes.status,
userPlaylists: userPlaylistsRes.status,
collaborative: collaborativeRes.status
});
const tabs = [
{ id: 'recommendations', label: '๐ Recommendations', hasData: false },
{ id: 'user', label: '๐ Your Playlists', hasData: false },
{ id: 'collaborative', label: '๐ค Collaborative', hasData: false }
];
// Track LB username for header display
let lbUsername = null;
// Check which tabs have data
if (createdForRes.ok) {
const data = await createdForRes.json();
console.log('๐ Created For data:', data);
if (data.username) lbUsername = data.username;
if (data.success && data.playlists && data.playlists.length > 0) {
listenbrainzPlaylistsCache['recommendations'] = data.playlists;
tabs[0].hasData = true;
console.log(`โ
Found ${data.playlists.length} recommendation playlists`);
}
}
if (userPlaylistsRes.ok) {
const data = await userPlaylistsRes.json();
console.log('๐ User Playlists data:', data);
if (data.username && !lbUsername) lbUsername = data.username;
if (data.success && data.playlists && data.playlists.length > 0) {
listenbrainzPlaylistsCache['user'] = data.playlists;
tabs[1].hasData = true;
console.log(`โ
Found ${data.playlists.length} user playlists`);
}
}
if (collaborativeRes.ok) {
const data = await collaborativeRes.json();
console.log('๐ค Collaborative data:', data);
if (data.username && !lbUsername) lbUsername = data.username;
if (data.success && data.playlists && data.playlists.length > 0) {
listenbrainzPlaylistsCache['collaborative'] = data.playlists;
tabs[2].hasData = true;
console.log(`โ
Found ${data.playlists.length} collaborative playlists`);
}
}
// Build tabs HTML
const tabsContainer = document.getElementById('listenbrainz-tabs');
console.log('๐ง Building tabs. Available tabs:', tabs.filter(t => t.hasData).map(t => t.label));
let tabsHtml = ''; // Reuse decade tabs styling
tabs.forEach(tab => {
if (tab.hasData) {
const isActive = tab.id === activeListenBrainzTab;
tabsHtml += `
${tab.label}
`;
}
});
tabsHtml += '
';
if (tabs.every(t => !t.hasData)) {
console.log('โ ๏ธ No tabs have data');
tabsContainer.innerHTML = `
🧠
Connect ListenBrainz
Link your ListenBrainz account to see personalized playlists, recommendations, and collaborative playlists.
Connect ListenBrainz
Get your token from listenbrainz.org/profile
`;
return;
}
tabsContainer.innerHTML = tabsHtml;
// Update section subtitle with username
const lbSubtitle = document.getElementById('listenbrainz-section-subtitle');
if (lbSubtitle) {
lbSubtitle.textContent = lbUsername ? `Playlists for ${lbUsername}` : 'Playlists from ListenBrainz';
}
// Load first available tab
const firstTab = tabs.find(t => t.hasData);
if (firstTab) {
console.log(`๐ฏ Loading first tab: ${firstTab.label} (${firstTab.id})`);
activeListenBrainzTab = firstTab.id;
loadListenBrainzTabContent(firstTab.id);
} else {
console.log('โ No first tab found');
}
} catch (error) {
console.error('Error initializing ListenBrainz tabs:', error);
const tabsContainer = document.getElementById('listenbrainz-tabs');
if (tabsContainer) {
tabsContainer.innerHTML = '';
}
}
}
function switchListenBrainzTab(tabId) {
// Update active tab
activeListenBrainzTab = tabId;
// Update tab buttons
const tabs = document.querySelectorAll('#listenbrainz-tabs .decade-tab');
tabs.forEach(tab => {
if (tab.dataset.tab === tabId) {
tab.classList.add('active');
} else {
tab.classList.remove('active');
}
});
// Load content
loadListenBrainzTabContent(tabId);
}
function groupListenBrainzPlaylists(playlists) {
const groups = {};
const groupOrder = [];
playlists.forEach(playlist => {
const playlistData = playlist.playlist || playlist;
const title = (playlistData.title || '').toLowerCase();
let groupName;
if (title.includes('weekly jams')) {
groupName = 'Weekly Jams';
} else if (title.includes('weekly exploration')) {
groupName = 'Weekly Exploration';
} else if (title.includes('top discoveries')) {
groupName = 'Top Discoveries';
} else if (title.includes('top missed recordings')) {
groupName = 'Top Missed Recordings';
} else if (title.includes('daily jams')) {
groupName = 'Daily Jams';
} else {
groupName = 'Other';
}
if (!groups[groupName]) {
groups[groupName] = [];
groupOrder.push(groupName);
}
groups[groupName].push(playlist);
});
// Move "Other" to the end if it exists
const otherIdx = groupOrder.indexOf('Other');
if (otherIdx !== -1 && otherIdx !== groupOrder.length - 1) {
groupOrder.splice(otherIdx, 1);
groupOrder.push('Other');
}
return { groups, groupOrder };
}
function buildListenBrainzPlaylistsHtml(playlists, tabId) {
let html = '';
playlists.forEach((playlist, index) => {
const playlistData = playlist.playlist || playlist;
const identifier = playlistData.identifier?.split('/').pop() || '';
console.log(`๐ Playlist ${index}:`, {
title: playlistData.title,
fullIdentifier: playlistData.identifier,
extractedIdentifier: identifier
});
const title = playlistData.title || 'Untitled Playlist';
const creator = playlistData.creator || 'ListenBrainz';
let trackCount = 50;
if (playlistData.annotation?.track_count && playlistData.annotation.track_count > 0) {
trackCount = playlistData.annotation.track_count;
} else if (playlistData.track && Array.isArray(playlistData.track) && playlistData.track.length > 0) {
trackCount = playlistData.track.length;
}
const playlistId = `discover-lb-playlist-${identifier}`; // Use consistent MBID-based ID
const virtualPlaylistId = `discover_lb_${tabId}_${identifier}`;
html += `
โณ
Syncing to media server...
โช 0
/
โ 0
/
โ 0
(0 %)
`;
});
return html;
}
function loadTracksForPlaylists(playlists) {
playlists.forEach((playlist) => {
const playlistData = playlist.playlist || playlist;
const identifier = playlistData.identifier?.split('/').pop() || '';
const playlistId = `discover-lb-playlist-${identifier}`;
loadListenBrainzPlaylistTracks(identifier, playlistId);
});
}
function switchListenBrainzSubTab(groupId) {
activeListenBrainzSubTab = groupId;
// Update sub-tab buttons
const subTabs = document.querySelectorAll('#lb-subtabs-bar .lb-subtab');
subTabs.forEach(tab => {
if (tab.dataset.group === groupId) {
tab.classList.add('active');
} else {
tab.classList.remove('active');
}
});
// Show/hide sub-tab content panels
const panels = document.querySelectorAll('.lb-subtab-panel');
panels.forEach(panel => {
if (panel.dataset.group === groupId) {
panel.style.display = 'block';
// Load tracks for playlists in this panel if not already loaded
const unloaded = panel.querySelectorAll('.discover-loading');
if (unloaded.length > 0) {
const groupPlaylists = panel._playlists;
if (groupPlaylists) {
loadTracksForPlaylists(groupPlaylists);
}
}
} else {
panel.style.display = 'none';
}
});
}
async function loadListenBrainzTabContent(tabId) {
const container = document.getElementById('listenbrainz-tab-content');
if (!container) return;
const playlists = listenbrainzPlaylistsCache[tabId] || [];
if (playlists.length === 0) {
container.innerHTML = 'No playlists in this category
';
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 = '';
groupOrder.forEach(groupName => {
const isActive = groupName === firstGroup;
const count = groups[groupName].length;
subTabsHtml += `
${groupName} (${count})
`;
});
subTabsHtml += '
';
// Build content panels for each group
let panelsHtml = '';
groupOrder.forEach(groupName => {
const isActive = groupName === firstGroup;
panelsHtml += ``;
panelsHtml += buildListenBrainzPlaylistsHtml(groups[groupName], tabId);
panelsHtml += '
';
});
container.innerHTML = subTabsHtml + panelsHtml;
// Store playlist references on panels for lazy loading
groupOrder.forEach(groupName => {
const panel = container.querySelector(`.lb-subtab-panel[data-group="${groupName}"]`);
if (panel) {
panel._playlists = groups[groupName];
}
});
// Load tracks only for the active sub-tab
loadTracksForPlaylists(groups[firstGroup]);
return;
}
// Default: flat list for user/collaborative tabs (or single-group recommendations)
const html = buildListenBrainzPlaylistsHtml(playlists, tabId);
container.innerHTML = html;
loadTracksForPlaylists(playlists);
}
async function loadListenBrainzPlaylistTracks(identifier, playlistId) {
try {
const playlistContainer = document.getElementById(`${playlistId}-playlist`);
if (!playlistContainer) return;
// Check cache first
if (listenbrainzTracksCache[identifier]) {
displayListenBrainzTracks(listenbrainzTracksCache[identifier], playlistId);
return;
}
console.log(`๐ Fetching tracks for playlist: ${identifier}`);
const response = await fetch(`/api/discover/listenbrainz/playlist/${identifier}`);
console.log(`๐ก Response status: ${response.status}`);
if (!response.ok) {
const errorText = await response.text();
console.error(`โ Failed to fetch playlist: ${response.status} - ${errorText}`);
throw new Error('Failed to fetch playlist tracks');
}
const data = await response.json();
console.log(`๐ Received data:`, data);
console.log(`๐ Tracks count: ${data.tracks?.length || 0}`);
if (!data.success || !data.tracks || data.tracks.length === 0) {
playlistContainer.innerHTML = '';
return;
}
// Cache the tracks
listenbrainzTracksCache[identifier] = data.tracks;
// Display tracks
displayListenBrainzTracks(data.tracks, playlistId);
} catch (error) {
console.error('Error loading ListenBrainz playlist tracks:', error);
const playlistContainer = document.getElementById(`${playlistId}-playlist`);
if (playlistContainer) {
playlistContainer.innerHTML = '';
}
}
}
/**
* Clean artist name by removing featured artists
* e.g., "Blackstreet feat. Dr. Dre & Queen Pen" -> "Blackstreet"
*/
function cleanArtistName(artistName) {
if (!artistName) return artistName;
// Remove everything after common featuring patterns (case insensitive)
const patterns = [
/\s+feat\.?\s+.*/i, // "feat." or "feat"
/\s+featuring\s+.*/i, // "featuring"
/\s+ft\.?\s+.*/i, // "ft." or "ft"
/\s+with\s+.*/i, // "with"
/\s+x\s+.*/i // " x " (common in collaborations)
];
let cleaned = artistName;
for (const pattern of patterns) {
cleaned = cleaned.replace(pattern, '');
}
return cleaned.trim();
}
function displayListenBrainzTracks(tracks, playlistId) {
const playlistContainer = document.getElementById(`${playlistId}-playlist`);
if (!playlistContainer) return;
console.log(`๐จ Displaying ${tracks.length} tracks for ${playlistId}`);
if (tracks.length > 0) {
console.log('Sample track data:', tracks[0]);
}
// Update track count in the metadata section
const metaElement = document.getElementById(`${playlistId}-meta`);
if (metaElement) {
// Extract creator from existing text (before the bullet)
const currentText = metaElement.textContent;
const creatorMatch = currentText.match(/by (.+?) โข/);
const creator = creatorMatch ? creatorMatch[1] : 'ListenBrainz';
metaElement.textContent = `by ${creator} โข ${tracks.length} track${tracks.length !== 1 ? 's' : ''}`;
}
// Simple SVG placeholder for missing album art
const placeholderImage = 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNDAiIGhlaWdodD0iNDAiIHZpZXdCb3g9IjAgMCA0MCA0MCIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48cmVjdCB3aWR0aD0iNDAiIGhlaWdodD0iNDAiIGZpbGw9IiMzMzMiLz48cGF0aCBkPSJNMjAgMTBDMTguMzQzMSAxMCAxNyAxMS4zNDMxIDE3IDEzQzE3IDE0LjY1NjkgMTguMzQzMSAxNiAyMCAxNkMyMS42NTY5IDE2IDIzIDE0LjY1NjkgMjMgMTNDMjMgMTEuMzQzMSAyMS42NTY5IDEwIDIwIDEwWk0yNSAyMEgyNUMyNSAxOC44OTU0IDI0LjEwNDYgMTggMjMgMThIMTdDMTUuODk1NCAxOCAxNSAxOC44OTU0IDE1IDIwVjI4QzE1IDI5LjEwNDYgMTUuODk1NCAzMCAxNyAzMEgyM0MyNC4xMDQ2IDMwIDI1IDI5LjEwNDYgMjUgMjhWMjBaIiBmaWxsPSIjNjY2Ii8+PC9zdmc+';
let html = '';
tracks.forEach((track, index) => {
const coverUrl = track.album_cover_url || placeholderImage;
const durationMin = Math.floor(track.duration_ms / 60000);
const durationSec = Math.floor((track.duration_ms % 60000) / 1000);
const duration = `${durationMin}:${durationSec.toString().padStart(2, '0')}`;
const albumName = escapeHtml(track.album_name || 'Unknown Album');
html += `
${index + 1}
${escapeHtml(track.track_name || 'Unknown Track')}
${escapeHtml(cleanArtistName(track.artist_name) || 'Unknown Artist')}
${albumName}
${duration}
`;
});
html += '
';
playlistContainer.innerHTML = html;
}
async function openDownloadModalForListenBrainzPlaylist(identifier, title) {
try {
const tracks = listenbrainzTracksCache[identifier];
if (!tracks || tracks.length === 0) {
showToast('No tracks to download', 'error');
return;
}
console.log(`๐ต Opening ListenBrainz discovery modal: ${title}`);
console.log(`๐ Looking for existing state with identifier: ${identifier}`);
console.log(`๐ All ListenBrainz states:`, Object.keys(listenbrainzPlaylistStates));
// Check if state already exists from backend hydration (like Beatport does)
const existingState = listenbrainzPlaylistStates[identifier];
console.log(`๐ Existing state found:`, existingState ? `Phase: ${existingState.phase}` : 'None');
if (existingState && existingState.phase !== 'fresh') {
// State exists - rehydrate the modal with existing data
console.log(`๐ Rehydrating existing ListenBrainz state (Phase: ${existingState.phase})`);
// If downloading/download_complete, rehydrate download modal instead
if ((existingState.phase === 'downloading' || existingState.phase === 'download_complete') &&
existingState.convertedSpotifyPlaylistId && existingState.download_process_id) {
console.log(`๐ฅ Rehydrating download modal for ListenBrainz playlist: ${title}`);
// Implement download modal rehydration (like Beatport does)
const convertedPlaylistId = existingState.convertedSpotifyPlaylistId;
try {
// Check if modal already exists (user just closed it)
if (activeDownloadProcesses[convertedPlaylistId]) {
console.log(`โ
Download modal already exists, just showing it`);
const process = activeDownloadProcesses[convertedPlaylistId];
if (process.modalElement) {
process.modalElement.style.display = 'flex';
}
return;
}
// Create the download modal using the ListenBrainz state
console.log(`๐ Creating new download modal for rehydration`);
// Get tracks from the existing state
let spotifyTracks = [];
if (existingState && existingState.discovery_results) {
spotifyTracks = existingState.discovery_results
.filter(result => result.spotify_data)
.map(result => {
const track = result.spotify_data;
// Ensure artists is an array of strings
if (track.artists && Array.isArray(track.artists)) {
track.artists = track.artists.map(artist =>
typeof artist === 'string' ? artist : (artist.name || artist)
);
} else if (track.artists && typeof track.artists === 'string') {
track.artists = [track.artists];
} else {
track.artists = ['Unknown Artist'];
}
return {
id: track.id,
name: track.name,
artists: track.artists,
album: track.album || 'Unknown Album',
duration_ms: track.duration_ms || 0,
external_urls: track.external_urls || {}
};
});
}
if (spotifyTracks.length > 0) {
await openDownloadMissingModalForYouTube(
convertedPlaylistId,
title,
spotifyTracks
);
// Set the modal to running state with the correct batch ID
const process = activeDownloadProcesses[convertedPlaylistId];
if (process) {
process.status = existingState.phase === 'download_complete' ? 'complete' : 'running';
process.batchId = existingState.download_process_id;
// Update UI to running state
const beginBtn = document.getElementById(`begin-analysis-btn-${convertedPlaylistId}`);
const cancelBtn = document.getElementById(`cancel-all-btn-${convertedPlaylistId}`);
if (beginBtn) beginBtn.style.display = 'none';
if (cancelBtn) cancelBtn.style.display = 'inline-block';
// Start polling for this process
startModalDownloadPolling(convertedPlaylistId);
// Add to discover download sidebar if this has discoverMetadata
if (process.discoverMetadata) {
const playlistName = title;
const imageUrl = process.discoverMetadata.imageUrl;
const type = process.discoverMetadata.type || 'album';
addDiscoverDownload(convertedPlaylistId, playlistName, type, imageUrl);
console.log(`๐ฅ [REHYDRATION] Added ListenBrainz download to sidebar: ${playlistName}`);
}
// Show modal since user clicked the download button (different from background rehydration)
if (process.modalElement) {
process.modalElement.style.display = 'flex';
}
console.log(`โ
Rehydrated download modal for ListenBrainz playlist: ${title}`);
}
} else {
console.warn(`โ ๏ธ No Spotify tracks found for ListenBrainz download modal: ${title}`);
}
} catch (error) {
console.warn(`โ ๏ธ Error setting up download process for ListenBrainz playlist "${title}":`, error.message);
}
return;
}
// Open discovery modal with existing state
openYouTubeDiscoveryModal(identifier);
// If still discovering, resume polling
if (existingState.phase === 'discovering') {
console.log(`๐ Resuming discovery polling for: ${title}`);
startListenBrainzDiscoveryPolling(identifier);
}
return;
}
// No existing state - create fresh state and start discovery
console.log(`๐ Creating fresh ListenBrainz state for: ${title}`);
// Create YouTube-style state entry for this ListenBrainz playlist (like Beatport does)
const listenbrainzState = {
phase: 'fresh',
playlist: {
name: title,
tracks: tracks.map(track => ({
track_name: track.track_name,
artist_name: track.artist_name,
album_name: track.album_name,
duration_ms: track.duration_ms || 0,
mbid: track.mbid,
release_mbid: track.release_mbid,
album_cover_url: track.album_cover_url
})),
description: `${tracks.length} tracks from ${title}`,
source: 'listenbrainz'
},
is_listenbrainz_playlist: true,
playlist_mbid: identifier, // Link to ListenBrainz playlist
// Initialize discovery state properties (both naming conventions for modal compatibility)
discovery_results: [],
discoveryResults: [],
discovery_progress: 0,
discoveryProgress: 0,
spotify_matches: 0,
spotifyMatches: 0,
spotify_total: tracks.length,
spotifyTotal: tracks.length
};
// Store in ListenBrainz playlist states
listenbrainzPlaylistStates[identifier] = listenbrainzState;
// Start discovery automatically (like Beatport and Tidal do)
try {
console.log(`๐ Starting ListenBrainz discovery for: ${title}`);
// Call the discovery start endpoint with playlist data
const response = await fetch(`/api/listenbrainz/discovery/start/${identifier}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
playlist: listenbrainzState.playlist
})
});
const result = await response.json();
if (result.success) {
// Update state to discovering
listenbrainzPlaylistStates[identifier].phase = 'discovering';
// Start polling for progress
startListenBrainzDiscoveryPolling(identifier);
console.log(`โ
Started ListenBrainz discovery for: ${title}`);
} else {
console.error('โ Error starting ListenBrainz discovery:', result.error);
showToast(`Error starting discovery: ${result.error}`, 'error');
}
} catch (error) {
console.error('โ Error starting ListenBrainz discovery:', error);
showToast(`Error starting discovery: ${error.message}`, 'error');
}
// Open the existing YouTube discovery modal infrastructure
openYouTubeDiscoveryModal(identifier);
console.log(`โ
ListenBrainz discovery modal opened for ${title} with ${tracks.length} tracks`);
} catch (error) {
console.error('Error opening discovery modal for ListenBrainz playlist:', error);
showToast('Failed to open discovery modal', 'error');
}
}
async function openListenBrainzPlaylist(playlistMbid, playlistName) {
try {
showLoadingOverlay(`Loading ${playlistName}...`);
const response = await fetch(`/api/discover/listenbrainz/playlist/${playlistMbid}`);
if (!response.ok) {
throw new Error('Failed to fetch playlist');
}
const data = await response.json();
if (!data.success || !data.playlist) {
showToast('Failed to load playlist', 'error');
hideLoadingOverlay();
return;
}
const playlist = data.playlist;
const tracks = playlist.tracks || [];
if (tracks.length === 0) {
showToast('This playlist is empty', 'info');
hideLoadingOverlay();
return;
}
// Convert to Spotify-like format for compatibility with download modal
const spotifyTracks = tracks.map(track => ({
id: track.recording_mbid || `listenbrainz_${track.title}_${track.creator}`.replace(/[^a-z0-9]/gi, '_'), // Generate ID if missing
name: track.title || 'Unknown',
artists: [{ name: cleanArtistName(track.creator || 'Unknown') }], // Proper Spotify format
album: {
name: track.album || 'Unknown Album',
images: track.album_cover_url ? [{ url: track.album_cover_url }] : []
},
duration_ms: track.duration_ms || 0,
listenbrainz_metadata: track.additional_metadata
}));
const virtualPlaylistId = `listenbrainz_${playlistMbid}`;
await openDownloadMissingModalForYouTube(virtualPlaylistId, playlistName, spotifyTracks);
hideLoadingOverlay();
} catch (error) {
console.error(`Error opening ListenBrainz playlist:`, error);
showToast(`Failed to load playlist`, 'error');
hideLoadingOverlay();
}
}
async function refreshListenBrainzPlaylists() {
const button = document.getElementById('listenbrainz-refresh-btn');
if (!button) return;
try {
// Show loading state on button
const originalContent = button.innerHTML;
button.disabled = true;
button.innerHTML = 'โณ Refreshing... ';
console.log('๐ Refreshing ListenBrainz playlists...');
showToast('Refreshing ListenBrainz playlists...', 'info');
const response = await fetch('/api/discover/listenbrainz/refresh', {
method: 'POST'
});
if (!response.ok) {
throw new Error(`Failed to refresh: ${response.statusText}`);
}
const data = await response.json();
if (data.success) {
const summary = data.summary || {};
let message = 'ListenBrainz playlists refreshed!';
// Build summary message
const updates = [];
for (const [type, stats] of Object.entries(summary)) {
const total = (stats.new || 0) + (stats.updated || 0);
if (total > 0) {
updates.push(`${total} ${type}`);
}
}
if (updates.length > 0) {
message += ` Updated: ${updates.join(', ')}`;
} else {
message = 'All playlists are up to date';
}
console.log('โ
Refresh complete:', data.summary);
showToast(message, 'success');
// Reload the tabs to show updated data
await initializeListenBrainzTabs();
} else {
throw new Error(data.error || 'Unknown error');
}
// Restore button
button.disabled = false;
button.innerHTML = originalContent;
} catch (error) {
console.error('Error refreshing ListenBrainz playlists:', error);
showToast(`Failed to refresh: ${error.message}`, 'error');
// Restore button
button.disabled = false;
button.innerHTML = '๐ Refresh ';
}
}
// ===============================
// SEASONAL DISCOVERY
// ===============================
async function loadSeasonalContent() {
try {
const response = await fetch('/api/discover/seasonal/current');
if (!response.ok) {
console.error('Failed to fetch seasonal content');
return;
}
const data = await response.json();
// If no active season, hide seasonal sections
if (!data.success || !data.season) {
hideSeasonalSections();
return;
}
currentSeasonKey = data.season;
// Load seasonal albums
await loadSeasonalAlbums(data);
// Load seasonal playlist if available
if (data.playlist_available) {
await loadSeasonalPlaylist(data);
}
} catch (error) {
console.error('Error loading seasonal content:', error);
hideSeasonalSections();
}
}
async function loadSeasonalAlbums(seasonData) {
try {
const carousel = document.getElementById('seasonal-albums-carousel');
if (!carousel) return;
// Show seasonal section
const seasonalSection = document.getElementById('seasonal-albums-section');
if (seasonalSection) {
seasonalSection.style.display = 'block';
}
// Update header
const seasonalTitle = document.getElementById('seasonal-albums-title');
const seasonalSubtitle = document.getElementById('seasonal-albums-subtitle');
if (seasonalTitle) {
seasonalTitle.textContent = `${seasonData.icon} ${seasonData.name}`;
}
if (seasonalSubtitle) {
seasonalSubtitle.textContent = seasonData.description;
}
// Store albums for download functionality
discoverSeasonalAlbums = seasonData.albums || [];
if (discoverSeasonalAlbums.length === 0) {
carousel.innerHTML = '';
return;
}
// Build carousel HTML
let html = '';
discoverSeasonalAlbums.forEach((album, index) => {
const coverUrl = album.album_cover_url || '/static/placeholder-album.png';
html += `
${album.album_name}
${album.artist_name}
${album.release_date ? `
${album.release_date}
` : ''}
`;
});
carousel.innerHTML = html;
} catch (error) {
console.error('Error loading seasonal albums:', error);
}
}
async function loadSeasonalPlaylist(seasonData) {
try {
const playlistContainer = document.getElementById('seasonal-playlist');
if (!playlistContainer) return;
// Show seasonal playlist section
const seasonalPlaylistSection = document.getElementById('seasonal-playlist-section');
if (seasonalPlaylistSection) {
seasonalPlaylistSection.style.display = 'block';
}
// Update header
const playlistTitle = document.getElementById('seasonal-playlist-title');
const playlistSubtitle = document.getElementById('seasonal-playlist-subtitle');
if (playlistTitle) {
playlistTitle.textContent = `${seasonData.icon} ${seasonData.name} Mix`;
}
if (playlistSubtitle) {
playlistSubtitle.textContent = `Curated playlist for ${seasonData.name.toLowerCase()}`;
}
playlistContainer.innerHTML = '';
// Fetch playlist tracks
const response = await fetch(`/api/discover/seasonal/${currentSeasonKey}/playlist`);
if (!response.ok) {
throw new Error('Failed to fetch seasonal playlist');
}
const data = await response.json();
if (!data.success || !data.tracks || data.tracks.length === 0) {
playlistContainer.innerHTML = '';
return;
}
// Store tracks for download/sync functionality
discoverSeasonalTracks = data.tracks;
// Build compact playlist HTML
let html = '';
data.tracks.forEach((track, index) => {
const coverUrl = track.album_cover_url || '/static/placeholder-album.png';
const durationMin = Math.floor(track.duration_ms / 60000);
const durationSec = Math.floor((track.duration_ms % 60000) / 1000);
const duration = `${durationMin}:${durationSec.toString().padStart(2, '0')}`;
html += `
${index + 1}
${track.track_name}
${track.artist_name}
${track.album_name}
${duration}
`;
});
html += '
';
playlistContainer.innerHTML = html;
} catch (error) {
console.error('Error loading seasonal playlist:', error);
const playlistContainer = document.getElementById('seasonal-playlist');
if (playlistContainer) {
playlistContainer.innerHTML = '';
}
}
}
function hideSeasonalSections() {
const seasonalAlbumsSection = document.getElementById('seasonal-albums-section');
const seasonalPlaylistSection = document.getElementById('seasonal-playlist-section');
if (seasonalAlbumsSection) {
seasonalAlbumsSection.style.display = 'none';
}
if (seasonalPlaylistSection) {
seasonalPlaylistSection.style.display = 'none';
}
}
async function openDownloadModalForSeasonalAlbum(albumIndex) {
const album = discoverSeasonalAlbums[albumIndex];
if (!album) {
showToast('Album data not found', 'error');
return;
}
console.log(`๐ฅ Opening Download Missing Tracks modal for seasonal album: ${album.album_name}`);
showLoadingOverlay(`Loading tracks for ${album.album_name}...`);
try {
// Determine source and album ID - use source-agnostic endpoint (matches Recent Releases)
const source = album.source || (album.spotify_album_id && !album.spotify_album_id.match(/^\d+$/) ? 'spotify' : 'itunes');
const albumId = album.spotify_album_id;
if (!albumId) {
throw new Error('No album ID available');
}
// Fetch album tracks from appropriate source (pass name/artist for Hydrabase support)
const _dap1 = new URLSearchParams({ name: album.album_name || '', artist: album.artist_name || '' });
const response = await fetch(`/api/discover/album/${source}/${albumId}?${_dap1}`);
if (!response.ok) {
throw new Error('Failed to fetch album tracks');
}
const albumData = await response.json();
if (!albumData.tracks || albumData.tracks.length === 0) {
throw new Error('No tracks found in album');
}
// Convert to expected format with full album context (matches Recent Releases)
const spotifyTracks = albumData.tracks.map(track => {
let artists = track.artists || albumData.artists || [{ name: album.artist_name }];
if (Array.isArray(artists)) {
artists = artists.map(a => a.name || a);
}
return {
id: track.id,
name: track.name,
artists: artists,
album: {
id: albumData.id,
name: albumData.name,
album_type: albumData.album_type || 'album',
total_tracks: albumData.total_tracks || 0,
release_date: albumData.release_date || '',
images: albumData.images || []
},
duration_ms: track.duration_ms || 0,
track_number: track.track_number || 0
};
});
// Create virtual playlist ID
const virtualPlaylistId = `seasonal_album_${albumId}`;
// Pass proper artist/album context for album download (1 worker + source reuse)
const artistContext = {
name: album.artist_name,
source: source
};
const albumContext = {
id: albumData.id,
name: albumData.name,
album_type: albumData.album_type || 'album',
total_tracks: albumData.total_tracks || 0,
release_date: albumData.release_date || '',
images: albumData.images || []
};
// Open download modal with album context (same as Recent Releases)
await openDownloadMissingModalForYouTube(virtualPlaylistId, albumData.name, spotifyTracks, artistContext, albumContext);
hideLoadingOverlay();
} catch (error) {
console.error(`Error loading seasonal album: ${error.message}`);
hideLoadingOverlay();
showToast(`Failed to load album tracks: ${error.message}`, 'error');
}
}
async function openDownloadModalForSeasonalPlaylist() {
if (!discoverSeasonalTracks || discoverSeasonalTracks.length === 0) {
alert('No seasonal tracks available');
return;
}
// Convert to track format expected by modal
const tracks = discoverSeasonalTracks.map(track => ({
id: track.spotify_track_id,
name: track.track_name,
artists: [{ name: track.artist_name }],
album: { name: track.album_name }
}));
openDownloadMissingModal(tracks, `${currentSeasonKey} Seasonal Mix`);
}
async function syncSeasonalPlaylist() {
if (!currentSeasonKey) {
alert('No active season');
return;
}
// Use the same sync logic as other discover playlists
// Create a virtual playlist ID for tracking
const virtualPlaylistId = `discover_seasonal_${currentSeasonKey}`;
// Build playlist data from seasonal tracks
const playlistData = {
id: virtualPlaylistId,
name: `${currentSeasonKey.charAt(0).toUpperCase() + currentSeasonKey.slice(1)} Mix`,
tracks: discoverSeasonalTracks.map(track => ({
id: track.spotify_track_id,
name: track.track_name,
artists: [{ name: track.artist_name }],
album: { name: track.album_name },
duration_ms: track.duration_ms
}))
};
// Trigger sync (reuse existing sync infrastructure)
await syncPlaylistToLibrary(playlistData);
}
// ===============================
// PERSONALIZED PLAYLISTS
// ===============================
async function loadPersonalizedRecentlyAdded() {
try {
const container = document.getElementById('personalized-recently-added');
if (!container) return;
const response = await fetch('/api/discover/personalized/recently-added');
if (!response.ok) return;
const data = await response.json();
if (!data.success || !data.tracks || data.tracks.length === 0) {
container.closest('.discover-section').style.display = 'none';
return;
}
personalizedRecentlyAdded = data.tracks;
renderCompactPlaylist(container, data.tracks);
container.closest('.discover-section').style.display = 'block';
} catch (error) {
console.error('Error loading recently added:', error);
}
}
async function loadPersonalizedTopTracks() {
try {
const container = document.getElementById('personalized-top-tracks');
if (!container) return;
const response = await fetch('/api/discover/personalized/top-tracks');
if (!response.ok) return;
const data = await response.json();
if (!data.success || !data.tracks || data.tracks.length === 0) {
container.closest('.discover-section').style.display = 'none';
return;
}
personalizedTopTracks = data.tracks;
renderCompactPlaylist(container, data.tracks);
container.closest('.discover-section').style.display = 'block';
} catch (error) {
console.error('Error loading top tracks:', error);
}
}
async function loadPersonalizedForgottenFavorites() {
try {
const container = document.getElementById('personalized-forgotten-favorites');
if (!container) return;
const response = await fetch('/api/discover/personalized/forgotten-favorites');
if (!response.ok) return;
const data = await response.json();
if (!data.success || !data.tracks || data.tracks.length === 0) {
container.closest('.discover-section').style.display = 'none';
return;
}
personalizedForgottenFavorites = data.tracks;
renderCompactPlaylist(container, data.tracks);
container.closest('.discover-section').style.display = 'block';
} catch (error) {
console.error('Error loading forgotten favorites:', error);
}
}
async function loadPersonalizedPopularPicks() {
try {
const container = document.getElementById('personalized-popular-picks');
if (!container) return;
const response = await fetch('/api/discover/personalized/popular-picks');
if (!response.ok) return;
const data = await response.json();
if (!data.success || !data.tracks || data.tracks.length === 0) {
container.closest('.discover-section').style.display = 'none';
return;
}
personalizedPopularPicks = data.tracks;
renderCompactPlaylist(container, data.tracks);
container.closest('.discover-section').style.display = 'block';
} catch (error) {
console.error('Error loading popular picks:', error);
}
}
async function loadPersonalizedHiddenGems() {
try {
const container = document.getElementById('personalized-hidden-gems');
if (!container) return;
const response = await fetch('/api/discover/personalized/hidden-gems');
if (!response.ok) return;
const data = await response.json();
if (!data.success || !data.tracks || data.tracks.length === 0) {
container.closest('.discover-section').style.display = 'none';
return;
}
personalizedHiddenGems = data.tracks;
renderCompactPlaylist(container, data.tracks);
container.closest('.discover-section').style.display = 'block';
} catch (error) {
console.error('Error loading hidden gems:', error);
}
}
async function loadPersonalizedDailyMixes() {
try {
const container = document.getElementById('daily-mixes-grid');
if (!container) return;
const response = await fetch('/api/discover/personalized/daily-mixes');
if (!response.ok) return;
const data = await response.json();
if (!data.success || !data.mixes || data.mixes.length === 0) {
container.closest('.discover-section').style.display = 'none';
return;
}
personalizedDailyMixes = data.mixes;
// Render Daily Mix cards
let html = '';
data.mixes.forEach((mix, index) => {
const coverUrl = mix.tracks && mix.tracks.length > 0 ?
(mix.tracks[0].album_cover_url || '/static/placeholder-album.png') :
'/static/placeholder-album.png';
html += `
โถ
${mix.name}
${mix.description}
${mix.track_count} tracks
`;
});
container.innerHTML = html;
container.closest('.discover-section').style.display = 'block';
} catch (error) {
console.error('Error loading daily mixes:', error);
}
}
function renderCompactPlaylist(container, tracks) {
let html = '';
tracks.forEach((track, index) => {
const coverUrl = track.album_cover_url || '/static/placeholder-album.png';
const durationMin = Math.floor(track.duration_ms / 60000);
const durationSec = Math.floor((track.duration_ms % 60000) / 1000);
const duration = `${durationMin}:${durationSec.toString().padStart(2, '0')}`;
html += `
${index + 1}
${track.track_name}
${track.artist_name}
${track.album_name}
${duration}
`;
});
html += '
';
container.innerHTML = html;
}
async function loadDiscoveryShuffle() {
try {
const container = document.getElementById('personalized-discovery-shuffle');
if (!container) return;
const response = await fetch('/api/discover/personalized/discovery-shuffle?limit=50');
if (!response.ok) return;
const data = await response.json();
if (!data.success || !data.tracks || data.tracks.length === 0) {
container.closest('.discover-section').style.display = 'none';
return;
}
personalizedDiscoveryShuffle = data.tracks;
renderCompactPlaylist(container, data.tracks);
container.closest('.discover-section').style.display = 'block';
} catch (error) {
console.error('Error loading discovery shuffle:', error);
}
}
async function loadFamiliarFavorites() {
try {
const container = document.getElementById('personalized-familiar-favorites');
if (!container) return;
const response = await fetch('/api/discover/personalized/familiar-favorites?limit=50');
if (!response.ok) return;
const data = await response.json();
if (!data.success || !data.tracks || data.tracks.length === 0) {
container.closest('.discover-section').style.display = 'none';
return;
}
personalizedFamiliarFavorites = data.tracks;
renderCompactPlaylist(container, data.tracks);
container.closest('.discover-section').style.display = 'block';
} catch (error) {
console.error('Error loading familiar favorites:', error);
}
}
// ===============================
// BUILD A PLAYLIST FEATURE
// ===============================
let buildPlaylistSearchTimeout = null;
async function searchBuildPlaylistArtists() {
const searchInput = document.getElementById('build-playlist-search');
const resultsContainer = document.getElementById('build-playlist-search-results');
const spinner = document.getElementById('bp-search-spinner');
const query = searchInput.value.trim();
if (!query) {
resultsContainer.innerHTML = '';
resultsContainer.style.display = 'none';
if (spinner) spinner.style.display = 'none';
return;
}
// Debounce search
clearTimeout(buildPlaylistSearchTimeout);
buildPlaylistSearchTimeout = setTimeout(async () => {
if (spinner) spinner.style.display = 'flex';
try {
const response = await fetch(`/api/discover/build-playlist/search-artists?query=${encodeURIComponent(query)}`);
const data = await response.json();
if (!response.ok) {
showToast(data.error || 'Search failed', 'error');
return;
}
if (!data.success || !data.artists || data.artists.length === 0) {
resultsContainer.innerHTML = 'No artists found for "' + query.replace(/';
resultsContainer.style.display = 'block';
return;
}
// Filter out already-selected artists
const selectedIds = new Set(buildPlaylistSelectedArtists.map(a => a.id));
const filtered = data.artists.filter(a => !selectedIds.has(a.id));
if (filtered.length === 0) {
resultsContainer.innerHTML = '
All results already selected
';
resultsContainer.style.display = 'block';
return;
}
// Render search results
let html = '';
filtered.forEach(artist => {
const imageUrl = artist.image_url || '/static/placeholder-album.png';
const escapedName = artist.name.replace(/'/g, "\\'").replace(/"/g, '"');
html += `
${artist.name}
+ Add
`;
});
resultsContainer.innerHTML = html;
resultsContainer.style.display = 'block';
} catch (error) {
console.error('Error searching artists:', error);
} finally {
if (spinner) spinner.style.display = 'none';
}
}, 400);
}
function addBuildPlaylistArtist(artistId, artistName, imageUrl) {
if (buildPlaylistSelectedArtists.some(a => a.id === artistId)) {
showToast('Artist already selected', 'warning');
return;
}
if (buildPlaylistSelectedArtists.length >= 5) {
showToast('Maximum 5 seed artists', 'warning');
return;
}
buildPlaylistSelectedArtists.push({
id: artistId,
name: artistName,
image_url: imageUrl
});
renderBuildPlaylistSelectedArtists();
// Clear search
document.getElementById('build-playlist-search').value = '';
document.getElementById('build-playlist-search-results').innerHTML = '';
document.getElementById('build-playlist-search-results').style.display = 'none';
}
function removeBuildPlaylistArtist(artistId) {
buildPlaylistSelectedArtists = buildPlaylistSelectedArtists.filter(a => a.id !== artistId);
renderBuildPlaylistSelectedArtists();
}
function renderBuildPlaylistSelectedArtists() {
const container = document.getElementById('build-playlist-selected-artists');
const generateBtn = document.getElementById('build-playlist-generate-btn');
const counter = document.getElementById('bp-selected-counter');
const count = buildPlaylistSelectedArtists.length;
if (counter) counter.textContent = `${count} / 5`;
if (count === 0) {
container.innerHTML = `
Search above to add seed artists
`;
generateBtn.disabled = true;
return;
}
let html = '';
buildPlaylistSelectedArtists.forEach(artist => {
const escapedId = artist.id.replace(/'/g, "\\'");
html += `
${artist.name}
ร
`;
});
container.innerHTML = html;
generateBtn.disabled = false;
}
let buildPlaylistTracks = [];
async function generateBuildPlaylist() {
if (buildPlaylistSelectedArtists.length === 0) {
showToast('Please select at least 1 artist', 'warning');
return;
}
const generateBtn = document.getElementById('build-playlist-generate-btn');
const resultsContainer = document.getElementById('build-playlist-results');
const resultsWrapper = document.getElementById('build-playlist-results-wrapper');
const loadingIndicator = document.getElementById('build-playlist-loading');
const metadataDisplay = document.getElementById('build-playlist-metadata-display');
const titleEl = document.getElementById('build-playlist-results-title');
const subtitleEl = document.getElementById('build-playlist-results-subtitle');
// Show loading, hide search area
generateBtn.disabled = true;
loadingIndicator.style.display = 'flex';
resultsWrapper.style.display = 'none';
resultsContainer.innerHTML = '';
try {
const seedIds = buildPlaylistSelectedArtists.map(a => a.id);
const response = await fetch('/api/discover/build-playlist/generate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
seed_artist_ids: seedIds,
playlist_size: 50
})
});
const data = await response.json();
if (!response.ok || !data.success) {
throw new Error(data.error || 'Failed to generate playlist');
}
if (!data.playlist || !data.playlist.tracks || data.playlist.tracks.length === 0) {
throw new Error(data.playlist?.error || 'No tracks found. Try different seed artists.');
}
// Store tracks globally
buildPlaylistTracks = data.playlist.tracks;
// Update title and subtitle
const artistNames = buildPlaylistSelectedArtists.map(a => a.name).join(', ');
titleEl.textContent = 'Custom Playlist';
subtitleEl.textContent = `Based on: ${artistNames}`;
// Render metadata
const metadata = data.playlist.metadata;
metadataDisplay.innerHTML = `
`;
// Render playlist
renderCompactPlaylist(resultsContainer, data.playlist.tracks);
// Show results wrapper
resultsWrapper.style.display = 'block';
} catch (error) {
console.error('Error generating playlist:', error);
resultsWrapper.style.display = 'none';
showToast(error.message || 'Failed to generate playlist', 'error');
} finally {
loadingIndicator.style.display = 'none';
generateBtn.disabled = false;
}
}
async function openDownloadModalForBuildPlaylist() {
if (!buildPlaylistTracks || buildPlaylistTracks.length === 0) {
showToast('No playlist tracks available', 'warning');
return;
}
const artistNames = buildPlaylistSelectedArtists.map(a => a.name).join(', ');
const playlistName = `Custom Playlist - ${artistNames}`;
const virtualPlaylistId = 'build_playlist_custom';
// Open download modal
await openDownloadMissingModalForYouTube(virtualPlaylistId, playlistName, buildPlaylistTracks);
}
function openDailyMix(mixIndex) {
const mix = personalizedDailyMixes[mixIndex];
if (!mix || !mix.tracks) return;
// TODO: Open modal or dedicated view for Daily Mix
console.log('Opening Daily Mix:', mix.name);
}
// ===============================
// DISCOVER PLAYLIST ACTIONS
// ===============================
async function openDownloadModalForDiscoverPlaylist(playlistType, playlistName) {
console.log(`๐ฅ Opening Download Missing Tracks modal for ${playlistName}`);
try {
// Get tracks based on playlist type
let tracks = [];
if (playlistType === 'release_radar') {
tracks = discoverReleaseRadarTracks;
} else if (playlistType === 'discovery_weekly') {
tracks = discoverWeeklyTracks;
} else if (playlistType === 'seasonal_playlist') {
tracks = discoverSeasonalTracks;
} else if (playlistType === 'popular_picks') {
tracks = personalizedPopularPicks;
} else if (playlistType === 'hidden_gems') {
tracks = personalizedHiddenGems;
} else if (playlistType === 'discovery_shuffle') {
tracks = personalizedDiscoveryShuffle;
} else if (playlistType === 'familiar_favorites') {
tracks = personalizedFamiliarFavorites;
} else if (playlistType === 'recently_added') {
tracks = personalizedRecentlyAdded;
} else if (playlistType === 'top_tracks') {
tracks = personalizedTopTracks;
} else if (playlistType === 'forgotten_favorites') {
tracks = personalizedForgottenFavorites;
} else if (playlistType === 'build_playlist') {
tracks = buildPlaylistTracks;
}
if (!tracks || tracks.length === 0) {
showToast(`No tracks available for ${playlistName}`, 'warning');
return;
}
// Convert discover tracks to format expected by download modal
const spotifyTracks = tracks.map(track => {
let spotifyTrack;
// Use track_data_json if available, otherwise construct from track data
if (track.track_data_json) {
spotifyTrack = track.track_data_json;
} else {
// Fallback: construct track object from available data
spotifyTrack = {
id: track.spotify_track_id,
name: track.track_name,
artists: [{ name: track.artist_name }],
album: {
name: track.album_name,
images: track.album_cover_url ? [{ url: track.album_cover_url }] : []
},
duration_ms: track.duration_ms || 0
};
}
// Normalize artists to array of strings for modal compatibility
if (spotifyTrack.artists && Array.isArray(spotifyTrack.artists)) {
spotifyTrack.artists = spotifyTrack.artists.map(a => a.name || a);
}
return spotifyTrack;
});
// Create virtual playlist ID
const virtualPlaylistId = `discover_${playlistType}`;
// Use existing modal system (same as YouTube/Tidal playlists)
await openDownloadMissingModalForYouTube(virtualPlaylistId, playlistName, spotifyTracks);
} catch (error) {
console.error('Error opening download modal for discover playlist:', error);
showToast(`Failed to open download modal: ${error.message}`, 'error');
hideLoadingOverlay(); // Ensure overlay is hidden on error
}
}
function updateDiscoverDownloadButton(playlistType, state) {
/**
* Update the download button appearance based on download state
* @param {string} playlistType - 'release_radar' or 'discovery_weekly'
* @param {string} state - 'idle', 'downloading', or 'complete'
*/
const buttonId = `${playlistType}-download-btn`;
const button = document.getElementById(buttonId);
if (!button) return;
const icon = button.querySelector('.button-icon');
const text = button.querySelector('.button-text');
if (state === 'downloading') {
if (icon) icon.textContent = 'โณ';
if (text) text.textContent = 'View Progress';
button.title = 'View download progress';
} else {
if (icon) icon.textContent = 'โ';
if (text) text.textContent = 'Download';
button.title = 'Download missing tracks';
}
}
function checkForActiveDiscoverDownloads() {
/**
* Check for active download processes and update button states
* Only runs if discover page is actually loaded in the DOM
*/
// Check if discover page is loaded by looking for a discover-specific element
const discoverPage = document.getElementById('release-radar-download-btn') ||
document.getElementById('discovery-weekly-download-btn');
if (!discoverPage) return;
const discoverPlaylists = [
{ id: 'discover_release_radar', type: 'release_radar' },
{ id: 'discover_discovery_weekly', type: 'discovery_weekly' }
];
discoverPlaylists.forEach(({ id, type }) => {
if (activeDownloadProcesses[id]) {
const process = activeDownloadProcesses[id];
if (process.status === 'running' || process.status === 'idle') {
updateDiscoverDownloadButton(type, 'downloading');
}
}
});
}
async function startDiscoverPlaylistSync(playlistType, playlistName) {
console.log(`๐ Starting sync for ${playlistName}`);
// Get tracks based on playlist type
let tracks = [];
if (playlistType === 'release_radar') {
tracks = discoverReleaseRadarTracks;
} else if (playlistType === 'discovery_weekly') {
tracks = discoverWeeklyTracks;
} else if (playlistType === 'seasonal_playlist') {
tracks = discoverSeasonalTracks;
} else if (playlistType === 'popular_picks') {
tracks = personalizedPopularPicks;
} else if (playlistType === 'hidden_gems') {
tracks = personalizedHiddenGems;
} else if (playlistType === 'discovery_shuffle') {
tracks = personalizedDiscoveryShuffle;
} else if (playlistType === 'familiar_favorites') {
tracks = personalizedFamiliarFavorites;
} else if (playlistType === 'build_playlist') {
tracks = buildPlaylistTracks;
}
if (!tracks || tracks.length === 0) {
showToast(`No tracks available for ${playlistName}`, 'warning');
return;
}
// Convert to format expected by sync API
const spotifyTracks = tracks.map(track => {
let spotifyTrack;
// Use track_data_json if available
if (track.track_data_json) {
spotifyTrack = track.track_data_json;
} else {
// Fallback: construct track object
spotifyTrack = {
id: track.spotify_track_id,
name: track.track_name,
artists: [{ name: track.artist_name }],
album: {
name: track.album_name,
images: track.album_cover_url ? [{ url: track.album_cover_url }] : []
},
duration_ms: track.duration_ms || 0
};
}
// Normalize artists to array of strings for sync compatibility
if (spotifyTrack.artists && Array.isArray(spotifyTrack.artists)) {
spotifyTrack.artists = spotifyTrack.artists.map(a => a.name || a);
}
return spotifyTrack;
});
// Create virtual playlist ID
const virtualPlaylistId = `discover_${playlistType}`;
// Store in cache for sync function
playlistTrackCache[virtualPlaylistId] = spotifyTracks;
// Create virtual playlist object
const virtualPlaylist = {
id: virtualPlaylistId,
name: playlistName,
track_count: spotifyTracks.length
};
// Add to spotify playlists array if not already there
if (!spotifyPlaylists.find(p => p.id === virtualPlaylistId)) {
spotifyPlaylists.push(virtualPlaylist);
}
// Show sync status display (convert underscores to hyphens for ID)
const statusId = playlistType.replace(/_/g, '-') + '-sync-status';
const statusDisplay = document.getElementById(statusId);
if (statusDisplay) {
statusDisplay.style.display = 'block';
}
// Disable sync button to prevent duplicate syncs (convert underscores to hyphens for ID)
const buttonId = playlistType.replace(/_/g, '-') + '-sync-btn';
const syncButton = document.getElementById(buttonId);
if (syncButton) {
syncButton.disabled = true;
syncButton.style.opacity = '0.5';
syncButton.style.cursor = 'not-allowed';
}
// Start sync using existing function
await startPlaylistSync(virtualPlaylistId);
// Extract image URL from first track for download bar bubble
let imageUrl = null;
if (spotifyTracks && spotifyTracks.length > 0) {
const firstTrack = spotifyTracks[0];
if (firstTrack.album && firstTrack.album.images && firstTrack.album.images.length > 0) {
imageUrl = firstTrack.album.images[0].url;
}
}
// Add to discover download bar
addDiscoverDownload(virtualPlaylistId, playlistName, playlistType, imageUrl);
// Start polling for progress updates
startDiscoverSyncPolling(playlistType, virtualPlaylistId);
}
// Track active discover sync pollers
const discoverSyncPollers = {};
function startDiscoverSyncPolling(playlistType, virtualPlaylistId) {
// Stop any existing poller for this playlist type
if (discoverSyncPollers[playlistType]) {
clearInterval(discoverSyncPollers[playlistType]);
}
console.log(`๐ Starting sync polling for ${playlistType} (${virtualPlaylistId})`);
// Phase 5: Subscribe via WebSocket
if (socketConnected) {
socket.emit('sync:subscribe', { playlist_ids: [virtualPlaylistId] });
_syncProgressCallbacks[virtualPlaylistId] = (data) => {
const prefix = playlistType.replace(/_/g, '-');
const progress = data.progress || {};
const total = progress.total_tracks || 0;
const matched = progress.matched_tracks || 0;
const failed = progress.failed_tracks || 0;
const processed = matched + failed;
const pending = total - processed;
const pct = total > 0 ? Math.round((processed / total) * 100) : 0;
const el = (id) => document.getElementById(id);
if (el(`${prefix}-sync-completed`)) el(`${prefix}-sync-completed`).textContent = matched;
if (el(`${prefix}-sync-pending`)) el(`${prefix}-sync-pending`).textContent = pending;
if (el(`${prefix}-sync-failed`)) el(`${prefix}-sync-failed`).textContent = failed;
if (el(`${prefix}-sync-percentage`)) el(`${prefix}-sync-percentage`).textContent = pct;
if (data.status === 'finished') {
if (discoverSyncPollers[playlistType]) { clearInterval(discoverSyncPollers[playlistType]); delete discoverSyncPollers[playlistType]; }
socket.emit('sync:unsubscribe', { playlist_ids: [virtualPlaylistId] });
delete _syncProgressCallbacks[virtualPlaylistId];
const buttonId = playlistType.replace(/_/g, '-') + '-sync-btn';
const syncButton = el(buttonId);
if (syncButton) { syncButton.disabled = false; syncButton.style.opacity = '1'; syncButton.style.cursor = 'pointer'; }
const playlistNames = {
'release_radar': 'Fresh Tape', 'discovery_weekly': 'The Archives',
'seasonal_playlist': 'Seasonal Mix', 'popular_picks': 'Popular Picks',
'hidden_gems': 'Hidden Gems', 'discovery_shuffle': 'Discovery Shuffle',
'familiar_favorites': 'Familiar Favorites', 'build_playlist': 'Custom Playlist'
};
showToast(`${playlistNames[playlistType] || playlistType} sync complete!`, 'success');
setTimeout(() => { const sd = el(`${prefix}-sync-status`); if (sd) sd.style.display = 'none'; }, 3000);
}
};
}
// Poll every 500ms for progress updates
discoverSyncPollers[playlistType] = setInterval(async () => {
if (socketConnected) return; // Phase 5: WS handles updates
try {
const response = await fetch(`/api/sync/status/${virtualPlaylistId}`);
if (!response.ok) {
console.log(`โ ๏ธ Sync status response not OK: ${response.status}`);
return;
}
const data = await response.json();
console.log(`๐ Sync status for ${playlistType}:`, data);
// Update UI with progress (data structure: {status: ..., progress: {...}})
// Convert underscores to hyphens for HTML IDs
const prefix = playlistType.replace(/_/g, '-');
const progress = data.progress || {};
const completedEl = document.getElementById(`${prefix}-sync-completed`);
const pendingEl = document.getElementById(`${prefix}-sync-pending`);
const failedEl = document.getElementById(`${prefix}-sync-failed`);
const percentageEl = document.getElementById(`${prefix}-sync-percentage`);
const total = progress.total_tracks || 0;
const matched = progress.matched_tracks || 0;
const failed = progress.failed_tracks || 0;
const processed = matched + failed;
const pending = total - processed;
const completionPercentage = total > 0 ? Math.round((processed / total) * 100) : 0;
if (completedEl) completedEl.textContent = matched;
if (pendingEl) pendingEl.textContent = pending;
if (failedEl) failedEl.textContent = failed;
if (percentageEl) percentageEl.textContent = completionPercentage;
// If complete, stop polling and hide status after delay
if (data.status === 'finished') {
console.log(`โ
Sync complete for ${playlistType}`);
clearInterval(discoverSyncPollers[playlistType]);
delete discoverSyncPollers[playlistType];
// Re-enable sync button
const buttonId = playlistType.replace(/_/g, '-') + '-sync-btn';
const syncButton = document.getElementById(buttonId);
if (syncButton) {
syncButton.disabled = false;
syncButton.style.opacity = '1';
syncButton.style.cursor = 'pointer';
}
// Show completion toast with playlist name
const playlistNames = {
'release_radar': 'Fresh Tape',
'discovery_weekly': 'The Archives',
'seasonal_playlist': 'Seasonal Mix',
'popular_picks': 'Popular Picks',
'hidden_gems': 'Hidden Gems',
'discovery_shuffle': 'Discovery Shuffle',
'familiar_favorites': 'Familiar Favorites',
'build_playlist': 'Custom Playlist'
};
const displayName = playlistNames[playlistType] || playlistType;
showToast(`${displayName} sync complete!`, 'success');
// Hide status display after 3 seconds
setTimeout(() => {
const statusDisplay = document.getElementById(`${prefix}-sync-status`);
if (statusDisplay) {
statusDisplay.style.display = 'none';
}
}, 3000);
}
} catch (error) {
console.error(`โ Error polling sync status for ${playlistType}:`, error);
}
}, 500);
}
async function openDownloadModalForRecentAlbum(albumIndex) {
const album = discoverRecentAlbums[albumIndex];
if (!album) {
showToast('Album data not found', 'error');
return;
}
console.log(`๐ฅ Opening Download Missing Tracks modal for album: ${album.album_name}`);
showLoadingOverlay(`Loading tracks for ${album.album_name}...`);
try {
// Determine source and album ID - use source-agnostic endpoint
const source = album.source || (album.album_spotify_id ? 'spotify' : 'itunes');
const albumId = source === 'spotify' ? album.album_spotify_id : album.album_itunes_id;
if (!albumId) {
throw new Error(`No ${source} album ID available`);
}
// Fetch album tracks from appropriate source (pass name/artist for Hydrabase support)
const _dap2 = new URLSearchParams({ name: album.album_name || '', artist: album.artist_name || '' });
const response = await fetch(`/api/discover/album/${source}/${albumId}?${_dap2}`);
if (!response.ok) {
throw new Error('Failed to fetch album tracks');
}
const albumData = await response.json();
if (!albumData.tracks || albumData.tracks.length === 0) {
throw new Error('No tracks found in album');
}
// Convert to expected format - CRITICAL FIX: Use fresh albumData from Spotify, not cached album
const spotifyTracks = albumData.tracks.map(track => {
// Normalize artists to array of strings
let artists = track.artists || albumData.artists || [{ name: album.artist_name }];
if (Array.isArray(artists)) {
artists = artists.map(a => a.name || a);
}
return {
id: track.id,
name: track.name,
artists: artists,
album: {
id: albumData.id, // โ
Album ID for proper tracking
name: albumData.name, // โ
Use fresh data, not cached
album_type: albumData.album_type || 'album', // โ
Critical: Album type for classification
total_tracks: albumData.total_tracks || 0, // โ
Total tracks for context
release_date: albumData.release_date || '', // โ
Release date
images: albumData.images || [] // โ
Use Spotify images
},
duration_ms: track.duration_ms || 0,
track_number: track.track_number || 0
};
});
// Create virtual playlist ID using the appropriate album ID
const virtualPlaylistId = `discover_album_${albumId}`;
// CRITICAL FIX: Pass proper artist/album context for modal display
const artistContext = {
id: source === 'spotify' ? album.artist_spotify_id : album.artist_itunes_id,
name: album.artist_name,
source: source
};
const albumContext = {
id: albumData.id,
name: albumData.name,
album_type: albumData.album_type || 'album',
total_tracks: albumData.total_tracks || 0,
release_date: albumData.release_date || '',
images: albumData.images || []
};
// Open download modal with artist/album context
await openDownloadMissingModalForYouTube(virtualPlaylistId, albumData.name, spotifyTracks, artistContext, albumContext);
hideLoadingOverlay();
} catch (error) {
console.error('Error opening album download modal:', error);
showToast(`Failed to load album: ${error.message}`, 'error');
hideLoadingOverlay();
}
}
// ===============================
// DISCOVER DOWNLOAD BAR
// ===============================
// Track discover page downloads
let discoverDownloads = {}; // playlistId -> { name, type, status, virtualPlaylistId, startTime }
/**
* Add a download to the discover download bar
*/
function addDiscoverDownload(playlistId, playlistName, playlistType, imageUrl = null) {
console.log(`๐ฅ [DOWNLOAD SIDEBAR] Adding discover download: ${playlistName} (${playlistId}) type: ${playlistType}, image: ${imageUrl}`);
// Always register the download in state (needed for dashboard even when not on discover page)
discoverDownloads[playlistId] = {
name: playlistName,
type: playlistType,
status: 'in_progress',
virtualPlaylistId: playlistId,
imageUrl: imageUrl,
startTime: new Date()
};
console.log(`๐ [DOWNLOAD SIDEBAR] Active downloads:`, Object.keys(discoverDownloads));
// Update discover page sidebar if it exists (user is on discover page)
const downloadSidebar = document.getElementById('discover-download-sidebar');
if (downloadSidebar) {
updateDiscoverDownloadBar(); // Also saves snapshot internally
} else {
console.log('โน๏ธ [DOWNLOAD SIDEBAR] Sidebar not present - skipping sidebar UI update');
saveDiscoverDownloadSnapshot(); // Persist state even when sidebar is absent
}
updateDashboardDownloads();
monitorDiscoverDownload(playlistId);
}
/**
* Monitor a discover download for completion
*/
function monitorDiscoverDownload(playlistId) {
let notFoundCount = 0;
const maxNotFoundAttempts = 5; // Give sync 10 seconds to start (5 checks * 2 seconds)
// Phase 5: Subscribe via WebSocket for sync status updates
if (socketConnected) {
socket.emit('sync:subscribe', { playlist_ids: [playlistId] });
_syncProgressCallbacks[playlistId] = (data) => {
if (!discoverDownloads[playlistId]) return;
if (data.status === 'complete' || data.status === 'finished') {
discoverDownloads[playlistId].status = 'completed';
updateDiscoverDownloadBar();
updateDashboardDownloads();
socket.emit('sync:unsubscribe', { playlist_ids: [playlistId] });
delete _syncProgressCallbacks[playlistId];
setTimeout(() => {
if (discoverDownloads[playlistId] && discoverDownloads[playlistId].status === 'completed') {
removeDiscoverDownload(playlistId);
}
}, 30000);
}
};
}
const checkInterval = setInterval(async () => {
try {
// Check if download still exists
if (!discoverDownloads[playlistId]) {
clearInterval(checkInterval);
if (_syncProgressCallbacks[playlistId]) {
if (socketConnected) socket.emit('sync:unsubscribe', { playlist_ids: [playlistId] });
delete _syncProgressCallbacks[playlistId];
}
return;
}
// First check if there's an active download process (modal-based downloads)
const activeProcess = activeDownloadProcesses[playlistId];
if (activeProcess) {
console.log(`๐ [DOWNLOAD BAR] Found active process for ${playlistId}, status: ${activeProcess.status}`);
if (activeProcess.status === 'complete') {
console.log(`โ
[DOWNLOAD BAR] Process completed: ${discoverDownloads[playlistId].name}`);
discoverDownloads[playlistId].status = 'completed';
updateDiscoverDownloadBar();
updateDashboardDownloads();
clearInterval(checkInterval);
// Auto-remove completed downloads after 30 seconds
setTimeout(() => {
if (discoverDownloads[playlistId] && discoverDownloads[playlistId].status === 'completed') {
removeDiscoverDownload(playlistId);
}
}, 30000);
}
return; // Continue monitoring
}
// Check sync status API (for sync-based downloads)
if (socketConnected) return; // Phase 5: WS handles sync status
const response = await fetch(`/api/sync/status/${playlistId}`);
if (response.ok) {
const data = await response.json();
notFoundCount = 0; // Reset counter if found
console.log(`๐ [DOWNLOAD BAR] Sync status for ${playlistId}: ${data.status}`);
if (data.status === 'complete') {
console.log(`โ
[DOWNLOAD BAR] Sync completed: ${discoverDownloads[playlistId].name}`);
discoverDownloads[playlistId].status = 'completed';
updateDiscoverDownloadBar();
updateDashboardDownloads();
clearInterval(checkInterval);
// Auto-remove completed downloads after 30 seconds
setTimeout(() => {
if (discoverDownloads[playlistId] && discoverDownloads[playlistId].status === 'completed') {
removeDiscoverDownload(playlistId);
}
}, 30000);
}
} else if (response.status === 404) {
notFoundCount++;
console.log(`๐ [DOWNLOAD BAR] Sync not found for ${playlistId} (attempt ${notFoundCount}/${maxNotFoundAttempts})`);
// Only remove after multiple attempts (give it time to start)
if (notFoundCount >= maxNotFoundAttempts) {
console.log(`โน๏ธ [DOWNLOAD BAR] Sync not found after ${maxNotFoundAttempts} attempts, removing`);
clearInterval(checkInterval);
removeDiscoverDownload(playlistId);
}
}
} catch (error) {
console.error(`โ [DOWNLOAD BAR] Error monitoring ${playlistId}:`, error);
}
}, 2000); // Check every 2 seconds
}
/**
* Remove a download from the bar
*/
function removeDiscoverDownload(playlistId) {
console.log(`๐๏ธ Removing discover download: ${playlistId}`);
delete discoverDownloads[playlistId];
updateDiscoverDownloadBar();
updateDashboardDownloads();
saveDiscoverDownloadSnapshot(); // Save state after removal
}
/**
* Update the discover download sidebar UI
*/
function updateDiscoverDownloadBar() {
const downloadSidebar = document.getElementById('discover-download-sidebar');
const bubblesContainer = document.getElementById('discover-download-bubbles');
const countElement = document.getElementById('discover-download-count');
console.log(`๐ [DOWNLOAD SIDEBAR] Updating sidebar - found elements:`, {
downloadSidebar: !!downloadSidebar,
bubblesContainer: !!bubblesContainer,
countElement: !!countElement
});
if (!downloadSidebar || !bubblesContainer || !countElement) {
console.warn('โ ๏ธ [DOWNLOAD SIDEBAR] Missing elements, cannot update');
return;
}
const activeDownloads = Object.keys(discoverDownloads);
const count = activeDownloads.length;
console.log(`๐ [DOWNLOAD SIDEBAR] Updating with ${count} active downloads`);
// Update count
countElement.textContent = count;
// Show/hide sidebar
if (count === 0) {
console.log(`๐๏ธ [DOWNLOAD SIDEBAR] No downloads, hiding sidebar`);
downloadSidebar.classList.add('hidden');
return;
} else {
console.log(`๐๏ธ [DOWNLOAD SIDEBAR] ${count} downloads, showing sidebar`);
downloadSidebar.classList.remove('hidden');
}
// Update bubbles
bubblesContainer.innerHTML = activeDownloads.map(playlistId => {
const download = discoverDownloads[playlistId];
const isCompleted = download.status === 'completed';
const icon = isCompleted ? 'โ
' : 'โณ';
// Use image if available, otherwise gradient background
const imageUrl = download.imageUrl || '';
const backgroundStyle = imageUrl ?
`background-image: url('${imageUrl}');` :
`background: linear-gradient(135deg, rgba(29, 185, 84, 0.3) 0%, rgba(24, 156, 71, 0.2) 100%);`;
return `
${escapeHtml(download.name)}
`;
}).join('');
console.log(`๐ Updated discover download sidebar: ${count} active downloads`);
// Save snapshot after UI update
saveDiscoverDownloadSnapshot();
}
/**
* Open download modal for a discover playlist
*/
async function openDiscoverDownloadModal(playlistId) {
console.log(`๐ [DOWNLOAD BAR] Opening download modal for: ${playlistId}`);
// Check if there's an active download process with modal
let process = activeDownloadProcesses[playlistId];
console.log(`๐ [DOWNLOAD BAR] Process found:`, {
exists: !!process,
hasModalElement: !!(process && process.modalElement),
hasModalId: !!(process && process.modalId)
});
if (process) {
// Try modalElement first (album downloads)
if (process.modalElement) {
console.log(`โ
[DOWNLOAD BAR] Opening modal via modalElement`);
process.modalElement.style.display = 'flex';
return;
}
// Try modalId (sync downloads)
if (process.modalId) {
const modal = document.getElementById(process.modalId);
if (modal) {
console.log(`โ
[DOWNLOAD BAR] Opening modal via modalId: ${process.modalId}`);
modal.style.display = 'flex';
return;
}
}
}
// If no process found, try to rehydrate from backend
console.log(`๐ง [DOWNLOAD BAR] No modal found, attempting to rehydrate from backend...`);
const rehydrated = await rehydrateDiscoverDownloadModal(playlistId);
if (rehydrated) {
console.log(`โ
[DOWNLOAD BAR] Successfully rehydrated modal, opening it...`);
// Try again after rehydration
process = activeDownloadProcesses[playlistId];
if (process && process.modalElement) {
process.modalElement.style.display = 'flex';
return;
}
}
// Fallback: show toast
const download = discoverDownloads[playlistId];
if (download) {
console.log(`โน๏ธ [DOWNLOAD BAR] No modal found after rehydration attempt, showing toast`);
showToast(`Download: ${download.name} - ${download.status}`, 'info');
} else {
console.warn(`โ ๏ธ [DOWNLOAD BAR] No download or process found for: ${playlistId}`);
}
}
/**
* Initialize discover download sidebar on page load
*/
function initializeDiscoverDownloadBar() {
console.log('๐ต Initializing discover download sidebar...');
// Start with sidebar hidden (will be shown if downloads exist after hydration)
const downloadSidebar = document.getElementById('discover-download-sidebar');
if (downloadSidebar) {
downloadSidebar.classList.add('hidden');
}
}
// --- Discover Download Modal Rehydration ---
async function rehydrateDiscoverDownloadModal(playlistId) {
/**
* Rehydrates a discover download modal from backend process data.
* Fetches tracks from backend API and recreates the modal (user-requested).
*/
try {
console.log(`๐ง [REHYDRATE] Attempting to rehydrate modal for: ${playlistId}`);
// Check if there's an active backend process for this playlist
const batchResponse = await fetch(`/api/download_status/batch`);
if (!batchResponse.ok) {
console.log(`โ ๏ธ [REHYDRATE] Failed to fetch batch info`);
return false;
}
const batchData = await batchResponse.json();
const batches = batchData.batches || {};
// Find the batch for this playlist (batches is an object with batch_id keys)
let batchId = null;
let batch = null;
for (const [id, batchStatus] of Object.entries(batches)) {
if (batchStatus.playlist_id === playlistId) {
batchId = id;
batch = batchStatus;
break;
}
}
if (!batch || !batchId) {
console.log(`โ ๏ธ [REHYDRATE] No active batch found for ${playlistId}`);
return false;
}
console.log(`โ
[REHYDRATE] Found active batch for ${playlistId}: ${batchId}`, batch);
// Get the download metadata from discoverDownloads
const downloadData = discoverDownloads[playlistId];
if (!downloadData) {
console.log(`โ ๏ธ [REHYDRATE] No download metadata found for ${playlistId}`);
return false;
}
// Handle album downloads from Recent Releases
if (playlistId.startsWith('discover_album_')) {
const albumId = playlistId.replace('discover_album_', '');
console.log(`๐ง [REHYDRATE] Album download - fetching album ${albumId}...`);
try {
const albumResponse = await fetch(`/api/spotify/album/${albumId}`);
if (!albumResponse.ok) {
console.error(`โ [REHYDRATE] Failed to fetch album: ${albumResponse.status}`);
return false;
}
const albumData = await albumResponse.json();
if (!albumData.tracks || albumData.tracks.length === 0) {
console.error(`โ [REHYDRATE] No tracks in album`);
return false;
}
// Convert tracks to expected format
const spotifyTracks = albumData.tracks.map(track => {
let artists = track.artists || [];
if (Array.isArray(artists)) {
artists = artists.map(a => a.name || a);
}
return {
id: track.id,
name: track.name,
artists: artists,
album: {
name: albumData.name || downloadData.name.split(' - ')[0],
images: downloadData.imageUrl ? [{ url: downloadData.imageUrl }] : []
},
duration_ms: track.duration_ms || 0
};
});
console.log(`โ
[REHYDRATE] Retrieved ${spotifyTracks.length} tracks for album`);
// Create modal
await openDownloadMissingModalForYouTube(playlistId, downloadData.name, spotifyTracks);
// Update process
const process = activeDownloadProcesses[playlistId];
if (process) {
process.status = 'running';
process.batchId = batchId;
subscribeToDownloadBatch(batchId);
const beginBtn = document.getElementById(`begin-analysis-btn-${playlistId}`);
const cancelBtn = document.getElementById(`cancel-all-btn-${playlistId}`);
if (beginBtn) beginBtn.style.display = 'none';
if (cancelBtn) cancelBtn.style.display = 'inline-block';
// Start polling for status updates
startModalDownloadPolling(playlistId);
console.log(`โ
[REHYDRATE] Successfully rehydrated album modal with polling`);
return true;
}
return false;
} catch (error) {
console.error(`โ [REHYDRATE] Error fetching album:`, error);
return false;
}
}
// Determine API endpoint based on playlist ID
let apiEndpoint;
if (playlistId === 'discover_release_radar') {
apiEndpoint = '/api/discover/release-radar';
} else if (playlistId === 'discover_discovery_weekly') {
apiEndpoint = '/api/discover/discovery-weekly';
} else if (playlistId === 'discover_seasonal_playlist') {
apiEndpoint = '/api/discover/seasonal-playlist';
} else if (playlistId === 'discover_popular_picks') {
apiEndpoint = '/api/discover/popular-picks';
} else if (playlistId === 'discover_hidden_gems') {
apiEndpoint = '/api/discover/hidden-gems';
} else if (playlistId === 'discover_discovery_shuffle') {
apiEndpoint = '/api/discover/discovery-shuffle';
} else if (playlistId === 'discover_familiar_favorites') {
apiEndpoint = '/api/discover/familiar-favorites';
} else if (playlistId === 'build_playlist_custom') {
apiEndpoint = '/api/discover/build-playlist';
} else if (playlistId.startsWith('discover_lb_')) {
// ListenBrainz playlist - fetch from cache
const identifier = playlistId.replace('discover_lb_', '');
const tracks = listenbrainzTracksCache[identifier];
if (!tracks || tracks.length === 0) {
console.log(`โ ๏ธ [REHYDRATE] No ListenBrainz tracks in cache for ${identifier}`);
return false;
}
// Convert to Spotify format
const spotifyTracks = tracks.map(track => ({
id: track.mbid || `listenbrainz_${track.track_name}_${track.artist_name}`.replace(/[^a-z0-9]/gi, '_'), // Generate ID if missing
name: track.track_name,
artists: [{ name: cleanArtistName(track.artist_name) }], // Proper Spotify format
album: {
name: track.album_name,
images: track.album_cover_url ? [{ url: track.album_cover_url }] : []
},
duration_ms: track.duration_ms || 0,
mbid: track.mbid
}));
// Create modal and update process
await openDownloadMissingModalForYouTube(playlistId, downloadData.name, spotifyTracks);
const process = activeDownloadProcesses[playlistId];
if (process) {
process.status = 'running';
process.batchId = batchId;
subscribeToDownloadBatch(batchId);
const beginBtn = document.getElementById(`begin-analysis-btn-${playlistId}`);
const cancelBtn = document.getElementById(`cancel-all-btn-${playlistId}`);
if (beginBtn) beginBtn.style.display = 'none';
if (cancelBtn) cancelBtn.style.display = 'inline-block';
// Start polling for status updates
startModalDownloadPolling(playlistId);
console.log(`โ
[REHYDRATE] Successfully rehydrated ListenBrainz modal with polling`);
return true;
}
return false;
} else if (playlistId.startsWith('listenbrainz_')) {
// ListenBrainz download from discovery modal - get from backend state
const mbid = playlistId.replace('listenbrainz_', '');
console.log(`๐ง [REHYDRATE] ListenBrainz download - fetching state for MBID: ${mbid}`);
try {
// Fetch ListenBrainz state from backend
const stateResponse = await fetch(`/api/listenbrainz/state/${mbid}`);
if (!stateResponse.ok) {
console.log(`โ ๏ธ [REHYDRATE] Failed to fetch ListenBrainz state`);
return false;
}
const stateData = await stateResponse.json();
if (!stateData || !stateData.discovery_results) {
console.log(`โ ๏ธ [REHYDRATE] No discovery results in ListenBrainz state`);
return false;
}
// Convert discovery results to Spotify tracks
const spotifyTracks = stateData.discovery_results
.filter(result => result.spotify_data)
.map(result => {
const track = result.spotify_data;
// Ensure artists is in proper Spotify format: [{name: ...}]
let artistsArray = [];
if (track.artists && Array.isArray(track.artists)) {
artistsArray = track.artists.map(artist => {
if (typeof artist === 'string') {
return { name: artist };
} else if (artist && artist.name) {
return { name: artist.name };
} else {
return { name: String(artist || 'Unknown Artist') };
}
});
} else if (track.artists && typeof track.artists === 'string') {
artistsArray = [{ name: track.artists }];
} else {
artistsArray = [{ name: 'Unknown Artist' }];
}
return {
id: track.id,
name: track.name,
artists: artistsArray,
album: track.album || { name: 'Unknown Album', images: [] },
duration_ms: track.duration_ms || 0,
external_urls: track.external_urls || {}
};
});
if (spotifyTracks.length === 0) {
console.log(`โ ๏ธ [REHYDRATE] No Spotify tracks in ListenBrainz discovery results`);
return false;
}
console.log(`โ
[REHYDRATE] Retrieved ${spotifyTracks.length} tracks from ListenBrainz state`);
// Create modal and update process
await openDownloadMissingModalForYouTube(playlistId, downloadData.name, spotifyTracks);
const process = activeDownloadProcesses[playlistId];
if (process) {
process.status = 'running';
process.batchId = batchId;
subscribeToDownloadBatch(batchId);
const beginBtn = document.getElementById(`begin-analysis-btn-${playlistId}`);
const cancelBtn = document.getElementById(`cancel-all-btn-${playlistId}`);
if (beginBtn) beginBtn.style.display = 'none';
if (cancelBtn) cancelBtn.style.display = 'inline-block';
// Start polling for status updates
startModalDownloadPolling(playlistId);
console.log(`โ
[REHYDRATE] Successfully rehydrated ListenBrainz download modal with polling`);
return true;
}
return false;
} catch (error) {
console.error(`โ [REHYDRATE] Error fetching ListenBrainz state:`, error);
return false;
}
} else {
console.error(`โ [REHYDRATE] Unknown discover playlist type: ${playlistId}`);
return false;
}
// Fetch tracks from API
console.log(`๐ก [REHYDRATE] Fetching tracks from ${apiEndpoint}...`);
const response = await fetch(apiEndpoint);
if (!response.ok) {
console.error(`โ [REHYDRATE] Failed to fetch tracks: ${response.status}`);
return false;
}
const data = await response.json();
if (!data.success || !data.tracks) {
console.error(`โ [REHYDRATE] Invalid track data:`, data);
return false;
}
const tracks = data.tracks;
console.log(`โ
[REHYDRATE] Retrieved ${tracks.length} tracks`);
// Transform tracks to Spotify format
const spotifyTracks = tracks.map(track => {
let spotifyTrack;
if (track.track_data_json) {
spotifyTrack = track.track_data_json;
} else {
spotifyTrack = {
id: track.spotify_track_id,
name: track.track_name,
artists: [{ name: track.artist_name }],
album: {
name: track.album_name,
images: track.album_cover_url ? [{ url: track.album_cover_url }] : []
},
duration_ms: track.duration_ms || 0
};
}
if (spotifyTrack.artists && Array.isArray(spotifyTrack.artists)) {
spotifyTrack.artists = spotifyTrack.artists.map(a => a.name || a);
}
return spotifyTrack;
});
// Create the modal
await openDownloadMissingModalForYouTube(playlistId, downloadData.name, spotifyTracks);
// Update process with batch info
const process = activeDownloadProcesses[playlistId];
if (process) {
process.status = 'running';
process.batchId = batchId;
subscribeToDownloadBatch(batchId);
// Update button states
const beginBtn = document.getElementById(`begin-analysis-btn-${playlistId}`);
const cancelBtn = document.getElementById(`cancel-all-btn-${playlistId}`);
if (beginBtn) beginBtn.style.display = 'none';
if (cancelBtn) cancelBtn.style.display = 'inline-block';
// Start polling for status updates
startModalDownloadPolling(playlistId);
// Don't hide the modal - user clicked to open it
console.log(`โ
[REHYDRATE] Successfully rehydrated modal for ${downloadData.name} with polling`);
return true;
} else {
console.error(`โ [REHYDRATE] Failed to find rehydrated process for ${playlistId}`);
return false;
}
} catch (error) {
console.error(`โ [REHYDRATE] Error rehydrating discover download modal:`, error);
return false;
}
}
// --- Discover Download Snapshot System ---
let discoverSnapshotSaveTimeout = null; // Debounce snapshot saves
async function saveDiscoverDownloadSnapshot() {
/**
* Saves current discoverDownloads state to backend for persistence.
* Debounced to prevent excessive backend calls.
*/
// Clear any existing timeout
if (discoverSnapshotSaveTimeout) {
clearTimeout(discoverSnapshotSaveTimeout);
}
// Debounce the actual save
discoverSnapshotSaveTimeout = setTimeout(async () => {
try {
const downloadCount = Object.keys(discoverDownloads).length;
// Don't save empty state
if (downloadCount === 0) {
console.log('๐ธ Skipping discover snapshot save - no downloads to save');
return;
}
console.log(`๐ธ Saving discover download snapshot: ${downloadCount} downloads`);
// Prepare snapshot data (clean format)
const cleanDownloads = {};
for (const [playlistId, downloadData] of Object.entries(discoverDownloads)) {
cleanDownloads[playlistId] = {
name: downloadData.name,
type: downloadData.type,
status: downloadData.status,
virtualPlaylistId: downloadData.virtualPlaylistId,
imageUrl: downloadData.imageUrl,
startTime: downloadData.startTime instanceof Date ? downloadData.startTime.toISOString() : downloadData.startTime
};
}
const response = await fetch('/api/discover_downloads/snapshot', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
downloads: cleanDownloads
})
});
const data = await response.json();
if (data.success) {
console.log(`โ
Discover download snapshot saved: ${downloadCount} downloads`);
} else {
console.error('โ Failed to save discover download snapshot:', data.error);
}
} catch (error) {
console.error('โ Error saving discover download snapshot:', error);
}
}, 1000); // 1 second debounce
}
async function hydrateDiscoverDownloadsFromSnapshot() {
/**
* Hydrates discover downloads from backend snapshot with live status.
* Called on page load to restore download state.
*/
try {
console.log('๐ Loading discover download snapshot from backend...');
const response = await fetch('/api/discover_downloads/hydrate');
const data = await response.json();
if (!data.success) {
console.error('โ Failed to load discover download snapshot:', data.error);
return;
}
const downloads = data.downloads || {};
const stats = data.stats || {};
console.log(`๐ Loaded discover snapshot: ${stats.total_downloads || 0} downloads, ${stats.active_downloads || 0} active, ${stats.completed_downloads || 0} completed`);
if (Object.keys(downloads).length === 0) {
console.log('โน๏ธ No discover downloads to hydrate');
return;
}
// Clear existing state
discoverDownloads = {};
// Restore discoverDownloads with hydrated data
for (const [playlistId, downloadData] of Object.entries(downloads)) {
discoverDownloads[playlistId] = {
name: downloadData.name,
type: downloadData.type,
status: downloadData.status, // Live status from backend
virtualPlaylistId: downloadData.virtualPlaylistId,
imageUrl: downloadData.imageUrl,
startTime: new Date(downloadData.startTime)
};
console.log(`๐ Hydrated download: ${downloadData.name} (${downloadData.status})`);
// Start monitoring for any in-progress downloads
if (downloadData.status === 'in_progress') {
console.log(`๐ก Starting monitoring for: ${downloadData.name}`);
monitorDiscoverDownload(playlistId);
}
}
// Don't update UI here - it will be updated when user navigates to discover page
// This allows hydration to work even if page loads on a different tab
const totalDownloads = Object.keys(discoverDownloads).length;
console.log(`โ
Successfully hydrated ${totalDownloads} discover downloads (UI will update on discover page navigation)`);
} catch (error) {
console.error('โ Error hydrating discover downloads from snapshot:', error);
}
}
// Initialize on page load
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initializeDiscoverDownloadBar);
} else {
initializeDiscoverDownloadBar();
}
// ============================================================================
// MUSICBRAINZ ENRICHMENT UI - PHASE 5 WEB UI
// ============================================================================
/**
* Poll MusicBrainz status every 2 seconds and update UI
*/
async function updateMusicBrainzStatus() {
if (socketConnected) return; // WebSocket handles this
if (document.hidden) return; // Skip polling when tab is not visible
try {
const response = await fetch('/api/musicbrainz/status');
if (!response.ok) { console.warn('MusicBrainz status endpoint unavailable'); return; }
const data = await response.json();
updateMusicBrainzStatusFromData(data);
} catch (error) {
console.error('Error updating MusicBrainz status:', error);
}
}
function updateMusicBrainzStatusFromData(data) {
const button = document.getElementById('musicbrainz-button');
if (!button) return;
// Update button state classes
button.classList.remove('active', 'paused', 'complete');
if (data.idle) {
button.classList.add('complete');
} else if (data.running && !data.paused) {
button.classList.add('active');
} else if (data.paused) {
button.classList.add('paused');
}
// Update tooltip content
const tooltipStatus = document.getElementById('mb-tooltip-status');
const tooltipCurrent = document.getElementById('mb-tooltip-current');
const tooltipProgress = document.getElementById('mb-tooltip-progress');
if (tooltipStatus) {
if (data.idle) {
tooltipStatus.textContent = 'Complete';
} else if (data.running && !data.paused) {
tooltipStatus.textContent = 'Running';
} else if (data.paused) {
tooltipStatus.textContent = 'Paused';
} else {
tooltipStatus.textContent = 'Idle';
}
}
if (tooltipCurrent) {
if (data.idle) {
tooltipCurrent.textContent = 'All items processed';
} else if (data.current_item && data.current_item.name) {
const type = data.current_item.type || 'item';
const name = data.current_item.name;
tooltipCurrent.textContent = `${type.charAt(0).toUpperCase() + type.slice(1)}: "${name}"`;
} else {
tooltipCurrent.textContent = 'No active matches';
}
}
if (tooltipProgress && data.progress) {
const artists = data.progress.artists || {};
const albums = data.progress.albums || {};
const tracks = data.progress.tracks || {};
const currentType = data.current_item?.type;
let progressText = '';
const artistsComplete = artists.matched >= artists.total;
const albumsComplete = albums.matched >= albums.total;
if (currentType === 'artist' || (!artistsComplete && !currentType)) {
progressText = `Artists: ${artists.matched || 0} / ${artists.total} (${artists.percent || 0}%)`;
} else if (currentType === 'album' || (artistsComplete && !albumsComplete)) {
progressText = `Albums: ${albums.matched || 0} / ${albums.total} (${albums.percent || 0}%)`;
} else if (currentType === 'track' || (artistsComplete && albumsComplete)) {
progressText = `Tracks: ${tracks.matched || 0} / ${tracks.total} (${tracks.percent || 0}%)`;
} else {
progressText = `Artists: ${artists.matched || 0} / ${artists.total} (${artists.percent || 0}%)`;
}
tooltipProgress.textContent = progressText;
}
}
/**
* Toggle MusicBrainz enrichment pause/resume
*/
async function toggleMusicBrainzEnrichment() {
try {
const button = document.getElementById('musicbrainz-button');
if (!button) return;
const isRunning = button.classList.contains('active');
const endpoint = isRunning ? '/api/musicbrainz/pause' : '/api/musicbrainz/resume';
const response = await fetch(endpoint, { method: 'POST' });
if (!response.ok) {
throw new Error(`Failed to ${isRunning ? 'pause' : 'resume'} MusicBrainz enrichment`);
}
// Immediately update UI
await updateMusicBrainzStatus();
console.log(`โ
MusicBrainz enrichment ${isRunning ? 'paused' : 'resumed'}`);
} catch (error) {
console.error('Error toggling MusicBrainz enrichment:', error);
showToast(`Error: ${error.message}`, 'error');
}
}
// Initialize MusicBrainz UI on page load
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => {
const button = document.getElementById('musicbrainz-button');
if (button) {
button.addEventListener('click', toggleMusicBrainzEnrichment);
// Start polling
updateMusicBrainzStatus();
setInterval(updateMusicBrainzStatus, 2000); // Poll every 2 seconds
console.log('โ
MusicBrainz UI initialized');
}
});
} else {
const button = document.getElementById('musicbrainz-button');
if (button) {
button.addEventListener('click', toggleMusicBrainzEnrichment);
// Start polling
updateMusicBrainzStatus();
setInterval(updateMusicBrainzStatus, 2000); // Poll every 2 seconds
console.log('โ
MusicBrainz UI initialized');
}
}
// ============================================================================
// AUDIODB ENRICHMENT UI
// ============================================================================
/**
* Poll AudioDB status every 2 seconds and update UI
*/
async function updateAudioDBStatus() {
if (socketConnected) return; // WebSocket handles this
if (document.hidden) return; // Skip polling when tab is not visible
try {
const response = await fetch('/api/audiodb/status');
if (!response.ok) { console.warn('AudioDB status endpoint unavailable'); return; }
const data = await response.json();
updateAudioDBStatusFromData(data);
} catch (error) {
console.error('Error updating AudioDB status:', error);
}
}
function updateAudioDBStatusFromData(data) {
const button = document.getElementById('audiodb-button');
if (!button) return;
button.classList.remove('active', 'paused', 'complete');
if (data.idle) {
button.classList.add('complete');
} else if (data.running && !data.paused) {
button.classList.add('active');
} else if (data.paused) {
button.classList.add('paused');
}
const tooltipStatus = document.getElementById('audiodb-tooltip-status');
const tooltipCurrent = document.getElementById('audiodb-tooltip-current');
const tooltipProgress = document.getElementById('audiodb-tooltip-progress');
if (tooltipStatus) {
if (data.idle) { tooltipStatus.textContent = 'Complete'; }
else if (data.running && !data.paused) { tooltipStatus.textContent = 'Running'; }
else if (data.paused) { tooltipStatus.textContent = 'Paused'; }
else { tooltipStatus.textContent = 'Idle'; }
}
if (tooltipCurrent) {
if (data.idle) {
tooltipCurrent.textContent = 'All items processed';
} else if (data.current_item && data.current_item.name) {
const type = data.current_item.type || 'item';
const name = data.current_item.name;
tooltipCurrent.textContent = `${type.charAt(0).toUpperCase() + type.slice(1)}: "${name}"`;
} else {
tooltipCurrent.textContent = 'No active matches';
}
}
if (tooltipProgress && data.progress) {
const artists = data.progress.artists || {};
const albums = data.progress.albums || {};
const tracks = data.progress.tracks || {};
const currentType = data.current_item?.type;
let progressText = '';
const artistsComplete = artists.matched >= artists.total;
const albumsComplete = albums.matched >= albums.total;
if (currentType === 'artist' || (!artistsComplete && !currentType)) {
progressText = `Artists: ${artists.matched || 0} / ${artists.total || 0} (${artists.percent || 0}%)`;
} else if (currentType === 'album' || (artistsComplete && !albumsComplete)) {
progressText = `Albums: ${albums.matched || 0} / ${albums.total || 0} (${albums.percent || 0}%)`;
} else if (currentType === 'track' || (artistsComplete && albumsComplete)) {
progressText = `Tracks: ${tracks.matched || 0} / ${tracks.total || 0} (${tracks.percent || 0}%)`;
} else {
progressText = `Artists: ${artists.matched || 0} / ${artists.total || 0} (${artists.percent || 0}%)`;
}
tooltipProgress.textContent = progressText;
}
}
/**
* Toggle AudioDB enrichment pause/resume
*/
async function toggleAudioDBEnrichment() {
try {
const button = document.getElementById('audiodb-button');
if (!button) return;
const isRunning = button.classList.contains('active');
const endpoint = isRunning ? '/api/audiodb/pause' : '/api/audiodb/resume';
const response = await fetch(endpoint, { method: 'POST' });
if (!response.ok) {
throw new Error(`Failed to ${isRunning ? 'pause' : 'resume'} AudioDB enrichment`);
}
// Immediately update UI
await updateAudioDBStatus();
console.log(`โ
AudioDB enrichment ${isRunning ? 'paused' : 'resumed'}`);
} catch (error) {
console.error('Error toggling AudioDB enrichment:', error);
showToast(`Error: ${error.message}`, 'error');
}
}
// Initialize AudioDB UI on page load
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => {
const button = document.getElementById('audiodb-button');
if (button) {
button.addEventListener('click', toggleAudioDBEnrichment);
updateAudioDBStatus();
setInterval(updateAudioDBStatus, 2000);
console.log('โ
AudioDB UI initialized');
}
});
} else {
const button = document.getElementById('audiodb-button');
if (button) {
button.addEventListener('click', toggleAudioDBEnrichment);
updateAudioDBStatus();
setInterval(updateAudioDBStatus, 2000);
console.log('โ
AudioDB UI initialized');
}
}
// ===================================================================
// DEEZER ENRICHMENT STATUS
// ===================================================================
async function updateDeezerStatus() {
if (socketConnected) return; // WebSocket handles this
if (document.hidden) return; // Skip polling when tab is not visible
try {
const response = await fetch('/api/deezer/status');
if (!response.ok) { console.warn('Deezer status endpoint unavailable'); return; }
const data = await response.json();
updateDeezerStatusFromData(data);
} catch (error) {
console.error('Error updating Deezer status:', error);
}
}
function updateDeezerStatusFromData(data) {
const button = document.getElementById('deezer-button');
if (!button) return;
button.classList.remove('active', 'paused', 'complete');
if (data.idle) {
button.classList.add('complete');
} else if (data.running && !data.paused) {
button.classList.add('active');
} else if (data.paused) {
button.classList.add('paused');
}
const tooltipStatus = document.getElementById('deezer-tooltip-status');
const tooltipCurrent = document.getElementById('deezer-tooltip-current');
const tooltipProgress = document.getElementById('deezer-tooltip-progress');
if (tooltipStatus) {
if (data.idle) { tooltipStatus.textContent = 'Complete'; }
else if (data.running && !data.paused) { tooltipStatus.textContent = 'Running'; }
else if (data.paused) { tooltipStatus.textContent = 'Paused'; }
else { tooltipStatus.textContent = 'Idle'; }
}
if (tooltipCurrent) {
if (data.idle) {
tooltipCurrent.textContent = 'All items processed';
} else if (data.current_item && data.current_item.name) {
tooltipCurrent.textContent = `Now: ${data.current_item.name}`;
}
}
if (data.progress && tooltipProgress) {
const artists = data.progress.artists || {};
const albums = data.progress.albums || {};
const tracks = data.progress.tracks || {};
const currentType = data.current_item?.type;
let progressText = '';
const artistsComplete = artists.matched >= artists.total;
const albumsComplete = albums.matched >= albums.total;
if (currentType === 'artist' || (!artistsComplete && !currentType)) {
progressText = `Artists: ${artists.matched || 0} / ${artists.total || 0} (${artists.percent || 0}%)`;
} else if (currentType === 'album' || (artistsComplete && !albumsComplete)) {
progressText = `Albums: ${albums.matched || 0} / ${albums.total || 0} (${albums.percent || 0}%)`;
} else if (currentType === 'track' || (artistsComplete && albumsComplete)) {
progressText = `Tracks: ${tracks.matched || 0} / ${tracks.total || 0} (${tracks.percent || 0}%)`;
} else {
progressText = `Artists: ${artists.matched || 0} / ${artists.total || 0} (${artists.percent || 0}%)`;
}
tooltipProgress.textContent = progressText;
}
}
async function toggleDeezerEnrichment() {
try {
const button = document.getElementById('deezer-button');
if (!button) return;
const isRunning = button.classList.contains('active');
const endpoint = isRunning ? '/api/deezer/pause' : '/api/deezer/resume';
const response = await fetch(endpoint, { method: 'POST' });
if (!response.ok) {
throw new Error(`Failed to ${isRunning ? 'pause' : 'resume'} Deezer enrichment`);
}
// Immediately update UI
await updateDeezerStatus();
console.log(`โ
Deezer enrichment ${isRunning ? 'paused' : 'resumed'}`);
} catch (error) {
console.error('Error toggling Deezer enrichment:', error);
showToast(`Error: ${error.message}`, 'error');
}
}
// Initialize Deezer UI on page load
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => {
const button = document.getElementById('deezer-button');
if (button) {
button.addEventListener('click', toggleDeezerEnrichment);
updateDeezerStatus();
setInterval(updateDeezerStatus, 2000);
console.log('โ
Deezer UI initialized');
}
});
} else {
const button = document.getElementById('deezer-button');
if (button) {
button.addEventListener('click', toggleDeezerEnrichment);
updateDeezerStatus();
setInterval(updateDeezerStatus, 2000);
console.log('โ
Deezer UI initialized');
}
}
// ===================================================================
// SPOTIFY ENRICHMENT STATUS
// ===================================================================
async function updateSpotifyEnrichmentStatus() {
if (socketConnected) return; // WebSocket handles this
if (document.hidden) return; // Skip polling when tab is not visible
try {
const response = await fetch('/api/spotify-enrichment/status');
if (!response.ok) { console.warn('Spotify enrichment status endpoint unavailable'); return; }
const data = await response.json();
updateSpotifyEnrichmentStatusFromData(data);
} catch (error) {
console.error('Error updating Spotify enrichment status:', error);
}
}
function updateSpotifyEnrichmentStatusFromData(data) {
const button = document.getElementById('spotify-enrich-button');
if (!button) return;
const notAuthenticated = data.authenticated === false;
button.classList.remove('active', 'paused', 'complete', 'no-auth');
if (data.paused) {
button.classList.add('paused');
} else if (notAuthenticated) {
button.classList.add('no-auth');
} else if (data.idle) {
button.classList.add('complete');
} else if (data.running && !data.paused) {
button.classList.add('active');
}
const tooltipStatus = document.getElementById('spotify-enrich-tooltip-status');
const tooltipCurrent = document.getElementById('spotify-enrich-tooltip-current');
const tooltipProgress = document.getElementById('spotify-enrich-tooltip-progress');
if (tooltipStatus) {
if (data.paused) { tooltipStatus.textContent = 'Paused'; }
else if (notAuthenticated) { tooltipStatus.textContent = 'Not Authenticated'; }
else if (data.idle) { tooltipStatus.textContent = 'Complete'; }
else if (data.running) { tooltipStatus.textContent = 'Running'; }
else { tooltipStatus.textContent = 'Idle'; }
}
if (tooltipCurrent) {
if (data.paused) {
tooltipCurrent.textContent = notAuthenticated ? 'Connect Spotify in Settings to enrich' : 'Click to resume';
} else if (notAuthenticated) {
tooltipCurrent.textContent = 'Connect Spotify in Settings to enrich';
} else if (data.idle) {
tooltipCurrent.textContent = 'All items processed';
} else if (data.current_item && data.current_item.name) {
tooltipCurrent.textContent = `Now: ${data.current_item.name}`;
}
}
if (data.progress && tooltipProgress) {
if (notAuthenticated) {
tooltipProgress.textContent = `Pending: ${data.stats?.pending || 0} items`;
} else {
const artists = data.progress.artists || {};
const albums = data.progress.albums || {};
const tracks = data.progress.tracks || {};
const currentType = data.current_item?.type || '';
let progressText = '';
const artistsComplete = artists.matched >= artists.total;
const albumsComplete = albums.matched >= albums.total;
if (currentType === 'artist') {
progressText = `Artists: ${artists.matched || 0} / ${artists.total || 0} (${artists.percent || 0}%)`;
} else if (currentType.includes('album')) {
progressText = `Albums: ${albums.matched || 0} / ${albums.total || 0} (${albums.percent || 0}%)`;
} else if (currentType.includes('track')) {
progressText = `Tracks: ${tracks.matched || 0} / ${tracks.total || 0} (${tracks.percent || 0}%)`;
} else if (!artistsComplete) {
progressText = `Artists: ${artists.matched || 0} / ${artists.total || 0} (${artists.percent || 0}%)`;
} else if (!albumsComplete) {
progressText = `Albums: ${albums.matched || 0} / ${albums.total || 0} (${albums.percent || 0}%)`;
} else {
progressText = `Tracks: ${tracks.matched || 0} / ${tracks.total || 0} (${tracks.percent || 0}%)`;
}
tooltipProgress.textContent = progressText;
}
}
}
async function toggleSpotifyEnrichment() {
try {
const button = document.getElementById('spotify-enrich-button');
if (!button) return;
const isRunning = button.classList.contains('active');
const endpoint = isRunning ? '/api/spotify-enrichment/pause' : '/api/spotify-enrichment/resume';
const response = await fetch(endpoint, { method: 'POST' });
if (!response.ok) {
const data = await response.json().catch(() => ({}));
if (data.rate_limited) {
showToast('Cannot resume โ Spotify is rate limited', 'warning');
return;
}
throw new Error(`Failed to ${isRunning ? 'pause' : 'resume'} Spotify enrichment`);
}
await updateSpotifyEnrichmentStatus();
console.log(`Spotify enrichment ${isRunning ? 'paused' : 'resumed'}`);
} catch (error) {
console.error('Error toggling Spotify enrichment:', error);
showToast(`Error: ${error.message}`, 'error');
}
}
// Initialize Spotify Enrichment UI on page load
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => {
const button = document.getElementById('spotify-enrich-button');
if (button) {
button.addEventListener('click', toggleSpotifyEnrichment);
updateSpotifyEnrichmentStatus();
setInterval(updateSpotifyEnrichmentStatus, 2000);
}
});
} else {
const button = document.getElementById('spotify-enrich-button');
if (button) {
button.addEventListener('click', toggleSpotifyEnrichment);
updateSpotifyEnrichmentStatus();
setInterval(updateSpotifyEnrichmentStatus, 2000);
}
}
// ===================================================================
// ITUNES ENRICHMENT STATUS
// ===================================================================
async function updateiTunesEnrichmentStatus() {
if (socketConnected) return; // WebSocket handles this
if (document.hidden) return; // Skip polling when tab is not visible
try {
const response = await fetch('/api/itunes-enrichment/status');
if (!response.ok) { console.warn('iTunes enrichment status endpoint unavailable'); return; }
const data = await response.json();
updateiTunesEnrichmentStatusFromData(data);
} catch (error) {
console.error('Error updating iTunes enrichment status:', error);
}
}
function updateiTunesEnrichmentStatusFromData(data) {
const button = document.getElementById('itunes-enrich-button');
if (!button) return;
button.classList.remove('active', 'paused', 'complete');
if (data.idle) {
button.classList.add('complete');
} else if (data.running && !data.paused) {
button.classList.add('active');
} else if (data.paused) {
button.classList.add('paused');
}
const tooltipStatus = document.getElementById('itunes-enrich-tooltip-status');
const tooltipCurrent = document.getElementById('itunes-enrich-tooltip-current');
const tooltipProgress = document.getElementById('itunes-enrich-tooltip-progress');
if (tooltipStatus) {
if (data.idle) { tooltipStatus.textContent = 'Complete'; }
else if (data.running && !data.paused) { tooltipStatus.textContent = 'Running'; }
else if (data.paused) { tooltipStatus.textContent = 'Paused'; }
else { tooltipStatus.textContent = 'Idle'; }
}
if (tooltipCurrent) {
if (data.idle) {
tooltipCurrent.textContent = 'All items processed';
} else if (data.current_item && data.current_item.name) {
tooltipCurrent.textContent = `Now: ${data.current_item.name}`;
}
}
if (data.progress && tooltipProgress) {
const artists = data.progress.artists || {};
const albums = data.progress.albums || {};
const tracks = data.progress.tracks || {};
const currentType = data.current_item?.type || '';
let progressText = '';
const artistsComplete = artists.matched >= artists.total;
const albumsComplete = albums.matched >= albums.total;
if (currentType === 'artist') {
progressText = `Artists: ${artists.matched || 0} / ${artists.total || 0} (${artists.percent || 0}%)`;
} else if (currentType.includes('album')) {
progressText = `Albums: ${albums.matched || 0} / ${albums.total || 0} (${albums.percent || 0}%)`;
} else if (currentType.includes('track')) {
progressText = `Tracks: ${tracks.matched || 0} / ${tracks.total || 0} (${tracks.percent || 0}%)`;
} else if (!artistsComplete) {
progressText = `Artists: ${artists.matched || 0} / ${artists.total || 0} (${artists.percent || 0}%)`;
} else if (!albumsComplete) {
progressText = `Albums: ${albums.matched || 0} / ${albums.total || 0} (${albums.percent || 0}%)`;
} else {
progressText = `Tracks: ${tracks.matched || 0} / ${tracks.total || 0} (${tracks.percent || 0}%)`;
}
tooltipProgress.textContent = progressText;
}
}
async function toggleiTunesEnrichment() {
try {
const button = document.getElementById('itunes-enrich-button');
if (!button) return;
const isRunning = button.classList.contains('active');
const endpoint = isRunning ? '/api/itunes-enrichment/pause' : '/api/itunes-enrichment/resume';
const response = await fetch(endpoint, { method: 'POST' });
if (!response.ok) {
throw new Error(`Failed to ${isRunning ? 'pause' : 'resume'} iTunes enrichment`);
}
await updateiTunesEnrichmentStatus();
console.log(`iTunes enrichment ${isRunning ? 'paused' : 'resumed'}`);
} catch (error) {
console.error('Error toggling iTunes enrichment:', error);
showToast(`Error: ${error.message}`, 'error');
}
}
// Initialize iTunes Enrichment UI on page load
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => {
const button = document.getElementById('itunes-enrich-button');
if (button) {
button.addEventListener('click', toggleiTunesEnrichment);
updateiTunesEnrichmentStatus();
setInterval(updateiTunesEnrichmentStatus, 2000);
}
});
} else {
const button = document.getElementById('itunes-enrich-button');
if (button) {
button.addEventListener('click', toggleiTunesEnrichment);
updateiTunesEnrichmentStatus();
setInterval(updateiTunesEnrichmentStatus, 2000);
}
}
// ===================================================================
// LAST.FM ENRICHMENT STATUS
// ===================================================================
async function updateLastFMEnrichmentStatus() {
if (socketConnected) return;
if (document.hidden) return;
try {
const response = await fetch('/api/lastfm-enrichment/status');
if (!response.ok) { console.warn('Last.fm status endpoint unavailable'); return; }
const data = await response.json();
updateLastFMEnrichmentStatusFromData(data);
} catch (error) {
console.error('Error updating Last.fm status:', error);
}
}
function updateLastFMEnrichmentStatusFromData(data) {
const button = document.getElementById('lastfm-enrich-button');
if (!button) return;
const notAuthenticated = data.authenticated === false;
button.classList.remove('active', 'paused', 'complete', 'no-auth');
if (data.paused) {
button.classList.add('paused');
} else if (notAuthenticated) {
button.classList.add('no-auth');
} else if (data.idle) {
button.classList.add('complete');
} else if (data.running && !data.paused) {
button.classList.add('active');
}
const tooltipStatus = document.getElementById('lastfm-enrich-tooltip-status');
const tooltipCurrent = document.getElementById('lastfm-enrich-tooltip-current');
const tooltipProgress = document.getElementById('lastfm-enrich-tooltip-progress');
if (tooltipStatus) {
if (data.paused) { tooltipStatus.textContent = 'Paused'; }
else if (notAuthenticated) { tooltipStatus.textContent = 'Not Authenticated'; }
else if (data.idle) { tooltipStatus.textContent = 'Complete'; }
else if (data.running) { tooltipStatus.textContent = 'Running'; }
else { tooltipStatus.textContent = 'Idle'; }
}
if (tooltipCurrent) {
if (data.paused) {
tooltipCurrent.textContent = notAuthenticated ? 'Add Last.fm API key in Settings to enrich' : 'Click to resume';
} else if (notAuthenticated) {
tooltipCurrent.textContent = 'Add Last.fm API key in Settings to enrich';
} else if (data.idle) {
tooltipCurrent.textContent = 'All items processed';
} else if (data.current_item && data.current_item.name) {
tooltipCurrent.textContent = `Now: ${data.current_item.name}`;
}
}
if (data.progress && tooltipProgress) {
if (notAuthenticated) {
tooltipProgress.textContent = `Pending: ${data.stats?.pending || 0} items`;
} else {
const artists = data.progress.artists || {};
const albums = data.progress.albums || {};
const tracks = data.progress.tracks || {};
const currentType = data.current_item?.type;
let progressText = '';
const artistsComplete = artists.matched >= artists.total;
const albumsComplete = albums.matched >= albums.total;
if (currentType === 'artist' || (!artistsComplete && !currentType)) {
progressText = `Artists: ${artists.matched || 0} / ${artists.total || 0} (${artists.percent || 0}%)`;
} else if (currentType === 'album' || (artistsComplete && !albumsComplete)) {
progressText = `Albums: ${albums.matched || 0} / ${albums.total || 0} (${albums.percent || 0}%)`;
} else if (currentType === 'track' || (artistsComplete && albumsComplete)) {
progressText = `Tracks: ${tracks.matched || 0} / ${tracks.total || 0} (${tracks.percent || 0}%)`;
} else {
progressText = `Artists: ${artists.matched || 0} / ${artists.total || 0} (${artists.percent || 0}%)`;
}
tooltipProgress.textContent = progressText;
}
}
}
async function toggleLastFMEnrichment() {
try {
const button = document.getElementById('lastfm-enrich-button');
if (!button) return;
const isRunning = button.classList.contains('active');
const endpoint = isRunning ? '/api/lastfm-enrichment/pause' : '/api/lastfm-enrichment/resume';
const response = await fetch(endpoint, { method: 'POST' });
if (!response.ok) {
throw new Error(`Failed to ${isRunning ? 'pause' : 'resume'} Last.fm enrichment`);
}
await updateLastFMEnrichmentStatus();
console.log(`Last.fm enrichment ${isRunning ? 'paused' : 'resumed'}`);
} catch (error) {
console.error('Error toggling Last.fm enrichment:', error);
showToast(`Error: ${error.message}`, 'error');
}
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => {
const button = document.getElementById('lastfm-enrich-button');
if (button) {
button.addEventListener('click', toggleLastFMEnrichment);
updateLastFMEnrichmentStatus();
setInterval(updateLastFMEnrichmentStatus, 2000);
}
});
} else {
const button = document.getElementById('lastfm-enrich-button');
if (button) {
button.addEventListener('click', toggleLastFMEnrichment);
updateLastFMEnrichmentStatus();
setInterval(updateLastFMEnrichmentStatus, 2000);
}
}
// ===================================================================
// GENIUS ENRICHMENT STATUS
// ===================================================================
async function updateGeniusEnrichmentStatus() {
if (socketConnected) return;
if (document.hidden) return;
try {
const response = await fetch('/api/genius-enrichment/status');
if (!response.ok) { console.warn('Genius status endpoint unavailable'); return; }
const data = await response.json();
updateGeniusEnrichmentStatusFromData(data);
} catch (error) {
console.error('Error updating Genius status:', error);
}
}
function updateGeniusEnrichmentStatusFromData(data) {
const button = document.getElementById('genius-enrich-button');
if (!button) return;
const notAuthenticated = data.authenticated === false;
button.classList.remove('active', 'paused', 'complete', 'no-auth');
if (data.paused) {
button.classList.add('paused');
} else if (notAuthenticated) {
button.classList.add('no-auth');
} else if (data.idle) {
button.classList.add('complete');
} else if (data.running && !data.paused) {
button.classList.add('active');
}
const tooltipStatus = document.getElementById('genius-enrich-tooltip-status');
const tooltipCurrent = document.getElementById('genius-enrich-tooltip-current');
const tooltipProgress = document.getElementById('genius-enrich-tooltip-progress');
if (tooltipStatus) {
if (data.paused) { tooltipStatus.textContent = 'Paused'; }
else if (notAuthenticated) { tooltipStatus.textContent = 'Not Authenticated'; }
else if (data.idle) { tooltipStatus.textContent = 'Complete'; }
else if (data.running) { tooltipStatus.textContent = 'Running'; }
else { tooltipStatus.textContent = 'Idle'; }
}
if (tooltipCurrent) {
if (data.paused) {
tooltipCurrent.textContent = notAuthenticated ? 'Add Genius access token in Settings to enrich' : 'Click to resume';
} else if (notAuthenticated) {
tooltipCurrent.textContent = 'Add Genius access token in Settings to enrich';
} else if (data.idle) {
tooltipCurrent.textContent = 'All items processed';
} else if (data.current_item && data.current_item.name) {
tooltipCurrent.textContent = `Now: ${data.current_item.name}`;
}
}
if (data.progress && tooltipProgress) {
if (notAuthenticated) {
tooltipProgress.textContent = `Pending: ${data.stats?.pending || 0} items`;
} else {
const artists = data.progress.artists || {};
const tracks = data.progress.tracks || {};
const currentType = data.current_item?.type;
let progressText = '';
const artistsComplete = artists.matched >= artists.total;
if (currentType === 'artist' || (!artistsComplete && !currentType)) {
progressText = `Artists: ${artists.matched || 0} / ${artists.total || 0} (${artists.percent || 0}%)`;
} else {
progressText = `Tracks: ${tracks.matched || 0} / ${tracks.total || 0} (${tracks.percent || 0}%)`;
}
tooltipProgress.textContent = progressText;
}
}
}
async function toggleGeniusEnrichment() {
try {
const button = document.getElementById('genius-enrich-button');
if (!button) return;
const isRunning = button.classList.contains('active');
const endpoint = isRunning ? '/api/genius-enrichment/pause' : '/api/genius-enrichment/resume';
const response = await fetch(endpoint, { method: 'POST' });
if (!response.ok) {
throw new Error(`Failed to ${isRunning ? 'pause' : 'resume'} Genius enrichment`);
}
await updateGeniusEnrichmentStatus();
console.log(`Genius enrichment ${isRunning ? 'paused' : 'resumed'}`);
} catch (error) {
console.error('Error toggling Genius enrichment:', error);
showToast(`Error: ${error.message}`, 'error');
}
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => {
const button = document.getElementById('genius-enrich-button');
if (button) {
button.addEventListener('click', toggleGeniusEnrichment);
updateGeniusEnrichmentStatus();
setInterval(updateGeniusEnrichmentStatus, 2000);
}
});
} else {
const button = document.getElementById('genius-enrich-button');
if (button) {
button.addEventListener('click', toggleGeniusEnrichment);
updateGeniusEnrichmentStatus();
setInterval(updateGeniusEnrichmentStatus, 2000);
}
}
// ===================================================================
// TIDAL ENRICHMENT WORKER
// ===================================================================
async function updateTidalEnrichmentStatus() {
if (socketConnected) return;
if (document.hidden) return;
try {
const response = await fetch('/api/tidal-enrichment/status');
if (!response.ok) { console.warn('Tidal status endpoint unavailable'); return; }
const data = await response.json();
updateTidalEnrichmentStatusFromData(data);
} catch (error) {
console.error('Error updating Tidal status:', error);
}
}
function updateTidalEnrichmentStatusFromData(data) {
const button = document.getElementById('tidal-enrich-button');
if (!button) return;
const notAuthenticated = data.authenticated === false;
button.classList.remove('active', 'paused', 'complete', 'no-auth');
if (data.paused) {
button.classList.add('paused');
} else if (notAuthenticated) {
button.classList.add('no-auth');
} else if (data.idle) {
button.classList.add('complete');
} else if (data.running && !data.paused) {
button.classList.add('active');
}
const tooltipStatus = document.getElementById('tidal-enrich-tooltip-status');
const tooltipCurrent = document.getElementById('tidal-enrich-tooltip-current');
const tooltipProgress = document.getElementById('tidal-enrich-tooltip-progress');
if (tooltipStatus) {
if (data.paused) { tooltipStatus.textContent = 'Paused'; }
else if (notAuthenticated) { tooltipStatus.textContent = 'Not Authenticated'; }
else if (data.idle) { tooltipStatus.textContent = 'Complete'; }
else if (data.running) { tooltipStatus.textContent = 'Running'; }
else { tooltipStatus.textContent = 'Idle'; }
}
if (tooltipCurrent) {
if (data.paused) {
tooltipCurrent.textContent = notAuthenticated ? 'Connect Tidal in Settings to enrich' : 'Click to resume';
} else if (notAuthenticated) {
tooltipCurrent.textContent = 'Connect Tidal in Settings to enrich';
} else if (data.idle) {
tooltipCurrent.textContent = 'All items processed';
} else if (data.current_item && data.current_item.name) {
tooltipCurrent.textContent = `Now: ${data.current_item.name}`;
}
}
if (data.progress && tooltipProgress) {
if (notAuthenticated) {
tooltipProgress.textContent = `Pending: ${data.stats?.pending || 0} items`;
} else {
const artists = data.progress.artists || {};
const albums = data.progress.albums || {};
const tracks = data.progress.tracks || {};
const currentType = data.current_item?.type;
let progressText = '';
const artistsComplete = artists.matched >= artists.total;
const albumsComplete = albums.matched >= albums.total;
if (currentType === 'artist' || (!artistsComplete && !currentType)) {
progressText = `Artists: ${artists.matched || 0} / ${artists.total || 0} (${artists.percent || 0}%)`;
} else if (currentType === 'album' || (!albumsComplete && !currentType)) {
progressText = `Albums: ${albums.matched || 0} / ${albums.total || 0} (${albums.percent || 0}%)`;
} else {
progressText = `Tracks: ${tracks.matched || 0} / ${tracks.total || 0} (${tracks.percent || 0}%)`;
}
tooltipProgress.textContent = progressText;
}
}
}
async function toggleTidalEnrichment() {
try {
const button = document.getElementById('tidal-enrich-button');
if (!button) return;
const isRunning = button.classList.contains('active');
const endpoint = isRunning ? '/api/tidal-enrichment/pause' : '/api/tidal-enrichment/resume';
const response = await fetch(endpoint, { method: 'POST' });
if (!response.ok) {
throw new Error(`Failed to ${isRunning ? 'pause' : 'resume'} Tidal enrichment`);
}
await updateTidalEnrichmentStatus();
console.log(`Tidal enrichment ${isRunning ? 'paused' : 'resumed'}`);
} catch (error) {
console.error('Error toggling Tidal enrichment:', error);
showToast(`Error: ${error.message}`, 'error');
}
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => {
const button = document.getElementById('tidal-enrich-button');
if (button) {
button.addEventListener('click', toggleTidalEnrichment);
updateTidalEnrichmentStatus();
setInterval(updateTidalEnrichmentStatus, 2000);
}
});
} else {
const button = document.getElementById('tidal-enrich-button');
if (button) {
button.addEventListener('click', toggleTidalEnrichment);
updateTidalEnrichmentStatus();
setInterval(updateTidalEnrichmentStatus, 2000);
}
}
// ===================================================================
// QOBUZ ENRICHMENT WORKER
// ===================================================================
async function updateQobuzEnrichmentStatus() {
if (socketConnected) return;
if (document.hidden) return;
try {
const response = await fetch('/api/qobuz-enrichment/status');
if (!response.ok) { console.warn('Qobuz status endpoint unavailable'); return; }
const data = await response.json();
updateQobuzEnrichmentStatusFromData(data);
} catch (error) {
console.error('Error updating Qobuz status:', error);
}
}
function updateQobuzEnrichmentStatusFromData(data) {
const button = document.getElementById('qobuz-enrich-button');
if (!button) return;
const notAuthenticated = data.authenticated === false;
button.classList.remove('active', 'paused', 'complete', 'no-auth');
if (data.paused) {
button.classList.add('paused');
} else if (notAuthenticated) {
button.classList.add('no-auth');
} else if (data.idle) {
button.classList.add('complete');
} else if (data.running && !data.paused) {
button.classList.add('active');
}
const tooltipStatus = document.getElementById('qobuz-enrich-tooltip-status');
const tooltipCurrent = document.getElementById('qobuz-enrich-tooltip-current');
const tooltipProgress = document.getElementById('qobuz-enrich-tooltip-progress');
if (tooltipStatus) {
if (data.paused) { tooltipStatus.textContent = 'Paused'; }
else if (notAuthenticated) { tooltipStatus.textContent = 'Not Authenticated'; }
else if (data.idle) { tooltipStatus.textContent = 'Complete'; }
else if (data.running) { tooltipStatus.textContent = 'Running'; }
else { tooltipStatus.textContent = 'Idle'; }
}
if (tooltipCurrent) {
if (data.paused) {
tooltipCurrent.textContent = notAuthenticated ? 'Connect Qobuz in Settings to enrich' : 'Click to resume';
} else if (notAuthenticated) {
tooltipCurrent.textContent = 'Connect Qobuz in Settings to enrich';
} else if (data.idle) {
tooltipCurrent.textContent = 'All items processed';
} else if (data.current_item && data.current_item.name) {
tooltipCurrent.textContent = `Now: ${data.current_item.name}`;
}
}
if (data.progress && tooltipProgress) {
if (notAuthenticated) {
tooltipProgress.textContent = `Pending: ${data.stats?.pending || 0} items`;
} else {
const artists = data.progress.artists || {};
const albums = data.progress.albums || {};
const tracks = data.progress.tracks || {};
const currentType = data.current_item?.type;
let progressText = '';
const artistsComplete = artists.matched >= artists.total;
const albumsComplete = albums.matched >= albums.total;
if (currentType === 'artist' || (!artistsComplete && !currentType)) {
progressText = `Artists: ${artists.matched || 0} / ${artists.total || 0} (${artists.percent || 0}%)`;
} else if (currentType === 'album' || (!albumsComplete && !currentType)) {
progressText = `Albums: ${albums.matched || 0} / ${albums.total || 0} (${albums.percent || 0}%)`;
} else {
progressText = `Tracks: ${tracks.matched || 0} / ${tracks.total || 0} (${tracks.percent || 0}%)`;
}
tooltipProgress.textContent = progressText;
}
}
}
async function toggleQobuzEnrichment() {
try {
const button = document.getElementById('qobuz-enrich-button');
if (!button) return;
const isRunning = button.classList.contains('active');
const endpoint = isRunning ? '/api/qobuz-enrichment/pause' : '/api/qobuz-enrichment/resume';
const response = await fetch(endpoint, { method: 'POST' });
if (!response.ok) {
throw new Error(`Failed to ${isRunning ? 'pause' : 'resume'} Qobuz enrichment`);
}
await updateQobuzEnrichmentStatus();
console.log(`Qobuz enrichment ${isRunning ? 'paused' : 'resumed'}`);
} catch (error) {
console.error('Error toggling Qobuz enrichment:', error);
showToast(`Error: ${error.message}`, 'error');
}
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => {
const button = document.getElementById('qobuz-enrich-button');
if (button) {
button.addEventListener('click', toggleQobuzEnrichment);
updateQobuzEnrichmentStatus();
setInterval(updateQobuzEnrichmentStatus, 2000);
}
});
} else {
const button = document.getElementById('qobuz-enrich-button');
if (button) {
button.addEventListener('click', toggleQobuzEnrichment);
updateQobuzEnrichmentStatus();
setInterval(updateQobuzEnrichmentStatus, 2000);
}
}
// ===================================================================
// HYDRABASE P2P MIRROR WORKER
// ===================================================================
async function updateHydrabaseStatus() {
if (socketConnected) return; // WebSocket handles this
if (document.hidden) return; // Skip polling when tab is not visible
try {
const response = await fetch('/api/hydrabase-worker/status');
if (!response.ok) return;
const data = await response.json();
updateHydrabaseStatusFromData(data);
} catch (error) {
// Silently ignore โ worker may not be available
}
}
function updateHydrabaseStatusFromData(data) {
const button = document.getElementById('hydrabase-button');
if (!button) return;
button.classList.remove('active', 'paused');
if (data.running && !data.paused) {
button.classList.add('active');
} else if (data.paused) {
button.classList.add('paused');
}
const statusEl = document.getElementById('hydrabase-tooltip-status');
if (statusEl) {
if (data.paused) {
statusEl.textContent = 'Paused';
statusEl.style.color = '#ffc107';
} else if (data.running) {
statusEl.textContent = 'Active';
statusEl.style.color = '#ffffff';
} else {
statusEl.textContent = 'Stopped';
statusEl.style.color = '#ff5252';
}
}
}
async function toggleHydrabaseWorker() {
const button = document.getElementById('hydrabase-button');
if (!button) return;
const isRunning = button.classList.contains('active');
const endpoint = isRunning ? '/api/hydrabase-worker/pause' : '/api/hydrabase-worker/resume';
try {
await fetch(endpoint, { method: 'POST' });
await updateHydrabaseStatus();
} catch (error) {
console.error('Error toggling Hydrabase worker:', error);
}
}
// Initialize Hydrabase UI on page load
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => {
const button = document.getElementById('hydrabase-button');
if (button) {
button.addEventListener('click', toggleHydrabaseWorker);
updateHydrabaseStatus();
setInterval(updateHydrabaseStatus, 2000);
}
});
} else {
const button = document.getElementById('hydrabase-button');
if (button) {
button.addEventListener('click', toggleHydrabaseWorker);
updateHydrabaseStatus();
setInterval(updateHydrabaseStatus, 2000);
}
}
// ===================================================================
// LIBRARY REPAIR WORKER
// ===================================================================
async function updateRepairStatus() {
if (socketConnected) return; // WebSocket handles this
if (document.hidden) return; // Skip polling when tab is not visible
try {
const response = await fetch('/api/repair/status');
if (!response.ok) { console.warn('Repair status endpoint unavailable'); return; }
const data = await response.json();
updateRepairStatusFromData(data);
} catch (error) {
console.error('Error updating repair status:', error);
}
}
function updateRepairStatusFromData(data) {
const button = document.getElementById('repair-button');
if (!button) return;
button.classList.remove('active', 'paused', 'complete');
if (data.idle) {
button.classList.add('complete');
} else if (data.running && !data.paused) {
button.classList.add('active');
} else if (data.paused) {
button.classList.add('paused');
}
const tooltipStatus = document.getElementById('repair-tooltip-status');
const tooltipCurrent = document.getElementById('repair-tooltip-current');
const tooltipProgress = document.getElementById('repair-tooltip-progress');
if (tooltipStatus) {
if (data.idle) { tooltipStatus.textContent = 'Complete'; }
else if (data.running && !data.paused) { tooltipStatus.textContent = 'Running'; }
else if (data.paused) { tooltipStatus.textContent = 'Paused'; }
else { tooltipStatus.textContent = 'Idle'; }
}
if (tooltipCurrent) {
if (data.idle) {
tooltipCurrent.textContent = 'All items processed';
} else if (data.current_item && data.current_item.name) {
const type = data.current_item.type || 'item';
const name = data.current_item.name;
tooltipCurrent.textContent = `${type.charAt(0).toUpperCase() + type.slice(1)}: "${name}"`;
} else {
tooltipCurrent.textContent = 'No active repairs';
}
}
if (tooltipProgress && data.progress) {
const tracks = data.progress.tracks || {};
tooltipProgress.textContent = `Checked: ${tracks.checked || 0} / ${tracks.total || 0} (${tracks.percent || 0}%) | Repaired: ${tracks.repaired || 0}`;
}
// Update findings badge
const badge = document.getElementById('repair-findings-badge');
const findingsPending = data.findings_pending || 0;
if (badge) {
badge.textContent = findingsPending;
badge.style.display = findingsPending > 0 ? '' : 'none';
}
const tabBadge = document.getElementById('repair-findings-tab-badge');
if (tabBadge) {
tabBadge.textContent = findingsPending;
tabBadge.style.display = findingsPending > 0 ? '' : 'none';
}
// Update master toggle in modal if open
const masterToggle = document.getElementById('repair-master-toggle');
const masterLabel = document.getElementById('repair-master-label');
if (masterToggle) masterToggle.checked = data.enabled || false;
if (masterLabel) masterLabel.textContent = data.enabled ? 'Enabled' : 'Disabled';
// Update button state
if (!data.enabled) {
button.classList.add('paused');
button.classList.remove('active', 'complete');
}
}
// โโ Repair Modal State โโ
let _repairCurrentTab = 'jobs';
let _repairFindingsPage = 0;
let _repairSelectedFindings = new Set();
const REPAIR_FINDINGS_PAGE_SIZE = 30;
let _repairJobsCache = {}; // Cache job data for help modal
/**
* Open the Library Maintenance modal
*/
async function openRepairModal() {
const modal = document.getElementById('repair-modal');
if (!modal) return;
modal.style.display = 'flex';
document.body.style.overflow = 'hidden';
_repairCurrentTab = 'jobs';
switchRepairTab('jobs');
// Load master toggle state
updateRepairStatus();
// Load any active job progress
try {
const resp = await fetch('/api/repair/progress');
if (resp.ok) {
const data = await resp.json();
if (Object.keys(data).length > 0) {
// Brief delay so job cards are rendered first
setTimeout(() => updateRepairJobProgressFromData(data), 300);
}
}
} catch (e) { /* ignore */ }
}
function closeRepairModal() {
const modal = document.getElementById('repair-modal');
if (!modal) return;
modal.style.display = 'none';
document.body.style.overflow = '';
}
async function toggleRepairMaster() {
try {
const response = await fetch('/api/repair/toggle', { method: 'POST' });
if (!response.ok) throw new Error('Failed to toggle');
const data = await response.json();
const label = document.getElementById('repair-master-label');
const toggle = document.getElementById('repair-master-toggle');
if (label) label.textContent = data.enabled ? 'Enabled' : 'Disabled';
if (toggle) toggle.checked = data.enabled;
await updateRepairStatus();
} catch (error) {
console.error('Error toggling repair master:', error);
showToast('Error toggling maintenance worker', 'error');
}
}
function switchRepairTab(tab) {
_repairCurrentTab = tab;
document.querySelectorAll('.repair-tab').forEach(t => {
t.classList.toggle('active', t.dataset.tab === tab);
});
document.querySelectorAll('.repair-tab-content').forEach(c => {
c.style.display = 'none';
});
const content = document.getElementById(`repair-tab-${tab}`);
if (content) content.style.display = '';
if (tab === 'jobs') loadRepairJobs();
else if (tab === 'findings') loadRepairFindings();
else if (tab === 'history') loadRepairHistory();
}
async function loadRepairJobs() {
const container = document.getElementById('repair-jobs-list');
if (!container) return;
try {
const response = await fetch('/api/repair/jobs');
if (!response.ok) throw new Error('Failed to fetch jobs');
const data = await response.json();
const jobs = data.jobs || [];
// Cache job data for help modal
_repairJobsCache = {};
jobs.forEach(j => { _repairJobsCache[j.job_id] = j; });
if (jobs.length === 0) {
container.innerHTML = `
๐ง
No Maintenance Jobs
Library maintenance jobs will appear here once available.
`;
return;
}
// Populate findings job filter dropdown
const jobFilter = document.getElementById('repair-findings-job-filter');
if (jobFilter && jobFilter.options.length <= 1) {
jobs.forEach(job => {
const opt = document.createElement('option');
opt.value = job.job_id;
opt.textContent = job.display_name;
jobFilter.appendChild(opt);
});
}
container.innerHTML = jobs.map(job => {
const lastRunText = job.last_run ? formatCacheAge(job.last_run.finished_at) : 'Never';
const nextRunText = job.next_run ? formatCacheAge(job.next_run) : (job.enabled ? 'Pending' : '-');
const statusClass = job.is_running ? 'running' : (job.enabled ? 'idle' : 'disabled');
const dotClass = job.is_running ? 'running' : (job.enabled ? 'enabled' : 'disabled');
const cardClass = job.is_running ? 'running' : (!job.enabled ? 'disabled' : '');
// Build flow badges
const flowParts = [];
flowParts.push(`
${job.is_running ? '▶ Running' : 'Scan'} `);
if (job.auto_fix) {
flowParts.push('
→ ');
const isDryRun = job.settings && job.settings.dry_run === true;
if (isDryRun) {
flowParts.push('
Dry Run ');
} else {
flowParts.push('
Auto-fix ');
}
}
// Show pending findings count
const findingsCount = job.last_run ? (job.last_run.findings_created || 0) : 0;
if (findingsCount > 0) {
flowParts.push('
→ ');
flowParts.push(`
${findingsCount} finding${findingsCount !== 1 ? 's' : ''} `);
}
// Build meta parts
const metaParts = [];
metaParts.push('Last: ' + lastRunText);
metaParts.push('Next: ' + nextRunText);
if (job.last_run) {
metaParts.push(`Scanned: ${(job.last_run.items_scanned || 0).toLocaleString()}`);
if (job.last_run.auto_fixed) metaParts.push(`Fixed: ${job.last_run.auto_fixed}`);
}
if (job.last_run && job.last_run.duration_seconds) {
metaParts.push(`${job.last_run.duration_seconds.toFixed(1)}s`);
}
// Build settings HTML
let settingsHtml = '';
if (job.settings && Object.keys(job.settings).length > 0) {
const settingsRows = Object.entries(job.settings).map(([key, val]) => {
const label = key.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase());
const inputType = typeof val === 'boolean' ? 'checkbox' :
typeof val === 'number' ? 'number' : 'text';
const inputVal = inputType === 'checkbox' ?
(val ? ' checked' : '') :
` value="${val}"`;
return `
${label}
`;
}).join('');
settingsHtml = `
`;
}
return `
${job.display_name}
${flowParts.join('')}
${metaParts.join(' · ')}
▶
${Object.keys(job.settings || {}).length > 0 ?
`⚙ ` : ''}
?
${settingsHtml}
`;
}).join('');
} catch (error) {
console.error('Error loading repair jobs:', error);
container.innerHTML = '
Error loading jobs
';
}
}
async function toggleRepairJob(jobId, enabled) {
try {
await fetch(`/api/repair/jobs/${jobId}/toggle`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ enabled })
});
// Update card visuals immediately
const card = document.querySelector(`.repair-job-card[data-job-id="${jobId}"]`);
if (card) {
card.classList.toggle('disabled', !enabled);
const dot = card.querySelector('.repair-job-status');
if (dot) dot.className = 'repair-job-status ' + (enabled ? 'enabled' : 'disabled');
}
} catch (error) {
console.error('Error toggling job:', error);
showToast('Error toggling job', 'error');
}
}
function expandRepairJobSettings(jobId) {
const el = document.getElementById(`repair-settings-${jobId}`);
if (el) el.style.display = el.style.display === 'none' ? '' : 'none';
}
function showRepairJobHelp(jobId) {
const job = _repairJobsCache[jobId];
if (!job) return;
// Remove existing overlay if present
let overlay = document.getElementById('repair-help-overlay');
if (overlay) overlay.remove();
// Build settings summary
let settingsHtml = '';
if (job.settings && Object.keys(job.settings).length > 0) {
const rows = Object.entries(job.settings).map(([key, val]) => {
const label = key.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase());
const display = typeof val === 'boolean' ? (val ? 'Yes' : 'No') : val;
return `
${label} ${display}
`;
}).join('');
settingsHtml = `
`;
}
// Build info badges
const badges = [];
if (job.auto_fix) {
const isDryRun = job.settings && job.settings.dry_run === true;
badges.push(isDryRun
? '
Dry Run '
: '
Auto-fix ');
} else {
badges.push('
Scan Only ');
}
badges.push(`
Every ${job.interval_hours}h `);
if (job.enabled) {
badges.push('
Enabled ');
} else {
badges.push('
Disabled ');
}
// Format help text paragraphs
const helpBody = (job.help_text || job.description || '').split('\n\n').map(p => {
if (p.startsWith('Settings:\n')) {
const lines = p.split('\n').slice(1);
return '
' +
lines.map(l => `
${l.replace(/^- /, '')}
`).join('') +
'
';
}
return `
${p.replace(/\n/g, ' ')}
`;
}).join('');
overlay = document.createElement('div');
overlay.id = 'repair-help-overlay';
overlay.className = 'repair-help-overlay';
overlay.onclick = (e) => { if (e.target === overlay) overlay.remove(); };
overlay.innerHTML = `
${badges.join('')}
${helpBody}
${settingsHtml}
`;
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 loadRepairFindings() {
const container = document.getElementById('repair-findings-list');
if (!container) return;
const jobFilter = document.getElementById('repair-findings-job-filter');
const severityFilter = document.getElementById('repair-findings-severity-filter');
const statusFilter = document.getElementById('repair-findings-status-filter');
const params = new URLSearchParams();
if (jobFilter && jobFilter.value) params.set('job_id', jobFilter.value);
if (severityFilter && severityFilter.value) params.set('severity', severityFilter.value);
if (statusFilter && statusFilter.value) params.set('status', statusFilter.value);
params.set('page', _repairFindingsPage);
params.set('limit', REPAIR_FINDINGS_PAGE_SIZE);
try {
const response = await fetch(`/api/repair/findings?${params}`);
if (!response.ok) throw new Error('Failed to fetch findings');
const data = await response.json();
const items = data.items || [];
_repairSelectedFindings.clear();
const bulkBar = document.getElementById('repair-findings-bulk');
if (bulkBar) bulkBar.style.display = 'none';
if (items.length === 0) {
container.innerHTML = `
✓
All Clear
No findings match your filters. Your library is looking good!
`;
document.getElementById('repair-findings-pagination').innerHTML = '';
return;
}
const severityIcons = { info: 'โน๏ธ', warning: 'โ ๏ธ', critical: '๐ด' };
container.innerHTML = items.map(f => {
const icon = severityIcons[f.severity] || 'โน๏ธ';
const age = formatCacheAge(f.created_at);
const statusBadge = f.status !== 'pending' ?
`
${f.status} ` : '';
return `
${icon}
${f.title}
${statusBadge}
${f.description || ''}
${f.job_id.replace(/_/g, ' ')} · ${f.entity_type || 'file'} · ${age}
${f.status === 'pending' ? `
✓
×
` : ''}
`;
}).join('');
// Pagination
renderRepairFindingsPagination(data.total, data.page);
} catch (error) {
console.error('Error loading findings:', error);
container.innerHTML = '
Error loading findings
';
}
}
function toggleFindingSelect(id, checked) {
if (checked) _repairSelectedFindings.add(id);
else _repairSelectedFindings.delete(id);
const bulkBar = document.getElementById('repair-findings-bulk');
if (bulkBar) bulkBar.style.display = _repairSelectedFindings.size > 0 ? '' : 'none';
}
function renderRepairFindingsPagination(total, currentPage) {
const container = document.getElementById('repair-findings-pagination');
if (!container) return;
const totalPages = Math.ceil(total / REPAIR_FINDINGS_PAGE_SIZE);
if (totalPages <= 1) { container.innerHTML = ''; return; }
let html = '';
if (currentPage > 0) {
html += `
← `;
}
for (let i = 0; i < totalPages && i < 10; i++) {
html += `
${i + 1} `;
}
if (currentPage < totalPages - 1) {
html += `
→ `;
}
container.innerHTML = html;
}
async function resolveRepairFinding(id) {
try {
await fetch(`/api/repair/findings/${id}/resolve`, { method: 'POST' });
loadRepairFindings();
updateRepairStatus();
} catch (error) {
console.error('Error resolving finding:', error);
}
}
async function dismissRepairFinding(id) {
try {
await fetch(`/api/repair/findings/${id}/dismiss`, { method: 'POST' });
loadRepairFindings();
updateRepairStatus();
} catch (error) {
console.error('Error dismissing finding:', error);
}
}
async function bulkRepairAction(action) {
if (_repairSelectedFindings.size === 0) return;
try {
await fetch('/api/repair/findings/bulk', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ids: Array.from(_repairSelectedFindings), action })
});
showToast(`${_repairSelectedFindings.size} findings ${action === 'dismiss' ? 'dismissed' : 'resolved'}`, 'success');
_repairSelectedFindings.clear();
loadRepairFindings();
updateRepairStatus();
} catch (error) {
console.error('Error bulk updating findings:', error);
showToast('Error updating findings', 'error');
}
}
async function loadRepairHistory() {
const container = document.getElementById('repair-history-list');
if (!container) return;
try {
const response = await fetch('/api/repair/history?limit=50');
if (!response.ok) throw new Error('Failed to fetch history');
const data = await response.json();
const runs = data.runs || [];
if (runs.length === 0) {
container.innerHTML = `
🕑
No History Yet
Job run history will appear here after maintenance jobs complete their first scan.
`;
return;
}
container.innerHTML = runs.map(run => {
const duration = run.duration_seconds ? `${run.duration_seconds.toFixed(1)}s` : '-';
const age = formatCacheAge(run.started_at);
const statusClass = run.status === 'completed' ? 'success' :
run.status === 'failed' ? 'error' : 'running';
return `
${run.display_name || run.job_id}
${run.status}
${age} · ${duration} ·
Scanned: ${(run.items_scanned || 0).toLocaleString()} ·
Fixed: ${run.auto_fixed || 0} ·
Findings: ${run.findings_created || 0}
${run.errors ? ` · Errors: ${run.errors}` : ''}
`;
}).join('');
} catch (error) {
console.error('Error loading repair history:', error);
container.innerHTML = '
Error loading history
';
}
}
// Initialize Repair Worker UI on page load
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => {
const button = document.getElementById('repair-button');
if (button) {
button.addEventListener('click', openRepairModal);
updateRepairStatus();
setInterval(updateRepairStatus, 5000);
}
});
} else {
const button = document.getElementById('repair-button');
if (button) {
button.addEventListener('click', openRepairModal);
updateRepairStatus();
setInterval(updateRepairStatus, 5000);
}
}
// ===================================================================
// IMPORT PAGE (full page, replaces old modal)
// ===================================================================
let importJobIdCounter = 0;
const importPageState = {
stagingFiles: [],
selectedSingles: new Set(),
albumData: null, // response from /api/import/album/match
matchOverrides: {}, // { trackIndex: stagingFileIndex } โ manual drag-drop overrides
singlesManualMatches: {}, // { stagingFileIndex: { id, name, artist, album, ... } }
initialized: false,
activeTab: 'album',
tapSelectedChip: null, // for mobile tap-to-assign fallback
};
// --- Initialization ---
function initializeImportPage() {
if (!importPageState.initialized) {
importPageState.initialized = true;
importPageRefreshStaging();
importPageLoadSuggestions();
}
}
async function importPageRefreshStaging() {
// Clear finished jobs from the queue
importPageClearFinishedJobs();
// Re-fetch suggestions (server rebuilds cache after imports)
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: Suggestions (server-side cache, just fetch and render) ---
async function importPageLoadSuggestions() {
const section = document.getElementById('import-page-suggestions');
const grid = document.getElementById('import-page-suggestions-grid');
if (!section || !grid) return;
try {
const resp = await fetch('/api/import/staging/suggestions');
if (!resp.ok) return;
const data = await resp.json();
if (!data.success || !data.suggestions || data.suggestions.length === 0) {
if (!data.ready) {
// Server is still building cache โ show placeholder, retry shortly
section.style.display = '';
grid.innerHTML = '
Loading suggestions...
';
setTimeout(() => importPageLoadSuggestions(), 3000);
} else {
section.style.display = 'none';
grid.innerHTML = '';
}
return;
}
section.style.display = '';
grid.innerHTML = data.suggestions.map(a => _renderSuggestionCard(a)).join('');
} catch (err) {
// Network error or server not ready โ fail silently
console.warn('Failed to load import suggestions:', err);
}
}
function _renderSuggestionCard(a) {
return `
${_esc(a.name)}
${_esc(a.artist)}
${a.total_tracks} tracks ยท ${a.release_date ? a.release_date.substring(0,4) : ''}
`;
}
// --- Album Tab: Search ---
async function importPageSearchAlbum() {
const query = document.getElementById('import-page-album-search-input').value.trim();
if (!query) return;
document.getElementById('import-page-suggestions').style.display = 'none';
const grid = document.getElementById('import-page-album-results');
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.length) {
grid.innerHTML = '
No albums found
';
return;
}
grid.innerHTML = data.albums.map(a => `
${_esc(a.name)}
${_esc(a.artist)}
${a.total_tracks} tracks ยท ${a.release_date ? a.release_date.substring(0,4) : ''}
`).join('');
document.getElementById('import-page-album-clear-btn').classList.remove('hidden');
} catch (err) {
grid.innerHTML = `
Error: ${err.message}
`;
}
}
// --- Album Tab: Select Album & Match ---
async function importPageSelectAlbum(albumId) {
document.getElementById('import-page-album-search-section').classList.add('hidden');
document.getElementById('import-page-album-match-section').classList.remove('hidden');
const matchList = document.getElementById('import-page-match-list');
matchList.innerHTML = '
Matching files to tracklist...
';
try {
const resp = await fetch('/api/import/album/match', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({album_id: albumId})
});
const data = await resp.json();
if (!data.success) {
matchList.innerHTML = `
Error: ${data.error}
`;
return;
}
importPageState.albumData = data;
importPageState.matchOverrides = {};
// Render hero
const album = data.album;
document.getElementById('import-page-album-hero').innerHTML = `
${_esc(album.name)}
${_esc(album.artist)}
${album.total_tracks} tracks ยท ${album.release_date ? album.release_date.substring(0,4) : ''}
`;
importPageRenderMatchList();
} catch (err) {
matchList.innerHTML = `
Error: ${err.message}
`;
}
}
function importPageRenderMatchList() {
const data = importPageState.albumData;
if (!data) return;
const matchList = document.getElementById('import-page-match-list');
const overrides = importPageState.matchOverrides;
// Build effective matches: auto-match overridden by manual overrides
// Also track which staging files are used (auto or override)
const usedStagingFiles = new Set();
// First pass: collect overridden indices
Object.values(overrides).forEach(sfIdx => usedStagingFiles.add(sfIdx));
// Build rows
let matchedCount = 0;
const rows = data.matches.map((m, idx) => {
let file = null;
let confidence = m.confidence;
let isOverride = false;
if (overrides.hasOwnProperty(idx)) {
const sfIdx = overrides[idx];
if (sfIdx === -1) {
// Forcibly unmatched โ no file
file = null;
} else {
// Manual override
file = importPageState.stagingFiles[sfIdx] || null;
confidence = 1.0;
isOverride = true;
usedStagingFiles.add(sfIdx);
}
} else if (m.staging_file) {
file = m.staging_file;
// Check if this file was reassigned to another track via override
const autoFileName = m.staging_file.filename;
const reassigned = Object.entries(overrides).some(([tIdx, sfIdx]) => {
const sf = importPageState.stagingFiles[sfIdx];
return sf && sf.filename === autoFileName && parseInt(tIdx) !== idx;
});
if (!reassigned) {
usedStagingFiles.add(-1); // placeholder โ auto-matched file
} else {
file = null; // file was reassigned elsewhere
}
}
if (file) matchedCount++;
const confPercent = Math.round(confidence * 100);
const confClass = confidence >= 0.7 ? '' : 'low';
return `
${m.spotify_track.track_number}
${_esc(m.spotify_track.name)}
${file
? `${_esc(file.filename)}
${confPercent}% `
: `Drop a file here `}
${file ? `โ ` : ''}
`;
});
matchList.innerHTML = rows.join('');
// Unmatched file pool
const unmatchedFiles = [];
importPageState.stagingFiles.forEach((f, i) => {
// Check if used by override
if (Object.values(overrides).includes(i)) return;
// Check if used by auto-match (not overridden away)
const autoUsed = data.matches.some((m, mIdx) => {
if (overrides.hasOwnProperty(mIdx)) return false;
return m.staging_file && m.staging_file.filename === f.filename;
});
if (autoUsed) return;
unmatchedFiles.push({file: f, index: i});
});
const poolChips = document.getElementById('import-page-pool-chips');
document.getElementById('import-page-unmatched-count').textContent = unmatchedFiles.length;
if (unmatchedFiles.length === 0) {
poolChips.innerHTML = '
All files matched ';
} else {
poolChips.innerHTML = unmatchedFiles.map(({file, index}) => `
${_esc(file.filename)}
`).join('');
}
// Stats & button
document.getElementById('import-page-match-stats').textContent = `${matchedCount} of ${data.matches.length} tracks matched`;
const processBtn = document.getElementById('import-page-album-process-btn');
processBtn.disabled = matchedCount === 0;
processBtn.textContent = `Process ${matchedCount} Track${matchedCount !== 1 ? 's' : ''}`;
}
// --- Album Tab: Drag and Drop ---
function importPageStartDrag(event, stagingFileIndex) {
event.dataTransfer.setData('text/plain', stagingFileIndex.toString());
event.dataTransfer.effectAllowed = 'move';
}
function importPageHandleDragOver(event) {
event.preventDefault();
event.dataTransfer.dropEffect = 'move';
event.currentTarget.classList.add('drag-over');
// Remove drag-over from others
document.querySelectorAll('.import-page-match-row.drag-over').forEach(el => {
if (el !== event.currentTarget) el.classList.remove('drag-over');
});
}
function importPageHandleDrop(event, trackIndex) {
event.preventDefault();
event.currentTarget.classList.remove('drag-over');
const stagingFileIndex = parseInt(event.dataTransfer.getData('text/plain'));
if (isNaN(stagingFileIndex)) return;
// Remove this staging file from any other track it was assigned to
Object.keys(importPageState.matchOverrides).forEach(k => {
if (importPageState.matchOverrides[k] === stagingFileIndex) {
delete importPageState.matchOverrides[k];
}
});
importPageState.matchOverrides[trackIndex] = stagingFileIndex;
importPageState.tapSelectedChip = null;
importPageRenderMatchList();
}
// Mobile tap-to-assign fallback
function importPageTapSelectChip(stagingFileIndex) {
if (importPageState.tapSelectedChip === stagingFileIndex) {
importPageState.tapSelectedChip = null;
} else {
importPageState.tapSelectedChip = stagingFileIndex;
}
importPageRenderMatchList();
}
function importPageTapAssign(trackIndex) {
if (importPageState.tapSelectedChip === null) return;
const stagingFileIndex = importPageState.tapSelectedChip;
// Remove from any other track
Object.keys(importPageState.matchOverrides).forEach(k => {
if (importPageState.matchOverrides[k] === stagingFileIndex) {
delete importPageState.matchOverrides[k];
}
});
importPageState.matchOverrides[trackIndex] = stagingFileIndex;
importPageState.tapSelectedChip = null;
importPageRenderMatchList();
}
function importPageUnmatchTrack(trackIndex) {
delete importPageState.matchOverrides[trackIndex];
// Also remove auto-match by setting override to -1 special value? No โ just delete override and let auto-match stay.
// Actually, to truly unmatch: we need to suppress the auto-match too.
// We'll use a sentinel: override = -1 means "forcibly unmatched"
const m = importPageState.albumData?.matches[trackIndex];
if (m && m.staging_file) {
importPageState.matchOverrides[trackIndex] = -1; // sentinel: force no match
}
importPageRenderMatchList();
}
function importPageAutoRematch() {
importPageState.matchOverrides = {};
importPageState.tapSelectedChip = null;
importPageRenderMatchList();
}
// --- Album Tab: Process ---
function importPageProcessAlbum() {
const data = importPageState.albumData;
if (!data) return;
// Build effective matches with overrides applied
const overrides = importPageState.matchOverrides;
const effectiveMatches = [];
data.matches.forEach((m, idx) => {
if (overrides.hasOwnProperty(idx)) {
if (overrides[idx] === -1) return; // forcibly unmatched โ skip
const sf = importPageState.stagingFiles[overrides[idx]];
effectiveMatches.push({ ...m, staging_file: sf, confidence: 1.0 });
} else if (m.staging_file !== null) {
effectiveMatches.push(m);
}
});
if (effectiveMatches.length === 0) return;
// Add to queue and reset search immediately so user can queue more
const album = data.album;
_importQueueAdd({
type: 'album',
label: album.name,
sublabel: `${album.artist} ยท ${effectiveMatches.length} tracks`,
imageUrl: album.image_url,
items: effectiveMatches,
albumData: album,
});
importPageResetAlbumSearch();
}
function importPageResetAlbumSearch() {
importPageState.albumData = null;
importPageState.matchOverrides = {};
importPageState.tapSelectedChip = null;
document.getElementById('import-page-album-search-section').classList.remove('hidden');
document.getElementById('import-page-album-match-section').classList.add('hidden');
// Clear search
document.getElementById('import-page-album-results').innerHTML = '';
document.getElementById('import-page-album-search-input').value = '';
document.getElementById('import-page-album-clear-btn').classList.add('hidden');
// Refresh suggestions & staging
importPageLoadSuggestions();
importPageRefreshStaging();
}
// --- Singles Tab ---
function importPageRenderSinglesList() {
const list = document.getElementById('import-page-singles-list');
const files = importPageState.stagingFiles;
if (files.length === 0) {
list.innerHTML = '
No audio files found in staging folder
';
return;
}
list.innerHTML = files.map((f, i) => {
const isSelected = importPageState.selectedSingles.has(i);
const manualMatch = importPageState.singlesManualMatches[i];
const searchOpen = document.querySelector(`[data-singles-search="${i}"]`);
let html = `
${_esc(f.filename)}
${f.title ? `${_esc(f.title)} ` : ''}
${f.artist ? `${_esc(f.artist)} ` : ''}
${f.extension ? `${f.extension} ` : ''}
${manualMatch ? `
✓ ${_esc(manualMatch.name)} - ${_esc(manualMatch.artist)}
change
` : ''}
🔍 Identify
`;
return html;
}).join('');
importPageUpdateSinglesProcessButton();
}
function importPageToggleSingle(idx) {
if (importPageState.selectedSingles.has(idx)) {
importPageState.selectedSingles.delete(idx);
} else {
importPageState.selectedSingles.add(idx);
}
// Update checkbox UI without full re-render
const item = document.querySelector(`[data-single-idx="${idx}"]`);
if (item) {
const cb = item.querySelector('.import-page-single-checkbox');
if (cb) cb.classList.toggle('checked', importPageState.selectedSingles.has(idx));
}
importPageUpdateSinglesProcessButton();
}
function importPageSelectAllSingles() {
const allSelected = importPageState.selectedSingles.size === importPageState.stagingFiles.length;
if (allSelected) {
importPageState.selectedSingles.clear();
} else {
importPageState.stagingFiles.forEach((_, i) => importPageState.selectedSingles.add(i));
}
document.getElementById('import-page-select-all-text').textContent = allSelected ? 'Select All' : 'Deselect All';
// Update all checkboxes
document.querySelectorAll('.import-page-single-checkbox').forEach((cb, i) => {
cb.classList.toggle('checked', importPageState.selectedSingles.has(i));
});
importPageUpdateSinglesProcessButton();
}
function importPageUpdateSinglesProcessButton() {
const btn = document.getElementById('import-page-singles-process-btn');
const count = importPageState.selectedSingles.size;
btn.textContent = `Process Selected (${count})`;
btn.disabled = count === 0;
}
function importPageOpenSingleSearch(fileIdx) {
const item = document.querySelector(`[data-single-idx="${fileIdx}"]`);
if (!item) return;
// Remove any existing search panel
const existing = item.querySelector('.import-page-single-search-panel');
if (existing) {
existing.remove();
return;
}
// Close other open panels
document.querySelectorAll('.import-page-single-search-panel').forEach(p => p.remove());
const f = importPageState.stagingFiles[fileIdx];
const defaultQuery = [f.artist, f.title].filter(Boolean).join(' ') || f.filename.replace(/\.[^.]+$/, '');
const panel = document.createElement('div');
panel.className = 'import-page-single-search-panel';
panel.innerHTML = `
Search
`;
item.appendChild(panel);
// Auto-search
const input = panel.querySelector('input');
input.focus();
if (defaultQuery) {
importPageSearchSingleTrack(fileIdx, defaultQuery);
}
}
async function importPageSearchSingleTrack(fileIdx, query) {
if (!query || !query.trim()) return;
const resultsDiv = document.getElementById(`import-single-results-${fileIdx}`);
if (!resultsDiv) return;
resultsDiv.innerHTML = '
Searching...
';
try {
const resp = await fetch(`/api/import/search/tracks?q=${encodeURIComponent(query.trim())}&limit=6`);
const data = await resp.json();
if (!data.success || !data.tracks.length) {
resultsDiv.innerHTML = '
No results found
';
return;
}
// Store results in a temp cache so we can reference by index
window._importSingleSearchResults = window._importSingleSearchResults || {};
window._importSingleSearchResults[fileIdx] = data.tracks;
resultsDiv.innerHTML = data.tracks.map((t, tIdx) => {
const dur = t.duration_ms ? `${Math.floor(t.duration_ms / 60000)}:${String(Math.floor((t.duration_ms % 60000) / 1000)).padStart(2, '0')}` : '';
return `
${t.image_url ? `
` : ''}
${_esc(t.name)} - ${_esc(t.artist)}
${_esc(t.album)}${dur ? ' ยท ' + dur : ''}
Select
`;
}).join('');
} catch (err) {
resultsDiv.innerHTML = `
Error: ${err.message}
`;
}
}
function importPageSelectSingleMatch(fileIdx, trackIdx) {
const trackData = window._importSingleSearchResults?.[fileIdx]?.[trackIdx];
if (!trackData) return;
importPageState.singlesManualMatches[fileIdx] = trackData;
// Auto-select this file
importPageState.selectedSingles.add(fileIdx);
// Close search panel and re-render this item
importPageRenderSinglesList();
}
// --- Singles Tab: Process ---
function importPageProcessSingles() {
if (importPageState.selectedSingles.size === 0) return;
const filesToProcess = Array.from(importPageState.selectedSingles).map(i => {
const f = importPageState.stagingFiles[i];
const manualMatch = importPageState.singlesManualMatches[i];
if (manualMatch) {
return { ...f, spotify_override: manualMatch };
}
return f;
});
// Add to queue and reset immediately
_importQueueAdd({
type: 'singles',
label: `${filesToProcess.length} Single${filesToProcess.length !== 1 ? 's' : ''}`,
sublabel: filesToProcess.map(f => f.title || f.filename).slice(0, 3).join(', ') + (filesToProcess.length > 3 ? '...' : ''),
imageUrl: null,
items: filesToProcess,
});
importPageState.selectedSingles.clear();
importPageState.singlesManualMatches = {};
importPageUpdateSinglesProcessButton();
importPageRefreshStaging();
}
// --- Processing Queue ---
const _importQueue = []; // { id, type, label, sublabel, imageUrl, status, processed, total, errors }
function _importQueueAdd(job) {
const id = ++importJobIdCounter;
const entry = {
id,
type: job.type,
label: job.label,
sublabel: job.sublabel,
imageUrl: job.imageUrl,
status: 'running', // running | done | error
processed: 0,
total: job.items.length,
errors: [],
};
_importQueue.push(entry);
_importQueueRender();
// Fire and forget โ runs in background
_importQueueRunJob(entry, job);
}
async function _importQueueRunJob(entry, job) {
for (let i = 0; i < job.items.length; i++) {
try {
let resp;
if (job.type === 'album') {
resp = await fetch('/api/import/album/process', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
album: job.albumData,
matches: [job.items[i]]
})
});
} else {
resp = await fetch('/api/import/singles/process', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ files: [job.items[i]] })
});
}
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
const data = await resp.json();
if (data.success) entry.processed += (data.processed || 0);
if (data.errors && data.errors.length > 0) entry.errors.push(...data.errors);
} catch (err) {
const itemName = job.type === 'album'
? (job.items[i].spotify_track?.name || `Track ${i + 1}`)
: (job.items[i].title || job.items[i].filename || `File ${i + 1}`);
entry.errors.push(`${itemName}: ${err.message}`);
}
_importQueueRender();
}
entry.status = entry.errors.length > 0 && entry.processed === 0 ? 'error' : 'done';
_importQueueRender();
// Refresh staging and suggestions since files moved
importPageRefreshStaging();
importPageLoadSuggestions();
}
function _importQueueRender() {
const container = document.getElementById('import-page-queue');
const list = document.getElementById('import-page-queue-list');
const clearBtn = document.getElementById('import-page-queue-clear');
if (!container || !list) return;
if (_importQueue.length === 0) {
container.classList.add('hidden');
return;
}
container.classList.remove('hidden');
// Show clear button only if there are finished jobs
const hasFinished = _importQueue.some(j => j.status !== 'running');
clearBtn.style.display = hasFinished ? '' : 'none';
list.innerHTML = _importQueue.map(j => {
const pct = j.total > 0 ? Math.round((j.processed / j.total) * 100) : 0;
const fillClass = j.status === 'error' ? 'error' : '';
let statusText, statusClass;
if (j.status === 'running') {
statusText = `${j.processed}/${j.total}`;
statusClass = '';
} else if (j.status === 'done') {
statusText = j.errors.length > 0 ? `${j.processed}/${j.total} (${j.errors.length} err)` : 'Done';
statusClass = j.errors.length > 0 ? 'error' : 'done';
} else {
statusText = 'Failed';
statusClass = 'error';
}
return `
${j.imageUrl
? `
`
: `
♪
`}
${_esc(j.label)}
${_esc(j.sublabel)}
`;
}).join('');
}
function importPageClearFinishedJobs() {
for (let i = _importQueue.length - 1; i >= 0; i--) {
if (_importQueue[i].status !== 'running') {
_importQueue.splice(i, 1);
}
}
_importQueueRender();
}
// โโ Import File Tab โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
let _importFileState = {
rawText: '',
fileName: '',
fileType: '', // 'csv' or 'text'
headers: [], // CSV column headers
rows: [], // raw parsed rows (arrays for csv, strings for text)
columnMap: {}, // { columnIndex: 'track_name' | 'artist_name' | 'album_name' | 'duration' | 'skip' }
parsedTracks: [] // final [{track_name, artist_name, album_name, duration_ms}]
};
function _initImportFileTab() {
const dropzone = document.getElementById('import-file-dropzone');
const fileInput = document.getElementById('import-file-input');
if (!dropzone || !fileInput) return;
dropzone.addEventListener('click', () => fileInput.click());
dropzone.addEventListener('dragover', (e) => { e.preventDefault(); dropzone.classList.add('drag-over'); });
dropzone.addEventListener('dragleave', () => dropzone.classList.remove('drag-over'));
dropzone.addEventListener('drop', (e) => {
e.preventDefault();
dropzone.classList.remove('drag-over');
const file = e.dataTransfer.files[0];
if (file) _importFileRead(file);
});
fileInput.addEventListener('change', () => {
if (fileInput.files[0]) _importFileRead(fileInput.files[0]);
fileInput.value = '';
});
// Enable/disable import button based on playlist name
const nameInput = document.getElementById('import-file-playlist-name');
if (nameInput) {
nameInput.addEventListener('input', () => {
const btn = document.getElementById('import-file-import-btn');
if (btn) btn.disabled = !nameInput.value.trim();
});
}
}
function _importFileRead(file) {
const ext = file.name.split('.').pop().toLowerCase();
if (!['csv', 'tsv', 'txt'].includes(ext)) {
showToast('Unsupported file type. Use CSV, TSV, or TXT.', 'error');
return;
}
const reader = new FileReader();
reader.onload = (e) => {
_importFileState.rawText = e.target.result;
_importFileState.fileName = file.name;
_importFileState.fileType = (ext === 'txt') ? 'text' : 'csv';
_importFileParseAndPreview();
};
reader.readAsText(file);
}
function _importFileDetectDelimiter(firstLine) {
const tab = (firstLine.match(/\t/g) || []).length;
const semi = (firstLine.match(/;/g) || []).length;
const comma = (firstLine.match(/,/g) || []).length;
if (tab >= comma && tab >= semi && tab > 0) return '\t';
if (semi >= comma && semi > 0) return ';';
return ',';
}
function _importFileParseCsv(text, delimiter) {
const lines = text.split(/\r?\n/).filter(l => l.trim());
if (lines.length < 2) return { headers: [], rows: [] };
// Parse CSV with basic quote handling
function parseLine(line) {
const result = [];
let current = '';
let inQuotes = false;
for (let i = 0; i < line.length; i++) {
const ch = line[i];
if (ch === '"') {
if (inQuotes && i + 1 < line.length && line[i + 1] === '"') {
current += '"';
i++;
} else {
inQuotes = !inQuotes;
}
} else if (ch === delimiter && !inQuotes) {
result.push(current.trim());
current = '';
} else {
current += ch;
}
}
result.push(current.trim());
return result;
}
const headers = parseLine(lines[0]);
const rows = [];
for (let i = 1; i < lines.length; i++) {
const row = parseLine(lines[i]);
if (row.some(cell => cell)) rows.push(row);
}
return { headers, rows };
}
function _importFileAutoMapColumns(headers) {
const map = {};
const lowerHeaders = headers.map(h => h.toLowerCase().trim());
const trackPatterns = ['track_name', 'track name', 'track', 'title', 'song', 'song_name', 'song name', 'name'];
const artistPatterns = ['artist_name', 'artist name', 'artist', 'artists', 'performer'];
const albumPatterns = ['album_name', 'album name', 'album'];
const durationPatterns = ['duration', 'duration_ms', 'length', 'time'];
function findMatch(patterns) {
for (const p of patterns) {
const idx = lowerHeaders.indexOf(p);
if (idx !== -1 && !(idx in map)) return idx;
}
return -1;
}
const trackIdx = findMatch(trackPatterns);
if (trackIdx !== -1) map[trackIdx] = 'track_name';
const artistIdx = findMatch(artistPatterns);
if (artistIdx !== -1) map[artistIdx] = 'artist_name';
const albumIdx = findMatch(albumPatterns);
if (albumIdx !== -1) map[albumIdx] = 'album_name';
const durIdx = findMatch(durationPatterns);
if (durIdx !== -1) map[durIdx] = 'duration';
return map;
}
function _importFileParseAndPreview() {
const state = _importFileState;
const text = state.rawText;
if (state.fileType === 'text') {
// Plain text: one track per line
const lines = text.split(/\r?\n/).filter(l => l.trim());
state.rows = lines;
state.headers = [];
state.columnMap = {};
} else {
// CSV/TSV
const firstLine = text.split(/\r?\n/)[0] || '';
const delimiter = _importFileDetectDelimiter(firstLine);
const { headers, rows } = _importFileParseCsv(text, delimiter);
state.headers = headers;
state.rows = rows;
state.columnMap = _importFileAutoMapColumns(headers);
}
_importFileBuildTracks();
_importFileRenderPreview();
}
function _importFileBuildTracks() {
const state = _importFileState;
state.parsedTracks = [];
if (state.fileType === 'text') {
const orderEl = document.getElementById('import-file-text-order');
const sepEl = document.getElementById('import-file-text-separator');
const order = orderEl ? orderEl.value : 'artist-title';
const sep = sepEl ? sepEl.value : ' - ';
for (const line of state.rows) {
const parts = line.split(sep);
if (parts.length >= 2) {
const a = parts[0].trim();
const b = parts.slice(1).join(sep).trim();
state.parsedTracks.push({
track_name: order === 'artist-title' ? b : a,
artist_name: order === 'artist-title' ? a : b,
album_name: '',
duration_ms: 0
});
} else {
// Can't split โ treat whole line as track name
state.parsedTracks.push({
track_name: line.trim(),
artist_name: '',
album_name: '',
duration_ms: 0
});
}
}
} else {
// CSV mapped
const map = state.columnMap;
const trackCol = Object.keys(map).find(k => map[k] === 'track_name');
const artistCol = Object.keys(map).find(k => map[k] === 'artist_name');
const albumCol = Object.keys(map).find(k => map[k] === 'album_name');
const durCol = Object.keys(map).find(k => map[k] === 'duration');
for (const row of state.rows) {
const track = trackCol !== undefined ? (row[trackCol] || '') : '';
const artist = artistCol !== undefined ? (row[artistCol] || '') : '';
const album = albumCol !== undefined ? (row[albumCol] || '') : '';
let dur = durCol !== undefined ? (row[durCol] || '') : '';
// Parse duration: could be ms, seconds, or mm:ss
let durationMs = 0;
if (dur) {
dur = dur.trim();
if (dur.includes(':')) {
const parts = dur.split(':');
durationMs = (parseInt(parts[0]) * 60 + parseInt(parts[1] || 0)) * 1000;
} else {
const num = parseFloat(dur);
durationMs = num > 10000 ? num : num * 1000; // assume ms if > 10000, else seconds
}
if (isNaN(durationMs)) durationMs = 0;
}
state.parsedTracks.push({
track_name: track,
artist_name: artist,
album_name: album,
duration_ms: durationMs
});
}
}
}
function _importFileRenderPreview() {
const state = _importFileState;
const validTracks = state.parsedTracks.filter(t => t.track_name || t.artist_name);
// Show/hide sections
document.getElementById('import-file-upload-zone').style.display = 'none';
document.getElementById('import-file-preview-section').style.display = '';
// File info
document.getElementById('import-file-name-label').textContent = state.fileName;
document.getElementById('import-file-track-count').textContent = `${validTracks.length} track${validTracks.length !== 1 ? 's' : ''} parsed`;
// Show format controls based on file type
document.getElementById('import-file-text-format').style.display = state.fileType === 'text' ? '' : 'none';
document.getElementById('import-file-column-mapping').style.display = state.fileType === 'csv' ? '' : 'none';
// Render column mapping for CSV
if (state.fileType === 'csv') {
_importFileRenderColumnMapping();
}
// Pre-fill playlist name from filename (strip extension)
const nameInput = document.getElementById('import-file-playlist-name');
if (nameInput && !nameInput.value) {
nameInput.value = state.fileName.replace(/\.[^.]+$/, '');
}
// Update button state
const btn = document.getElementById('import-file-import-btn');
if (btn) btn.disabled = !nameInput.value.trim();
// Render preview table
const tbody = document.getElementById('import-file-preview-tbody');
tbody.innerHTML = '';
state.parsedTracks.forEach((t, i) => {
const valid = !!(t.track_name || t.artist_name);
const tr = document.createElement('tr');
if (!valid) tr.classList.add('invalid-row');
tr.innerHTML = `
${i + 1}
${_esc(t.track_name)}
${_esc(t.artist_name)}
${_esc(t.album_name)}
`;
tbody.appendChild(tr);
});
}
function _importFileRenderColumnMapping() {
const state = _importFileState;
const container = document.getElementById('import-file-mapping-selects');
container.innerHTML = '';
const options = ['skip', 'track_name', 'artist_name', 'album_name', 'duration'];
const optLabels = { skip: 'Skip', track_name: 'Track', artist_name: 'Artist', album_name: 'Album', duration: 'Duration' };
state.headers.forEach((header, idx) => {
const mapped = state.columnMap[idx] || 'skip';
const wrap = document.createElement('div');
wrap.className = 'import-file-col-map';
if (mapped === 'track_name') wrap.classList.add('mapped-track');
else if (mapped === 'artist_name') wrap.classList.add('mapped-artist');
else if (mapped === 'album_name') wrap.classList.add('mapped-album');
const label = document.createElement('span');
label.className = 'import-file-col-label';
label.textContent = header;
label.title = header;
const sel = document.createElement('select');
sel.className = 'import-file-select';
options.forEach(o => {
const opt = document.createElement('option');
opt.value = o;
opt.textContent = optLabels[o];
if (o === mapped) opt.selected = true;
sel.appendChild(opt);
});
sel.addEventListener('change', () => {
if (sel.value === 'skip') {
delete state.columnMap[idx];
} else {
// Remove this mapping from any other column
for (const k of Object.keys(state.columnMap)) {
if (state.columnMap[k] === sel.value) delete state.columnMap[k];
}
state.columnMap[idx] = sel.value;
}
_importFileBuildTracks();
_importFileRenderPreview();
});
wrap.appendChild(label);
wrap.appendChild(sel);
container.appendChild(wrap);
});
}
function importFileReparse() {
_importFileBuildTracks();
_importFileRenderPreview();
}
function importFileClear() {
_importFileState = {
rawText: '', fileName: '', fileType: '',
headers: [], rows: [], columnMap: {}, parsedTracks: []
};
document.getElementById('import-file-upload-zone').style.display = '';
document.getElementById('import-file-preview-section').style.display = 'none';
document.getElementById('import-file-playlist-name').value = '';
document.getElementById('import-file-preview-tbody').innerHTML = '';
}
function importFileSubmit() {
const nameInput = document.getElementById('import-file-playlist-name');
const name = nameInput ? nameInput.value.trim() : '';
if (!name) {
showToast('Please enter a playlist name.', 'error');
nameInput && nameInput.focus();
return;
}
const tracks = _importFileState.parsedTracks.filter(t => t.track_name || t.artist_name);
if (!tracks.length) {
showToast('No valid tracks to import.', 'error');
return;
}
// Use a unique ID based on timestamp so multiple imports don't collide
const sourceId = `file_${Date.now()}`;
mirrorPlaylist('file', sourceId, name, tracks, {
description: `Imported from ${_importFileState.fileName}`,
owner: 'local'
});
showToast(`Imported "${name}" with ${tracks.length} tracks`, 'success');
importFileClear();
// Switch to mirrored tab so user sees the result
const mirroredBtn = document.querySelector('.sync-tab-button[data-tab="mirrored"]');
if (mirroredBtn) {
mirroredBtn.click();
// Reload mirrored playlists to show the new one
setTimeout(() => loadMirroredPlaylists(), 500);
}
}
// โโ Mirrored Playlists โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
let mirroredPlaylistsLoaded = false;
/**
* Fire-and-forget helper: send parsed playlist data to be mirrored on the backend.
*/
function mirrorPlaylist(source, sourceId, name, tracks, metadata = {}) {
const normalizedTracks = tracks.map(t => ({
track_name: t.track_name || t.name || '',
artist_name: t.artist_name || (Array.isArray(t.artists) ? (typeof t.artists[0] === 'object' ? t.artists[0].name : t.artists[0]) : t.artists || ''),
album_name: t.album_name || (typeof t.album === 'object' ? (t.album && t.album.name) : t.album) || '',
duration_ms: t.duration_ms || 0,
image_url: t.image_url || (t.album && typeof t.album === 'object' && t.album.images && t.album.images[0] ? t.album.images[0].url : null),
source_track_id: t.source_track_id || t.id || t.spotify_track_id || '',
extra_data: t.extra_data || null
}));
fetch('/api/mirror-playlist', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
source,
source_playlist_id: String(sourceId),
name,
tracks: normalizedTracks,
description: metadata.description || '',
owner: metadata.owner || '',
image_url: metadata.image_url || ''
})
}).then(r => r.json()).then(data => {
if (data.success) console.log(`Mirrored ${source} playlist: ${name} (${normalizedTracks.length} tracks)`);
}).catch(err => console.warn('Mirror save failed:', err));
}
/**
* Load and render all mirrored playlists into the Mirrored tab.
*/
async function loadMirroredPlaylists() {
const container = document.getElementById('mirrored-playlist-container');
if (!container) return;
container.innerHTML = `
Loading mirrored playlists...
`;
try {
const res = await fetch('/api/mirrored-playlists');
const playlists = await res.json();
if (playlists.error) throw new Error(playlists.error);
if (!playlists.length) {
container.innerHTML = `
Playlists you parse from any service will appear here as persistent backups.
`;
return;
}
container.innerHTML = '';
playlists.forEach(p => renderMirroredCard(p, container));
mirroredPlaylistsLoaded = true;
// Hydrate discovery states from backend (survives page refresh)
await hydrateMirroredDiscoveryStates();
} catch (err) {
container.innerHTML = `
Error loading mirrored playlists: ${err.message}
`;
}
}
function renderMirroredCard(p, container) {
const ago = timeAgo(p.updated_at || p.mirrored_at);
const hash = `mirrored_${p.id}`;
const state = youtubePlaylistStates[hash];
const phase = state ? state.phase : null;
// Build phase indicator
let phaseHtml = '';
if (phase === 'discovering') {
const pct = state.discoveryProgress || state.discovery_progress || 0;
phaseHtml = `
Discovering ${pct}% `;
} else if (phase === 'discovered') {
const matches = state.spotifyMatches || state.spotify_matches || 0;
const total = state.spotify_total || p.track_count;
phaseHtml = `
Discovered ${matches}/${total} `;
} else if (phase === 'syncing' || phase === 'sync_complete') {
phaseHtml = `
${phase === 'syncing' ? 'Syncing...' : 'Synced'} `;
} else if (phase === 'downloading') {
phaseHtml = `
Downloading... `;
} else if (phase === 'download_complete') {
phaseHtml = `
Downloaded `;
}
const sourceIcons = { spotify: '๐ต', tidal: '๐', youtube: 'โถ', beatport: '๐', file: '๐' };
const srcIcon = sourceIcons[p.source] || '๐';
// Discovery ratio
const disc = p.discovered_count || 0;
const tot = p.total_count || p.track_count || 0;
let ratioHtml = '';
if (disc > 0) {
const complete = disc >= tot;
ratioHtml = `
${disc}/${tot} discovered `;
}
const card = document.createElement('div');
card.className = 'mirrored-playlist-card';
card.id = `mirrored-card-${p.id}`;
card.innerHTML = `
${srcIcon}
${_esc(p.name)}
${_esc(p.source)}
${p.track_count} tracks
Mirrored ${ago}
${ratioHtml}
${phaseHtml}
${disc > 0 ? `
โบ ` : ''}
โ
`;
card.addEventListener('click', () => {
const st = youtubePlaylistStates[hash];
// Treat as non-fresh if phase is set, or if a poller/discovery modal exists
const hasActiveDiscovery = activeYouTubePollers[hash] || document.getElementById(`youtube-discovery-modal-${hash}`);
if (st && ((st.phase && st.phase !== 'fresh') || hasActiveDiscovery)) {
if (st.phase === 'downloading' || st.phase === 'download_complete') {
// Open download modal directly (follows Tidal/YouTube card click pattern)
const spotifyPlaylistId = st.convertedSpotifyPlaylistId;
if (spotifyPlaylistId && activeDownloadProcesses[spotifyPlaylistId]) {
// Modal already exists โ just show it
const process = activeDownloadProcesses[spotifyPlaylistId];
if (process.modalElement) {
if (process.status === 'complete') {
showToast('Showing previous results. Close this modal to start a new analysis.', 'info');
}
process.modalElement.style.display = 'flex';
}
} else if (spotifyPlaylistId) {
// Need to rehydrate the download modal
rehydrateMirroredDownloadModal(hash, st);
} else {
// No converted playlist ID yet, fall back to discovery modal
openYouTubeDiscoveryModal(hash);
}
} else {
openYouTubeDiscoveryModal(hash);
if (st.phase === 'discovering' && !activeYouTubePollers[hash]) {
startYouTubeDiscoveryPolling(hash);
}
}
} else {
openMirroredPlaylistModal(p.id);
}
});
container.appendChild(card);
}
function updateMirroredCardPhase(urlHash, phase) {
// Update the state phase (updateYouTubeCardPhase skips this for mirrored playlists due to no cardElement)
const state = youtubePlaylistStates[urlHash];
if (state) state.phase = phase;
// Extract the numeric ID from urlHash (e.g., 'mirrored_3' โ '3')
const mirroredId = urlHash.replace('mirrored_', '');
const card = document.getElementById(`mirrored-card-${mirroredId}`);
if (!card) return;
const metaEl = card.querySelector('.card-meta');
if (!metaEl) return;
// Remove old phase indicator
const oldPhase = metaEl.querySelector('span[style]');
if (oldPhase) oldPhase.remove();
// Add new phase indicator
let phaseHtml = '';
switch (phase) {
case 'discovering':
phaseHtml = `
Discovering... `;
break;
case 'discovered':
const matches = state?.spotifyMatches || state?.spotify_matches || 0;
const total = state?.spotify_total || 0;
phaseHtml = `
Discovered ${matches}/${total} `;
break;
case 'syncing':
phaseHtml = `
Syncing... `;
break;
case 'sync_complete':
phaseHtml = `
Synced `;
break;
case 'downloading':
phaseHtml = `
Downloading... `;
break;
case 'download_complete':
phaseHtml = `
Downloaded `;
break;
}
if (phaseHtml) {
metaEl.insertAdjacentHTML('beforeend', phaseHtml);
}
}
async function rehydrateMirroredDownloadModal(urlHash, state) {
try {
if (!state || !state.playlist) {
showToast('Cannot open download modal - invalid playlist data', 'error');
return;
}
console.log(`๐ง [Rehydration] Rehydrating mirrored download modal for: ${state.playlist.name}`);
// Get discovery results from backend if not already loaded
let discoveryRes = state.discoveryResults || state.discovery_results;
if (!discoveryRes || discoveryRes.length === 0) {
console.log(`๐ Fetching discovery results from backend for mirrored playlist: ${urlHash}`);
const stateResponse = await fetch(`/api/youtube/state/${urlHash}`);
if (stateResponse.ok) {
const fullState = await stateResponse.json();
state.discovery_results = fullState.discovery_results;
state.discoveryResults = fullState.discovery_results;
state.convertedSpotifyPlaylistId = fullState.converted_spotify_playlist_id;
state.download_process_id = fullState.download_process_id;
discoveryRes = fullState.discovery_results;
console.log(`โ
Loaded ${discoveryRes?.length || 0} discovery results from backend`);
} else {
showToast('Error loading playlist data', 'error');
return;
}
}
// Extract Spotify tracks from discovery results
const spotifyTracks = (discoveryRes || [])
.filter(r => r.spotify_data || (r.spotify_track && r.status_class === 'found'))
.map(r => {
if (r.spotify_data) return r.spotify_data;
const albumData = r.spotify_album || 'Unknown Album';
return {
id: r.spotify_id || 'unknown',
name: r.spotify_track || 'Unknown Track',
artists: r.spotify_artist ? [r.spotify_artist] : ['Unknown Artist'],
album: typeof albumData === 'object' ? albumData : { name: albumData, album_type: 'album', images: [] },
duration_ms: 0
};
});
if (spotifyTracks.length === 0) {
showToast('No Spotify matches found for download', 'error');
return;
}
const virtualPlaylistId = state.convertedSpotifyPlaylistId;
const playlistName = state.playlist.name;
// Create the download modal
await openDownloadMissingModalForYouTube(virtualPlaylistId, playlistName, spotifyTracks);
// If we have a download process ID, set up the modal for the running/complete state
if (state.download_process_id) {
const process = activeDownloadProcesses[virtualPlaylistId];
if (process) {
process.status = state.phase === 'download_complete' ? 'complete' : 'running';
process.batchId = state.download_process_id;
const beginBtn = document.getElementById(`begin-analysis-btn-${virtualPlaylistId}`);
const cancelBtn = document.getElementById(`cancel-all-btn-${virtualPlaylistId}`);
if (state.phase === 'downloading') {
if (beginBtn) beginBtn.style.display = 'none';
if (cancelBtn) cancelBtn.style.display = 'inline-block';
// Start polling for live updates
startModalDownloadPolling(virtualPlaylistId);
console.log(`๐ Started polling for active mirrored download: ${state.download_process_id}`);
} else if (state.phase === 'download_complete') {
if (beginBtn) beginBtn.style.display = 'none';
if (cancelBtn) cancelBtn.style.display = 'none';
console.log(`โ
Showing completed mirrored download results: ${state.download_process_id}`);
// Fetch final results to populate the modal
try {
const response = await fetch(`/api/playlists/${state.download_process_id}/download_status`);
if (response.ok) {
const data = await response.json();
if (data.phase === 'complete' && data.tasks) {
updateCompletedModalResults(virtualPlaylistId, data);
}
}
} catch (err) {
console.warn('Could not load completed download results:', err);
}
}
}
}
console.log(`โ
Successfully rehydrated mirrored download modal for: ${state.playlist.name}`);
} catch (error) {
console.error('โ Error rehydrating mirrored download modal:', error);
showToast('Error opening download modal', 'error');
}
}
async function hydrateMirroredDiscoveryStates() {
try {
const res = await fetch('/api/mirrored-playlists/discovery-states');
const data = await res.json();
if (data.error || !data.states || data.states.length === 0) return;
console.log(`Hydrating ${data.states.length} mirrored discovery states`);
for (const s of data.states) {
const hash = s.url_hash;
youtubePlaylistStates[hash] = {
playlist: s.playlist,
phase: s.phase,
discovery_results: s.discovery_results || [],
discoveryResults: s.discovery_results || [],
discovery_progress: s.discovery_progress || 0,
discoveryProgress: s.discovery_progress || 0,
spotify_matches: s.spotify_matches || 0,
spotifyMatches: s.spotify_matches || 0,
spotify_total: s.spotify_total || 0,
status: s.status || '',
url: s.playlist?.url || '',
sync_playlist_id: null,
converted_spotify_playlist_id: s.converted_spotify_playlist_id,
convertedSpotifyPlaylistId: s.converted_spotify_playlist_id,
download_process_id: s.download_process_id,
created_at: Date.now() / 1000,
last_accessed: Date.now() / 1000,
discovery_future: null,
sync_progress: {},
is_mirrored_playlist: true,
mirrored_source: s.playlist?.source || ''
};
// Update the card to reflect the current phase
const card = document.getElementById(`mirrored-card-${s.playlist_id}`);
if (card) {
const metaEl = card.querySelector('.card-meta');
if (metaEl) {
// Remove old phase span and add new one
const oldPhase = metaEl.querySelector('span[style]');
if (oldPhase) oldPhase.remove();
if (s.phase === 'discovering') {
metaEl.insertAdjacentHTML('beforeend', `
Discovering ${s.discovery_progress || 0}% `);
} else if (s.phase === 'discovered') {
metaEl.insertAdjacentHTML('beforeend', `
Discovered ${s.spotify_matches || 0}/${s.spotify_total || 0} `);
} else if (s.phase === 'syncing' || s.phase === 'sync_complete') {
metaEl.insertAdjacentHTML('beforeend', `
${s.phase === 'syncing' ? 'Syncing...' : 'Synced'} `);
} else if (s.phase === 'downloading') {
metaEl.insertAdjacentHTML('beforeend', `
Downloading... `);
} else if (s.phase === 'download_complete') {
metaEl.insertAdjacentHTML('beforeend', `
Downloaded `);
}
}
}
// Resume polling if discovery is in progress
if (s.phase === 'discovering' && !activeYouTubePollers[hash]) {
startYouTubeDiscoveryPolling(hash);
}
}
} catch (err) {
console.warn('Failed to hydrate mirrored discovery states:', err);
}
}
function timeAgo(dateStr) {
if (!dateStr) return '';
// Handle ISO formats: "Z" suffix, "+00:00" offset, or bare (assume UTC)
let ts = dateStr;
if (!ts.includes('Z') && !ts.includes('+') && !ts.includes('-', 10)) ts += 'Z';
const diff = Date.now() - new Date(ts).getTime();
const secs = Math.floor(diff / 1000);
if (secs < 5) return 'just now';
if (secs < 60) return `${secs}s ago`;
const mins = Math.floor(secs / 60);
if (mins < 60) return `${mins}m ago`;
const hrs = Math.floor(mins / 60);
if (hrs < 24) return `${hrs}h ago`;
const days = Math.floor(hrs / 24);
if (days < 30) return `${days}d ago`;
return `${Math.floor(days / 30)}mo ago`;
}
/**
* Open modal showing all tracks in a mirrored playlist.
*/
async function openMirroredPlaylistModal(playlistId) {
showLoadingOverlay('Loading mirrored playlist...');
try {
const res = await fetch(`/api/mirrored-playlists/${playlistId}`);
const data = await res.json();
if (data.error) throw new Error(data.error);
hideLoadingOverlay();
// Remove any existing modal
const old = document.getElementById('mirrored-track-modal');
if (old) old.remove();
const overlay = document.createElement('div');
overlay.id = 'mirrored-track-modal';
overlay.className = 'mirrored-modal-overlay';
const tracks = data.tracks || [];
const source = data.source || 'unknown';
const sourceIcons = { spotify: '๐ต', tidal: '๐', youtube: 'โถ', beatport: '๐' };
const sourceIcon = sourceIcons[source] || '๐';
const trackRows = tracks.map(t => {
const dur = t.duration_ms ? `${Math.floor(t.duration_ms / 60000)}:${String(Math.floor((t.duration_ms % 60000) / 1000)).padStart(2, '0')}` : '';
return `
${t.position}
${_esc(t.track_name)}
${_esc(t.artist_name)}
${_esc(t.album_name)}
${dur}
`;
}).join('');
overlay.innerHTML = `
`;
overlay.addEventListener('click', e => { if (e.target === overlay) closeMirroredModal(); });
document.body.appendChild(overlay);
} catch (err) {
hideLoadingOverlay();
showToast(`Error: ${err.message}`, 'error');
}
}
function closeMirroredModal() {
const m = document.getElementById('mirrored-track-modal');
if (m) m.remove();
}
/**
* Delete a mirrored playlist after confirmation.
*/
async function clearMirroredDiscovery(playlistId, name) {
if (!await showConfirmDialog({ title: 'Clear Discovery Data', message: `Clear discovery data for "${name}"? You can re-discover afterwards to get updated cover art.` })) return;
try {
const res = await fetch(`/api/mirrored-playlists/${playlistId}/clear-discovery`, { method: 'POST' });
const data = await res.json();
if (data.success) {
showToast(`Cleared discovery for ${name} (${data.cleared} tracks)`, 'success');
// Signal cancellation to any running worker, then clear state
const hash = `mirrored_${playlistId}`;
if (youtubePlaylistStates[hash]) {
youtubePlaylistStates[hash].phase = 'cancelled';
}
delete youtubePlaylistStates[hash];
const staleModal = document.getElementById(`youtube-discovery-modal-${hash}`);
if (staleModal) staleModal.remove();
loadMirroredPlaylists();
} else {
showToast(data.error || 'Failed to clear discovery', 'error');
}
} catch (err) {
showToast(`Error: ${err.message}`, 'error');
}
}
// ==================== Discovery Pool Modal ====================
let _discoveryPoolOverlay = null;
let _discoveryPoolData = null;
let _discoveryPoolView = 'categories'; // 'categories' | 'failed' | 'matched'
let _discoveryPoolPlaylistFilter = null;
async function loadDiscoveryPoolStats() {
try {
const res = await fetch('/api/discovery-pool');
const data = await res.json();
const matchedEl = document.getElementById('discovery-pool-matched-count');
const failedEl = document.getElementById('discovery-pool-failed-count');
if (matchedEl) matchedEl.textContent = data.stats.matched || 0;
if (failedEl) failedEl.textContent = data.stats.failed || 0;
} catch (e) {}
}
async function openDiscoveryPoolModal(playlistId = null) {
_discoveryPoolPlaylistFilter = playlistId;
_discoveryPoolView = 'categories';
// Fetch pool data
let url = '/api/discovery-pool';
if (playlistId) url += `?playlist_id=${playlistId}`;
try {
const res = await fetch(url);
_discoveryPoolData = await res.json();
} catch (err) {
showToast('Failed to load discovery pool', 'error');
return;
}
// Remove existing overlay if present
if (_discoveryPoolOverlay) _discoveryPoolOverlay.remove();
const overlay = document.createElement('div');
overlay.className = 'modal-overlay';
overlay.id = 'discovery-pool-overlay';
overlay.onclick = (e) => { if (e.target === overlay) closeDiscoveryPoolModal(); };
const playlistOptions = (_discoveryPoolData.playlists || [])
.map(p => `
${_esc(p.name)} `)
.join('');
const failedCount = _discoveryPoolData.stats.failed || 0;
const matchedCount = _discoveryPoolData.stats.matched || 0;
overlay.innerHTML = `
⚠
${failedCount}
tracks need attention
✓
${matchedCount}
cached matches
`;
document.body.appendChild(overlay);
overlay.style.display = 'flex';
_discoveryPoolOverlay = overlay;
// Build matched mosaic if images available
_buildPoolMatchedMosaic();
}
function _buildPoolMatchedMosaic() {
const entries = _discoveryPoolData.matched || [];
const images = [];
for (const e of entries) {
const md = e.matched_data || {};
if (md.image_url && images.indexOf(md.image_url) === -1) {
images.push(md.image_url);
if (images.length >= 20) break;
}
}
const bgEl = document.getElementById('pool-matched-bg');
if (!bgEl || images.length < 4) return; // keep fallback gradient
// Build mosaic rows similar to wishlist
bgEl.innerHTML = '';
bgEl.className = 'wishlist-mosaic-background';
const rows = 4;
const imgPerRow = Math.ceil(images.length / rows) * 2; // duplicate for seamless loop
for (let r = 0; r < rows; r++) {
const wrapper = document.createElement('div');
wrapper.className = 'wishlist-mosaic-row-wrapper';
const row = document.createElement('div');
row.className = 'wishlist-mosaic-row' + (r % 2 === 1 ? ' scroll-right' : '');
row.style.setProperty('--speed', (25 + r * 5) + 's');
row.style.animationDelay = (r * 0.15) + 's';
for (let i = 0; i < imgPerRow; i++) {
const img = images[(i + r * 3) % images.length];
const tile = document.createElement('div');
tile.className = 'wishlist-mosaic-tile';
tile.innerHTML = `
`;
row.appendChild(tile);
}
wrapper.appendChild(row);
bgEl.appendChild(wrapper);
}
}
function closeDiscoveryPoolModal() {
if (_discoveryPoolOverlay) {
_discoveryPoolOverlay.remove();
_discoveryPoolOverlay = null;
}
_discoveryPoolData = null;
// Refresh dashboard stats
loadDiscoveryPoolStats();
}
function showPoolCategories() {
_discoveryPoolView = 'categories';
const grid = document.getElementById('pool-category-grid');
const list = document.getElementById('pool-list-view');
if (grid) grid.style.display = '';
if (list) list.style.display = 'none';
}
function showPoolList(category) {
_discoveryPoolView = category;
const grid = document.getElementById('pool-category-grid');
const list = document.getElementById('pool-list-view');
if (grid) grid.style.display = 'none';
if (list) list.style.display = '';
const titleEl = document.getElementById('pool-list-title');
if (titleEl) titleEl.textContent = category === 'failed' ? 'Failed Tracks' : 'Matched Tracks';
renderPoolList();
}
async function filterDiscoveryPool(playlistId) {
_discoveryPoolPlaylistFilter = playlistId || null;
let url = '/api/discovery-pool';
if (playlistId) url += `?playlist_id=${playlistId}`;
try {
const res = await fetch(url);
_discoveryPoolData = await res.json();
// Update header counts
_updatePoolHeaderCounts();
// Update category card counts
const failedCountEl = document.getElementById('pool-cat-failed-count');
const matchedCountEl = document.getElementById('pool-cat-matched-count');
if (failedCountEl) failedCountEl.textContent = _discoveryPoolData.stats.failed || 0;
if (matchedCountEl) matchedCountEl.textContent = _discoveryPoolData.stats.matched || 0;
// If viewing a list, refresh it
if (_discoveryPoolView === 'failed' || _discoveryPoolView === 'matched') {
renderPoolList();
}
} catch (err) {
showToast('Failed to filter discovery pool', 'error');
}
}
function _updatePoolHeaderCounts() {
if (!_discoveryPoolData) return;
const failedCount = _discoveryPoolData.stats.failed || 0;
const matchedCount = _discoveryPoolData.stats.matched || 0;
const matchedEl = document.getElementById('pool-header-matched');
const failedEl = document.getElementById('pool-header-failed');
if (matchedEl) matchedEl.textContent = `${matchedCount} Matched`;
if (failedEl) {
failedEl.textContent = `${failedCount} Failed`;
failedEl.classList.toggle('pool-header-failed-highlight', failedCount > 0);
}
}
function renderPoolList() {
const container = document.getElementById('pool-list-content');
if (!container || !_discoveryPoolData) return;
if (_discoveryPoolView === 'failed') {
const tracks = _discoveryPoolData.failed || [];
if (tracks.length === 0) {
container.innerHTML = '
No failed discoveries. All tracks matched successfully.
';
return;
}
container.innerHTML = tracks.map(t => `
${_esc(t.track_name)}
${_esc(t.artist_name)}
${_esc(t.playlist_name)}
Fix Match
`).join('');
} else {
const entries = _discoveryPoolData.matched || [];
if (entries.length === 0) {
container.innerHTML = '
No cached discovery matches yet.
';
return;
}
container.innerHTML = entries.map(e => {
const md = e.matched_data || {};
const matchedArtists = (md.artists || []).map(a => typeof a === 'string' ? a : (a.name || '')).join(', ');
const conf = Math.round((e.confidence || 0) * 100);
const confClass = conf >= 80 ? 'high' : (conf >= 70 ? 'mid' : 'low');
const album = md.album || {};
const albumImages = (typeof album === 'object' && album.images) ? album.images : [];
const imgUrl = md.image_url || (albumImages.length > 0 ? albumImages[0].url || '' : '');
return `
${imgUrl ? `
` : '
'}
${_esc(e.original_title)}
${_esc(e.original_artist)}
→
${_esc(md.name || '?')}
${_esc(e.provider)}
${conf}%
${e.use_count}×
×
`;
}).join('');
}
}
async function removePoolCacheEntry(entryId) {
if (!await showConfirmDialog({ title: 'Remove Cache Entry', message: 'Remove this cached match? The track will be re-discovered fresh next time.' })) return;
try {
const res = await fetch(`/api/discovery-pool/cache/${entryId}`, { method: 'DELETE' });
const data = await res.json();
if (data.success) {
showToast('Cache entry removed', 'success');
filterDiscoveryPool(_discoveryPoolPlaylistFilter || '');
} else {
showToast(data.error || 'Failed to remove', 'error');
}
} catch (err) {
showToast(`Error: ${err.message}`, 'error');
}
}
// --- Pool Fix Sub-Modal ---
function openPoolFixModal(trackId, trackName, artistName) {
// Create sub-modal overlay inside the pool modal
let fixOverlay = document.getElementById('pool-fix-overlay');
if (fixOverlay) fixOverlay.remove();
fixOverlay = document.createElement('div');
fixOverlay.className = 'pool-fix-overlay';
fixOverlay.id = 'pool-fix-overlay';
fixOverlay.onclick = (e) => { if (e.target === fixOverlay) closePoolFixModal(); };
fixOverlay.innerHTML = `
Source Track
Track: ${_esc(trackName)}
Artist: ${_esc(artistName)}
`;
fixOverlay.dataset.trackId = trackId;
document.body.appendChild(fixOverlay);
// Add enter key support
const trackInput = fixOverlay.querySelector('#pool-fix-track-input');
const artistInput = fixOverlay.querySelector('#pool-fix-artist-input');
const enterHandler = (e) => { if (e.key === 'Enter') searchPoolFix(); };
trackInput.addEventListener('keypress', enterHandler);
artistInput.addEventListener('keypress', enterHandler);
// Auto-search
setTimeout(() => searchPoolFix(), 100);
}
function closePoolFixModal() {
const fixOverlay = document.getElementById('pool-fix-overlay');
if (fixOverlay) fixOverlay.remove();
}
async function searchPoolFix() {
const trackInput = document.getElementById('pool-fix-track-input');
const artistInput = document.getElementById('pool-fix-artist-input');
const resultsContainer = document.getElementById('pool-fix-results');
if (!trackInput || !resultsContainer) return;
const query = `${artistInput.value.trim()} ${trackInput.value.trim()}`.trim();
if (!query) {
resultsContainer.innerHTML = '
Enter a search term
';
return;
}
resultsContainer.innerHTML = '
Searching...
';
try {
const res = await fetch(`/api/spotify/search_tracks?query=${encodeURIComponent(query)}&limit=20`);
const data = await res.json();
const tracks = data.tracks || [];
if (tracks.length === 0) {
resultsContainer.innerHTML = '
No results found
';
return;
}
resultsContainer.innerHTML = tracks.map((track, i) => {
const artists = (track.artists || []).join(', ');
const duration = track.duration_ms ? formatDuration(track.duration_ms) : '';
return `
${_esc(track.name || 'Unknown')}
${_esc(artists)}
${_esc(track.album || '')}
${duration ? `
${duration}
` : ''}
`;
}).join('');
} catch (err) {
resultsContainer.innerHTML = `
Search failed: ${_esc(err.message)}
`;
}
}
async function selectPoolFixTrack(track) {
const fixOverlay = document.getElementById('pool-fix-overlay');
if (!fixOverlay) return;
const trackId = parseInt(fixOverlay.dataset.trackId);
try {
const res = await fetch('/api/discovery-pool/fix', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
track_id: trackId,
spotify_track: track,
}),
});
const data = await res.json();
if (data.success) {
showToast(`Matched: ${track.name}`, 'success');
closePoolFixModal();
// Refresh pool data
filterDiscoveryPool(_discoveryPoolPlaylistFilter || '');
} else {
showToast(data.error || 'Failed to fix track', 'error');
}
} catch (err) {
showToast(`Error: ${err.message}`, 'error');
}
}
async function deleteMirroredPlaylist(playlistId, name) {
if (!await showConfirmDialog({ title: 'Delete Playlist', message: `Delete mirrored playlist "${name}"?`, confirmText: 'Delete', destructive: true })) return;
try {
const res = await fetch(`/api/mirrored-playlists/${playlistId}`, { method: 'DELETE' });
const data = await res.json();
if (data.success) {
showToast(`Deleted mirror: ${name}`, 'success');
loadMirroredPlaylists();
} else {
showToast(data.error || 'Failed to delete', 'error');
}
} catch (err) {
showToast(`Error: ${err.message}`, 'error');
}
}
/**
* Launch the existing discovery modal for a mirrored playlist by creating
* a temporary entry in youtubePlaylistStates and reusing openYouTubeDiscoveryModal.
*/
async function discoverMirroredPlaylist(playlistId) {
closeMirroredModal();
const tempHash = `mirrored_${playlistId}`;
// If state already exists (discovery in progress or completed), just reopen the modal
const existingState = youtubePlaylistStates[tempHash];
const hasActiveDiscovery = activeYouTubePollers[tempHash] || document.getElementById(`youtube-discovery-modal-${tempHash}`);
if (existingState && (existingState.phase !== 'fresh' || hasActiveDiscovery)) {
openYouTubeDiscoveryModal(tempHash);
// Resume polling if discovery is in progress but poller stopped
if (existingState.phase === 'discovering' && !activeYouTubePollers[tempHash]) {
startYouTubeDiscoveryPolling(tempHash);
}
return;
}
showLoadingOverlay('Preparing discovery...');
try {
// Register the mirrored playlist on the backend so the YouTube discovery pipeline can find it
const prepRes = await fetch(`/api/mirrored-playlists/${playlistId}/prepare-discovery`, { method: 'POST' });
const prepData = await prepRes.json();
if (prepData.error) throw new Error(prepData.error);
// Also fetch the full data for the frontend state
const res = await fetch(`/api/mirrored-playlists/${playlistId}`);
const data = await res.json();
if (data.error) throw new Error(data.error);
hideLoadingOverlay();
// Build tracks in the format the discovery modal expects
const tracks = (data.tracks || []).map(t => ({
id: t.source_track_id || `mirrored_${t.id}`,
name: t.track_name,
artists: [t.artist_name],
album: t.album_name || '',
duration_ms: t.duration_ms || 0
}));
// Check if backend returned cached results
if (prepData.from_cache) {
// Fetch the pre-populated status from the backend
const statusRes = await fetch(`/api/youtube/discovery/status/${tempHash}`);
const statusData = await statusRes.json();
if (statusData.error) throw new Error(statusData.error);
youtubePlaylistStates[tempHash] = {
playlist: {
name: data.name,
tracks: tracks,
track_count: tracks.length
},
phase: statusData.phase || 'discovered',
discovery_results: statusData.results || [],
discoveryResults: statusData.results || [],
discovery_progress: statusData.progress || 100,
spotify_matches: statusData.spotify_matches || 0,
spotifyMatches: statusData.spotify_matches || 0,
spotify_total: tracks.length,
status: statusData.status || 'complete',
url: `mirrored://${data.source}/${data.source_playlist_id}`,
sync_playlist_id: null,
converted_spotify_playlist_id: null,
download_process_id: null,
created_at: Date.now() / 1000,
last_accessed: Date.now() / 1000,
discovery_future: null,
sync_progress: {},
is_mirrored_playlist: true,
mirrored_source: data.source
};
const cached = prepData.cached_matches || 0;
const total = prepData.total_tracks || tracks.length;
showToast(`Loaded ${cached}/${total} cached discovery results`, 'success');
} else {
// No cached data โ fresh state
youtubePlaylistStates[tempHash] = {
playlist: {
name: data.name,
tracks: tracks,
track_count: tracks.length
},
phase: 'fresh',
discovery_results: [],
discovery_progress: 0,
spotify_matches: 0,
spotify_total: tracks.length,
status: 'parsed',
url: `mirrored://${data.source}/${data.source_playlist_id}`,
sync_playlist_id: null,
converted_spotify_playlist_id: null,
download_process_id: null,
created_at: Date.now() / 1000,
last_accessed: Date.now() / 1000,
discovery_future: null,
sync_progress: {},
is_mirrored_playlist: true,
mirrored_source: data.source
};
}
openYouTubeDiscoveryModal(tempHash);
} catch (err) {
hideLoadingOverlay();
showToast(`Error: ${err.message}`, 'error');
}
}
// ===============================
// AUTOMATIONS โ Visual Builder
// ===============================
async function retryFailedMirroredDiscovery(urlHash) {
// Extract playlist ID from url_hash (format: "mirrored_
")
const playlistId = urlHash.replace('mirrored_', '');
try {
const res = await fetch(`/api/mirrored-playlists/${playlistId}/retry-failed-discovery`, { method: 'POST' });
const data = await res.json();
if (data.error) {
showToast(`Error: ${data.error}`, 'error');
return;
}
if (data.retry_count === 0) {
showToast('All tracks already found!', 'success');
return;
}
// Update frontend state to discovering
const state = youtubePlaylistStates[urlHash];
if (state) {
state.phase = 'discovering';
state.status = 'discovering';
state.discovery_progress = 0;
}
// Update modal buttons to show discovering state
updateYouTubeModalButtons(urlHash, 'discovering');
// Start polling for progress
startYouTubeDiscoveryPolling(urlHash);
showToast(`Retrying ${data.retry_count} failed tracks...`, 'info');
} catch (err) {
showToast(`Error retrying discovery: ${err.message}`, 'error');
}
}
let _autoBlocks = null; // cached block definitions from /api/automations/blocks
let _autoBuilder = { editId: null, when: null, do: null, then: [], isSystem: false };
let _autoMirroredPlaylists = null; // cached mirrored playlist list
const _autoIcons = {
schedule: '\u23F1\uFE0F', daily_time: '\u{1F570}\uFE0F', weekly_time: '\uD83D\uDCC5', app_started: '\uD83D\uDE80', track_downloaded: '\u2B07\uFE0F', batch_complete: '\u2705',
watchlist_new_release: '\uD83D\uDD14', playlist_synced: '\uD83D\uDD04',
playlist_changed: '\u270F\uFE0F',
process_wishlist: '\uD83D\uDCCB', scan_watchlist: '\uD83D\uDC41\uFE0F',
scan_library: '\uD83D\uDD04', refresh_mirrored: '\uD83D\uDCC2', sync_playlist: '\uD83D\uDD01',
discover_playlist: '\uD83D\uDD0D', discovery_completed: '\uD83D\uDD0D',
notify_only: '\uD83D\uDD14', discord_webhook: '\uD83D\uDCAC', pushbullet: '\uD83D\uDD14', telegram: '\u2709\uFE0F',
signal_received: '\u26A1', fire_signal: '\u26A1',
// Phase 3
wishlist_processing_completed: '\u2705', watchlist_scan_completed: '\u2705',
database_update_completed: '\uD83D\uDDC4\uFE0F', download_failed: '\u274C',
download_quarantined: '\u26A0\uFE0F', wishlist_item_added: '\u2795',
watchlist_artist_added: '\uD83D\uDC64', watchlist_artist_removed: '\uD83D\uDC64',
import_completed: '\uD83D\uDCE5', mirrored_playlist_created: '\uD83D\uDCC2',
quality_scan_completed: '\uD83D\uDCCA', duplicate_scan_completed: '\uD83D\uDDC2\uFE0F', library_scan_completed: '\uD83D\uDCE1',
start_database_update: '\uD83D\uDDC4\uFE0F', run_duplicate_cleaner: '\uD83D\uDDC2\uFE0F',
clear_quarantine: '\uD83D\uDDD1\uFE0F', cleanup_wishlist: '\uD83E\uDDF9',
update_discovery_pool: '\uD83E\uDDED', start_quality_scan: '\uD83D\uDCCA',
backup_database: '\uD83D\uDCBE',
refresh_beatport_cache: '\uD83C\uDFB5',
clean_search_history: '\uD83D\uDDD1\uFE0F',
clean_completed_downloads: '\u2705',
full_cleanup: '\uD83E\uDDF9',
};
// --- Load & Render List ---
function _buildAutomationSection(id, label, automations, useGrid) {
const section = document.createElement('div');
section.className = 'automations-section';
section.id = id;
const collapsed = localStorage.getItem('auto_section_' + id) === '1';
if (collapsed) section.classList.add('collapsed');
const header = document.createElement('div');
header.className = 'automations-section-header';
header.innerHTML = `
▼
${label}
${automations.length}
`;
header.onclick = () => {
section.classList.toggle('collapsed');
localStorage.setItem('auto_section_' + id, section.classList.contains('collapsed') ? '1' : '0');
};
const body = document.createElement('div');
body.className = 'automations-section-body';
const container = document.createElement('div');
container.className = useGrid ? 'automations-grid' : 'automations-user-list';
automations.forEach(a => container.appendChild(renderAutomationCard(a)));
body.appendChild(container);
section.appendChild(header);
section.appendChild(body);
return section;
}
async function loadAutomations() {
const list = document.getElementById('automations-list');
const empty = document.getElementById('automations-empty');
const statsBar = document.getElementById('automations-stats');
if (!list || !empty) return;
try {
const res = await fetch('/api/automations');
const automations = await res.json();
if (automations.error) throw new Error(automations.error);
if (!automations.length) {
list.innerHTML = ''; empty.style.display = '';
if (statsBar) statsBar.innerHTML = '';
return;
}
empty.style.display = 'none';
list.innerHTML = '';
const systemAutos = automations.filter(a => a.is_system);
const userAutos = automations.filter(a => !a.is_system);
if (systemAutos.length) {
list.appendChild(_buildAutomationSection('auto-section-system', 'System', systemAutos, true));
}
if (userAutos.length) {
list.appendChild(_buildAutomationSection('auto-section-custom', 'My Automations', userAutos, false));
}
// Stats summary bar
if (statsBar) {
const total = automations.length;
const active = automations.filter(a => a.enabled).length;
const sys = systemAutos.length;
const custom = userAutos.length;
statsBar.innerHTML = `
${active} Active
${sys} System
${custom} Custom
`;
}
// Catch up on current automation progress
try {
const progRes = await fetch('/api/automations/progress');
const progData = await progRes.json();
if (!progData.error) updateAutomationProgressFromData(progData);
} catch (e) {}
} catch (err) {
list.innerHTML = ''; empty.style.display = '';
if (statsBar) statsBar.innerHTML = '';
}
}
function renderAutomationCard(a) {
const card = document.createElement('div');
card.className = 'automation-card' + (a.enabled ? '' : ' disabled') + (a.is_system ? ' system' : '');
card.dataset.id = a.id;
const tIcon = _autoIcons[a.trigger_type] || '\u2699\uFE0F';
const aIcon = _autoIcons[a.action_type] || '\u2699\uFE0F';
const tl = tIcon + ' ' + _autoFormatTrigger(a.trigger_type, a.trigger_config);
const al = aIcon + ' ' + _autoFormatAction(a.action_type);
const thenItems = a.then_actions || [];
const actionDelay = a.action_config && a.action_config.delay ? a.action_config.delay : 0;
const metaParts = [];
if (a.last_run) metaParts.push('Last: ' + _autoTimeAgo(a.last_run));
const _timerTriggers = ['schedule', 'daily_time', 'weekly_time'];
if (a.next_run && a.enabled && _timerTriggers.includes(a.trigger_type)) metaParts.push('Next: ' + _autoTimeUntil(a.next_run) + ' ');
if (!_timerTriggers.includes(a.trigger_type) && a.enabled) metaParts.push('Listening');
if (a.run_count) metaParts.push('Runs: ' + a.run_count + ' ');
if (a.last_error) metaParts.push('Error: ' + _esc(a.last_error));
const deleteBtn = a.is_system ? '' :
`🗑 `;
card.innerHTML = `
${_esc(a.name)}
${_esc(tl)}
→
${actionDelay ? `\u23F3 ${actionDelay}m → ` : ''}
${_esc(al)}
${thenItems.length ? thenItems.map(t => `→ ${_esc(_autoFormatNotify(t.type))} `).join('') : ''}
${metaParts.join(' · ')}
▶
⚙
${deleteBtn}
`;
return card;
}
function _autoFormatTrigger(type, config) {
if (type === 'schedule' && config) return 'Every ' + (config.interval || 1) + ' ' + (config.unit || 'hours');
if (type === 'daily_time' && config) return 'Daily at ' + (config.time || '00:00');
if (type === 'weekly_time' && config) {
const days = (config.days || []).map(d => d.charAt(0).toUpperCase() + d.slice(1)).join(', ');
return (days || 'Every day') + ' at ' + (config.time || '00:00');
}
if (type === 'signal_received' && config) {
const sig = config.signal_name || 'unknown';
return 'Signal: ' + sig;
}
const labels = { app_started: 'App Started', track_downloaded: 'Track Downloaded', batch_complete: 'Batch Complete',
watchlist_new_release: 'New Release Found', playlist_synced: 'Playlist Synced',
playlist_changed: 'Playlist Changed', discovery_completed: 'Discovery Complete',
wishlist_processing_completed: 'Wishlist Processed', watchlist_scan_completed: 'Watchlist Scan Done',
database_update_completed: 'Database Updated', download_failed: 'Download Failed',
download_quarantined: 'File Quarantined', wishlist_item_added: 'Wishlist Item Added',
watchlist_artist_added: 'Artist Watched', watchlist_artist_removed: 'Artist Unwatched',
import_completed: 'Import Complete', mirrored_playlist_created: 'Playlist Mirrored',
quality_scan_completed: 'Quality Scan Done', duplicate_scan_completed: 'Duplicate Scan Done',
library_scan_completed: 'Library Scan Done', signal_received: 'Signal Received' };
let label = labels[type] || type || 'Unknown';
if (config && config.conditions && config.conditions.length) {
const first = config.conditions[0];
label += ' (' + first.field + ' ' + first.operator + ' "' + first.value + '"' +
(config.conditions.length > 1 ? ' +' + (config.conditions.length - 1) + ' more' : '') + ')';
}
return label;
}
function _autoFormatAction(type) {
const labels = { process_wishlist: 'Process Wishlist', scan_watchlist: 'Scan Watchlist',
scan_library: 'Scan Library', refresh_mirrored: 'Refresh Mirrored',
sync_playlist: 'Sync Playlist', discover_playlist: 'Discover Playlist',
notify_only: 'Notify Only',
start_database_update: 'Update Database', run_duplicate_cleaner: 'Run Duplicate Cleaner',
clear_quarantine: 'Clear Quarantine', cleanup_wishlist: 'Clean Up Wishlist',
update_discovery_pool: 'Update Discovery', start_quality_scan: 'Run Quality Scan',
backup_database: 'Backup Database',
refresh_beatport_cache: 'Refresh Beatport Cache', clean_search_history: 'Clean Search History',
clean_completed_downloads: 'Clean Completed Downloads',
full_cleanup: 'Full Cleanup' };
return labels[type] || type || 'Unknown';
}
function _autoFormatNotify(type) {
if (type === 'discord_webhook') return 'Discord';
if (type === 'pushbullet') return 'Pushbullet';
if (type === 'telegram') return 'Telegram';
if (type === 'fire_signal') return '\u26A1 Signal';
return type || '';
}
function _autoParseUTC(ts) {
// If timestamp already has timezone info (+00:00 or Z), parse as-is; otherwise append Z to treat as UTC
if (/[Zz]$/.test(ts) || /[+-]\d{2}:\d{2}$/.test(ts)) return new Date(ts).getTime();
return new Date(ts + 'Z').getTime();
}
function _autoTimeAgo(ts) {
if (!ts) return 'Never';
const d = (Date.now() - _autoParseUTC(ts)) / 1000;
if (d < 60) return 'just now'; if (d < 3600) return Math.floor(d/60) + 'm ago';
if (d < 86400) return Math.floor(d/3600) + 'h ago'; return Math.floor(d/86400) + 'd ago';
}
function _autoTimeUntil(ts) {
if (!ts) return '';
const d = (_autoParseUTC(ts) - Date.now()) / 1000;
if (d <= 0) return 'soon'; if (d < 60) return 'in ' + Math.ceil(d) + 's';
if (d < 3600) return 'in ' + Math.ceil(d/60) + 'm'; if (d < 86400) return 'in ' + Math.round(d/3600) + 'h';
return 'in ' + Math.round(d/86400) + 'd';
}
// --- Live countdown for "Next: in Xs" ---
setInterval(() => {
document.querySelectorAll('.auto-next-run[data-next]').forEach(el => {
el.textContent = 'Next: ' + _autoTimeUntil(el.dataset.next);
});
}, 1000);
// --- CRUD ---
async function deleteAutomation(id, name) {
if (!await showConfirmDialog({ title: 'Delete Automation', message: `Delete automation "${name}"?`, confirmText: 'Delete', destructive: true })) return;
try {
const res = await fetch('/api/automations/' + id, { method: 'DELETE' });
const data = await res.json();
if (data.error) throw new Error(data.error);
showToast('Automation deleted', 'success');
await loadAutomations();
} catch (err) { showToast('Error: ' + err.message, 'error'); }
}
async function toggleAutomation(id) {
try {
const res = await fetch('/api/automations/' + id + '/toggle', { method: 'POST' });
const data = await res.json();
if (data.error) throw new Error(data.error);
await loadAutomations();
} catch (err) { showToast('Error: ' + err.message, 'error'); }
}
// --- Automation Progress Tracking ---
const _autoProgressLogCounts = {};
const _autoProgressHideTimers = {};
function updateAutomationProgressFromData(data) {
for (const [aidStr, state] of Object.entries(data)) {
const aid = parseInt(aidStr);
const card = document.querySelector(`.automation-card[data-id="${aid}"]`);
if (!card) continue;
let panel = card.querySelector('.automation-output');
if (!panel) {
panel = document.createElement('div');
panel.className = 'automation-output';
panel.innerHTML = `
`;
card.appendChild(panel);
_autoProgressLogCounts[aid] = 0;
}
// Update progress bar
const bar = panel.querySelector('.auto-progress-bar');
bar.style.width = (state.progress || 0) + '%';
// Update phase text
const phaseEl = panel.querySelector('.auto-progress-phase');
phaseEl.textContent = state.phase || '';
// Status indicator on card
const statusDot = card.querySelector('.automation-status');
if (state.status === 'running') {
if (statusDot) statusDot.className = 'automation-status running';
card.classList.add('running');
panel.classList.add('visible');
panel.classList.remove('finished', 'error');
if (_autoProgressHideTimers[aid]) {
clearTimeout(_autoProgressHideTimers[aid]);
delete _autoProgressHideTimers[aid];
}
// Reset log for new run (handles re-run within hide window)
if (_autoProgressLogCounts[aid] > 0 && state.log && state.log.length < _autoProgressLogCounts[aid]) {
const existingLog = panel.querySelector('.auto-progress-log');
if (existingLog) existingLog.innerHTML = '';
_autoProgressLogCounts[aid] = 0;
}
} else if (state.status === 'finished' || state.status === 'error') {
if (statusDot) statusDot.className = 'automation-status ' + (card.querySelector('input[type=checkbox]')?.checked ? 'enabled' : 'disabled');
card.classList.remove('running');
bar.style.width = '100%';
panel.classList.add('finished');
if (state.status === 'error') panel.classList.add('error');
if (!_autoProgressHideTimers[aid]) {
_autoProgressHideTimers[aid] = setTimeout(() => {
panel.classList.remove('visible');
delete _autoProgressHideTimers[aid];
_autoProgressLogCounts[aid] = 0;
}, 30000);
}
}
// Update log lines
const logEl = panel.querySelector('.auto-progress-log');
const rendered = _autoProgressLogCounts[aid] || 0;
const logLines = state.log || [];
if (logLines.length > rendered) {
// Normal append โ log is still growing
for (let i = rendered; i < logLines.length; i++) {
const line = logLines[i];
const div = document.createElement('div');
div.className = 'auto-log-line ' + (line.type || 'info');
div.textContent = line.text;
logEl.appendChild(div);
}
_autoProgressLogCounts[aid] = logLines.length;
logEl.scrollTop = logEl.scrollHeight;
} else if (logLines.length === rendered && logLines.length >= 50) {
// Log buffer is full and rotating โ replace last few lines
const children = logEl.children;
if (children.length > 0) {
const lastServerLine = logLines[logLines.length - 1];
const lastDomLine = children[children.length - 1];
if (lastServerLine && lastDomLine.textContent !== lastServerLine.text) {
// Content changed โ full re-render
logEl.innerHTML = '';
for (const line of logLines) {
const div = document.createElement('div');
div.className = 'auto-log-line ' + (line.type || 'info');
div.textContent = line.text;
logEl.appendChild(div);
}
_autoProgressLogCounts[aid] = logLines.length;
logEl.scrollTop = logEl.scrollHeight;
}
}
}
}
}
async function runAutomation(id) {
try {
const res = await fetch('/api/automations/' + id + '/run', { method: 'POST' });
const data = await res.json();
if (data.error) throw new Error(data.error);
showToast('Automation triggered', 'success');
setTimeout(() => loadAutomations(), 1500);
} catch (err) { showToast('Error: ' + err.message, 'error'); }
}
const _RESULT_DISPLAY_MAP = {
'start_database_update': [
{ key: 'artists', label: 'Artists' },
{ key: 'albums', label: 'Albums' },
{ key: 'tracks', label: 'Tracks' },
{ key: 'removed_artists', label: 'Removed Artists', hideZero: true },
{ key: 'removed_albums', label: 'Removed Albums', hideZero: true },
{ key: 'removed_tracks', label: 'Removed Tracks', hideZero: true },
],
'deep_scan_library': [
{ key: 'artists', label: 'Artists' },
{ key: 'albums', label: 'Albums' },
{ key: 'tracks', label: 'Tracks' },
{ key: 'removed_artists', label: 'Removed Artists', hideZero: true },
{ key: 'removed_albums', label: 'Removed Albums', hideZero: true },
{ key: 'removed_tracks', label: 'Removed Tracks', hideZero: true },
],
'scan_watchlist': [
{ key: 'artists_scanned', label: 'Artists Scanned' },
{ key: 'successful_scans', label: 'Successful' },
{ key: 'new_tracks_found', label: 'New Tracks' },
{ key: 'tracks_added_to_wishlist', label: 'Added to Wishlist' },
],
'run_duplicate_cleaner': [
{ key: 'files_scanned', label: 'Files Scanned' },
{ key: 'duplicates_found', label: 'Duplicates Found' },
{ key: 'files_deleted', label: 'Files Deleted' },
{ key: 'space_freed_mb', label: 'Space Freed (MB)' },
],
'start_quality_scan': [
{ key: 'tracks_scanned', label: 'Tracks Scanned' },
{ key: 'quality_met', label: 'Quality Met' },
{ key: 'low_quality', label: 'Low Quality' },
{ key: 'matched', label: 'Added to Wishlist' },
],
'scan_library': [
{ key: 'scan_duration_seconds', label: 'Duration (s)' },
],
'backup_database': [
{ key: 'size_mb', label: 'Backup Size (MB)' },
],
'refresh_mirrored': [
{ key: 'refreshed', label: 'Playlists Refreshed' },
{ key: 'errors', label: 'Errors', hideZero: true },
],
'clear_quarantine': [
{ key: 'removed', label: 'Items Removed' },
],
'cleanup_wishlist': [
{ key: 'removed', label: 'Duplicates Removed' },
],
'full_cleanup': [
{ key: 'quarantine_removed', label: 'Quarantine Removed' },
{ key: 'staging_removed', label: 'Staging Dirs Removed' },
{ key: 'total_removed', label: 'Total Items Removed' },
],
};
function _renderResultStats(resultJson, actionType) {
if (!resultJson || typeof resultJson !== 'object') return '';
var fields = _RESULT_DISPLAY_MAP[actionType];
var items = [];
if (fields) {
fields.forEach(function(f) {
var val = resultJson[f.key];
if (val == null) return;
if (f.hideZero && (val === 0 || val === '0')) return;
items.push({ label: f.label, value: val });
});
} else {
// Generic fallback: show all non-status, non-underscore keys
Object.keys(resultJson).forEach(function(k) {
if (k === 'status' || k.startsWith('_')) return;
var label = k.replace(/_/g, ' ').replace(/\b\w/g, function(c) { return c.toUpperCase(); });
items.push({ label: label, value: resultJson[k] });
});
}
if (items.length === 0) return '';
var html = '';
items.forEach(function(it) {
html += '
' + _esc(it.label) + '
' + _esc(String(it.value)) + '
';
});
html += '
';
return html;
}
async function showAutomationHistory(automationId, automationName, actionType) {
let modal = document.getElementById('automation-history-modal');
if (!modal) {
modal = document.createElement('div');
modal.id = 'automation-history-modal';
modal.className = 'modal-overlay';
document.body.appendChild(modal);
}
modal.innerHTML = '';
modal.style.display = 'flex';
modal.onclick = function(e) { if (e.target === modal) modal.style.display = 'none'; };
try {
const res = await fetch('/api/automations/' + automationId + '/history?limit=50');
const data = await res.json();
if (data.error) throw new Error(data.error);
const body = modal.querySelector('.history-modal-body');
if (!data.history || data.history.length === 0) {
body.innerHTML = 'No run history yet. History will be recorded on future runs.
';
return;
}
let html = '';
data.history.forEach(function(entry) {
const statusClass = 'history-status-' + (entry.status || 'completed');
const statusLabel = (entry.status || 'completed').charAt(0).toUpperCase() + (entry.status || 'completed').slice(1);
const timeAgo = _autoTimeAgo(entry.started_at);
const duration = entry.duration_seconds != null ? _formatDuration(entry.duration_seconds) : '';
const summary = entry.summary ? _esc(entry.summary) : '';
const hasLogs = entry.log_lines && entry.log_lines.length > 0;
const entryId = 'history-entry-' + entry.id;
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 += 'Showing ' + data.history.length + ' of ' + data.total + ' runs
';
}
body.innerHTML = html;
} catch (err) {
const body = modal.querySelector('.history-modal-body');
if (body) body.innerHTML = 'Error loading history: ' + _esc(err.message) + '
';
}
}
function _formatDuration(seconds) {
if (seconds < 1) return '<1s';
if (seconds < 60) return Math.round(seconds) + 's';
var m = Math.floor(seconds / 60);
var s = Math.round(seconds % 60);
if (m < 60) return m + 'm ' + s + 's';
var h = Math.floor(m / 60);
m = m % 60;
return h + 'h ' + m + 'm';
}
async function saveAutomation() {
const name = document.getElementById('builder-name').value.trim();
if (!name) { showToast('Name is required', 'error'); return; }
if (!_autoBuilder.when) { showToast('Add a trigger (WHEN)', 'error'); return; }
if (!_autoBuilder.do) { showToast('Add an action (DO)', 'error'); return; }
// Read configs from DOM
const triggerConfig = _readPlacedConfig('when');
const actionConfig = _readPlacedConfig('do');
// Read THEN actions (multi-slot)
const thenActions = _autoBuilder.then.map((item, i) => ({
type: item.type,
config: _readPlacedConfig('then-' + i),
}));
// Read optional delay from DO slot
const delayEl = document.getElementById('cfg-do-delay');
const delayVal = delayEl ? parseInt(delayEl.value) : 0;
if (delayVal > 0) actionConfig.delay = delayVal;
const body = {
name,
trigger_type: _autoBuilder.when.type, trigger_config: triggerConfig,
action_type: _autoBuilder.do.type, action_config: actionConfig,
then_actions: thenActions,
};
try {
let res;
if (_autoBuilder.editId) {
res = await fetch('/api/automations/' + _autoBuilder.editId, { method: 'PUT', headers: {'Content-Type':'application/json'}, body: JSON.stringify(body) });
} else {
res = await fetch('/api/automations', { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify(body) });
}
const data = await res.json();
if (data.error) throw new Error(data.error);
showToast(_autoBuilder.editId ? 'Automation updated' : 'Automation created', 'success');
hideAutomationBuilder();
await loadAutomations();
} catch (err) { showToast('Error: ' + err.message, 'error'); }
}
// --- Builder View ---
async function showAutomationBuilder(editId) {
// Load block definitions (always refresh)
try {
const res = await fetch('/api/automations/blocks');
_autoBlocks = await res.json();
} catch (e) {
if (!_autoBlocks) { showToast('Failed to load blocks', 'error'); return; }
}
_autoMirroredPlaylists = null; // invalidate so it re-fetches
_autoBuilder = { editId: editId || null, when: null, do: null, then: [], isSystem: false };
// If editing, load automation data
if (editId) {
try {
const res = await fetch('/api/automations/' + editId);
const a = await res.json();
if (a.error) throw new Error(a.error);
document.getElementById('builder-name').value = a.name || '';
_autoBuilder.when = { type: a.trigger_type, config: a.trigger_config || {} };
_autoBuilder.do = { type: a.action_type, config: a.action_config || {} };
// Load then_actions array
_autoBuilder.then = (a.then_actions || []).map(item => ({
type: item.type, config: item.config || {}
}));
// Backward compat: if no then_actions but has notify_type
if (!_autoBuilder.then.length && a.notify_type) {
_autoBuilder.then = [{ type: a.notify_type, config: a.notify_config || {} }];
}
_autoBuilder.isSystem = !!a.is_system;
} catch (err) { showToast('Failed to load automation', 'error'); return; }
} else {
document.getElementById('builder-name').value = '';
}
// System automations: lock the name field
document.getElementById('builder-name').readOnly = _autoBuilder.isSystem;
_renderBuilderSidebar();
_renderBuilderCanvas();
document.getElementById('automations-list-view').style.display = 'none';
document.getElementById('automations-builder-view').style.display = '';
}
function hideAutomationBuilder() {
document.getElementById('automations-builder-view').style.display = 'none';
document.getElementById('automations-list-view').style.display = '';
document.getElementById('builder-name').readOnly = false;
_autoBuilder = { editId: null, when: null, do: null, then: [], isSystem: false };
}
// --- Sidebar ---
function _renderBuilderSidebar() {
const sidebar = document.getElementById('builder-sidebar');
if (!sidebar || !_autoBlocks) return;
let html = '';
const sections = [
{ key: 'triggers', title: 'Triggers', slot: 'when' },
{ key: 'actions', title: 'Actions', slot: 'do' },
{ key: 'notifications', title: 'Then', slot: 'then' },
];
sections.forEach(sec => {
html += `';
});
sidebar.innerHTML = html;
}
// --- Canvas ---
function _renderBuilderCanvas() {
const canvas = document.getElementById('builder-canvas');
if (!canvas) return;
let html = '';
// WHEN slot
const whenData = _autoBuilder.when;
html += 'WHEN ';
if (whenData) {
html += `
${_renderPlacedBlock('when', whenData)}
`;
} else {
html += `
Drag a trigger here โ WHEN does this run?
`;
}
html += '
';
// DO slot
const doData = _autoBuilder.do;
html += 'DO ';
if (doData) {
html += `
${_renderPlacedBlock('do', doData)}
`;
} else {
html += `
Drag an action here โ WHAT should it do?
`;
}
html += '
';
// THEN section (multi-slot, 1-3 items)
html += 'THEN ';
if (_autoBuilder.then.length > 0) {
_autoBuilder.then.forEach((item, i) => {
if (i > 0) html += '
';
html += `
${_renderPlacedBlock('then-' + i, item)}
`;
});
}
if (_autoBuilder.then.length < 3) {
if (_autoBuilder.then.length > 0) html += '
';
html += `
${_autoBuilder.then.length === 0
? 'Drag a then-action here (optional)'
: '+ Add another (max 3)'}
`;
}
canvas.innerHTML = html;
// Load mirrored playlist selects if any are present
_autoLoadMirroredSelects();
// Set up checkbox state for refresh_mirrored
['when', 'do'].forEach(sk => {
const allCb = document.getElementById('cfg-' + sk + '-all');
if (allCb) _autoTogglePlaylistSelect(sk);
});
// Also check then slots
_autoBuilder.then.forEach((item, i) => {
const allCb = document.getElementById('cfg-then-' + i + '-all');
if (allCb) _autoTogglePlaylistSelect('then-' + i);
});
}
function _renderPlacedBlock(slotKey, data) {
const blockDef = _findBlockDef(data.type);
const icon = _autoIcons[data.type] || '\u2699\uFE0F';
const label = blockDef ? blockDef.label : data.type;
const configHtml = _renderBlockConfigFields(slotKey, data.type, data.config || {});
// Add optional delay field for action blocks
let delayHtml = '';
if (slotKey === 'do') {
const delayVal = (data.config && data.config.delay) || '';
delayHtml = ``;
}
// System automations: lock trigger and action slots (no remove, no replace)
const locked = _autoBuilder.isSystem && (slotKey === 'when' || slotKey === 'do');
const removeBtn = locked ? '' : `\u2715 `;
return `
${configHtml ? '
' + configHtml + '
' : ''}
${delayHtml}
`;
}
function _renderBlockConfigFields(slotKey, blockType, config) {
if (blockType === 'schedule') {
const interval = config.interval || 6;
const unit = config.unit || 'hours';
return `
Every
Minutes
Hours
Days
`;
}
if (blockType === 'daily_time') {
const timeVal = config.time || '03:00';
return `
At
`;
}
if (blockType === 'weekly_time') {
const timeVal = config.time || '03:00';
const selectedDays = config.days || [];
const allDays = [['mon','Mon'],['tue','Tue'],['wed','Wed'],['thu','Thu'],['fri','Fri'],['sat','Sat'],['sun','Sun']];
let dayHtml = 'Days ';
allDays.forEach(([val, lbl]) => {
const active = selectedDays.includes(val) ? ' active' : '';
dayHtml += `${lbl} `;
});
dayHtml += '
';
return `
At
${dayHtml}`;
}
// Event triggers with conditions
const blockDef = _findBlockDef(blockType);
if (blockDef && blockDef.has_conditions) {
return _renderConditionBuilder(slotKey, blockDef, config);
}
if (blockType === 'process_wishlist') {
const cat = config.category || 'all';
return `
Category
All
Albums
Singles
`;
}
if (blockType === 'signal_received') {
const sigName = _escAttr(config.signal_name || '');
const knownSignals = (_autoBlocks && _autoBlocks.known_signals) || [];
return `
Signal Name
${knownSignals.map(s => ``).join('')}
Triggers when another automation fires this signal
`;
}
if (blockType === 'fire_signal') {
const sigName = _escAttr(config.signal_name || '');
const knownSignals = (_autoBlocks && _autoBlocks.known_signals) || [];
return `
Signal Name
${knownSignals.map(s => ``).join('')}
Other automations with "Signal Received" trigger will wake up
`;
}
if (blockType === 'scan_watchlist' || blockType === 'scan_library' || blockType === 'notify_only') {
return 'No configuration needed
';
}
if (blockType === 'refresh_mirrored') {
const allChecked = config.all ? ' checked' : '';
return `
Playlist
Loading...
Refresh all mirrored playlists
`;
}
if (blockType === 'sync_playlist') {
return `
Playlist
Loading...
`;
}
if (blockType === 'discover_playlist') {
const allChecked = config.all ? ' checked' : '';
return `
Playlist
Loading...
Discover all mirrored playlists
`;
}
// Shared variable tags builder for notification types
function _notifyVarHtml(slotKey) {
let allVars = ['time', 'name', 'run_count', 'status'];
const triggerDef = _autoBuilder.when ? _findBlockDef(_autoBuilder.when.type) : null;
if (triggerDef && triggerDef.variables) {
triggerDef.variables.forEach(v => { if (!allVars.includes(v)) allVars.push(v); });
}
let html = '';
allVars.forEach(v => { html += `{${v}} `; });
return html + '
';
}
if (blockType === 'discord_webhook') {
const url = _escAttr(config.webhook_url || '');
return `
Webhook URL
Message
${_notifyVarHtml(slotKey)}`;
}
if (blockType === 'pushbullet') {
const token = _escAttr(config.access_token || '');
return `
Access Token
Title
Message
${_notifyVarHtml(slotKey)}`;
}
if (blockType === 'telegram') {
const botToken = _escAttr(config.bot_token || '');
const chatId = _escAttr(config.chat_id || '');
return `
Bot Token
Chat ID
Message
${_notifyVarHtml(slotKey)}`;
}
return '';
}
// --- Condition Builder ---
function _renderConditionBuilder(slotKey, blockDef, config) {
const conditions = config.conditions || [];
const match = config.match || 'all';
const fields = blockDef.condition_fields || [];
let html = '';
html += ``;
html += '
';
if (conditions.length) {
conditions.forEach((cond, i) => {
html += _renderConditionRow(slotKey, i, fields, cond);
});
}
html += '
';
html += `
+ Add Condition `;
html += '
';
if (!conditions.length) {
html += 'No conditions = triggers on every event
';
}
return html;
}
function _renderConditionRow(slotKey, index, fields, cond) {
const field = cond ? cond.field : (fields[0] || '');
const operator = cond ? cond.operator : 'equals';
const value = cond ? _escAttr(cond.value) : '';
let fieldOpts = '';
fields.forEach(f => { fieldOpts += `${f} `; });
// For playlist-related triggers, use a mirrored playlist dropdown instead of free text
const triggerType = _autoBuilder.when ? _autoBuilder.when.type : '';
const usePlaylistSelect = ((triggerType === 'playlist_changed' || triggerType === 'discovery_completed') && field === 'playlist_name');
const valueHtml = usePlaylistSelect
? ` `
: ` `;
return `
${fieldOpts}
equals
contains
starts with
not contains
${valueHtml}
\u2715
`;
}
function _autoAddCondition(slotKey) {
const data = _autoBuilder[slotKey];
if (!data) return;
if (!data.config) data.config = {};
if (!data.config.conditions) data.config.conditions = [];
// Save existing conditions from DOM before re-render
_autoSaveConditionsFromDOM(slotKey);
const blockDef = _findBlockDef(data.type);
const fields = blockDef ? (blockDef.condition_fields || []) : [];
data.config.conditions.push({ field: fields[0] || '', operator: 'contains', value: '' });
_renderBuilderCanvas();
// Re-populate mirrored playlist selects if needed
_autoLoadMirroredSelects();
}
function _autoRemoveCondition(slotKey, index) {
const data = _autoBuilder[slotKey];
if (!data || !data.config || !data.config.conditions) return;
_autoSaveConditionsFromDOM(slotKey);
data.config.conditions.splice(index, 1);
_renderBuilderCanvas();
_autoLoadMirroredSelects();
}
function _autoSaveConditionsFromDOM(slotKey) {
const data = _autoBuilder[slotKey];
if (!data || !data.config) return;
const container = document.getElementById('condition-rows-' + slotKey);
if (!container) return;
const rows = container.querySelectorAll('.condition-row');
const conditions = [];
rows.forEach(row => {
const field = row.querySelector('.cond-field')?.value || '';
const operator = row.querySelector('.cond-operator')?.value || 'contains';
const value = row.querySelector('.cond-value')?.value || '';
conditions.push({ field, operator, value });
});
data.config.conditions = conditions;
// Also save match mode
const matchEl = document.getElementById('cfg-' + slotKey + '-match');
if (matchEl) data.config.match = matchEl.value;
}
// --- Mirrored Playlist Select ---
function _autoTogglePlaylistSelect(slotKey) {
const allCb = document.getElementById('cfg-' + slotKey + '-all');
const sel = document.getElementById('cfg-' + slotKey + '-playlist_id');
if (sel) sel.disabled = allCb && allCb.checked;
}
async function _autoLoadMirroredSelects() {
const selects = document.querySelectorAll('.mirrored-playlist-select');
const nameSelects = document.querySelectorAll('.mirrored-playlist-name-select');
if (!selects.length && !nameSelects.length) return;
if (!_autoMirroredPlaylists) {
try {
const res = await fetch('/api/mirrored-playlists/list');
_autoMirroredPlaylists = await res.json();
} catch (e) { _autoMirroredPlaylists = []; }
}
selects.forEach(sel => {
const savedValue = sel.dataset.value || '';
sel.innerHTML = '-- Select playlist -- ';
_autoMirroredPlaylists.forEach(p => {
sel.innerHTML += `${_esc(p.name)} `;
});
});
nameSelects.forEach(sel => {
const savedValue = sel.dataset.value || '';
sel.innerHTML = '-- Select playlist -- ';
_autoMirroredPlaylists.forEach(p => {
sel.innerHTML += `${_esc(p.name)} `;
});
});
}
function _readPlacedConfig(slotKey) {
let data;
if (slotKey.startsWith('then-')) {
const idx = parseInt(slotKey.split('-')[1]);
data = _autoBuilder.then[idx];
} else {
data = _autoBuilder[slotKey];
}
if (!data) return {};
const type = data.type;
if (type === 'schedule') {
return {
interval: parseInt(document.getElementById('cfg-' + slotKey + '-interval')?.value) || 6,
unit: document.getElementById('cfg-' + slotKey + '-unit')?.value || 'hours',
};
}
if (type === 'daily_time') {
return { time: document.getElementById('cfg-' + slotKey + '-time')?.value || '03:00' };
}
if (type === 'weekly_time') {
const daysEl = document.getElementById('cfg-' + slotKey + '-days');
const days = daysEl ? Array.from(daysEl.querySelectorAll('.day-btn.active')).map(b => b.dataset.day) : [];
return {
time: document.getElementById('cfg-' + slotKey + '-time')?.value || '03:00',
days,
};
}
// Event triggers with conditions
const blockDef = _findBlockDef(type);
if (blockDef && blockDef.has_conditions) {
_autoSaveConditionsFromDOM(slotKey);
return {
conditions: (data.config && data.config.conditions) || [],
match: document.getElementById('cfg-' + slotKey + '-match')?.value || 'all',
};
}
if (type === 'process_wishlist') {
return { category: document.getElementById('cfg-' + slotKey + '-category')?.value || 'all' };
}
if (type === 'refresh_mirrored') {
const allCb = document.getElementById('cfg-' + slotKey + '-all');
return {
playlist_id: document.getElementById('cfg-' + slotKey + '-playlist_id')?.value || '',
all: allCb ? allCb.checked : false,
};
}
if (type === 'sync_playlist') {
return { playlist_id: document.getElementById('cfg-' + slotKey + '-playlist_id')?.value || '' };
}
if (type === 'discover_playlist') {
const allCb = document.getElementById('cfg-' + slotKey + '-all');
return {
playlist_id: document.getElementById('cfg-' + slotKey + '-playlist_id')?.value || '',
all: allCb ? allCb.checked : false,
};
}
if (type === 'signal_received' || type === 'fire_signal') {
return { signal_name: document.getElementById('cfg-' + slotKey + '-signal_name')?.value?.trim() || '' };
}
if (type === 'discord_webhook') {
return {
webhook_url: document.getElementById('cfg-' + slotKey + '-webhook_url')?.value?.trim() || '',
message: document.getElementById('cfg-' + slotKey + '-message')?.value || '',
};
}
if (type === 'pushbullet') {
return {
access_token: document.getElementById('cfg-' + slotKey + '-access_token')?.value?.trim() || '',
title: document.getElementById('cfg-' + slotKey + '-title')?.value || '',
message: document.getElementById('cfg-' + slotKey + '-message')?.value || '',
};
}
if (type === 'telegram') {
return {
bot_token: document.getElementById('cfg-' + slotKey + '-bot_token')?.value?.trim() || '',
chat_id: document.getElementById('cfg-' + slotKey + '-chat_id')?.value?.trim() || '',
message: document.getElementById('cfg-' + slotKey + '-message')?.value || '',
};
}
return {};
}
function _findBlockDef(type) {
if (!_autoBlocks) return null;
for (const cat of ['triggers','actions','notifications']) {
const found = (_autoBlocks[cat] || []).find(b => b.type === type);
if (found) return found;
}
return null;
}
// --- Drag & Drop ---
function _autoDragStart(e, blockType, slotCategory) {
e.dataTransfer.setData('text/plain', JSON.stringify({ type: blockType, slot: slotCategory }));
e.dataTransfer.effectAllowed = 'copy';
}
function _autoDragOver(e, slotKey) {
e.preventDefault();
e.dataTransfer.dropEffect = 'copy';
const targetId = slotKey === 'then' ? 'slot-then-add' : 'slot-' + slotKey;
document.getElementById(targetId)?.classList.add('drag-over');
}
function _autoDragLeave(e, slotKey) {
const targetId = slotKey === 'then' ? 'slot-then-add' : 'slot-' + slotKey;
document.getElementById(targetId)?.classList.remove('drag-over');
}
function _autoDrop(e, slotKey) {
e.preventDefault();
const dropTargetId = slotKey === 'then' ? 'slot-then-add' : 'slot-' + slotKey;
document.getElementById(dropTargetId)?.classList.remove('drag-over');
if (_autoBuilder.isSystem && (slotKey === 'when' || slotKey === 'do')) return;
try {
const data = JSON.parse(e.dataTransfer.getData('text/plain'));
// Handle THEN slot (append to array)
if (slotKey === 'then') {
if (data.slot !== 'then') { showToast('Wrong slot โ drop ' + data.slot + ' blocks here', 'error'); return; }
if (_autoBuilder.then.length >= 3) { showToast('Maximum 3 then-actions', 'error'); return; }
_autoBuilder.then.push({ type: data.type, config: {} });
} else {
if (data.slot !== slotKey) { showToast('Wrong slot โ drop ' + data.slot + ' blocks here', 'error'); return; }
_autoBuilder[slotKey] = { type: data.type, config: {} };
}
_renderBuilderCanvas();
} catch (err) {}
}
// Click-to-add (alternative to drag)
function _autoClickBlock(blockType, slotCategory) {
if (_autoBuilder.isSystem && (slotCategory === 'when' || slotCategory === 'do')) return;
if (slotCategory === 'then') {
if (_autoBuilder.then.length >= 3) { showToast('Maximum 3 then-actions', 'error'); return; }
_autoBuilder.then.push({ type: blockType, config: {} });
} else {
_autoBuilder[slotCategory] = { type: blockType, config: {} };
}
_renderBuilderCanvas();
}
function _autoRemoveBlock(slotKey) {
if (_autoBuilder.isSystem && (slotKey === 'when' || slotKey === 'do')) return;
// Handle then-N slots
if (slotKey.startsWith('then-')) {
const idx = parseInt(slotKey.split('-')[1]);
if (!isNaN(idx) && idx >= 0 && idx < _autoBuilder.then.length) {
_autoBuilder.then.splice(idx, 1);
}
} else {
_autoBuilder[slotKey] = null;
}
_renderBuilderCanvas();
}
// Variable insertion
function _autoInsertVar(textareaId, variable) {
const el = document.getElementById(textareaId);
if (!el) return;
const start = el.selectionStart, end = el.selectionEnd;
el.value = el.value.substring(0, start) + variable + el.value.substring(end);
el.selectionStart = el.selectionEnd = start + variable.length;
el.focus();
}
// ===== ISSUES PAGE =====
const ISSUE_CATEGORIES = {
wrong_track: { label: 'Wrong Track', icon: '❌', description: 'This file plays a completely different song than expected', applies: ['track'] },
wrong_metadata: { label: 'Wrong Metadata', icon: '✎', description: 'Title, artist, year, or other tags are incorrect', applies: ['track', 'album'] },
wrong_cover: { label: 'Wrong Cover Art', icon: '📷', description: 'The album artwork is wrong or missing', applies: ['album'] },
wrong_artist: { label: 'Wrong Artist', icon: '👤', description: 'This track is filed under the wrong artist', applies: ['track'] },
duplicate_tracks: { label: 'Duplicate Tracks', icon: '🔁', description: 'The same track appears more than once in this album', applies: ['album'] },
missing_tracks: { label: 'Missing Tracks', icon: '❓', description: 'Tracks that should be here are missing from this album', applies: ['album'] },
audio_quality: { label: 'Audio Quality', icon: '🎵', description: 'Audio has quality issues โ clipping, low bitrate, silence, etc.', applies: ['track'] },
wrong_album: { label: 'Wrong Album', icon: '💿', description: 'This track belongs to a different album', applies: ['track'] },
incomplete_album: { label: 'Incomplete Album', icon: '⚠', description: 'Album is partially downloaded โ some tracks present, others not', applies: ['album'] },
other: { label: 'Other', icon: '💬', description: 'Any other issue not listed above', applies: ['track', 'album'] },
};
const ISSUE_STATUS_META = {
open: { label: 'Open', cls: 'issue-status-open' },
in_progress: { label: 'In Progress', cls: 'issue-status-progress' },
resolved: { label: 'Resolved', cls: 'issue-status-resolved' },
dismissed: { label: 'Dismissed', cls: 'issue-status-dismissed' },
};
let _issuesPageState = { loaded: false };
function _issueHeaders(extra) {
const h = { 'X-Profile-Id': String(currentProfile ? currentProfile.id : 1) };
if (extra) Object.assign(h, extra);
return h;
}
async function loadIssuesPage() {
const admin = isEnhancedAdmin();
const subtitle = document.getElementById('issues-subtitle');
if (subtitle) {
subtitle.textContent = admin ? 'Manage and resolve reported library problems' : 'Track and resolve library problems';
}
await Promise.all([loadIssuesList(), loadIssuesCounts()]);
}
async function loadIssuesCounts() {
try {
const resp = await fetch('/api/issues/counts', { headers: _issueHeaders() });
const data = await resp.json();
if (!data.success) return;
const counts = data.counts;
const statsEl = document.getElementById('issues-stats');
if (!statsEl) return;
const total = (counts.open || 0) + (counts.in_progress || 0) + (counts.resolved || 0) + (counts.dismissed || 0);
statsEl.innerHTML = `
${counts.in_progress || 0}
In Progress
${counts.resolved || 0}
Resolved
${counts.dismissed || 0}
Dismissed
`;
// Update nav badge
const badge = document.getElementById('issues-nav-badge');
if (badge) {
const openCount = counts.open || 0;
badge.textContent = openCount;
badge.classList.toggle('hidden', openCount === 0);
}
} catch (e) {
console.error('Failed to load issue counts:', e);
}
}
async function loadIssuesList() {
const listEl = document.getElementById('issues-list');
if (!listEl) return;
listEl.innerHTML = '';
const statusFilter = document.getElementById('issues-filter-status')?.value || '';
const categoryFilter = document.getElementById('issues-filter-category')?.value || '';
let url = '/api/issues?';
if (statusFilter) url += `status=${encodeURIComponent(statusFilter)}&`;
if (categoryFilter) url += `category=${encodeURIComponent(categoryFilter)}&`;
try {
const profileId = currentProfile ? currentProfile.id : 1;
const resp = await fetch(url, { headers: { 'X-Profile-Id': String(profileId) } });
const data = await resp.json();
if (!data.success || !data.issues || data.issues.length === 0) {
listEl.innerHTML = `
🔍
No issues found
${statusFilter || categoryFilter ? 'Try adjusting your filters' : 'No issues have been reported yet'}
`;
return;
}
listEl.innerHTML = '';
data.issues.forEach(issue => {
listEl.appendChild(renderIssueCard(issue));
});
} catch (e) {
console.error('Failed to load issues:', e);
listEl.innerHTML = '';
}
}
function renderIssueCard(issue) {
const card = document.createElement('div');
card.className = 'issue-card';
card.dataset.issueId = issue.id;
card.onclick = () => showIssueDetailModal(issue.id);
const catMeta = ISSUE_CATEGORIES[issue.category] || ISSUE_CATEGORIES.other;
const statusMeta = ISSUE_STATUS_META[issue.status] || ISSUE_STATUS_META.open;
const admin = isEnhancedAdmin();
let snapshot = {};
try { snapshot = typeof issue.snapshot_data === 'string' ? JSON.parse(issue.snapshot_data || '{}') : (issue.snapshot_data || {}); } catch (e) {}
const entityLabel = issue.entity_type === 'track' ? 'Track' : (issue.entity_type === 'album' ? 'Album' : 'Artist');
const entityName = snapshot.title || snapshot.name || `${entityLabel} #${issue.entity_id}`;
const artistName = snapshot.artist_name || '';
const albumName = snapshot.album_title || '';
const thumbUrl = snapshot.thumb_url || snapshot.album_thumb || '';
const createdDate = issue.created_at ? new Date(issue.created_at).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) : '';
const createdTime = issue.created_at ? new Date(issue.created_at).toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' }) : '';
// Priority indicator
const priorityCls = issue.priority === 'high' ? 'issue-priority-high' : (issue.priority === 'low' ? 'issue-priority-low' : 'issue-priority-normal');
let thumbHtml = '';
if (thumbUrl) {
thumbHtml = ` `;
} else {
thumbHtml = `${catMeta.icon}
`;
}
let metaLine = '';
if (issue.entity_type === 'track') {
metaLine = [artistName, albumName].filter(Boolean).map(s => _esc(s)).join(' — ');
} else if (issue.entity_type === 'album') {
metaLine = artistName ? _esc(artistName) : '';
}
let profileBadge = '';
if (admin && issue.reporter_name) {
profileBadge = `by ${_esc(issue.reporter_name)} `;
}
let adminResponseIndicator = '';
if (issue.admin_response) {
adminResponseIndicator = '💬 ';
}
card.innerHTML = `
${thumbHtml}
${catMeta.icon}
${_esc(issue.title)}
${adminResponseIndicator}
${_esc(entityLabel)}
${_esc(entityName)}
${metaLine ? `${metaLine} ` : ''}
${issue.description ? `
${_esc(issue.description)}
` : ''}
${_esc(statusMeta.label)}
`;
return card;
}
// --- Report Issue Modal ---
let _reportIssueState = {};
function showReportIssueModal(entityType, entityId, entityName, artistName, albumTitle) {
_reportIssueState = { entityType, entityId, entityName, artistName, albumTitle: albumTitle || '' };
const overlay = document.getElementById('report-issue-overlay');
const titleEl = document.getElementById('report-issue-title');
const body = document.getElementById('report-issue-body');
if (!overlay || !body) return;
const entityLabel = entityType === 'track' ? 'Track' : (entityType === 'album' ? 'Album' : 'Artist');
titleEl.textContent = `Report Issue โ ${entityLabel}`;
body.innerHTML = `
${_esc(entityName)}
${artistName ? `
${_esc(artistName)}${albumTitle ? ' — ' + _esc(albumTitle) : ''}
` : ''}
What's the problem?
${Object.entries(ISSUE_CATEGORIES)
.filter(([, cat]) => !cat.applies || cat.applies.includes(entityType))
.map(([key, cat]) => `
${cat.icon}
${_esc(cat.label)}
${_esc(cat.description)}
`).join('')}
`;
_reportIssueState.selectedCategory = null;
_reportIssueState.selectedPriority = 'normal';
const submitBtn = document.getElementById('report-issue-submit-btn');
if (submitBtn) submitBtn.disabled = true;
overlay.classList.remove('hidden');
}
function selectIssueCategory(el, category) {
document.querySelectorAll('.report-issue-category-card').forEach(c => c.classList.remove('selected'));
el.classList.add('selected');
_reportIssueState.selectedCategory = category;
const detailsSection = document.getElementById('report-issue-details-section');
if (detailsSection) detailsSection.style.display = '';
// Auto-generate title based on category
const titleInput = document.getElementById('report-issue-input-title');
const catMeta = ISSUE_CATEGORIES[category];
if (titleInput && !titleInput._userEdited) {
const entityName = _reportIssueState.entityName || '';
titleInput.value = `${catMeta.label}: ${entityName}`;
}
const submitBtn = document.getElementById('report-issue-submit-btn');
if (submitBtn) submitBtn.disabled = false;
}
function selectIssuePriority(el, priority) {
document.querySelectorAll('.report-issue-priority-btn').forEach(b => b.classList.remove('selected'));
el.classList.add('selected');
_reportIssueState.selectedPriority = priority;
}
function closeReportIssueModal() {
const overlay = document.getElementById('report-issue-overlay');
if (overlay) overlay.classList.add('hidden');
_reportIssueState = {};
}
async function submitIssue() {
if (_reportIssueState._submitting) return;
const category = _reportIssueState.selectedCategory;
if (!category) {
showToast('Please select an issue category', 'error');
return;
}
const titleInput = document.getElementById('report-issue-input-title');
const descInput = document.getElementById('report-issue-input-desc');
const title = (titleInput?.value || '').trim();
const description = (descInput?.value || '').trim();
if (!title) {
showToast('Please provide a title for the issue', 'error');
return;
}
_reportIssueState._submitting = true;
const submitBtn = document.getElementById('report-issue-submit-btn');
if (submitBtn) {
submitBtn.disabled = true;
submitBtn.textContent = 'Submitting...';
}
try {
const resp = await fetch('/api/issues', {
method: 'POST',
headers: _issueHeaders({ 'Content-Type': 'application/json' }),
body: JSON.stringify({
profile_id: currentProfile ? currentProfile.id : 1,
entity_type: _reportIssueState.entityType,
entity_id: String(_reportIssueState.entityId),
category: category,
title: title,
description: description,
priority: _reportIssueState.selectedPriority || 'normal',
}),
});
const data = await resp.json();
if (data.success) {
showToast('Issue reported successfully', 'success');
closeReportIssueModal();
// Refresh issues page if visible
const issuesPage = document.getElementById('issues-page');
if (issuesPage && issuesPage.classList.contains('active')) {
loadIssuesPage();
}
// Update badge
loadIssuesBadge();
} else {
showToast(data.error || 'Failed to submit issue', 'error');
}
} catch (e) {
console.error('Failed to submit issue:', e);
showToast('Failed to submit issue', 'error');
} finally {
_reportIssueState._submitting = false;
if (submitBtn) {
submitBtn.disabled = false;
submitBtn.textContent = 'Submit Issue';
}
}
}
// --- Issue Detail Modal ---
async function showIssueDetailModal(issueId) {
const overlay = document.getElementById('issue-detail-overlay');
const body = document.getElementById('issue-detail-body');
const footer = document.getElementById('issue-detail-footer');
const titleEl = document.getElementById('issue-detail-title');
if (!overlay || !body) return;
body.innerHTML = '';
footer.innerHTML = 'Close ';
overlay.classList.remove('hidden');
try {
const resp = await fetch(`/api/issues/${issueId}`, { headers: _issueHeaders() });
const data = await resp.json();
if (!data.success || !data.issue) {
body.innerHTML = '';
return;
}
renderIssueDetail(data.issue, body, footer, titleEl);
} catch (e) {
console.error('Failed to load issue:', e);
body.innerHTML = '';
}
}
function renderIssueDetail(issue, body, footer, titleEl) {
const admin = isEnhancedAdmin();
const catMeta = ISSUE_CATEGORIES[issue.category] || ISSUE_CATEGORIES.other;
const statusMeta = ISSUE_STATUS_META[issue.status] || ISSUE_STATUS_META.open;
let snapshot = {};
try { snapshot = typeof issue.snapshot_data === 'string' ? JSON.parse(issue.snapshot_data || '{}') : (issue.snapshot_data || {}); } catch (e) {}
const entityLabel = issue.entity_type === 'track' ? 'Track' : (issue.entity_type === 'album' ? 'Album' : 'Artist');
const entityName = snapshot.title || snapshot.name || `${entityLabel} #${issue.entity_id}`;
const artistName = snapshot.artist_name || (issue.entity_type === 'artist' ? snapshot.name : '') || '';
const albumTitle = issue.entity_type === 'album' ? (snapshot.title || '') : (snapshot.album_title || '');
const artistId = issue.entity_type === 'artist' ? snapshot.id : snapshot.artist_id;
// Resolve image URLs โ album art and artist photo
let artistThumb = '';
let albumThumb = '';
if (issue.entity_type === 'album') {
albumThumb = snapshot.thumb_url || '';
artistThumb = snapshot.artist_thumb || '';
} else if (issue.entity_type === 'track') {
albumThumb = snapshot.album_thumb || '';
artistThumb = snapshot.artist_thumb || '';
} else {
// Artist issue
artistThumb = snapshot.thumb_url || '';
}
// Determine the album-level Spotify ID for download/wishlist actions
const spotifyAlbumId = snapshot.spotify_album_id || '';
console.log('Issue detail snapshot:', { entityType: issue.entity_type, albumThumb, artistThumb, spotifyAlbumId, snapshotKeys: Object.keys(snapshot) });
const createdDate = issue.created_at ? new Date(issue.created_at).toLocaleString() : 'Unknown';
const resolvedDate = issue.resolved_at ? new Date(issue.resolved_at).toLocaleString() : '';
titleEl.textContent = `Issue #${issue.id}`;
// --- Build external links chips ---
function _extLinks(snap) {
const links = [];
if (snap.spotify_artist_id) links.push({ svc: 'Spotify', type: 'Artist', url: `https://open.spotify.com/artist/${snap.spotify_artist_id}`, cls: 'ext-spotify' });
if (snap.spotify_album_id) links.push({ svc: 'Spotify', type: 'Album', url: `https://open.spotify.com/album/${snap.spotify_album_id}`, cls: 'ext-spotify' });
if (snap.spotify_track_id) links.push({ svc: 'Spotify', type: 'Track', url: `https://open.spotify.com/track/${snap.spotify_track_id}`, cls: 'ext-spotify' });
if (snap.artist_musicbrainz_id) links.push({ svc: 'MusicBrainz', type: 'Artist', url: `https://musicbrainz.org/artist/${snap.artist_musicbrainz_id}`, cls: 'ext-mb' });
if (snap.musicbrainz_release_id) links.push({ svc: 'MusicBrainz', type: 'Release', url: `https://musicbrainz.org/release/${snap.musicbrainz_release_id}`, cls: 'ext-mb' });
if (snap.musicbrainz_recording_id) links.push({ svc: 'MusicBrainz', type: 'Recording', url: `https://musicbrainz.org/recording/${snap.musicbrainz_recording_id}`, cls: 'ext-mb' });
if (snap.artist_deezer_id) links.push({ svc: 'Deezer', type: 'Artist', url: `https://www.deezer.com/artist/${snap.artist_deezer_id}`, cls: 'ext-deezer' });
if (snap.album_deezer_id) links.push({ svc: 'Deezer', type: 'Album', url: `https://www.deezer.com/album/${snap.album_deezer_id}`, cls: 'ext-deezer' });
if (snap.track_deezer_id) links.push({ svc: 'Deezer', type: 'Track', url: `https://www.deezer.com/track/${snap.track_deezer_id}`, cls: 'ext-deezer' });
if (snap.artist_tidal_id) links.push({ svc: 'Tidal', type: 'Artist', url: `https://listen.tidal.com/artist/${snap.artist_tidal_id}`, cls: 'ext-tidal' });
if (snap.album_tidal_id) links.push({ svc: 'Tidal', type: 'Album', url: `https://listen.tidal.com/album/${snap.album_tidal_id}`, cls: 'ext-tidal' });
if (snap.artist_qobuz_id) links.push({ svc: 'Qobuz', type: 'Artist', cls: 'ext-qobuz', id: snap.artist_qobuz_id });
if (snap.album_qobuz_id) links.push({ svc: 'Qobuz', type: 'Album', cls: 'ext-qobuz', id: snap.album_qobuz_id });
return links;
}
const extLinks = _extLinks(snapshot);
let extLinksHtml = '';
if (extLinks.length > 0) {
const chips = extLinks.map(l => {
if (l.url) {
return `${_esc(l.svc)} ${_esc(l.type)} `;
}
return `${_esc(l.svc)} ${_esc(l.type)} `;
}).join('');
extLinksHtml = `${chips}
`;
}
// --- Build enhanced-library-style album/track widget ---
// Determine which album data to show (for album issues it's the entity, for track issues it's the parent)
const showAlbumWidget = (issue.entity_type === 'album' || issue.entity_type === 'track');
const albumName = issue.entity_type === 'album' ? (snapshot.title || '') : (snapshot.album_title || '');
const albumYear = snapshot.year || '';
const albumLabel = snapshot.label || '';
const albumType = snapshot.record_type || '';
const albumTrackCount = issue.entity_type === 'album' ? (snapshot.track_count || '') : (snapshot.album_track_count || '');
const albumGenres = snapshot.genres || [];
// --- Build the hero section (artist photo + album art + info) ---
let heroHtml = '';
if (showAlbumWidget) {
// Genre tags
let genreTagsHtml = '';
if (Array.isArray(albumGenres) && albumGenres.length > 0) {
genreTagsHtml = `${albumGenres.slice(0, 5).map(g => `${_esc(g)} `).join('')}
`;
}
// 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
? `♫ ${_esc(entityName)}
` : '';
heroHtml = `
${artistThumb ? `
` : ''}
${albumThumb ? `
` : ''}
${catMeta.icon}
${artistName ? `
${_esc(artistName)}
` : ''}
${_esc(albumName)}
${trackNameLine}
${albumMetaParts.length > 0 ? `
${_esc(albumMetaParts.join(' \u00B7 '))}
` : ''}
${genreTagsHtml}
${extLinksHtml}
`;
} else {
// Artist-level issue โ simpler hero
heroHtml = `
${artistThumb ? `
` : `
${catMeta.icon}
`}
${_esc(entityName)}
${extLinksHtml}
`;
}
// --- Issue info bar ---
let issueInfoHtml = `
${_esc(statusMeta.label)}
${catMeta.icon} ${_esc(catMeta.label)}
Reported ${_esc(createdDate)}
${issue.reporter_name && admin ? `by ${_esc(issue.reporter_name)} ` : ''}
${resolvedDate ? `Resolved ${_esc(resolvedDate)} ` : ''}
`;
// --- Issue description ---
let descriptionHtml = `
Issue
${_esc(issue.title)}
${issue.description ? `
${_esc(issue.description)}
` : '
No additional details provided
'}
`;
// --- Action buttons (Download Album / Add to Wishlist) for admin ---
let actionButtonsHtml = '';
if (admin && (issue.entity_type === 'album' || issue.entity_type === 'track')) {
actionButtonsHtml = `
`;
}
// --- Metadata grid for track-level issues ---
let metaGridHtml = '';
if (issue.entity_type === 'track') {
const metaItems = [];
if (snapshot.track_number) metaItems.push({ icon: '#', label: 'Track', value: String(snapshot.track_number) });
if (snapshot.duration) metaItems.push({ icon: '◷', label: 'Duration', value: typeof snapshot.duration === 'number' ? formatDurationMs(snapshot.duration) : String(snapshot.duration) });
if (snapshot.format) metaItems.push({ icon: '💾', label: 'Format', value: snapshot.format });
if (snapshot.bitrate) metaItems.push({ icon: '🎶', label: 'Bitrate', value: snapshot.bitrate + ' kbps' });
if (snapshot.bpm) metaItems.push({ icon: '♫', label: 'BPM', value: String(snapshot.bpm) });
if (snapshot.quality) metaItems.push({ icon: '★', label: 'Quality', value: snapshot.quality });
if (metaItems.length > 0) {
metaGridHtml = `
`;
}
}
// --- File path display for tracks ---
let filePathHtml = '';
if (snapshot.file_path) {
filePathHtml = `
File Path
${_esc(snapshot.file_path)}
`;
}
// --- Enhanced-library-style track listing ---
let trackListHtml = '';
if (snapshot.tracks && Array.isArray(snapshot.tracks) && snapshot.tracks.length > 0) {
let lastDisc = null;
let rows = '';
const hasMultiDisc = snapshot.tracks.some(tr => (tr.disc_number || 1) > 1);
snapshot.tracks.forEach(t => {
const disc = t.disc_number || 1;
if (hasMultiDisc && disc !== lastDisc) {
rows += `Disc ${disc}
`;
lastDisc = disc;
}
const fmt = t.format || (t.file_path ? t.file_path.split('.').pop().toUpperCase() : '');
const fmtLower = fmt.toLowerCase();
const fmtClass = fmtLower === 'flac' ? 'flac' : (fmtLower === 'mp3' ? 'mp3' : 'other');
const br = t.bitrate ? parseInt(t.bitrate) : 0;
const brClass = br >= 320 || fmtLower === 'flac' ? 'high' : (br >= 192 ? 'medium' : 'low');
const durStr = t.duration && typeof t.duration === 'number' ? formatDurationMs(t.duration) : '';
rows += `
${_esc(String(t.track_number || '-'))}
${_esc(t.title || 'Unknown')}
${durStr ? `${durStr} ` : ''}
${fmt ? `${_esc(fmt)} ` : ''}
${br ? `${br}k ` : ''}
`;
});
trackListHtml = `
Track Listing ${snapshot.tracks.length} tracks
${rows}
`;
}
// --- Admin response section ---
let adminResponseHtml = '';
if (admin) {
adminResponseHtml = `
`;
} else if (issue.admin_response) {
adminResponseHtml = `
Admin Response
${_esc(issue.admin_response)}
`;
}
body.innerHTML = `
${heroHtml}
${issueInfoHtml}
${actionButtonsHtml}
${descriptionHtml}
${metaGridHtml}
${filePathHtml}
${trackListHtml}
${adminResponseHtml}
`;
// --- Footer with status action buttons ---
const safeId = parseInt(issue.id, 10);
let footerHtml = 'Close ';
if (admin) {
if (issue.status === 'open' || issue.status === 'in_progress') {
if (issue.status === 'open') {
footerHtml += `Mark In Progress `;
}
footerHtml += `Resolve `;
footerHtml += `Dismiss `;
} else {
footerHtml += `Reopen `;
}
footerHtml += `Delete `;
} else {
if (issue.status === 'open') {
footerHtml += `Withdraw `;
}
}
footer.innerHTML = footerHtml;
// --- Attach action button handlers ---
const dlBtn = document.getElementById('issue-action-download');
if (dlBtn) {
dlBtn.onclick = () => issueDownloadAlbum(spotifyAlbumId, artistName, albumName);
}
const wlBtn = document.getElementById('issue-action-wishlist');
if (wlBtn) {
wlBtn.onclick = () => issueAddToWishlist(spotifyAlbumId, artistName, albumName);
}
}
// --- Issue Action: Download Album ---
async function issueDownloadAlbum(spotifyAlbumId, artistName, albumName) {
const btn = document.getElementById('issue-action-download');
if (!spotifyAlbumId && (!artistName || !albumName)) {
showToast('No album ID or artist/album info available for download', 'warning');
return;
}
try {
if (btn) { btn.disabled = true; btn.textContent = 'Loading...'; }
let response;
if (spotifyAlbumId) {
const albumParams = new URLSearchParams({ name: albumName || '', artist: artistName || '' });
response = await fetch(`/api/spotify/album/${encodeURIComponent(spotifyAlbumId)}?${albumParams}`);
} else {
// No Spotify album ID โ search for the album by name
const query = `${artistName} ${albumName}`;
const searchResp = await fetch('/api/enhanced-search', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ query })
});
if (!searchResp.ok) throw new Error('Album search failed');
const searchData = await searchResp.json();
const foundAlbum = searchData.spotify_albums?.[0];
if (!foundAlbum || !foundAlbum.id) {
showToast(`Could not find "${albumName}" by ${artistName}`, 'warning');
return;
}
const albumParams = new URLSearchParams({ name: foundAlbum.name || albumName, artist: foundAlbum.artist || artistName });
response = await fetch(`/api/spotify/album/${encodeURIComponent(foundAlbum.id)}?${albumParams}`);
}
if (!response.ok) {
if (response.status === 401) throw new Error('Spotify not authenticated');
throw new Error(`Failed to load album: ${response.status}`);
}
const albumData = await response.json();
if (!albumData || !albumData.tracks || albumData.tracks.length === 0) {
showToast(`No tracks available for "${albumName}"`, 'warning');
return;
}
// Close the issue modal first
closeIssueDetailModal();
const resolvedAlbumId = albumData.id || spotifyAlbumId || Date.now();
const virtualPlaylistId = `issue_download_${resolvedAlbumId}`;
// Enrich tracks with album metadata
const enrichedTracks = albumData.tracks.map(track => ({
...track,
album: {
name: albumData.name,
id: albumData.id,
album_type: albumData.album_type || 'album',
images: albumData.images || [],
release_date: albumData.release_date,
total_tracks: albumData.total_tracks
}
}));
const playlistName = `[${artistName}] ${albumData.name}`;
const artistObject = { id: null, name: artistName };
const fullAlbumObject = {
name: albumData.name,
id: albumData.id,
album_type: albumData.album_type || 'album',
images: albumData.images || [],
release_date: albumData.release_date,
total_tracks: albumData.total_tracks,
artists: albumData.artists || [{ name: artistName }]
};
await openDownloadMissingModalForArtistAlbum(
virtualPlaylistId, playlistName, enrichedTracks, fullAlbumObject, artistObject, true
);
} catch (error) {
console.error('Issue download error:', error);
showToast(`Error: ${error.message}`, 'error');
} finally {
if (btn) { btn.disabled = false; btn.innerHTML = ' Download Album'; }
}
}
// --- Issue Action: Add to Wishlist ---
async function issueAddToWishlist(spotifyAlbumId, artistName, albumName) {
const btn = document.getElementById('issue-action-wishlist');
if (!spotifyAlbumId && (!artistName || !albumName)) {
showToast('No album ID or artist/album info available', 'warning');
return;
}
try {
if (btn) { btn.disabled = true; btn.textContent = 'Loading...'; }
let response;
if (spotifyAlbumId) {
const albumParams = new URLSearchParams({ name: albumName || '', artist: artistName || '' });
response = await fetch(`/api/spotify/album/${encodeURIComponent(spotifyAlbumId)}?${albumParams}`);
} else {
// No Spotify album ID โ search for the album by name
const query = `${artistName} ${albumName}`;
const searchResp = await fetch('/api/enhanced-search', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ query })
});
if (!searchResp.ok) throw new Error('Album search failed');
const searchData = await searchResp.json();
const foundAlbum = searchData.spotify_albums?.[0];
if (!foundAlbum || !foundAlbum.id) {
showToast(`Could not find "${albumName}" by ${artistName}`, 'warning');
return;
}
const albumParams = new URLSearchParams({ name: foundAlbum.name || albumName, artist: foundAlbum.artist || artistName });
response = await fetch(`/api/spotify/album/${encodeURIComponent(foundAlbum.id)}?${albumParams}`);
}
if (!response.ok) throw new Error(`Failed to load album: ${response.status}`);
const albumData = await response.json();
if (!albumData || !albumData.tracks || albumData.tracks.length === 0) {
showToast(`No tracks available for "${albumName}"`, 'warning');
return;
}
// Close issue modal and open wishlist modal
closeIssueDetailModal();
const albumArtists = albumData.artists || [{ name: artistName }];
const album = {
name: albumData.name,
id: albumData.id,
album_type: albumData.album_type || 'album',
images: albumData.images || [],
release_date: albumData.release_date,
total_tracks: albumData.total_tracks,
artists: albumArtists
};
const artist = { id: null, name: artistName };
// Enrich tracks with album metadata โ use album artist for wishlist grouping
// (Spotify returns per-track artists which can differ on compilations/soundtracks)
const tracks = albumData.tracks.map(t => ({
...t,
artists: albumArtists,
album: album
}));
await openAddToWishlistModal(album, artist, tracks, albumData.album_type || 'album');
} catch (error) {
console.error('Issue wishlist error:', error);
showToast(`Error: ${error.message}`, 'error');
} finally {
if (btn) { btn.disabled = false; btn.innerHTML = ' Add to Wishlist'; }
}
}
async function updateIssueStatus(issueId, newStatus) {
const payload = { status: newStatus };
// Include admin response if present
const responseInput = document.getElementById('issue-detail-response-input');
if (responseInput) {
payload.admin_response = responseInput.value.trim();
}
try {
const resp = await fetch(`/api/issues/${issueId}`, {
method: 'PUT',
headers: _issueHeaders({ 'Content-Type': 'application/json' }),
body: JSON.stringify(payload),
});
const data = await resp.json();
if (data.success) {
showToast(`Issue ${newStatus === 'resolved' ? 'resolved' : newStatus === 'dismissed' ? 'dismissed' : newStatus === 'in_progress' ? 'marked in progress' : 'reopened'}`, 'success');
closeIssueDetailModal();
// Refresh if on issues page
const issuesPage = document.getElementById('issues-page');
if (issuesPage && issuesPage.classList.contains('active')) {
loadIssuesPage();
}
loadIssuesBadge();
} else {
showToast(data.error || 'Failed to update issue', 'error');
}
} catch (e) {
console.error('Failed to update issue:', e);
showToast('Failed to update issue', 'error');
}
}
async function deleteIssue(issueId) {
if (!confirm('Are you sure you want to delete this issue?')) return;
try {
const resp = await fetch(`/api/issues/${issueId}`, { method: 'DELETE', headers: _issueHeaders() });
const data = await resp.json();
if (data.success) {
showToast('Issue deleted', 'success');
closeIssueDetailModal();
const issuesPage = document.getElementById('issues-page');
if (issuesPage && issuesPage.classList.contains('active')) {
loadIssuesPage();
}
loadIssuesBadge();
} else {
showToast(data.error || 'Failed to delete issue', 'error');
}
} catch (e) {
console.error('Failed to delete issue:', e);
showToast('Failed to delete issue', 'error');
}
}
function closeIssueDetailModal() {
const overlay = document.getElementById('issue-detail-overlay');
if (overlay) overlay.classList.add('hidden');
}
async function loadIssuesBadge() {
try {
const resp = await fetch('/api/issues/counts', { headers: _issueHeaders() });
const data = await resp.json();
if (!data.success) return;
const badge = document.getElementById('issues-nav-badge');
if (badge) {
const openCount = data.counts.open || 0;
badge.textContent = openCount;
badge.classList.toggle('hidden', openCount === 0);
}
} catch (e) {}
}
// ===== END ISSUES PAGE =====
// --- Helpers ---
function _esc(str) {
if (!str) return '';
const d = document.createElement('div');
d.textContent = str;
return d.innerHTML;
}
function _escAttr(str) {
if (!str) return '';
return String(str).replace(/&/g, '&').replace(/"/g, '"').replace(/'/g, ''').replace(//g, '>');
}
// ===== ENHANCE QUALITY MODAL =====
let _enhanceQualityData = null;
let _enhanceArtistId = null;
const ENHANCE_TIER_MAP = {
'lossless': { num: 1, label: 'Lossless', cssClass: 'lossless' },
'high_lossy': { num: 2, label: 'High Lossy', cssClass: 'high-lossy' },
'standard_lossy': { num: 3, label: 'Standard Lossy', cssClass: 'standard-lossy' },
'low_lossy': { num: 4, label: 'Low Lossy', cssClass: 'low-lossy' },
'unknown': { num: 999, label: 'Unknown', cssClass: 'unknown' },
};
async function checkArtistEnhanceEligibility(artistId) {
const btn = document.getElementById('library-artist-enhance-btn');
if (!btn) return;
btn.classList.add('hidden');
_enhanceArtistId = artistId;
try {
const resp = await fetch(`/api/library/artist/${artistId}/quality-analysis`);
if (!resp.ok) return;
const data = await resp.json();
if (!data.success || !data.tracks || data.tracks.length === 0) return;
_enhanceQualityData = data;
// Show button if any tracks are below the user's min acceptable tier
const minTier = data.min_acceptable_tier || 1;
const belowCount = data.tracks.filter(t => t.tier_num > minTier).length;
if (belowCount > 0) {
btn.classList.remove('hidden');
btn.querySelector('.enhance-text').textContent = `Enhance Quality (${belowCount})`;
}
} catch (e) {
console.debug('Enhance eligibility check failed:', e);
}
}
function openEnhanceQualityModal() {
if (!_enhanceQualityData) return;
const data = _enhanceQualityData;
// Remove existing modal if any
const existing = document.getElementById('enhance-quality-overlay');
if (existing) existing.remove();
const overlay = document.createElement('div');
overlay.id = 'enhance-quality-overlay';
overlay.className = 'enhance-modal-overlay';
overlay.onclick = (e) => { if (e.target === overlay) closeEnhanceQualityModal(); };
const minTier = data.min_acceptable_tier || 1;
const summary = data.quality_summary || {};
overlay.innerHTML = `
${_buildEnhanceSummaryChips(summary)}
Upgrade tracks below:
Lossless (FLAC/WAV)
High Lossy (OGG/Opus)
Standard Lossy (M4A/AAC)
= 4 ? 'selected' : ''}>Low Lossy (MP3/WMA)
Select All Below
Deselect All
0 selected
#
Title
Album
Format
Bitrate
`;
document.body.appendChild(overlay);
renderEnhanceTrackRows(minTier);
}
function _buildEnhanceSummaryChips(summary) {
const chips = [
{ key: 'lossless', label: 'FLAC', cssClass: 'lossless' },
{ key: 'high_lossy', label: 'OGG/Opus', cssClass: 'high-lossy' },
{ key: 'standard_lossy', label: 'M4A/AAC', cssClass: 'standard-lossy' },
{ key: 'low_lossy', label: 'MP3/WMA', cssClass: 'low-lossy' },
];
return chips
.filter(c => (summary[c.key] || 0) > 0)
.map(c => `
${summary[c.key]}
${c.label}
`).join('');
}
function renderEnhanceTrackRows(thresholdTier) {
const tbody = document.getElementById('enhance-track-tbody');
if (!tbody || !_enhanceQualityData) return;
const tracks = _enhanceQualityData.tracks;
// Sort: below-threshold first, then by album + track number
const sorted = [...tracks].sort((a, b) => {
const aBt = a.tier_num > thresholdTier ? 0 : 1;
const bBt = b.tier_num > thresholdTier ? 0 : 1;
if (aBt !== bBt) return aBt - bBt;
const albumCmp = (a.album_title || '').localeCompare(b.album_title || '');
if (albumCmp !== 0) return albumCmp;
return (a.disc_number || 1) * 1000 + (a.track_number || 0) - ((b.disc_number || 1) * 1000 + (b.track_number || 0));
});
tbody.innerHTML = sorted.map(track => {
const isBelow = track.tier_num > thresholdTier;
const tierInfo = ENHANCE_TIER_MAP[track.tier_name] || ENHANCE_TIER_MAP['unknown'];
const bitrateStr = track.bitrate ? `${track.bitrate} kbps` : '-';
return `
${track.track_number || '-'}
${_esc(track.title)}
${_esc(track.album_title)}
${_esc(track.format)}
${bitrateStr}
`;
}).join('');
updateEnhanceSelectedCount();
}
function updateEnhanceThreshold(tierNum) {
const rows = document.querySelectorAll('.enhance-track-row');
rows.forEach(row => {
const trackTier = parseInt(row.dataset.tier);
const isBelow = trackTier > tierNum;
const cb = row.querySelector('.enhance-track-check');
row.classList.toggle('below-threshold', isBelow);
row.classList.toggle('above-threshold', !isBelow);
if (cb) cb.checked = isBelow;
});
updateEnhanceSelectedCount();
}
function enhanceSelectAll(select) {
const thresholdTier = parseInt(document.getElementById('enhance-tier-dropdown')?.value || '1');
const checks = document.querySelectorAll('.enhance-track-check');
checks.forEach(cb => {
const row = cb.closest('.enhance-track-row');
const trackTier = parseInt(row?.dataset.tier || '999');
if (select) {
cb.checked = trackTier > thresholdTier;
} else {
cb.checked = false;
}
});
updateEnhanceSelectedCount();
}
function updateEnhanceSelectedCount() {
const checks = document.querySelectorAll('.enhance-track-check:checked');
const count = checks.length;
const countEl = document.getElementById('enhance-selected-count');
const submitBtn = document.getElementById('enhance-submit-btn');
if (countEl) countEl.textContent = `${count} selected`;
if (submitBtn) {
submitBtn.textContent = `โก Enhance ${count} Track${count !== 1 ? 's' : ''}`;
submitBtn.disabled = count === 0;
}
}
async function submitEnhanceQuality() {
const checks = document.querySelectorAll('.enhance-track-check:checked');
const trackIds = [];
checks.forEach(cb => {
const row = cb.closest('.enhance-track-row');
if (row?.dataset.trackId) trackIds.push(row.dataset.trackId);
});
if (trackIds.length === 0) return;
const submitBtn = document.getElementById('enhance-submit-btn');
const footerInfo = document.getElementById('enhance-footer-info');
if (submitBtn) {
submitBtn.disabled = true;
submitBtn.innerHTML = ' Processing...';
}
if (footerInfo) footerInfo.textContent = 'Matching tracks to Spotify and adding to wishlist...';
try {
const resp = await fetch(`/api/library/artist/${_enhanceArtistId}/enhance`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ track_ids: trackIds })
});
const result = await resp.json();
if (result.success) {
const msg = `${result.enhanced_count} track${result.enhanced_count !== 1 ? 's' : ''} queued for enhancement`;
if (footerInfo) footerInfo.textContent = msg;
showToast(msg + (result.failed_count > 0 ? ` (${result.failed_count} failed)` : ''), 'success');
// Update button count
const enhBtn = document.getElementById('library-artist-enhance-btn');
if (enhBtn && result.enhanced_count > 0) {
const remaining = trackIds.length - result.enhanced_count;
if (remaining <= 0) {
enhBtn.classList.add('hidden');
}
}
if (submitBtn) {
submitBtn.textContent = 'โ
Done';
submitBtn.disabled = true;
}
// Auto-close after short delay
setTimeout(() => closeEnhanceQualityModal(), 1500);
} else {
throw new Error(result.error || 'Enhancement failed');
}
} catch (e) {
console.error('Enhance quality error:', e);
showToast(`Enhancement failed: ${e.message}`, 'error');
if (submitBtn) {
submitBtn.textContent = `โก Enhance ${trackIds.length} Tracks`;
submitBtn.disabled = false;
}
if (footerInfo) footerInfo.textContent = '';
}
}
function closeEnhanceQualityModal() {
const overlay = document.getElementById('enhance-quality-overlay');
if (overlay) {
overlay.classList.add('hidden');
setTimeout(() => overlay.remove(), 300);
}
}
// Global exports
window.openEnhanceQualityModal = openEnhanceQualityModal;
window.closeEnhanceQualityModal = closeEnhanceQualityModal;
window.updateEnhanceThreshold = updateEnhanceThreshold;
window.enhanceSelectAll = enhanceSelectAll;
window.updateEnhanceSelectedCount = updateEnhanceSelectedCount;
window.submitEnhanceQuality = submitEnhanceQuality;
// ===== END ENHANCE QUALITY MODAL =====