// 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;
// Separate handler for the MBID-paste input — targets lookup-by-MBID
// instead of fuzzy search so Enter does the obvious right thing per field.
let discoveryFixMbidEnterHandler = 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
} else if (platform === 'spotify_public') {
state = youtubePlaylistStates[identifier]; // Spotify public 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
});
// MBID input — separate ref because its Enter binding targets a
// different function (direct lookup vs fuzzy search). Optional: older
// modal markup that doesn't have the MBID row will get null here.
const mbidInput = fixModalOverlay.querySelector('#fix-modal-mbid-input');
if (mbidInput) mbidInput.value = '';
// Remove old enter key handler if exists
if (discoveryFixEnterHandler) {
trackInput.removeEventListener('keypress', discoveryFixEnterHandler);
artistInput.removeEventListener('keypress', discoveryFixEnterHandler);
}
if (discoveryFixMbidEnterHandler && mbidInput) {
mbidInput.removeEventListener('keypress', discoveryFixMbidEnterHandler);
}
// Add new enter key handler
discoveryFixEnterHandler = function (e) {
if (e.key === 'Enter') searchDiscoveryFix();
};
trackInput.addEventListener('keypress', discoveryFixEnterHandler);
artistInput.addEventListener('keypress', discoveryFixEnterHandler);
if (mbidInput) {
discoveryFixMbidEnterHandler = function (e) {
if (e.key === 'Enter') lookupDiscoveryFixByMbid();
};
mbidInput.addEventListener('keypress', discoveryFixMbidEnterHandler);
}
// Show modal BEFORE auto-search so elements are visible
fixModalOverlay.classList.remove('hidden');
console.log('✅ Fix modal opened, starting auto-search...');
// Auto-search with initial values (delay allows modal layout to settle and prevents accidental clicks)
setTimeout(() => searchDiscoveryFix(), 500);
}
/**
* Close discovery fix modal
*/
function closeDiscoveryFixModal() {
if (!currentDiscoveryFix.identifier) {
console.warn('No active fix modal to close');
return;
}
const discoveryModal = document.getElementById(`youtube-discovery-modal-${currentDiscoveryFix.identifier}`);
if (discoveryModal) {
const fixModalOverlay = discoveryModal.querySelector('.discovery-fix-modal-overlay');
if (fixModalOverlay) {
fixModalOverlay.classList.add('hidden');
}
}
currentDiscoveryFix = { platform: null, identifier: null, trackIndex: null, sourceTrack: null, sourceArtist: null };
}
/**
* Search for tracks in the configured metadata source
*/
async function searchDiscoveryFix() {
if (!currentDiscoveryFix.identifier) {
console.error('No active fix modal context');
return;
}
const discoveryModal = document.getElementById(`youtube-discovery-modal-${currentDiscoveryFix.identifier}`);
if (!discoveryModal) {
console.error('Discovery modal not found');
return;
}
const fixModalOverlay = discoveryModal.querySelector('.discovery-fix-modal-overlay');
if (!fixModalOverlay) {
console.error('Fix modal not found');
return;
}
const trackInput = fixModalOverlay.querySelector('#fix-modal-track-input').value.trim();
const artistInput = fixModalOverlay.querySelector('#fix-modal-artist-input').value.trim();
if (!trackInput && !artistInput) {
showToast('Enter track name or artist', 'error');
return;
}
const resultsContainer = fixModalOverlay.querySelector('#fix-modal-results');
// Build search params
const params = new URLSearchParams();
if (trackInput) params.set('track', trackInput);
if (artistInput) params.set('artist', artistInput);
if (!trackInput && !artistInput) {
resultsContainer.innerHTML = '
Enter a track name or artist.
';
return;
}
params.set('limit', '50');
// Use the user's active metadata source first, then fall back to others.
// MusicBrainz is included so users on MB-as-primary get MB queried first,
// and so MB is available as a fallback for fuzzy / niche / non-mainstream
// recordings that Spotify / Deezer / iTunes miss (different catalogues,
// different cover coverage). MB sits last by default because it's
// rate-limited to 1 req/sec — when it's the active primary the activeIdx
// reorder below moves it to the front. Discogs is intentionally absent —
// Discogs has no track-level search API (releases only).
const activeSource = (currentMusicSourceName || 'Spotify').toLowerCase();
const allSources = [
{ key: 'spotify', endpoint: '/api/spotify/search_tracks', label: 'Spotify' },
{ key: 'deezer', endpoint: '/api/deezer/search_tracks', label: 'Deezer' },
{ key: 'itunes', endpoint: '/api/itunes/search_tracks', label: 'iTunes' },
{ key: 'musicbrainz', endpoint: '/api/musicbrainz/search_tracks', label: 'MusicBrainz' },
];
// Put the active source first, keep others as fallbacks
const activeIdx = allSources.findIndex(s => activeSource.includes(s.key));
const searchSources = activeIdx > 0
? [allSources[activeIdx], ...allSources.filter((_, i) => i !== activeIdx)]
: allSources;
resultsContainer.innerHTML = `
🔍 Searching ${searchSources[0].label}...
`;
try {
for (let i = 0; i < searchSources.length; i++) {
const source = searchSources[i];
try {
const response = await fetch(`${source.endpoint}?${params.toString()}`);
const data = await response.json();
if (data.tracks && data.tracks.length > 0) {
renderDiscoveryFixResults(data.tracks, fixModalOverlay);
return;
}
// No results from this source — show next source status if there is one
if (i < searchSources.length - 1) {
resultsContainer.innerHTML = `
';
}
}
/**
* Look up a track directly by MusicBrainz recording MBID — bypasses fuzzy
* search entirely. Escape hatch for cases where the user knows the exact
* record (e.g. there are 10 same-title recordings from different sessions
* and auto-search keeps ranking the wrong one). Accepts full URLs
* (`https://musicbrainz.org/recording/`) or bare UUIDs.
*/
async function lookupDiscoveryFixByMbid() {
if (!currentDiscoveryFix.identifier) {
console.error('No active fix modal context');
return;
}
const discoveryModal = document.getElementById(`youtube-discovery-modal-${currentDiscoveryFix.identifier}`);
if (!discoveryModal) return;
const fixModalOverlay = discoveryModal.querySelector('.discovery-fix-modal-overlay');
if (!fixModalOverlay) return;
const mbidInput = fixModalOverlay.querySelector('#fix-modal-mbid-input');
if (!mbidInput) return;
const mbid = parseMusicBrainzMbid(mbidInput.value);
const resultsContainer = fixModalOverlay.querySelector('#fix-modal-results');
if (!mbid) {
if (resultsContainer) {
resultsContainer.innerHTML = '
❌ Not a valid MusicBrainz recording URL or MBID. Paste a URL like https://musicbrainz.org/recording/<uuid> or the bare UUID.
';
}
try {
const response = await fetch(`/api/musicbrainz/recording/${encodeURIComponent(mbid)}`);
if (response.status === 404) {
if (resultsContainer) {
resultsContainer.innerHTML = '
Recording not found on MusicBrainz. Double-check the MBID.
';
}
return;
}
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const track = await response.json();
if (!track || !track.id) {
if (resultsContainer) {
resultsContainer.innerHTML = '
Recording not found on MusicBrainz.
';
}
return;
}
// Render as a single-result list — user still clicks to confirm,
// matching the existing search-result flow exactly.
renderDiscoveryFixResults([track], fixModalOverlay);
} catch (error) {
console.error('MBID lookup error:', error);
if (resultsContainer) {
resultsContainer.innerHTML = '
`;
resultsContainer.appendChild(card);
});
}
/**
* User selected a track - update discovery state
*/
async function selectDiscoveryFixTrack(track) {
console.log('✅ User selected track:', track);
// Confirm selection to prevent accidental clicks from layout shift
const artists = (track.artists || ['Unknown Artist']).join(', ');
if (!await showConfirmDialog({ title: 'Confirm Match', message: `Match to "${track.name}" by ${artists}?`, confirmText: 'Confirm' })) return;
const { platform, identifier, trackIndex } = currentDiscoveryFix;
console.log('📡 Updating backend match:', { platform, identifier, trackIndex, track });
// Update backend
try {
// Get the correct backend identifier based on platform
let backendIdentifier = identifier;
if (platform === 'tidal') {
// For Tidal, backend expects the actual playlist_id, not url_hash
const state = youtubePlaylistStates[identifier];
backendIdentifier = state?.tidal_playlist_id || identifier;
} else if (platform === 'deezer') {
// For Deezer, backend expects the actual playlist_id, not url_hash
const state = youtubePlaylistStates[identifier];
backendIdentifier = state?.deezer_playlist_id || identifier;
} else if (platform === 'spotify_public') {
// For Spotify Public, backend expects the url_hash
const state = youtubePlaylistStates[identifier];
backendIdentifier = state?.spotify_public_playlist_id || identifier;
} else if (platform === 'itunes_link') {
const state = youtubePlaylistStates[identifier];
backendIdentifier = state?.itunes_link_playlist_id || identifier;
} else if (platform === 'beatport') {
// For Beatport, backend expects url_hash (same as identifier)
backendIdentifier = identifier;
}
// Mirrored playlists route through the YouTube endpoint (which already handles mirrored_ prefixes)
const apiPlatform = platform === 'mirrored' ? 'youtube' : (platform === 'spotify_public' ? 'spotify-public' : (platform === 'itunes_link' ? 'itunes-link' : platform));
const requestBody = {
identifier: backendIdentifier,
track_index: trackIndex,
// #843: send the original (source) track so the backend can still save
// the match to the discovery cache when its in-memory discovery state
// is gone (server restart / imported playlist not discovered this run).
original_name: currentDiscoveryFix.sourceTrack || '',
original_artist: currentDiscoveryFix.sourceArtist || '',
spotify_track: {
id: track.id,
name: track.name,
artists: track.artists,
album: track.album,
duration_ms: track.duration_ms,
image_url: track.image_url || null
}
};
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];
} else if (platform === 'spotify_public') {
state = youtubePlaylistStates[identifier];
} else if (platform === 'itunes_link') {
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
.map(a => (typeof a === 'object' && a !== null) ? (a.name || '') : a)
.filter(Boolean)
.join(', ') || '-'
: (track.artists || '-');
result.spotify_album = track.album;
result.spotify_id = track.id;
result.duration = formatDuration(track.duration_ms);
result.manual_match = true;
// User picked a real metadata match — no longer a wing-it track
result.wing_it_fallback = false;
// IMPORTANT: Also set spotify_data for download/sync compatibility.
// Build album as a dict (not a bare string) so the download
// pipeline can find cover art via album.image_url / album.images.
// This matches the shape that normal discovery produces.
const _fixImageUrl = track.image_url || '';
let _fixAlbumObj;
if (track.album && typeof track.album === 'object') {
_fixAlbumObj = { ...track.album };
if (_fixImageUrl && !_fixAlbumObj.image_url) _fixAlbumObj.image_url = _fixImageUrl;
if (_fixImageUrl && !_fixAlbumObj.images) _fixAlbumObj.images = [{ url: _fixImageUrl }];
} else {
_fixAlbumObj = { name: track.album || '' };
if (_fixImageUrl) {
_fixAlbumObj.image_url = _fixImageUrl;
_fixAlbumObj.images = [{ url: _fixImageUrl }];
}
}
result.spotify_data = {
id: track.id,
name: track.name,
artists: track.artists,
album: _fixAlbumObj,
duration_ms: track.duration_ms,
image_url: _fixImageUrl
};
// Increment match count if this was previously not_found or error
if (wasNotFound) {
state.spotifyMatches = (state.spotifyMatches || 0) + 1;
// Update progress bar and text
const spotify_total = state.spotify_total || state.playlist?.tracks?.length || 0;
const progress = spotify_total > 0 ? Math.round((state.spotifyMatches / spotify_total) * 100) : 0;
const progressBar = document.getElementById(`youtube-discovery-progress-${identifier}`);
const progressText = document.getElementById(`youtube-discovery-progress-text-${identifier}`);
if (progressBar) {
progressBar.style.width = `${progress}%`;
}
if (progressText) {
progressText.textContent = `${state.spotifyMatches} / ${spotify_total} tracks matched (${progress}%)`;
}
console.log(`✅ Updated progress: ${state.spotifyMatches}/${spotify_total} (${progress}%)`);
// Also update the Deezer playlist card if this is a Deezer fix
if (platform === 'deezer' && state.deezer_playlist_id) {
const deezerState = deezerPlaylistStates[state.deezer_playlist_id];
if (deezerState) {
deezerState.spotifyMatches = state.spotifyMatches;
updateDeezerCardProgress(state.deezer_playlist_id, {
spotify_matches: state.spotifyMatches,
spotify_total: spotify_total
});
}
}
// Also update the Tidal playlist card if this is a Tidal fix
if (platform === 'tidal' && state.tidal_playlist_id) {
const tidalState = tidalPlaylistStates?.[state.tidal_playlist_id];
if (tidalState) {
tidalState.spotifyMatches = state.spotifyMatches;
}
}
// Also update the Spotify Public playlist card if this is a Spotify Public fix
if (platform === 'spotify_public' && state.spotify_public_playlist_id) {
const spState = spotifyPublicPlaylistStates?.[state.spotify_public_playlist_id];
if (spState) {
spState.spotifyMatches = state.spotifyMatches;
updateSpotifyPublicCardProgress(state.spotify_public_playlist_id, {
spotify_matches: state.spotifyMatches,
spotify_total: spotify_total
});
}
}
if (platform === 'itunes_link' && state.itunes_link_playlist_id) {
const itunesState = itunesLinkPlaylistStates?.[state.itunes_link_playlist_id];
if (itunesState) {
itunesState.spotifyMatches = state.spotifyMatches;
updateITunesLinkCardProgress(state.itunes_link_playlist_id, {
spotify_matches: state.spotifyMatches,
spotify_total: spotify_total
});
}
}
}
// Update UI - refresh the table row
updateDiscoveryModalSingleRow(platform, identifier, trackIndex);
}
// Close modal
closeDiscoveryFixModal();
} catch (error) {
console.error('Error updating match:', error);
showToast('Failed to update match', 'error');
}
}
/**
* Update a single row in the discovery modal table
*/
function updateDiscoveryModalSingleRow(platform, identifier, trackIndex) {
// Check both state maps - ListenBrainz uses its own, others reuse youtubePlaylistStates
const state = listenbrainzPlaylistStates[identifier] || youtubePlaylistStates[identifier];
// Support both camelCase and snake_case
const results = state?.discoveryResults || state?.discovery_results;
if (!state || !results || !results[trackIndex]) {
console.warn(`Cannot update row: state or result not found`);
return;
}
const result = results[trackIndex];
const row = document.getElementById(`discovery-row-${identifier}-${trackIndex}`);
if (!row) {
console.warn(`Cannot update row: row element not found for ${identifier}-${trackIndex}`);
return;
}
// Update cells
const statusCell = row.querySelector('.discovery-status');
const spotifyTrackCell = row.querySelector('.spotify-track');
const spotifyArtistCell = row.querySelector('.spotify-artist');
const spotifyAlbumCell = row.querySelector('.spotify-album');
const actionsCell = row.querySelector('.discovery-actions');
if (statusCell) {
statusCell.textContent = result.status;
statusCell.className = `discovery-status ${result.status_class}`;
}
if (spotifyTrackCell) spotifyTrackCell.textContent = result.spotify_track || '-';
if (spotifyArtistCell) spotifyArtistCell.textContent = result.spotify_artist || '-';
if (spotifyAlbumCell) spotifyAlbumCell.textContent = result.spotify_album || '-';
// Update action button
if (actionsCell) {
actionsCell.innerHTML = generateDiscoveryActionButton(result, identifier, platform);
}
console.log(`✅ Updated row ${trackIndex} in discovery modal`);
}
async function unmatchDiscoveryTrack(platform, identifier, trackIndex) {
const uiState = (typeof youtubePlaylistStates !== 'undefined' ? youtubePlaylistStates[identifier] : null)
|| (typeof listenbrainzPlaylistStates !== 'undefined' ? listenbrainzPlaylistStates[identifier] : null);
const backendIdentifier = platform === 'spotify_public'
? (uiState?.spotify_public_playlist_id || identifier)
: platform === 'itunes_link'
? (uiState?.itunes_link_playlist_id || identifier)
: identifier;
// Determine the correct API base for this platform
const apiBase = platform === 'tidal' ? '/api/tidal'
: platform === 'deezer' ? '/api/deezer'
: (platform === 'spotify-public' || platform === 'spotify_public') ? '/api/spotify-public'
: (platform === 'itunes-link' || platform === 'itunes_link') ? '/api/itunes-link'
: platform === 'beatport' ? '/api/beatport'
: platform === 'listenbrainz' ? '/api/listenbrainz'
: '/api/youtube';
try {
const response = await fetch(`${apiBase}/discovery/unmatch`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ identifier: backendIdentifier, track_index: trackIndex })
});
const data = await response.json();
if (data.success) {
// Update the row in the discovery modal table
const state = youtubePlaylistStates[identifier]
|| (window.tidalDiscoveryStates && window.tidalDiscoveryStates[identifier])
|| {};
if (state.discovery_results && state.discovery_results[trackIndex]) {
const r = state.discovery_results[trackIndex];
r.status = '❌ Not Found';
r.status_class = 'not-found';
r.spotify_track = '-';
r.spotify_artist = '-';
r.spotify_album = '-';
r.spotify_data = null;
r.matched_data = null;
r.confidence = 0;
r.wing_it_fallback = false;
r.manual_match = false;
}
// Re-render the row — discovery rows use id="discovery-row-{urlHash}-{index}"
const row = document.getElementById(`discovery-row-${identifier}-${trackIndex}`);
if (row) {
const statusCell = row.querySelector('.discovery-status');
if (statusCell) { statusCell.textContent = '❌ Not Found'; statusCell.className = 'discovery-status not-found'; }
const matchedCells = row.querySelectorAll('.spotify-track, .spotify-artist, .spotify-album');
matchedCells.forEach(c => c.textContent = '-');
const actionsCell = row.querySelector('.discovery-actions');
if (actionsCell) {
actionsCell.innerHTML = ``;
}
}
showToast('Match removed', 'success');
} else {
showToast(data.error || 'Failed to remove match', 'error');
}
} catch (e) {
console.error('Unmatch error:', e);
showToast('Failed to remove match', 'error');
}
}
// Make discovery-fix functions available globally for onclick handlers
window.openDiscoveryFixModal = openDiscoveryFixModal;
window.closeDiscoveryFixModal = closeDiscoveryFixModal;
window.searchDiscoveryFix = searchDiscoveryFix;
window.unmatchDiscoveryTrack = unmatchDiscoveryTrack;
window.openMatchingModal = openMatchingModal;
window.closeMatchingModal = closeMatchingModal;
window.selectArtist = selectArtist;
window.selectAlbum = selectAlbum;
/**
* Handle post-download cleanup: clear finished downloads from slskd.
* Scan and database update are now handled by system automations
* (batch_complete → scan_library → library_scan_completed → start_database_update).
*/
async function handlePostDownloadAutomation(playlistId, process) {
try {
const successfulDownloads = getSuccessfulDownloadCount(process);
if (successfulDownloads === 0) {
console.log(`🔄 [AUTO] No successful downloads for ${playlistId} - skipping cleanup`);
return;
}
console.log(`🔄 [AUTO] Post-download cleanup for ${playlistId} (${successfulDownloads} successful downloads)`);
// Clear completed downloads from slskd
try {
const clearResponse = await fetch('/api/downloads/clear-finished', {
method: 'POST',
headers: { 'Content-Type': 'application/json' }
});
if (clearResponse.ok) {
console.log(`✅ [AUTO] Completed downloads cleared`);
} else {
console.warn(`⚠️ [AUTO] Clear downloads failed, continuing anyway`);
}
} catch (error) {
console.warn(`⚠️ [AUTO] Clear error: ${error.message}`);
}
} catch (error) {
console.error(`❌ [AUTO] Error in post-download cleanup: ${error.message}`);
}
}
/**
* Extract successful download count from a download process
*/
function getSuccessfulDownloadCount(process) {
try {
// For processes that have completed, check the modal for completed count
if (process && process.modalElement) {
const statElement = process.modalElement.querySelector('[id*="stat-downloaded-"]');
if (statElement && statElement.textContent) {
const count = parseInt(statElement.textContent, 10);
return isNaN(count) ? 0 : count;
}
}
// Fallback: assume successful if process completed without obvious failure
if (process && process.status === 'complete') {
return 1; // Conservative assumption for single download
}
return 0;
} catch (error) {
console.warn(`⚠️ [AUTO] Error getting successful download count: ${error.message}`);
return 0;
}
}
// ===============================
// ADD TO WISHLIST MODAL FUNCTIONS
// ===============================
let currentWishlistModalData = null;
let wishlistModalVersion = 0;
/**
* Open the Add to Wishlist modal for an album/EP/single
* @param {Object} album - Album object with id, name, image_url, etc.
* @param {Object} artist - Artist object with id, name, image_url
* @param {Array} tracks - Array of track objects
* @param {string} albumType - Type of release (album, EP, single)
*/
async function openAddToWishlistModal(album, artist, tracks, albumType, trackOwnership) {
wishlistModalVersion++;
showLoadingOverlay('Preparing wishlist...');
console.log(`🎵 Opening Add to Wishlist modal for: ${artist.name} - ${album.name}`);
try {
// Store current modal data for use by other functions
currentWishlistModalData = {
album,
artist,
tracks,
albumType
};
const modal = document.getElementById('add-to-wishlist-modal');
const overlay = document.getElementById('add-to-wishlist-modal-overlay');
if (!modal || !overlay) {
console.error('Add to wishlist modal elements not found');
return;
}
// Generate and populate hero section
const heroContent = generateWishlistModalHeroSection(album, artist, tracks, albumType, trackOwnership);
const heroContainer = document.getElementById('add-to-wishlist-modal-hero');
if (heroContainer) {
heroContainer.innerHTML = heroContent;
}
// Generate and populate track list
const trackListHTML = generateWishlistTrackList(tracks, trackOwnership);
const trackListContainer = document.getElementById('wishlist-track-list');
if (trackListContainer) {
trackListContainer.innerHTML = trackListHTML;
}
// Set up the "Add to Wishlist" button click handler
const addToWishlistBtn = document.getElementById('confirm-add-to-wishlist-btn');
if (addToWishlistBtn) {
addToWishlistBtn.onclick = () => handleAddToWishlist();
}
// Show the modal
overlay.classList.remove('hidden');
hideLoadingOverlay();
console.log(`✅ Successfully opened Add to Wishlist modal for: ${album.name}`);
} catch (error) {
console.error('❌ Error opening Add to Wishlist modal:', error);
hideLoadingOverlay();
showToast(`Error opening wishlist modal: ${error.message}`, 'error');
}
}
/**
* Generate the hero section HTML for the wishlist modal
*/
function generateWishlistModalHeroSection(album, artist, tracks, albumType, trackOwnership) {
const artistImage = artist.image_url || '';
const albumImage = album.image_url || '';
const trackCount = tracks.length;
// Calculate missing tracks if ownership info is available
let trackDetailText = `${trackCount} track${trackCount !== 1 ? 's' : ''}`;
if (trackOwnership) {
const ownedCount = Object.values(trackOwnership).filter(v => v === true).length;
const missingCount = trackCount - ownedCount;
if (missingCount > 0 && ownedCount > 0) {
trackDetailText = `${missingCount} of ${trackCount} tracks missing`;
}
}
let heroBackgroundImage = '';
if (albumImage) {
heroBackgroundImage = ``;
}
const heroContent = `
${artistImage ? `` : ''}
${albumImage ? `` : ''}
${escapeHtml(album.name || 'Unknown Album')}
by ${escapeHtml(artist.name || 'Unknown Artist')}
${albumType || 'Album'}${trackDetailText}
`;
return `
${heroBackgroundImage}
${heroContent}
`;
}
/**
* Generate the track list HTML for the wishlist modal
*/
function generateWishlistTrackList(tracks, trackOwnership) {
if (!tracks || tracks.length === 0) {
return '
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
? ''
: '';
// Play button shows for every track. ``playWishlistModalTrack`` ->
// ``playTrackFromLibraryOrStream`` resolves a local file when one
// exists and falls back to the streaming source otherwise, matching
// the same pattern used by the download-missing modals elsewhere.
const playButton = ``;
return `
${trackNumber}
${playButton}
${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;
let skippedCount = 0; // already-in-library tracks the backend declined to add (#825)
// 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 && result.skipped) {
skippedCount++; // already in library — not added (#825)
console.log(`⏭️ "${track.name}" already in library — skipped`);
} else 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 (#825: report already-owned tracks honestly
// instead of counting them as "added").
if (successCount === 0 && skippedCount > 0 && errorCount === 0) {
showToast(`All ${skippedCount} track${skippedCount !== 1 ? 's' : ''} already in your library`, 'success');
} else if (successCount > 0) {
let message = `Added ${successCount} track${successCount !== 1 ? 's' : ''} to wishlist`;
if (skippedCount > 0) message += ` (${skippedCount} already owned)`;
if (errorCount > 0) message += ` — ${errorCount} failed`;
showToast(message, errorCount > 0 ? 'warning' : 'success');
} 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 (release-source discrepancy), also fixes the source card.
*/
async function lazyLoadTrackOwnership(artistName, tracks, sourceCard, albumName = null) {
const myVersion = wishlistModalVersion;
try {
const checkBody = {
artist_name: artistName,
tracks: tracks.map(t => ({ name: t.name, track_number: t.track_number }))
};
if (albumName) checkBody.album_name = albumName;
const resp = await fetch('/api/library/check-tracks', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(checkBody)
});
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');
// Play button is rendered up front for every track now. Upgrade
// the tooltip + click handler once ownership confirms so the
// local-file path is used directly without the resolve-track
// round trip. Falls back to creating a fresh button if the
// initial render somehow skipped it (defensive — should not
// happen post-refactor).
let playBtn = item.querySelector('.wishlist-track-play-btn');
if (!playBtn) {
playBtn = document.createElement('button');
playBtn.className = 'modal-track-play-btn wishlist-track-play-btn';
playBtn.innerHTML = '▶';
item.querySelector('.wishlist-track-number')?.after(playBtn);
}
playBtn.title = 'Play from library';
playBtn.onclick = null;
playBtn.addEventListener('click', event => {
event.stopPropagation();
playWishlistModalTrack(index, trackData);
});
// 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 release-source 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);
}
}
async function playWishlistModalTrack(index, ownershipData = null) {
if (!currentWishlistModalData || !currentWishlistModalData.tracks) {
showToast('Track is no longer available in this modal', 'error');
return;
}
const track = currentWishlistModalData.tracks[index];
if (!track) {
showToast('Track is no longer available in this modal', 'error');
return;
}
const trackData = ownershipData || {};
const playbackTrack = {
...track,
id: trackData.track_id || track.id || null,
title: trackData.title || track.title || track.name,
file_path: trackData.file_path || track.file_path || null,
bitrate: trackData.bitrate || track.bitrate,
_stats_image: currentWishlistModalData.album?.image_url || currentWishlistModalData.album?.images?.[0]?.url || null
};
await playTrackFromLibraryOrStream(
playbackTrack,
trackData.album || currentWishlistModalData.album?.name || currentWishlistModalData.album?.title || '',
getModalTrackArtistName(track, currentWishlistModalData.artist?.name || '')
);
}
/**
* 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 album context if available (for artist album downloads)
// Artist is resolved per-track below — process.artist is only set for album downloads,
// not for playlists, so we must NOT use it as a blanket default.
const processArtist = process.artist || null;
const album = process.album || process.playlist || { name: 'Playlist', id: playlistId };
console.log(`🔄 Adding ${tracks.length} tracks from "${album.name}" to wishlist (process artist: ${processArtist?.name || 'per-track'})`);
// 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;
let skippedCount = 0; // already-in-library tracks the backend declined to add (#825)
// Add each track to wishlist individually
let wingItSkipped = 0;
for (const track of tracks) {
try {
// Skip wing-it fallback tracks — they have no real metadata,
// adding them to wishlist would just retry with raw data
const trackId = track.id || '';
if (String(trackId).startsWith('wing_it_')) {
wingItSkipped++;
console.log(`⏭️ Skipping wing-it track from wishlist: ${track.name}`);
continue;
}
// 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';
}
// Resolve artist: for album downloads, use the album-level artist to keep
// all tracks grouped under one artist in the wishlist. Per-track artists
// (like individual vocalists on a soundtrack) should NOT split the album.
let trackArtist;
if (processArtist && processArtist.name) {
// Album context exists — use album artist to keep tracks grouped
trackArtist = processArtist;
} else if (formattedArtists.length > 0 && formattedArtists[0].name && formattedArtists[0].name !== 'Unknown Artist') {
// No album context (playlist/single) — use track's own artist
trackArtist = formattedArtists[0];
} else {
trackArtist = { name: 'Unknown Artist', id: null };
}
const response = await fetch('/api/add-album-to-wishlist', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
track: formattedTrack,
artist: trackArtist,
album: trackAlbum,
source_type: 'album',
source_context: {
album_name: trackAlbum.name,
artist_name: trackArtist.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 (#825: report already-owned skips honestly).
if (successCount === 0 && skippedCount > 0 && errorCount === 0 && wingItSkipped === 0) {
showToast(`All ${skippedCount} track${skippedCount !== 1 ? 's' : ''} already in your library`, 'success');
await closeDownloadMissingModal(playlistId);
} else if (successCount > 0) {
let message = `Added ${successCount} track${successCount !== 1 ? 's' : ''} to wishlist`;
if (skippedCount > 0) message += ` (${skippedCount} already owned)`;
if (errorCount > 0) message += ` — ${errorCount} failed`;
if (wingItSkipped > 0) message += ` (${wingItSkipped} wing-it skipped)`;
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')}`;
}
// Note: Functions from other modules (downloads.js, sync-spotify.js, sync-services.js, artists.js)
// are already global via their function declarations and do not need window.X = X assignments.
// Add to Wishlist Modal functions (new)
window.openAddToWishlistModal = openAddToWishlistModal;
window.closeAddToWishlistModal = closeAddToWishlistModal;
window.handleAddToWishlist = handleAddToWishlist;
window.handleWishlistDownloadNow = handleWishlistDownloadNow;
window.addModalTracksToWishlist = addModalTracksToWishlist;
// 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 release';
artistStageTitle.textContent = 'Step 1: Select the correct Artist';
document.getElementById('album-selection-stage').style.display = 'block';
} else {
modalTitle.textContent = 'Match track download to release';
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 = `
`;
return;
}
// Cache entries by id for the per-row audit button lookup.
data.entries.forEach(e => {
if (e && e.id != null) _libraryHistoryEntryCache[e.id] = e;
});
list.innerHTML = data.entries.map(renderHistoryEntry).join('');
renderHistoryPagination(data.total, page, limit);
} catch (err) {
console.error('Error loading library history:', err);
list.innerHTML = '
Error loading history
';
}
}
function renderHistoryEntry(entry) {
// Server import thumb_urls are relative paths (e.g. /library/metadata/...) — use placeholder
const hasValidThumb = entry.thumb_url && (entry.thumb_url.startsWith('http://') || entry.thumb_url.startsWith('https://'));
const thumb = hasValidThumb
? ``
: `
${entry.event_type === 'download' ? '📥' : '📚'}
`;
let badge = '';
if (entry.event_type === 'download') {
const parts = [];
if (entry.download_source) parts.push(entry.download_source);
if (entry.quality) parts.push(entry.quality);
badge = parts.map(p => {
const cls = String(p || '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
return `${escapeHtml(p)}`;
}).join('');
} else if (entry.event_type === 'import' && entry.server_source) {
const sourceName = { plex: 'Plex', jellyfin: 'Jellyfin', navidrome: 'Navidrome' }[entry.server_source] || entry.server_source;
badge = `${escapeHtml(sourceName)}`;
}
// AcoustID badge
let acoustidBadge = '';
if (entry.acoustid_result) {
const _aidColors = { pass: '#4caf50', fail: '#ef5350', skip: '#ff9800', disabled: '#666', error: '#ef5350' };
const _aidLabels = { pass: 'Verified', fail: 'Failed', skip: 'Skipped', disabled: 'Off', error: 'Error' };
const color = _aidColors[entry.acoustid_result] || '#666';
const label = _aidLabels[entry.acoustid_result] || entry.acoustid_result;
acoustidBadge = `AcoustID: ${label}`;
}
const meta = [entry.artist_name, entry.album_name].filter(Boolean).join(' — ');
// Source provenance — expected vs downloaded
let sourceDetail = '';
if (entry.event_type === 'download') {
const lines = [];
// Expected line (what we asked for)
if (entry.title || entry.artist_name) {
lines.push(`Expected: ${escapeHtml(entry.title || '?')} by ${escapeHtml(entry.artist_name || '?')}`);
}
// Downloaded line (what the source provided)
const srcTitle = entry.source_track_title || '';
const srcArtist = entry.source_artist || '';
if (srcTitle || srcArtist) {
const isMismatch = (srcTitle && entry.title && srcTitle.toLowerCase() !== entry.title.toLowerCase())
|| (srcArtist && entry.artist_name && srcArtist.toLowerCase() !== entry.artist_name.toLowerCase());
const mismatchClass = isMismatch ? ' lh-prov-mismatch' : '';
lines.push(`Downloaded:${escapeHtml(srcTitle || '?')} by ${escapeHtml(srcArtist || '?')}`);
}
// Source file + ID line
if (entry.source_filename || entry.source_track_id) {
const fileParts = [];
if (entry.source_filename) fileParts.push(`File: ${escapeHtml(entry.source_filename)}`);
if (entry.source_track_id) fileParts.push(`${entry.source_filename ? '' : 'Source '}ID: ${escapeHtml(entry.source_track_id)}`);
lines.push(fileParts.join(` · `));
}
if (lines.length > 0) {
sourceDetail = `
${lines.join(' ')}
`;
}
}
const hasDetails = sourceDetail || acoustidBadge;
const expandIndicator = hasDetails ? `▾` : '';
// Audit button — only for download events (import rows have no
// source/match/verify decisions to trace). stopPropagation keeps
// the click from triggering the row-expand toggle. Inline onclick
// dispatches through `openDownloadAuditModalById` (a function so
// the script-split-integrity test sees it) rather than touching
// the cache directly.
const auditBtn = entry.event_type === 'download' && entry.id != null
? ``
: '';
return `
${thumb}
${escapeHtml(entry.title || 'Unknown')}
${escapeHtml(meta)}
${badge}
${formatHistoryTime(entry.created_at)}
${auditBtn}
${expandIndicator}
${hasDetails ? `
${sourceDetail}
${acoustidBadge ? `
${acoustidBadge}
` : ''}
` : ''}
`;
}
function formatHistoryTime(isoStr) {
if (!isoStr) return '';
try {
// SQLite CURRENT_TIMESTAMP is UTC but lacks timezone marker — append Z
let normalized = isoStr;
if (/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}/.test(normalized) && !normalized.includes('Z') && !normalized.includes('+')) {
normalized = normalized.replace(' ', 'T') + 'Z';
}
const date = new Date(normalized);
const now = new Date();
const diffMs = now - date;
const diffMins = Math.floor(diffMs / 60000);
if (diffMins < 1) return 'Just now';
if (diffMins < 60) return `${diffMins}m ago`;
const diffHours = Math.floor(diffMins / 60);
if (diffHours < 24) return `${diffHours}h ago`;
const diffDays = Math.floor(diffHours / 24);
if (diffDays < 7) return `${diffDays}d ago`;
return date.toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
} catch { return ''; }
}
// ── Download Audit Trail Modal ──────────────────────────────────────
//
// First-pass implementation per spec: builds a "summary audit" from
// fields already on the library_history row. Steps the backend doesn't
// yet capture (per-source plan, per-candidate scoring, post-processing
// substeps) render as 'Not captured yet' rather than fabricating
// details — preserves trust until the backend event recorder lands.
let _downloadAuditEntry = null;
let _downloadAuditActiveTab = 'lifecycle';
let _downloadAuditActiveStep = 'request';
function openDownloadAuditModalById(historyId) {
const entry = _libraryHistoryEntryCache[historyId];
if (!entry) return;
openDownloadAuditModal(entry);
}
function openDownloadAuditModal(entry) {
if (!entry) return;
_downloadAuditEntry = entry;
_downloadAuditActiveTab = 'lifecycle';
_downloadAuditActiveStep = 'request';
const overlay = document.getElementById('download-audit-overlay');
const hero = document.getElementById('download-audit-hero');
const tabs = document.getElementById('download-audit-tabs');
const body = document.getElementById('download-audit-body');
if (!overlay || !hero || !tabs || !body) return;
hero.innerHTML = renderDownloadAuditHero(entry);
tabs.innerHTML = renderDownloadAuditTabs(_downloadAuditActiveTab);
body.innerHTML = renderDownloadAuditTabPanel(_downloadAuditActiveTab, entry);
overlay.classList.remove('hidden');
// Live tag read for the Tags tab. Fire-and-forget on open so the
// data is ready by the time the user switches to the Tags tab.
// Synthetic entries (quarantine review queue) have no history id but
// carry their own tags URL in _file_tags_url.
if (entry.id != null || entry._file_tags_url) {
fetchAndRenderEmbeddedTags(entry.id, entry._file_tags_url);
}
}
function switchAuditTab(tabName) {
if (!_downloadAuditEntry) return;
if (!['lifecycle', 'tags', 'lyrics'].includes(tabName)) return;
_downloadAuditActiveTab = tabName;
const tabs = document.getElementById('download-audit-tabs');
const body = document.getElementById('download-audit-body');
if (tabs) tabs.innerHTML = renderDownloadAuditTabs(tabName);
if (body) body.innerHTML = renderDownloadAuditTabPanel(tabName, _downloadAuditEntry);
// Re-fire the live fetch when switching to Tags (in case it
// raced past first mount before the user got there).
if ((tabName === 'tags' || tabName === 'lyrics') &&
(_downloadAuditEntry.id != null || _downloadAuditEntry._file_tags_url)) {
fetchAndRenderEmbeddedTags(_downloadAuditEntry.id, _downloadAuditEntry._file_tags_url);
}
}
window.switchAuditTab = switchAuditTab;
function selectAuditStep(stepKey) {
if (!_downloadAuditEntry) return;
_downloadAuditActiveStep = stepKey;
const body = document.getElementById('download-audit-body');
if (body) body.innerHTML = renderDownloadAuditTabPanel('lifecycle', _downloadAuditEntry);
}
window.selectAuditStep = selectAuditStep;
function closeDownloadAuditModal() {
const overlay = document.getElementById('download-audit-overlay');
if (overlay) overlay.classList.add('hidden');
_downloadAuditEntry = null;
}
function renderDownloadAuditHero(entry) {
const title = entry.title || 'Unknown Track';
const artist = entry.artist_name || 'Unknown Artist';
const album = entry.album_name || '';
const year = (entry.created_at || '').slice(0, 4); // fallback to history year
// Album-art thumb from row when present; placeholder otherwise.
const hasValidThumb = entry.thumb_url && (entry.thumb_url.startsWith('http://') || entry.thumb_url.startsWith('https://'));
const art = hasValidThumb
? ``
: `
♪
`;
const metaParts = [];
if (artist) metaParts.push(escapeHtml(artist));
if (album) metaParts.push(escapeHtml(album));
if (year && /^\d{4}$/.test(year)) metaParts.push(escapeHtml(year));
const meta = metaParts.join(' · ');
const pills = [];
if (entry.download_source) pills.push(_auditHeroPill('source', entry.download_source));
if (entry.quality) pills.push(_auditHeroPill('quality', entry.quality));
const _vsLabels = { verified: 'Verified', unverified: 'Unverified', force_imported: 'Force-imported', human_verified: 'Human verified' };
if (entry.verification_status && _vsLabels[entry.verification_status]) {
pills.push(_auditHeroPill('verify', _vsLabels[entry.verification_status], entry.verification_status));
} else if (entry._quarantined) {
pills.push(_auditHeroPill('verify', 'Quarantined', 'fail'));
} else if (entry.acoustid_result) {
pills.push(_auditHeroPill('verify', formatAcoustidLabel(entry.acoustid_result), entry.acoustid_result));
}
return `
${art}
Final path${escapeHtml(entry.file_path || 'Not captured yet')}
`;
}
function _auditStepShortLabel(key) {
return ({
request: 'Request',
source: 'Source',
match: 'Match',
verify: 'Verify',
process: 'Process',
transfer: 'Place',
})[key] || key;
}
function renderDownloadAuditTagsTab(entry) {
// Live tags get filled in by fetchAndRenderEmbeddedTags via the
// slot id below. Until the fetch resolves, show a loading state
// so the panel isn't blank.
return `
Reading from file…
`;
}
function renderDownloadAuditLyricsTab(entry) {
return `
Reading from file…
`;
}
function renderEmbeddedTagsSection(entry) {
// Legacy entry point retained for any other caller — now unused
// by the modal directly (Tags tab owns the live render).
return renderDownloadAuditTagsTab(entry);
}
async function fetchAndRenderEmbeddedTags(historyId, urlOverride) {
const tagsSlot = document.getElementById('download-audit-tags-body');
const lyricsSlot = document.getElementById('download-audit-lyrics-body');
if (!tagsSlot && !lyricsSlot) return;
const renderError = (msg) => {
const html = `
${escapeHtml(msg)}
`;
if (tagsSlot) tagsSlot.innerHTML = html;
if (lyricsSlot) lyricsSlot.innerHTML = html;
};
try {
const resp = await fetch(urlOverride || `/api/library/history/${historyId}/file-tags`);
if (!resp.ok) { renderError(`Could not read file tags (HTTP ${resp.status}).`); return; }
const data = await resp.json();
if (!data.success) { renderError(data.error || 'Could not read file tags.'); return; }
if (data.available === false) { renderError(data.reason || 'File tags not available.'); return; }
if (tagsSlot) tagsSlot.innerHTML = _renderEmbeddedTagsGrid(data);
if (lyricsSlot) lyricsSlot.innerHTML = _renderLyricsBody(data);
} catch (e) {
renderError(`Could not read file tags: ${String(e)}`);
}
}
function _renderLyricsBody(data) {
const tags = data.tags || {};
const text = (tags.lyrics || tags.unsyncedlyrics || '').toString();
if (!text.trim()) {
return `
No lyrics embedded in this file.
`;
}
// Highlight the timecodes at the start of each line in dim color
// so the body reads like a clean transcript. Pure CSS via a span
// wrapper around each match.
const html = escapeHtml(text)
.replace(/^(\[\d{1,2}:\d{2}(?:\.\d{1,3})?\])/gm, '$1')
.replace(/\n/g, ' ');
return `
${html}
`;
}
// Friendly labels for tag keys (lowercased canonical key →
// human-readable). Keys not in the map render with title-case
// fallback applied at render time.
const _AUDIT_TAG_LABELS = {
title: 'Title',
artist: 'Artist',
artists: 'All Artists',
albumartist: 'Album Artist',
album_artist: 'Album Artist',
album: 'Album',
date: 'Date',
year: 'Year',
originaldate: 'Original Date',
genre: 'Genre',
mood: 'Mood',
style: 'Style',
tracknumber: 'Track #',
tracktotal: 'Total Tracks',
discnumber: 'Disc #',
totaldiscs: 'Total Discs',
bpm: 'BPM',
isrc: 'ISRC',
barcode: 'Barcode',
catalognumber: 'Catalog #',
asin: 'ASIN',
copyright: 'Copyright',
publisher: 'Publisher',
language: 'Language',
script: 'Script',
media: 'Media',
releasetype: 'Release Type',
releasestatus: 'Release Status',
releasecountry: 'Country',
composer: 'Composer',
performer: 'Performer',
quality: 'Quality',
replaygain_track_gain: 'Track Gain',
replaygain_track_peak: 'Track Peak',
replaygain_album_gain: 'Album Gain',
replaygain_album_peak: 'Album Peak',
};
// Source-ID services. Each service has a key-prefix matcher and a
// per-key label map. Order matters for display (MusicBrainz first
// since it's the canonical identity layer).
const _AUDIT_SOURCE_SERVICES = [
{
name: 'MusicBrainz',
prefix: 'musicbrainz_',
labels: {
musicbrainz_trackid: 'Track',
musicbrainz_releasetrackid: 'Recording',
musicbrainz_albumid: 'Album',
musicbrainz_artistid: 'Artist',
musicbrainz_albumartistid: 'Album Artist',
musicbrainz_releasegroupid: 'Release Group',
},
},
{ name: 'Spotify', prefix: 'spotify_', labels: { spotify_track_id: 'Track', spotify_artist_id: 'Artist', spotify_album_id: 'Album' } },
{ name: 'Tidal', prefix: 'tidal_', labels: { tidal_track_id: 'Track', tidal_artist_id: 'Artist', tidal_album_id: 'Album' } },
{ name: 'Deezer', prefix: 'deezer_', labels: { deezer_track_id: 'Track', deezer_artist_id: 'Artist', deezer_album_id: 'Album' } },
{ name: 'AudioDB', prefix: 'audiodb_', labels: { audiodb_track_id: 'Track', audiodb_artist_id: 'Artist', audiodb_album_id: 'Album' } },
{ name: 'iTunes', prefix: 'itunes_', labels: { itunes_track_id: 'Track', itunes_artist_id: 'Artist', itunes_album_id: 'Album' } },
{ name: 'Genius', prefix: 'genius_', labels: { genius_track_id: 'Track', genius_url: 'URL' } },
{ name: 'Last.fm', prefix: 'lastfm_', labels: { lastfm_url: 'URL' } },
{ name: 'Beatport', prefix: 'beatport_', labels: { beatport_track_id: 'Track' } },
];
function _auditFriendlyLabel(key, fallbackToTitleCase = true) {
if (_AUDIT_TAG_LABELS[key]) return _AUDIT_TAG_LABELS[key];
if (!fallbackToTitleCase) return key;
// Snake-case → Title Case
return key.split('_').map(w => w.charAt(0).toUpperCase() + w.slice(1)).join(' ');
}
function _renderEmbeddedTagsGrid(data) {
const tags = data.tags || {};
const fmt = data.format || '';
const bitrate = data.bitrate || 0;
const duration = data.duration || 0;
const hasPicture = data.has_picture === true;
// Categorize keys. Anything not matched and not a Source ID
// falls under "Other" — but we drop "Other" entirely when its
// only contents are keys already shown elsewhere (like `quality`,
// which is already a chip on the modal status strip).
const TRACK_KEYS = ['title', 'artist', 'artists', 'tracknumber', 'tracktotal', 'discnumber', 'totaldiscs', 'bpm', 'isrc'];
const ALBUM_KEYS = ['album', 'album_artist', 'albumartist', 'date', 'year', 'originaldate', 'genre', 'mood', 'style', 'copyright', 'publisher', 'language', 'script', 'media', 'releasetype', 'releasestatus', 'releasecountry', 'barcode', 'catalognumber', 'asin'];
const REPLAYGAIN_KEYS = ['replaygain_track_gain', 'replaygain_track_peak', 'replaygain_album_gain', 'replaygain_album_peak'];
const LYRICS_KEYS = ['lyrics', 'unsyncedlyrics'];
// Duplicates of top-bar chips that should NOT appear in the
// "Other" bucket (already visible elsewhere in the modal).
const DUPLICATE_KEYS = new Set(['quality']);
const isSourceKey = (k) => /(_id|_url)$/.test(k) || k.startsWith('musicbrainz_');
const buckets = { track: [], album: [], source: {}, replaygain: [], lyrics: [], other: [] };
Object.keys(tags).sort().forEach(key => {
const val = tags[key];
if (!val) return;
if (DUPLICATE_KEYS.has(key)) return;
if (TRACK_KEYS.includes(key)) buckets.track.push([key, val]);
else if (ALBUM_KEYS.includes(key)) buckets.album.push([key, val]);
else if (REPLAYGAIN_KEYS.includes(key)) buckets.replaygain.push([key, val]);
else if (LYRICS_KEYS.includes(key)) buckets.lyrics.push([key, val]);
else if (isSourceKey(key)) {
// Slot into the matching service. Unmatched IDs land in
// an "Other Sources" bucket.
const svc = _AUDIT_SOURCE_SERVICES.find(s => key.startsWith(s.prefix));
const slot = svc ? svc.name : 'Other Sources';
if (!buckets.source[slot]) buckets.source[slot] = [];
buckets.source[slot].push([key, val, svc]);
} else {
buckets.other.push([key, val]);
}
});
// Horizontal file-info chip strip at the top.
const fileChips = [];
if (fmt) fileChips.push(`${escapeHtml(fmt)}`);
if (bitrate) fileChips.push(`${Math.round(bitrate / 1000)} kbps`);
if (duration > 0) fileChips.push(`${_formatDuration(duration)}`);
fileChips.push(`Cover ${hasPicture ? '✓' : '—'}`);
const fileStrip = `
${fileChips.join('')}
`;
// Pretty key-value rows using friendly labels.
const renderRow = ([key, val], labelOverride) => _auditTagRow(
labelOverride || _auditFriendlyLabel(key),
val,
);
const sections = [];
if (buckets.track.length > 0) {
sections.push(_renderTagGroup('Track', buckets.track.map(p => renderRow(p)).join('')));
}
if (buckets.album.length > 0) {
sections.push(_renderTagGroup('Album', buckets.album.map(p => renderRow(p)).join('')));
}
if (buckets.replaygain.length > 0) {
sections.push(_renderTagGroup('ReplayGain', buckets.replaygain.map(p => renderRow(p)).join('')));
}
if (buckets.other.length > 0) {
sections.push(_renderTagGroup('Other', buckets.other.map(p => renderRow(p)).join('')));
}
// Source IDs section — sub-card per service so the user can
// scan instead of reading a 14-row dump.
let sourcesBlock = '';
const sourceServiceNames = Object.keys(buckets.source);
if (sourceServiceNames.length > 0) {
const orderedNames = _AUDIT_SOURCE_SERVICES
.map(s => s.name)
.filter(n => buckets.source[n])
.concat(sourceServiceNames.filter(n => !_AUDIT_SOURCE_SERVICES.find(s => s.name === n)));
const subCards = orderedNames.map(name => {
const rows = buckets.source[name].map(([key, val, svc]) => {
const label = svc?.labels[key] || _auditFriendlyLabel(key.replace(/^[a-z]+_/, ''));
return _auditTagRow(label, val);
});
return `
${escapeHtml(name)}
${rows.join('')}
`;
});
sourcesBlock = `
Source IDs
${subCards.join('')}
`;
}
// Lyrics live in their own tab now — don't render them inline.
if (sections.length === 0 && !sourcesBlock && fileChips.length === 0) {
return `
No tags were found on this file.
`;
}
return `${fileStrip}
${sections.join('')}
${sourcesBlock}`;
}
function _renderTagGroup(title, rowsHtml) {
return `
${escapeHtml(title)}
${rowsHtml}
`;
}
function _formatDuration(seconds) {
const s = Math.round(seconds || 0);
const m = Math.floor(s / 60);
const rem = s % 60;
return `${m}:${rem.toString().padStart(2, '0')}`;
}
function _auditTagRow(label, value) {
return `
${escapeHtml(label)}${escapeHtml(String(value))}
`;
}
function renderAuditChip(label) {
return `${escapeHtml(label)}`;
}
function renderAuditStep(step, index, total) {
const status = step.status || 'unknown';
const detail = step.detail ? `
The Metadata Updater triggers all enrichment workers simultaneously, re-checking every item in your library against all connected services (Spotify, MusicBrainz, iTunes, Deezer, AudioDB, Last.fm, Genius, Tidal, Qobuz).
Refresh Interval Options
6 months: Only updates metadata for artists not updated in the last 180 days
3 months: Updates metadata for artists not updated in the last 90 days
1 month: Updates metadata for artists not updated in the last 30 days
Force All: Updates all artists regardless of when they were last updated
What gets updated?
Artist profile photos, genres, and descriptions
Album cover artwork, labels, and release info
Track ISRCs, explicit flags, and external IDs
Service match status for all 9 enrichment workers
Note
Available for Plex and Jellyfin media servers. Each enrichment worker only runs if its service is authenticated.
The Duplicate Cleaner scans your output folder for duplicate audio files and automatically removes lower-quality versions, keeping only the best copy.
How it detects duplicates
Files are considered duplicates when:
They are in the same folder
They have the exact same filename (ignoring file extension)
Example: Song.flac and Song.mp3 in the same folder = duplicates ✓
Example: Song.flac and Song (Remaster).flac = NOT duplicates ✗
Which file is kept?
Priority order (best to worst):
Format priority: FLAC/Lossless > OPUS/OGG > M4A/AAC > MP3/WMA
If same format: Larger file size is kept (usually indicates better bitrate)
Where do deleted files go?
Removed files are moved to Transfer/deleted/ folder (not permanently deleted). You can review and recover them if needed.
Safety Features
Only processes audio files (FLAC, MP3, M4A, etc.)
Only removes files with identical names in the same folder
Files are moved, not deleted - fully recoverable
Preserves original folder structure in the deleted folder
Stats Explained
Files Scanned: Total audio files checked
Duplicates Found: Number of duplicate files detected
Deleted: Files moved to deleted folder
Space Freed: Total disk space reclaimed
`
},
'media-scan': {
title: 'Media Server Scan',
content: `
What does this tool do?
The Media Server Scan tool manually triggers a Plex media library scan to detect newly downloaded music files.
When to use it?
After downloading new tracks to refresh your Plex library
When new music isn't showing up in Plex
To force an immediate library update instead of waiting for auto-scan
What happens when you scan?
Plex library scan: Plex scans your music folder for new/changed files
Automatic database update: After the scan completes, SoulSync automatically updates its internal database with new tracks
Library refreshed: New music appears in Plex and SoulSync within moments
Plex only?
Yes! This tool only appears when Plex is your active media server because:
Jellyfin automatically detects new files instantly (real-time monitoring)
Navidrome automatically detects new files instantly (real-time monitoring)
Plex requires manual scans or has delayed auto-scanning
Stats Explained
Last Scan: Time of the most recent scan request
Status: Current scan state (Idle, Scanning, Error)
Scan workflow
This tool replicates the same scan process that runs automatically after completing a download modal - ensuring your new tracks are immediately available in your library!
The Discover page is your personalized music discovery hub. It uses your watchlist, library listening history, and MusicMap to surface new music, create curated playlists, and organize your collection in dynamic ways.
🎯 Hero Section (Featured Artists)
The rotating hero showcases similar artists discovered via MusicMap. These are artists you don't already have in your library but might enjoy based on your watchlist.
Auto-rotates every 8 seconds through 10 featured artists
Similar artists sourced from MusicMap and matched to Spotify
Click arrows to navigate manually
Add artists to watchlist or view their full discography
Data refreshed when watchlist scanner runs
📀 Recent Releases
New albums from artists you're watching and their MusicMap similar artists. Cached from Spotify and updated during watchlist scans.
Shows up to 20 recent albums
Click any album to view tracks and add to wishlist
Automatically filtered to show albums released in the last 90 days
Includes both watchlist artists and similar artists from MusicMap
🍂 Seasonal Content (Auto-detected)
Seasonal albums and playlists that appear automatically based on the current season (Winter, Spring, Summer, Fall).
Seasonal Albums: Albums matching the current season's vibe
Seasonal Playlist: Curated playlist that refreshes with each season
Only visible during the matching season
Can download and sync to your media server
🎵 Fresh Tape (Release Radar)
Curated playlist of brand new releases from your discovery pool. Focuses on tracks released in the past 30 days.
50 tracks, refreshed weekly by watchlist scanner
Stays consistent until next update (not random)
Download missing tracks or sync to media server
Tracks from watchlist artists and MusicMap similar artists
📚 The Archives (Discovery Weekly)
Curated playlist from your entire discovery pool - a mix of new and catalog tracks from MusicMap discoveries.
50 tracks, refreshed weekly by watchlist scanner
Stays consistent until next update (not random)
Broader selection than Fresh Tape (includes older releases)
Download missing tracks or sync to media server
📊 Personalized Library Playlists
Playlists generated from your existing library using listening statistics:
Recently Added: Latest 50 tracks added to your library
Your Top 50: All-time most played tracks (requires play count data)
Forgotten Favorites: Tracks you loved but haven't played recently
🎲 Discovery Pool Playlists
Playlists generated from your discovery pool (tracks from watchlist/similar artists you don't own yet):
Popular Picks: High-popularity tracks (Spotify popularity 70+)
Fires when the watchlist scanner detects new music from an artist you're watching. This means a new album, EP, or single has been released that you don't already have.
Conditions
Artist: Only fire for specific watched artists
Available variables for notifications
{artist}, {new_tracks}, {added_to_wishlist}
Good for
Getting notified when your favorite artists drop new music
Auto-processing the wishlist immediately after new releases are found
Fires after a mirrored playlist is synced to your media server (Plex, Jellyfin, or Navidrome). This means the playlist has been matched and created/updated on your server.
Fires when Spotify/iTunes metadata discovery finishes for a mirrored playlist. Discovery is the process of matching playlist tracks to official Spotify or iTunes metadata.
Fires when the library database refresh finishes — either incremental or full. This means SoulSync's internal database has been synced with your media server.
Available variables for notifications
{total_artists}, {total_albums}, {total_tracks}
Good for
Running a quality scan after the database is refreshed
Fires when a downloaded file fails AcoustID verification and is moved to the quarantine folder. This means the audio fingerprint didn't match what was expected — the file might be the wrong song.
Conditions
Artist: Only fire for specific artists
Title: Match on track title
Available variables for notifications
{artist}, {title}, {reason}
What is quarantine?
Files that fail audio fingerprint verification are moved to a quarantine folder instead of your library. This prevents wrong songs from polluting your collection. You can review quarantined files manually.
Fires when the quality scanner finishes. The scanner identifies tracks below your quality preferences and adds them to your wishlist for re-downloading.
Fires when a media server library scan is considered complete. This only happens after a Scan Library action was triggered — it cannot fire on its own.
How does it know the scan is done?
Your media server (Plex, Jellyfin, Navidrome) doesn't send a "scan finished" signal back to SoulSync. So after telling the server to scan, SoulSync waits approximately 5 minutes and then assumes the scan has finished. This is a generous estimate that works for most libraries.
Timing
From the moment a download finishes to when this trigger fires, expect roughly 6-7 minutes:
60 second debounce wait (groups multiple downloads together)
Media server scan triggered
~5 minute wait (assumed scan completion)
This event fires
Default use
The system automation Auto-Update Database After Scan listens for this trigger to start an incremental database update, keeping your SoulSync library in sync with your media server.
Available variables
{server_type} — which media server was scanned (plex, jellyfin, navidrome)
Searches Soulseek for tracks in your wishlist and downloads them. This is the same process that runs automatically on a timer — this action lets you trigger it manually or chain it to events.
Configuration
Category: Process all wishlist tracks, or only Albums/EPs, or only Singles
How it works
Picks tracks from the wishlist (alternating Albums and Singles cycles)
Tells your media server (Plex, Jellyfin, or Navidrome) to scan its music library folder for new or changed files. This makes newly downloaded music appear in your media server.
How it works
A 60 second debounce groups rapid requests — if multiple downloads finish close together, only one scan is triggered
After the debounce, your media server is told to scan
SoulSync waits ~5 minutes (your media server doesn't report when it's finished, so this is an assumed completion time)
The Library Scan Done event fires, which can trigger follow-up actions like a database update
Default use
The system automation Auto-Scan After Downloads uses this action to automatically scan your library when a batch download completes. You can disable that automation if you prefer to scan manually.
Note
Jellyfin and Navidrome often detect new files automatically, but the scan ensures nothing is missed.
Syncs a mirrored playlist to your media server. It matches discovered tracks against your library and creates or updates the playlist on Plex, Jellyfin, or Navidrome.
Configuration
Playlist: Select which mirrored playlist to sync
Prerequisites
Tracks should be discovered first (matched to Spotify/iTunes metadata) before syncing. Undiscovered tracks will be skipped.
Finds official Spotify or iTunes metadata for tracks in a mirrored playlist. This is required before syncing — it matches each track to a known release so it can be found in your library.
Configuration
Playlist: Select a specific playlist, or check "Discover all" to process all mirrored playlists
How it works
Takes each track name and artist from the mirror
Searches Spotify (or iTunes as fallback) for a match
Stores the best match with confidence score in the discovery cache
Already-discovered tracks are skipped for efficiency
Runs the full playlist lifecycle in one automation — no signal wiring needed. Executes four phases sequentially:
Refresh — Re-fetches playlist tracks from the source platform (Spotify, Tidal, YouTube, Deezer)
Discover — Matches each track to official metadata (Spotify/iTunes/Deezer IDs)
Sync — Pushes the playlist to your media server (Plex, Jellyfin, Navidrome)
Download Missing — Queues unmatched tracks to the wishlist for automatic download
Configuration
Playlist: Select a specific mirrored playlist, or check "Process all" to run the pipeline for every mirrored playlist
Skip wishlist: Check this to skip the download phase (useful if you only want to sync, not download)
How the re-sync loop works
Set this on a schedule (e.g., every 6 hours). Between runs, the wishlist processor downloads missing tracks in the background. On the next pipeline run, those newly downloaded tracks will match during the sync phase — so your server playlist gets more complete with each cycle until fully synced.
Replaces
This single automation replaces the 4-automation signal chain pattern (Refresh → signal → Discover → signal → Sync → signal → Download). No signals, no chaining, no room for misconfiguration.
Scans your output folder for duplicate audio files (same filename, different format) and removes the lower-quality version. For example, if you have both Song.flac and Song.mp3, the MP3 is removed.
Safety
Removed files are moved to a deleted/ subfolder, not permanently deleted. You can recover them if needed.
Permanently deletes all files in the quarantine folder. Quarantined files are downloads that failed AcoustID audio fingerprint verification — they might be the wrong song.
Warning
This permanently deletes files. Make sure you've reviewed quarantined files before setting up an automation for this.
`
},
'auto-cleanup_wishlist': {
title: 'Clean Up Wishlist',
content: `
What does this action do?
Removes duplicate entries and tracks you already own from your wishlist. Keeps the wishlist lean by removing items that no longer need downloading.
Refreshes the discovery pool with new tracks from your mirrored playlists. The discovery pool tracks which playlist tracks have been successfully matched and which ones failed.
Scans your library for tracks that don't meet your quality preferences (e.g., MP3 when you prefer FLAC). Low-quality tracks are matched to Spotify and added to your wishlist for re-downloading in better quality.
Configuration
Scope: Scan only watchlist artists (faster) or your entire library (thorough)
Scrapes the Beatport homepage for top charts and caches the results locally. Keeps the Beatport charts page loading instantly without needing to scrape on every visit.
Cache duration
Cache lasts 24 hours. This action refreshes it early so it's always warm when you visit the charts page.
Good for
Keeping Beatport charts available instantly
Scheduling daily cache refreshes (e.g. every morning)
Clear Quarantine — permanently deletes all quarantined files
Clear Download Queue — removes completed, errored, and cancelled downloads from Soulseek
Sweep Empty Directories — removes empty folders left behind in the input directory
Sweep Import Folder — removes empty directories from the import folder
Clean Search History — trims old Soulseek search queries
Safety
Skips download queue cleanup if batches are actively downloading or post-processing. Each step runs independently — a failure in one step won't stop the others.
Good for
Scheduled housekeeping every 12 hours
Keeping disk usage and queue clutter under control
Walks your entire media server library and compares it against SoulSync's database. Adds any new tracks found and removes stale entries that no longer exist on the server.
How is this different from Database Update?
Database Update: Incremental — only looks for new artists/albums added since last update
Deep Scan: Full comparison — checks every track on the server against the database, catches anything missed
Safety
Never overwrites existing enrichment data (genres, Spotify IDs, artwork)
Only inserts tracks that don't already exist in the database
Stale track removal has a 50% safety threshold — if more than half the library appears missing, removal is skipped
Sends an HTTP POST request with a JSON payload to any URL when the automation's action completes. Use it to integrate with Gotify, Home Assistant, Slack, n8n, or any service that accepts webhooks.
Configuration
URL: The endpoint to POST to (e.g. https://gotify.example.com/message?token=xxx)
Headers: Optional custom headers, one per line in Key: Value format. Useful for auth tokens.
Custom Message: Optional message with variable placeholders. Added as a "message" field in the JSON payload.
JSON payload
The POST body always includes all event variables as JSON fields:
{"time": "2026-04-02 ...", "name": "My Automation", "status": "success", ...}
Available variables
Use these in your message or header values:
{time} — When the automation ran
{name} — Automation name
{run_count} — How many times this automation has run
{status} — Result status of the action
`
},
// ==================== Signal System Help ====================
'auto-signal_received': {
title: 'Signal Received',
content: `
What is this trigger?
Fires when another automation sends a named signal using the Fire Signal then-action. This lets you chain automations together — one automation finishes and wakes up another.
Configuration
Signal Name: The name to listen for (e.g. library_ready, scan_done). Must match the name used in the Fire Signal action.
How chaining works
Automation A: Trigger = Batch Complete, Action = Scan Library, Then = Fire Signal "scan_done"
Automation B: Trigger = Signal Received "scan_done", Action = Update Database
When a download finishes → A scans library → fires signal → B wakes up → updates database
Safety
Circular signal chains are detected and blocked when you save
Maximum chain depth of 5 levels to prevent runaway cascades
Same signal can only fire once every 10 seconds (cooldown)
Signal names
Use descriptive lowercase names with underscores: library_ready, scan_complete, downloads_done. Existing signal names from other automations appear as suggestions.
Fires a named signal after the automation's action completes. Any other automation with a Signal Received trigger listening for this signal name will wake up and run.
Configuration
Signal Name: The signal to fire (e.g. library_ready). Use the same name in a Signal Received trigger on another automation to connect them.
Use cases
Multi-step workflows: Scan library → fire signal → update database → fire signal → send notification
Fan-out: One signal can trigger multiple automations simultaneously
Decoupled logic: Keep each automation simple with one job, chain them via signals
Combining with notifications
You can add up to 3 then-actions per automation. For example: Fire Signal + Discord notification + Telegram notification — all run after the action completes.
The Backup Manager lets you create, view, download, restore, and delete database backups directly from the dashboard.
Features
Backup Now: Create an instant backup of the current database using SQLite's hot-copy API
Download: Download any backup file to your local machine
Restore: Roll back the database to a previous backup state
Delete: Remove old backups you no longer need
Auto-Backups
SoulSync automatically creates a backup every 3 days via the automation engine. Up to 5 rolling backups are kept (oldest are removed when the limit is exceeded).
Restore Safety
When you restore from a backup, a safety backup of your current database is created first. This means you can always undo a restore if something goes wrong.
Stats Explained
Last Backup: When the most recent backup was created
The Metadata Cache stores every API response from Spotify and iTunes so SoulSync can reuse them instead of making duplicate API calls. This reduces rate limit pressure and speeds up lookups.
How it works
When SoulSync fetches artist, album, or track data from Spotify or iTunes, the response is cached locally. The next time the same data is needed, it's served from cache instantly — no API call required. Cached data is even served during Spotify rate limit bans.
Browsing the Cache
Click Browse Cache to explore all cached metadata. You can filter by entity type (artists, albums, tracks), search by name, filter by source (Spotify/iTunes), and sort by different fields. Click any card to see full details including the raw API response.
Cache Management
TTL: Entities expire after 30 days, search mappings after 7 days
Eviction: Expired entries are automatically cleaned up
Clear: You can clear the entire cache or filter by source/type
Stats Explained
Artists: Total cached artist profiles
Albums: Total cached album records
Tracks: Total cached track records
Hits: Total number of times cached data was served instead of making an API call
`
}
};
function initializeToolHelpButtons() {
const helpButtons = document.querySelectorAll('.tool-help-button');
const modal = document.getElementById('tool-help-modal');
const closeButton = modal.querySelector('.tool-help-modal-close');
// Attach click handlers to all help buttons
helpButtons.forEach(button => {
button.addEventListener('click', (e) => {
e.stopPropagation();
const toolId = button.getAttribute('data-tool');
openToolHelpModal(toolId);
});
});
// Close modal when clicking close button
closeButton.addEventListener('click', closeToolHelpModal);
// Close modal when clicking outside content
modal.addEventListener('click', (e) => {
if (e.target === modal) {
closeToolHelpModal();
}
});
// Close modal on Escape key
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && modal.classList.contains('active')) {
closeToolHelpModal();
}
});
}
function openToolHelpModal(toolId) {
const modal = document.getElementById('tool-help-modal');
const titleElement = document.getElementById('tool-help-modal-title');
const bodyElement = document.getElementById('tool-help-modal-body');
const helpData = TOOL_HELP_CONTENT[toolId];
if (!helpData) {
console.warn(`No help content found for tool: ${toolId}`);
return;
}
titleElement.textContent = helpData.title;
bodyElement.innerHTML = helpData.content;
modal.classList.add('active');
document.body.style.overflow = 'hidden'; // Prevent background scrolling
}
function closeToolHelpModal() {
const modal = document.getElementById('tool-help-modal');
if (modal) modal.classList.remove('active');
document.body.style.overflow = ''; // Restore scrolling
}
// Global Escape key handler for tool help modal (works even if Tools page wasn't visited)
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
const modal = document.getElementById('tool-help-modal');
if (modal && modal.classList.contains('active')) closeToolHelpModal();
}
});
// ===============================
// == RETAG TOOL FUNCTIONS ==
// ===============================
let retagStatusInterval = null;
let retagCurrentGroupId = null;
async function loadRetagStats() {
try {
const response = await fetch('/api/retag/stats');
const data = await response.json();
if (data.success !== false) {
const groupsEl = document.getElementById('retag-stat-groups');
const tracksEl = document.getElementById('retag-stat-tracks');
const artistsEl = document.getElementById('retag-stat-artists');
if (groupsEl) groupsEl.textContent = data.groups || 0;
if (tracksEl) tracksEl.textContent = data.tracks || 0;
if (artistsEl) artistsEl.textContent = data.artists || 0;
}
} catch (e) {
console.warn('Failed to load retag stats:', e);
}
}
async function openRetagModal() {
const modal = document.getElementById('retag-modal');
if (!modal) return;
modal.style.display = 'flex';
document.body.style.overflow = 'hidden';
// Reset batch bar and clear-all button
const batchBar = document.getElementById('retag-batch-bar');
if (batchBar) batchBar.style.display = 'none';
const clearBtn = document.getElementById('retag-clear-all-btn');
if (clearBtn) { clearBtn.textContent = 'Clear All'; clearBtn.dataset.confirming = ''; clearBtn.style.background = ''; }
const body = document.getElementById('retag-modal-body');
body.innerHTML = '
';
}
}
/**
* Show inline confirmation on a search result before retagging
*/
function showRetagConfirm(el, groupId, albumId, albumName) {
// Clear any other confirming states
document.querySelectorAll('.retag-search-result.retag-confirming').forEach(r => {
r.classList.remove('retag-confirming');
const bar = r.querySelector('.retag-result-confirm-bar');
if (bar) bar.remove();
r.onclick = r._originalOnclick || null;
});
el.classList.add('retag-confirming');
el._originalOnclick = el.onclick;
el.onclick = null; // Disable clicking the row again
const confirmBar = document.createElement('div');
confirmBar.className = 'retag-result-confirm-bar';
confirmBar.innerHTML = `
Re-tag with "${escapeHtml(albumName)}"?
`;
el.appendChild(confirmBar);
}
function cancelRetagConfirm(cancelBtn) {
const result = cancelBtn.closest('.retag-search-result');
if (!result) return;
result.classList.remove('retag-confirming');
const bar = result.querySelector('.retag-result-confirm-bar');
if (bar) bar.remove();
if (result._originalOnclick) {
result.onclick = result._originalOnclick;
}
}
async function executeRetag(groupId, albumId, albumName) {
closeRetagSearch();
closeRetagModal();
try {
const response = await fetch('/api/retag/execute', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ group_id: groupId, album_id: albumId })
});
const data = await response.json();
if (data.success) {
showToast('Retag operation started', 'success');
startRetagPolling();
} else {
showToast(`Error: ${data.error || 'Unknown error'}`, 'error');
}
} catch (e) {
showToast('Failed to start retag operation', 'error');
}
}
function startRetagPolling() {
if (retagStatusInterval) return;
retagStatusInterval = setInterval(checkRetagStatus, 1000);
checkRetagStatus();
}
async function checkRetagStatus() {
if (socketConnected) return; // WebSocket handles this
try {
const response = await fetch('/api/retag/status');
const state = await response.json();
updateRetagProgressUI(state);
if (state.status === 'running' && !retagStatusInterval) {
startRetagPolling();
}
if (state.status !== 'running' && retagStatusInterval) {
clearInterval(retagStatusInterval);
retagStatusInterval = null;
if (state.status === 'finished') {
showToast('Retag completed successfully', 'success');
loadRetagStats();
} else if (state.status === 'error') {
showToast(`Retag error: ${state.error_message || 'Unknown error'}`, 'error');
}
}
} catch (e) {
// Ignore fetch errors during polling
}
}
function updateRetagStatusFromData(data) {
const prev = _lastToolStatus['retag'];
_lastToolStatus['retag'] = data.status;
if (prev !== undefined && data.status === prev && data.status !== 'running') return;
updateRetagProgressUI(data);
// Handle terminal state toasts (only on transition)
if (prev === 'running' || prev === undefined) {
if (data.status === 'finished') {
showToast('Retag completed successfully', 'success');
loadRetagStats();
} else if (data.status === 'error') {
showToast(`Retag error: ${data.error_message || 'Unknown error'}`, 'error');
}
}
}
function updateRetagProgressUI(state) {
const phaseLabel = document.getElementById('retag-phase-label');
const progressBar = document.getElementById('retag-progress-bar');
const progressLabel = document.getElementById('retag-progress-label');
const statusEl = document.getElementById('retag-stat-status');
if (phaseLabel) phaseLabel.textContent = state.phase || 'Ready';
if (progressBar) progressBar.style.width = `${state.progress || 0}%`;
if (progressLabel) {
progressLabel.textContent = `${state.processed || 0} / ${state.total_tracks || 0} tracks (${(state.progress || 0).toFixed(1)}%)`;
}
if (statusEl) {
statusEl.textContent = state.status === 'running' ? 'Running' : 'Idle';
}
// Color the progress bar red on error
if (progressBar) {
progressBar.style.backgroundColor = state.status === 'error' ? '#ff4444' : '';
}
}
/**
* Show inline delete confirmation for a retag group
*/
function showRetagDeleteConfirm(groupId) {
const area = document.getElementById(`retag-delete-area-${groupId}`);
if (!area) return;
area.innerHTML = `
Remove?
`;
}
function cancelRetagDeleteConfirm(groupId) {
const area = document.getElementById(`retag-delete-area-${groupId}`);
if (!area) return;
area.innerHTML = ``;
}
async function executeRetagGroupDelete(groupId) {
try {
const response = await fetch(`/api/retag/groups/${groupId}`, { method: 'DELETE' });
const data = await response.json();
if (data.success) {
const card = document.querySelector(`.retag-group-card[data-group-id="${groupId}"]`);
if (card) {
const section = card.closest('.retag-artist-section');
card.remove();
if (section && section.querySelectorAll('.retag-group-card').length === 0) {
section.remove();
}
}
loadRetagStats();
updateRetagBatchBar();
showToast('Group removed', 'success');
} else {
showToast('Failed to remove group', 'error');
}
} catch (e) {
showToast('Failed to remove group', 'error');
}
}
/**
* Update the retag batch action bar based on checkbox selection
*/
function updateRetagBatchBar() {
const checked = document.querySelectorAll('.retag-select-cb:checked');
const bar = document.getElementById('retag-batch-bar');
const countEl = document.getElementById('retag-batch-count');
if (!bar) return;
if (checked.length > 0) {
bar.style.display = 'flex';
countEl.textContent = `${checked.length} selected`;
} else {
bar.style.display = 'none';
}
}
/**
* Batch remove selected retag groups
*/
async function batchRemoveRetagGroups() {
const checked = document.querySelectorAll('.retag-select-cb:checked');
if (checked.length === 0) return;
const groupIds = Array.from(checked).map(cb => parseInt(cb.getAttribute('data-group-id')));
try {
const response = await fetch('/api/retag/groups/delete-batch', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ group_ids: groupIds })
});
const data = await response.json();
if (data.success) {
showToast(`Removed ${data.removed} group${data.removed !== 1 ? 's' : ''}`, 'success');
openRetagModal(); // Refresh
} else {
showToast('Failed to remove groups', 'error');
}
} catch (e) {
showToast('Failed to remove groups', 'error');
}
}
/**
* Clear all retag groups — inline confirm on the button itself
*/
function clearAllRetagGroups(btn) {
if (!btn) return;
if (btn.dataset.confirming === 'true') {
// Already confirming — execute
btn.dataset.confirming = '';
btn.textContent = 'Clear All';
executeClearAllRetag();
return;
}
// First click — show confirm state
btn.dataset.confirming = 'true';
btn.textContent = 'Confirm Clear?';
btn.style.background = 'rgba(255, 59, 48, 0.15)';
// Auto-reset after 3 seconds if not clicked again
setTimeout(() => {
if (btn.dataset.confirming === 'true') {
btn.dataset.confirming = '';
btn.textContent = 'Clear All';
btn.style.background = '';
}
}, 3000);
}
async function executeClearAllRetag() {
try {
const response = await fetch('/api/retag/groups/clear-all', {
method: 'POST',
headers: { 'Content-Type': 'application/json' }
});
const data = await response.json();
if (data.success) {
showToast(`Cleared ${data.removed} group${data.removed !== 1 ? 's' : ''}`, 'success');
openRetagModal(); // Refresh
} else {
showToast('Failed to clear groups', 'error');
}
} catch (e) {
showToast('Failed to clear groups', 'error');
}
}
function stopWishlistCountPolling() {
if (wishlistCountInterval) {
clearInterval(wishlistCountInterval);
wishlistCountInterval = null;
}
}
function resetWishlistModalToIdleState() {
// Reset wishlist modal to idle state after background processing completes
const playlistId = 'wishlist';
const process = activeDownloadProcesses[playlistId];
if (process) {
console.log('🔄 Resetting wishlist modal to idle state...');
// Reset button states
const beginBtn = document.getElementById(`begin-analysis-btn-${playlistId}`);
const cancelBtn = document.getElementById(`cancel-all-btn-${playlistId}`);
if (beginBtn) {
beginBtn.style.display = 'inline-block';
beginBtn.disabled = false;
beginBtn.textContent = 'Begin Analysis';
}
if (cancelBtn) {
cancelBtn.style.display = 'none';
}
// Show the force download toggle again
const forceToggleContainer = document.querySelector(`#force-download-all-${playlistId}`)?.closest('.force-download-toggle-container');
if (forceToggleContainer) {
forceToggleContainer.style.display = 'flex';
}
// Reset progress displays
const analysisText = document.getElementById(`analysis-progress-text-${playlistId}`);
const analysisBar = document.getElementById(`analysis-progress-fill-${playlistId}`);
const downloadText = document.getElementById(`download-progress-text-${playlistId}`);
const downloadBar = document.getElementById(`download-progress-fill-${playlistId}`);
if (analysisText) analysisText.textContent = 'Ready to start';
if (analysisBar) analysisBar.style.width = '0%';
if (downloadText) downloadText.textContent = 'Waiting for analysis';
if (downloadBar) downloadBar.style.width = '0%';
// Reset all track rows to pending state
const trackRows = document.querySelectorAll(`#download-missing-modal-${CSS.escape(playlistId)} tr[data-track-index]`);
trackRows.forEach((row, index) => {
const matchCell = row.querySelector(`#match-${playlistId}-${index}`);
const downloadCell = row.querySelector(`#download-${playlistId}-${index}`);
const actionsCell = row.querySelector(`#actions-${playlistId}-${index}`);
if (matchCell) matchCell.textContent = '🔍 Pending';
if (downloadCell) downloadCell.textContent = '-';
if (actionsCell) actionsCell.innerHTML = '-';
});
// Reset stats
const foundElement = document.getElementById(`stat-found-${playlistId}`);
const missingElement = document.getElementById(`stat-missing-${playlistId}`);
const downloadedElement = document.getElementById(`stat-downloaded-${playlistId}`);
if (foundElement) foundElement.textContent = '-';
if (missingElement) missingElement.textContent = '-';
if (downloadedElement) downloadedElement.textContent = '0';
// Reset process status
process.status = 'idle';
process.batchId = null;
if (process.poller) {
clearInterval(process.poller);
process.poller = null;
}
console.log('✅ Wishlist modal fully reset to idle state');
} else {
console.log('⚠️ No wishlist process found to reset');
}
}
let toolsPageState = { isInitialized: false };
async function initializeToolsPage() {
// Attach event listeners for tool buttons (idempotent — getElementById returns null if already wired)
const updateButton = document.getElementById('db-update-button');
if (updateButton && !updateButton._toolsWired) {
updateButton.addEventListener('click', handleDbUpdateButtonClick);
updateButton._toolsWired = true;
}
const metadataButton = document.getElementById('metadata-update-button');
if (metadataButton && !metadataButton._toolsWired) {
metadataButton.addEventListener('click', handleMetadataUpdateButtonClick);
metadataButton._toolsWired = true;
}
const duplicateCleanButton = document.getElementById('duplicate-clean-button');
if (duplicateCleanButton && !duplicateCleanButton._toolsWired) {
duplicateCleanButton.addEventListener('click', handleDuplicateCleanButtonClick);
duplicateCleanButton._toolsWired = true;
}
const reconcileIdsButton = document.getElementById('reconcile-ids-button');
if (reconcileIdsButton && !reconcileIdsButton._toolsWired) {
reconcileIdsButton.addEventListener('click', handleReconcileIdsButtonClick);
reconcileIdsButton._toolsWired = true;
// Hydrate the card with any in-progress / completed run.
checkAndUpdateReconcileIdsProgress();
}
const mediaScanButton = document.getElementById('media-scan-button');
if (mediaScanButton && !mediaScanButton._toolsWired) {
mediaScanButton.addEventListener('click', handleMediaScanButtonClick);
mediaScanButton._toolsWired = true;
}
const backupNowButton = document.getElementById('backup-now-button');
if (backupNowButton && !backupNowButton._toolsWired) {
backupNowButton.addEventListener('click', handleBackupNowClick);
backupNowButton._toolsWired = true;
}
// Tool-specific init
await checkAndHideMetadataUpdaterForNonPlex();
await checkAndRestoreMetadataUpdateState();
await checkAndShowMediaScanForPlex();
loadBackupList();
initializeToolHelpButtons();
await fetchAndUpdateDbStats();
loadDiscoveryPoolStats();
loadMetadataCacheStats();
// Start polling (cleared when navigating away via loadPageData preamble)
stopDbStatsPolling();
dbStatsInterval = setInterval(fetchAndUpdateDbStats, 10000);
// Check for ongoing operations
await checkAndUpdateDbProgress();
await checkAndUpdateDuplicateCleanProgress();
// Initialize library maintenance section
updateRepairStatus();
switchRepairTab('jobs');
toolsPageState.isInitialized = true;
}
async function loadDashboardData() {
// Start periodic refreshers up front (independent of the initial loads).
stopWishlistCountPolling(); // Ensure no duplicates
wishlistCountInterval = setInterval(updateWishlistCount, 10000);
setInterval(fetchAndUpdateSystemStats, 10000); // dashboard-specific (service status polled globally)
setInterval(fetchAndUpdateActivityFeed, 2000); // responsive activity feed
setInterval(checkForActivityToasts, 3000);
// Fire all independent initial loads in parallel instead of sequentially.
// Sequential awaits meant 6 back-to-back round-trips, each triggering its own
// reflow — the layout kept shifting for ~1-2s, which made the page feel
// unscrollable. Concurrent loads collapse that into a single settle.
await Promise.all([
updateWishlistCount(),
fetchAndUpdateServiceStatus(),
fetchAndUpdateSystemStats(),
fetchAndUpdateDbStats(),
fetchAndUpdateActivityFeed(),
checkForActiveProcesses(),
]);
// Render existing downloads once active processes are known.
updateDashboardDownloads();
// Automatic wishlist processing now runs server-side
}
// --- Data Fetching and UI Updates ---
async function fetchAndUpdateDbStats() {
if (socketConnected) return; // WebSocket handles this
try {
const response = await fetch('/api/database/stats');
if (!response.ok) return;
const stats = await response.json();
// This function updates the stat cards in the top grid
updateDashboardStatCards(stats);
// This function updates the info within the DB Updater tool card
updateDbUpdaterCardInfo(stats);
} catch (error) {
console.warn('Could not fetch DB stats:', error);
}
}
function updateDashboardStatCards(stats) {
// Update the Library Status card on the dashboard
updateLibraryStatusCard(stats);
}
/**
* Smart Library Status card on the Dashboard.
* Shows different states: no server, empty library, healthy library, scanning.
*/
function updateLibraryStatusCard(dbStats) {
const card = document.getElementById('library-status-card');
if (!card) return;
const title = document.getElementById('library-status-title');
const subtitle = document.getElementById('library-status-subtitle');
const statsRow = document.getElementById('library-status-stats');
const scanBtn = document.getElementById('library-status-scan-btn');
const scanLabel = document.getElementById('library-status-scan-label');
const deepBtn = document.getElementById('library-status-deep-btn');
const progressDiv = document.getElementById('library-status-progress');
const messageDiv = document.getElementById('library-status-message');
const artists = dbStats ? (dbStats.artists || 0) : 0;
const albums = dbStats ? (dbStats.albums || 0) : 0;
const tracks = dbStats ? (dbStats.tracks || 0) : 0;
const sizeMb = dbStats ? (dbStats.database_size_mb || 0) : 0;
const lastUpdate = dbStats ? dbStats.last_update : null;
const serverSource = dbStats ? dbStats.server_source : null;
// Check if a scan is in progress
const isScanning = window._libraryStatusScanning || false;
// Determine state
const serverConnected = _lastStatusPayload && _lastStatusPayload.media_server && _lastStatusPayload.media_server.connected;
const serverType = _lastStatusPayload && _lastStatusPayload.active_media_server;
const hasData = tracks > 0;
const hasServer = !!serverType && serverType !== 'none';
// Reset classes
card.className = 'library-status-card';
if (isScanning) {
// State: Scanning
card.classList.add('scanning');
if (title) title.textContent = 'Library Scan';
if (subtitle) subtitle.textContent = 'Updating library database...';
if (scanBtn) {
scanBtn.style.display = '';
scanBtn.classList.add('scanning');
scanLabel.textContent = 'Stop';
scanBtn.disabled = false;
}
if (deepBtn) deepBtn.style.display = 'none';
if (statsRow) statsRow.style.display = hasData ? '' : 'none';
if (progressDiv) progressDiv.style.display = '';
if (messageDiv) messageDiv.style.display = 'none';
} else if (!hasServer) {
// State: No server configured
card.classList.add('needs-setup');
if (title) title.textContent = 'No Media Server';
if (subtitle) subtitle.textContent = 'Connect a server to get started';
if (scanBtn) scanBtn.style.display = 'none';
if (deepBtn) deepBtn.style.display = 'none';
if (statsRow) statsRow.style.display = 'none';
if (progressDiv) progressDiv.style.display = 'none';
if (messageDiv) {
messageDiv.style.display = '';
messageDiv.innerHTML = 'SoulSync needs a media server to manage your library. '
+ 'Go to Settings '
+ 'to connect Plex, Jellyfin, or Navidrome.';
}
} else if (!serverConnected) {
// State: Server configured but not connected
card.classList.add('needs-setup');
const serverName = _capitalize(serverType);
if (title) title.textContent = `${serverName} — Disconnected`;
if (subtitle) subtitle.textContent = 'Cannot reach your media server';
if (scanBtn) scanBtn.style.display = 'none';
if (deepBtn) deepBtn.style.display = 'none';
if (statsRow) statsRow.style.display = 'none';
if (progressDiv) progressDiv.style.display = 'none';
if (messageDiv) {
messageDiv.style.display = '';
messageDiv.innerHTML = `Your ${serverName} server is configured but not responding. `
+ 'Check that it\'s running and the connection details are correct in '
+ 'Settings.';
}
} else if (!hasData) {
// State: Server connected but library is empty
card.classList.add('empty-library');
const serverName = _capitalize(serverType);
if (title) title.textContent = `${serverName} Connected`;
if (subtitle) subtitle.textContent = 'Library database is empty';
if (scanBtn) {
scanBtn.style.display = '';
scanBtn.classList.remove('scanning');
scanLabel.textContent = 'Scan Now';
scanBtn.disabled = false;
}
if (deepBtn) deepBtn.style.display = 'none';
if (statsRow) statsRow.style.display = 'none';
if (progressDiv) progressDiv.style.display = 'none';
if (messageDiv) {
messageDiv.style.display = '';
messageDiv.innerHTML = 'Your server is connected but SoulSync hasn\'t imported your library yet. '
+ 'Click Scan Now to pull your artists, albums, and tracks into SoulSync.';
}
} else {
// State: Healthy library with data
card.classList.add('has-data');
const serverName = _capitalize(serverType);
let lastRefreshText = 'Never';
if (lastUpdate) {
const d = new Date(lastUpdate);
if (!isNaN(d.getTime())) {
lastRefreshText = typeof _formatTimeAgo === 'function' ? _formatTimeAgo(d) : d.toLocaleDateString();
}
}
if (title) title.textContent = `${serverName} Library`;
if (subtitle) subtitle.textContent = `Last refreshed ${lastRefreshText}`;
if (scanBtn) {
scanBtn.style.display = '';
scanBtn.classList.remove('scanning');
scanLabel.textContent = 'Refresh';
scanBtn.disabled = false;
}
if (deepBtn) deepBtn.style.display = '';
if (statsRow) {
statsRow.style.display = '';
document.getElementById('library-status-artists').textContent = artists.toLocaleString();
document.getElementById('library-status-albums').textContent = albums.toLocaleString();
document.getElementById('library-status-tracks').textContent = tracks.toLocaleString();
document.getElementById('library-status-size').textContent = sizeMb < 1 ? `${Math.round(sizeMb * 1024)} KB` : `${sizeMb.toFixed(1)} MB`;
}
if (progressDiv) progressDiv.style.display = 'none';
if (messageDiv) messageDiv.style.display = 'none';
}
}
// _lastStatusPayload and _isSoulsyncStandalone are declared in core.js
const _origFetchServiceStatus = typeof fetchAndUpdateServiceStatus === 'function' ? fetchAndUpdateServiceStatus : null;
function _capitalize(s) { return s ? s.charAt(0).toUpperCase() + s.slice(1) : ''; }
/**
* Dashboard library scan button handler — triggers incremental DB update.
*/
async function dashboardLibraryScan(fullRefresh = false) {
const scanBtn = document.getElementById('library-status-scan-btn');
const scanLabel = document.getElementById('library-status-scan-label');
// If already scanning, stop it
if (window._libraryStatusScanning) {
try {
await fetch('/api/database/update/stop', { method: 'POST' });
window._libraryStatusScanning = false;
showToast('Library scan stopped', 'info');
// Refresh the card
try {
const r = await fetch('/api/database/stats');
if (r.ok) updateLibraryStatusCard(await r.json());
} catch (e) {}
} catch (e) {
showToast('Failed to stop scan', 'error');
}
return;
}
// Start scan
try {
window._libraryStatusScanning = true;
updateLibraryStatusCard(null); // Update to scanning state
const response = await fetch('/api/database/update', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ full_refresh: fullRefresh })
});
const data = await response.json();
if (!data.success) {
window._libraryStatusScanning = false;
showToast(data.error || 'Failed to start scan', 'error');
return;
}
showToast('Library scan started', 'success');
// Poll for progress
const pollInterval = setInterval(async () => {
try {
const statusResp = await fetch('/api/database/update/status');
if (!statusResp.ok) return;
const status = await statusResp.json();
const phase = document.getElementById('library-status-phase');
const barFill = document.getElementById('library-status-bar-fill');
const detail = document.getElementById('library-status-progress-detail');
if (phase) phase.textContent = status.phase || 'Scanning...';
if (barFill) barFill.style.width = `${status.progress || 0}%`;
if (detail && status.processed !== undefined) {
detail.textContent = `${status.processed} / ${status.total || '?'}`;
}
if (status.status === 'completed' || status.status === 'finished' || status.status === 'error' || status.status === 'idle') {
clearInterval(pollInterval);
window._libraryStatusScanning = false;
if (status.status === 'completed' || status.status === 'finished') {
showToast('Library scan complete', 'success');
} else if (status.status === 'error') {
showToast(`Scan error: ${status.error_message || 'Unknown'}`, 'error');
}
// Refresh stats
try {
const r = await fetch('/api/database/stats');
if (r.ok) updateLibraryStatusCard(await r.json());
} catch (e) {}
}
} catch (e) {
clearInterval(pollInterval);
window._libraryStatusScanning = false;
}
}, 2000);
} catch (e) {
window._libraryStatusScanning = false;
showToast(`Scan failed: ${e.message}`, 'error');
}
}
/**
* Dashboard deep scan — finds new tracks, removes stale ones, preserves enrichment data.
*/
async function dashboardLibraryDeepScan() {
if (window._libraryStatusScanning) {
showToast('A scan is already running', 'warning');
return;
}
if (!await showConfirmDialog({
title: 'Deep Scan Library',
message: 'A deep scan re-checks every track in your media server library.\n\n' +
'• Adds any new tracks that were missed\n' +
'• Removes tracks no longer on your server\n' +
'• Preserves all existing metadata and enrichment data\n\n' +
'This may take a while for large libraries. Continue?',
})) return;
// Use the same scan flow as dashboardLibraryScan but with deep_scan flag
try {
window._libraryStatusScanning = true;
updateLibraryStatusCard(null);
const response = await fetch('/api/database/update', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ deep_scan: true })
});
const data = await response.json();
if (!data.success) {
window._libraryStatusScanning = false;
showToast(data.error || 'Failed to start deep scan', 'error');
try { const r = await fetch('/api/database/stats'); if (r.ok) updateLibraryStatusCard(await r.json()); } catch (e) {}
return;
}
showToast('Deep scan started — this may take a while', 'success');
const pollInterval = setInterval(async () => {
try {
const statusResp = await fetch('/api/database/update/status');
if (!statusResp.ok) return;
const status = await statusResp.json();
const phase = document.getElementById('library-status-phase');
const barFill = document.getElementById('library-status-bar-fill');
const detail = document.getElementById('library-status-progress-detail');
if (phase) phase.textContent = status.phase || 'Deep scanning...';
if (barFill) barFill.style.width = `${status.progress || 0}%`;
if (detail && status.processed !== undefined) {
detail.textContent = `${status.processed} / ${status.total || '?'}`;
}
if (status.status === 'completed' || status.status === 'finished' || status.status === 'error' || status.status === 'idle') {
clearInterval(pollInterval);
window._libraryStatusScanning = false;
if (status.status === 'completed' || status.status === 'finished') {
showToast('Deep scan complete', 'success');
} else if (status.status === 'error') {
showToast(`Deep scan error: ${status.error_message || 'Unknown'}`, 'error');
}
try { const r = await fetch('/api/database/stats'); if (r.ok) updateLibraryStatusCard(await r.json()); } catch (e) {}
}
} catch (e) {
clearInterval(pollInterval);
window._libraryStatusScanning = false;
}
}, 2000);
} catch (e) {
window._libraryStatusScanning = false;
showToast(`Deep scan failed: ${e.message}`, 'error');
}
}
/**
* Update the Active Downloads section on the dashboard.
* Called from artist, search, and discover update points (event-driven, no polling).
*/
function updateDashboardDownloads() {
const section = document.getElementById('dashboard-active-downloads-section');
const container = document.getElementById('dashboard-downloads-container');
if (!section || !container) return;
// Collect active entries from each source
const activeArtists = Object.keys(artistDownloadBubbles).filter(id =>
artistDownloadBubbles[id].downloads.length > 0
);
const activeSearch = Object.keys(searchDownloadBubbles).filter(name =>
searchDownloadBubbles[name].downloads.length > 0
);
const activeDiscover = Object.keys(discoverDownloads);
const activeBeatport = Object.keys(beatportDownloadBubbles).filter(key =>
beatportDownloadBubbles[key].downloads.length > 0
);
const totalCount = activeArtists.length + activeSearch.length + activeDiscover.length + activeBeatport.length;
if (totalCount === 0) {
section.style.display = 'none';
container.innerHTML = '';
return;
}
section.style.display = '';
let html = '';
// --- Artists group ---
if (activeArtists.length > 0) {
html += `