// MUSICBRAINZ ENRICHMENT UI - PHASE 5 WEB UI // ============================================================================ /** * Poll MusicBrainz status every 2 seconds and update UI */ async function updateMusicBrainzStatus() { if (socketConnected) return; // WebSocket handles this if (document.hidden) return; // Skip polling when tab is not visible try { const response = await fetch('/api/enrichment/musicbrainz/status'); if (!response.ok) { console.warn('MusicBrainz status endpoint unavailable'); return; } const data = await response.json(); updateMusicBrainzStatusFromData(data); } catch (error) { console.error('Error updating MusicBrainz status:', error); } } function updateMusicBrainzStatusFromData(data) { const button = document.getElementById('musicbrainz-button'); if (!button) return; // Update button state classes button.classList.remove('active', 'paused', 'complete'); if (data.idle) { button.classList.add('complete'); } else if (data.running && !data.paused) { button.classList.add('active'); } else if (data.paused) { button.classList.add('paused'); } // Update tooltip content const tooltipStatus = document.getElementById('mb-tooltip-status'); const tooltipCurrent = document.getElementById('mb-tooltip-current'); const tooltipProgress = document.getElementById('mb-tooltip-progress'); if (tooltipStatus) { if (data.idle) { tooltipStatus.textContent = 'Complete'; } else if (data.running && !data.paused) { tooltipStatus.textContent = 'Running'; } else if (data.paused) { tooltipStatus.textContent = data.yield_reason === 'downloads' ? 'Yielding for downloads' : 'Paused'; } else { tooltipStatus.textContent = 'Idle'; } } if (tooltipCurrent) { if (data.idle) { tooltipCurrent.textContent = 'All items processed'; } else if (data.current_item && data.current_item.name) { const type = data.current_item.type || 'item'; const name = data.current_item.name; tooltipCurrent.textContent = `${type.charAt(0).toUpperCase() + type.slice(1)}: "${name}"`; } else { tooltipCurrent.textContent = 'No active matches'; } } if (tooltipProgress && data.progress) { const artists = data.progress.artists || {}; const albums = data.progress.albums || {}; const tracks = data.progress.tracks || {}; const currentType = data.current_item?.type; let progressText = ''; const artistsComplete = artists.matched >= artists.total; const albumsComplete = albums.matched >= albums.total; if (currentType === 'artist' || (!artistsComplete && !currentType)) { progressText = `Artists: ${artists.matched || 0} / ${artists.total} (${artists.percent || 0}%)`; } else if (currentType === 'album' || (artistsComplete && !albumsComplete)) { progressText = `Albums: ${albums.matched || 0} / ${albums.total} (${albums.percent || 0}%)`; } else if (currentType === 'track' || (artistsComplete && albumsComplete)) { progressText = `Tracks: ${tracks.matched || 0} / ${tracks.total} (${tracks.percent || 0}%)`; } else { progressText = `Artists: ${artists.matched || 0} / ${artists.total} (${artists.percent || 0}%)`; } tooltipProgress.textContent = progressText; } } /** * Toggle MusicBrainz enrichment pause/resume */ async function toggleMusicBrainzEnrichment() { try { const button = document.getElementById('musicbrainz-button'); if (!button) return; const isRunning = button.classList.contains('active'); const endpoint = isRunning ? '/api/enrichment/musicbrainz/pause' : '/api/enrichment/musicbrainz/resume'; const response = await fetch(endpoint, { method: 'POST' }); if (!response.ok) { throw new Error(`Failed to ${isRunning ? 'pause' : 'resume'} MusicBrainz enrichment`); } // Immediately update UI await updateMusicBrainzStatus(); console.log(`✅ MusicBrainz enrichment ${isRunning ? 'paused' : 'resumed'}`); } catch (error) { console.error('Error toggling MusicBrainz enrichment:', error); showToast(`Error: ${error.message}`, 'error'); } } // Initialize MusicBrainz UI on page load if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', () => { const button = document.getElementById('musicbrainz-button'); if (button) { button.addEventListener('click', toggleMusicBrainzEnrichment); // Start polling updateMusicBrainzStatus(); setInterval(updateMusicBrainzStatus, 2000); // Poll every 2 seconds console.log('✅ MusicBrainz UI initialized'); } }); } else { const button = document.getElementById('musicbrainz-button'); if (button) { button.addEventListener('click', toggleMusicBrainzEnrichment); // Start polling updateMusicBrainzStatus(); setInterval(updateMusicBrainzStatus, 2000); // Poll every 2 seconds console.log('✅ MusicBrainz UI initialized'); } } // ============================================================================ // AUDIODB ENRICHMENT UI // ============================================================================ /** * Poll AudioDB status every 2 seconds and update UI */ async function updateAudioDBStatus() { if (socketConnected) return; // WebSocket handles this if (document.hidden) return; // Skip polling when tab is not visible try { const response = await fetch('/api/enrichment/audiodb/status'); if (!response.ok) { console.warn('AudioDB status endpoint unavailable'); return; } const data = await response.json(); updateAudioDBStatusFromData(data); } catch (error) { console.error('Error updating AudioDB status:', error); } } function updateAudioDBStatusFromData(data) { const button = document.getElementById('audiodb-button'); if (!button) return; button.classList.remove('active', 'paused', 'complete'); if (data.idle) { button.classList.add('complete'); } else if (data.running && !data.paused) { button.classList.add('active'); } else if (data.paused) { button.classList.add('paused'); } const tooltipStatus = document.getElementById('audiodb-tooltip-status'); const tooltipCurrent = document.getElementById('audiodb-tooltip-current'); const tooltipProgress = document.getElementById('audiodb-tooltip-progress'); if (tooltipStatus) { if (data.idle) { tooltipStatus.textContent = 'Complete'; } else if (data.running && !data.paused) { tooltipStatus.textContent = 'Running'; } else if (data.paused) { tooltipStatus.textContent = data.yield_reason === 'downloads' ? 'Yielding for downloads' : 'Paused'; } else { tooltipStatus.textContent = 'Idle'; } } if (tooltipCurrent) { if (data.idle) { tooltipCurrent.textContent = 'All items processed'; } else if (data.current_item && data.current_item.name) { const type = data.current_item.type || 'item'; const name = data.current_item.name; tooltipCurrent.textContent = `${type.charAt(0).toUpperCase() + type.slice(1)}: "${name}"`; } else { tooltipCurrent.textContent = 'No active matches'; } } if (tooltipProgress && data.progress) { const artists = data.progress.artists || {}; const albums = data.progress.albums || {}; const tracks = data.progress.tracks || {}; const currentType = data.current_item?.type; let progressText = ''; const artistsComplete = artists.matched >= artists.total; const albumsComplete = albums.matched >= albums.total; if (currentType === 'artist' || (!artistsComplete && !currentType)) { progressText = `Artists: ${artists.matched || 0} / ${artists.total || 0} (${artists.percent || 0}%)`; } else if (currentType === 'album' || (artistsComplete && !albumsComplete)) { progressText = `Albums: ${albums.matched || 0} / ${albums.total || 0} (${albums.percent || 0}%)`; } else if (currentType === 'track' || (artistsComplete && albumsComplete)) { progressText = `Tracks: ${tracks.matched || 0} / ${tracks.total || 0} (${tracks.percent || 0}%)`; } else { progressText = `Artists: ${artists.matched || 0} / ${artists.total || 0} (${artists.percent || 0}%)`; } tooltipProgress.textContent = progressText; } } function updateDiscogsStatusFromData(data) { const button = document.getElementById('discogs-button'); if (!button) return; button.classList.remove('active', 'paused', 'complete'); if (data.idle) { button.classList.add('complete'); } else if (data.running && !data.paused) { button.classList.add('active'); } else if (data.paused) { button.classList.add('paused'); } const tooltipStatus = document.getElementById('discogs-tooltip-status'); const tooltipCurrent = document.getElementById('discogs-tooltip-current'); const tooltipProgress = document.getElementById('discogs-tooltip-progress'); if (tooltipStatus) { if (data.idle) tooltipStatus.textContent = 'Complete'; else if (data.running && !data.paused) tooltipStatus.textContent = 'Running'; else if (data.paused) tooltipStatus.textContent = data.yield_reason === 'downloads' ? 'Yielding for downloads' : 'Paused'; else tooltipStatus.textContent = 'Idle'; } if (tooltipCurrent) { if (data.idle) tooltipCurrent.textContent = 'All items processed'; else if (data.current_item) tooltipCurrent.textContent = `Processing: "${data.current_item}"`; else tooltipCurrent.textContent = 'No active matches'; } if (tooltipProgress && data.stats) { const s = data.stats; tooltipProgress.textContent = `Matched: ${s.matched || 0} | Not found: ${s.not_found || 0} | Pending: ${s.pending || 0}`; } } async function toggleDiscogsEnrichment() { try { const button = document.getElementById('discogs-button'); if (!button) return; const isPaused = button.classList.contains('paused') || button.classList.contains('complete'); const endpoint = isPaused ? '/api/enrichment/discogs/resume' : '/api/enrichment/discogs/pause'; const response = await fetch(endpoint, { method: 'POST' }); if (response.ok) { showToast(isPaused ? 'Discogs enrichment resumed' : 'Discogs enrichment paused', 'info'); } } catch (e) { showToast('Failed to toggle Discogs enrichment', 'error'); } } /** * Toggle AudioDB enrichment pause/resume */ async function toggleAudioDBEnrichment() { try { const button = document.getElementById('audiodb-button'); if (!button) return; const isRunning = button.classList.contains('active'); const endpoint = isRunning ? '/api/enrichment/audiodb/pause' : '/api/enrichment/audiodb/resume'; const response = await fetch(endpoint, { method: 'POST' }); if (!response.ok) { throw new Error(`Failed to ${isRunning ? 'pause' : 'resume'} AudioDB enrichment`); } // Immediately update UI await updateAudioDBStatus(); console.log(`✅ AudioDB enrichment ${isRunning ? 'paused' : 'resumed'}`); } catch (error) { console.error('Error toggling AudioDB enrichment:', error); showToast(`Error: ${error.message}`, 'error'); } } // Initialize AudioDB UI on page load if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', () => { const button = document.getElementById('audiodb-button'); if (button) { button.addEventListener('click', toggleAudioDBEnrichment); updateAudioDBStatus(); setInterval(updateAudioDBStatus, 2000); console.log('✅ AudioDB UI initialized'); } }); } else { const button = document.getElementById('audiodb-button'); if (button) { button.addEventListener('click', toggleAudioDBEnrichment); updateAudioDBStatus(); setInterval(updateAudioDBStatus, 2000); console.log('✅ AudioDB UI initialized'); } } // =================================================================== // DEEZER ENRICHMENT STATUS // =================================================================== async function updateDeezerStatus() { if (socketConnected) return; // WebSocket handles this if (document.hidden) return; // Skip polling when tab is not visible try { const response = await fetch('/api/enrichment/deezer/status'); if (!response.ok) { console.warn('Deezer status endpoint unavailable'); return; } const data = await response.json(); updateDeezerStatusFromData(data); } catch (error) { console.error('Error updating Deezer status:', error); } } function updateDeezerStatusFromData(data) { const button = document.getElementById('deezer-button'); if (!button) return; button.classList.remove('active', 'paused', 'complete'); if (data.idle) { button.classList.add('complete'); } else if (data.running && !data.paused) { button.classList.add('active'); } else if (data.paused) { button.classList.add('paused'); } const tooltipStatus = document.getElementById('deezer-tooltip-status'); const tooltipCurrent = document.getElementById('deezer-tooltip-current'); const tooltipProgress = document.getElementById('deezer-tooltip-progress'); if (tooltipStatus) { if (data.idle) { tooltipStatus.textContent = 'Complete'; } else if (data.running && !data.paused) { tooltipStatus.textContent = 'Running'; } else if (data.paused) { tooltipStatus.textContent = data.yield_reason === 'downloads' ? 'Yielding for downloads' : 'Paused'; } else { tooltipStatus.textContent = 'Idle'; } } if (tooltipCurrent) { if (data.idle) { tooltipCurrent.textContent = 'All items processed'; } else if (data.current_item && data.current_item.name) { tooltipCurrent.textContent = `Now: ${data.current_item.name}`; } } if (data.progress && tooltipProgress) { const artists = data.progress.artists || {}; const albums = data.progress.albums || {}; const tracks = data.progress.tracks || {}; const currentType = data.current_item?.type; let progressText = ''; const artistsComplete = artists.matched >= artists.total; const albumsComplete = albums.matched >= albums.total; if (currentType === 'artist' || (!artistsComplete && !currentType)) { progressText = `Artists: ${artists.matched || 0} / ${artists.total || 0} (${artists.percent || 0}%)`; } else if (currentType === 'album' || (artistsComplete && !albumsComplete)) { progressText = `Albums: ${albums.matched || 0} / ${albums.total || 0} (${albums.percent || 0}%)`; } else if (currentType === 'track' || (artistsComplete && albumsComplete)) { progressText = `Tracks: ${tracks.matched || 0} / ${tracks.total || 0} (${tracks.percent || 0}%)`; } else { progressText = `Artists: ${artists.matched || 0} / ${artists.total || 0} (${artists.percent || 0}%)`; } tooltipProgress.textContent = progressText; } } async function toggleDeezerEnrichment() { try { const button = document.getElementById('deezer-button'); if (!button) return; const isRunning = button.classList.contains('active'); const endpoint = isRunning ? '/api/enrichment/deezer/pause' : '/api/enrichment/deezer/resume'; const response = await fetch(endpoint, { method: 'POST' }); if (!response.ok) { throw new Error(`Failed to ${isRunning ? 'pause' : 'resume'} Deezer enrichment`); } // Immediately update UI await updateDeezerStatus(); console.log(`✅ Deezer enrichment ${isRunning ? 'paused' : 'resumed'}`); } catch (error) { console.error('Error toggling Deezer enrichment:', error); showToast(`Error: ${error.message}`, 'error'); } } // Initialize Deezer UI on page load if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', () => { const button = document.getElementById('deezer-button'); if (button) { button.addEventListener('click', toggleDeezerEnrichment); updateDeezerStatus(); setInterval(updateDeezerStatus, 2000); console.log('✅ Deezer UI initialized'); } }); } else { const button = document.getElementById('deezer-button'); if (button) { button.addEventListener('click', toggleDeezerEnrichment); updateDeezerStatus(); setInterval(updateDeezerStatus, 2000); console.log('✅ Deezer UI initialized'); } } // =================================================================== // SPOTIFY ENRICHMENT STATUS // =================================================================== async function updateSpotifyEnrichmentStatus() { if (socketConnected) return; // WebSocket handles this if (document.hidden) return; // Skip polling when tab is not visible try { const response = await fetch('/api/enrichment/spotify/status'); if (!response.ok) { console.warn('Spotify enrichment status endpoint unavailable'); return; } const data = await response.json(); updateSpotifyEnrichmentStatusFromData(data); } catch (error) { console.error('Error updating Spotify enrichment status:', error); } } function updateSpotifyEnrichmentStatusFromData(data) { const button = document.getElementById('spotify-enrich-button'); if (!button) return; const notAuthenticated = data.authenticated === false; const isRateLimited = data.rate_limited === true; // The real API is banned but the worker is still matching via the no-creds // Spotify Free source — treat it as running, not stuck (#798 bridge). const bridgingFree = data.using_free === true; const rateLimitedStuck = isRateLimited && !bridgingFree; // Budget is a real-API cap; when bridging to free it no longer applies, so // only treat the budget as a stop when we're NOT serving via free (#798). const budgetStuck = (data.daily_budget && data.daily_budget.exhausted) && !bridgingFree; button.classList.remove('active', 'paused', 'complete', 'no-auth'); if (data.paused) { button.classList.add('paused'); } else if (notAuthenticated) { button.classList.add('no-auth'); } else if (rateLimitedStuck || budgetStuck) { button.classList.add('paused'); } else if (data.idle) { button.classList.add('complete'); } else if (data.running && !data.paused) { button.classList.add('active'); } const tooltipStatus = document.getElementById('spotify-enrich-tooltip-status'); const tooltipCurrent = document.getElementById('spotify-enrich-tooltip-current'); const tooltipProgress = document.getElementById('spotify-enrich-tooltip-progress'); if (tooltipStatus) { if (data.paused) { tooltipStatus.textContent = 'Paused'; } else if (notAuthenticated) { tooltipStatus.textContent = 'Not Authenticated'; } else if (rateLimitedStuck) { tooltipStatus.textContent = 'Rate Limited'; } else if (bridgingFree) { tooltipStatus.textContent = 'Running (Spotify Free)'; } else if (budgetStuck) { tooltipStatus.textContent = 'Daily Limit Reached'; } else if (data.idle) { tooltipStatus.textContent = 'Complete'; } else if (data.running) { tooltipStatus.textContent = 'Running'; } else { tooltipStatus.textContent = 'Idle'; } } if (tooltipCurrent) { if (data.paused) { tooltipCurrent.textContent = notAuthenticated ? 'Connect Spotify in Settings to enrich' : 'Click to resume'; } else if (notAuthenticated) { tooltipCurrent.textContent = 'Connect Spotify in Settings to enrich'; } else if (rateLimitedStuck) { const info = data.rate_limit || {}; const remaining = info.remaining_seconds || 0; tooltipCurrent.textContent = remaining > 0 ? `Waiting ${Math.ceil(remaining / 60)}m for rate limit to clear` : 'Waiting for rate limit to clear'; } else if (bridgingFree && data.current_item && data.current_item.name) { tooltipCurrent.textContent = `Now: ${data.current_item.name} (via Spotify Free)`; } else if (budgetStuck) { const resets = data.daily_budget.resets_in_seconds || 0; const hours = Math.floor(resets / 3600); const mins = Math.floor((resets % 3600) / 60); tooltipCurrent.textContent = `Resets in ${hours}h ${mins}m`; } else if (data.idle) { tooltipCurrent.textContent = 'All items processed'; } else if (data.current_item && data.current_item.name) { tooltipCurrent.textContent = `Now: ${data.current_item.name}`; } else { tooltipCurrent.textContent = 'Waiting for next item...'; } } if (data.progress && tooltipProgress) { if (notAuthenticated) { tooltipProgress.textContent = `Pending: ${data.stats?.pending || 0} items`; } else { const artists = data.progress.artists || {}; const albums = data.progress.albums || {}; const tracks = data.progress.tracks || {}; const currentType = data.current_item?.type || ''; let progressText = ''; const artistsComplete = artists.matched >= artists.total; const albumsComplete = albums.matched >= albums.total; if (currentType === 'artist') { progressText = `Artists: ${artists.matched || 0} / ${artists.total || 0} (${artists.percent || 0}%)`; } else if (currentType.includes('album')) { progressText = `Albums: ${albums.matched || 0} / ${albums.total || 0} (${albums.percent || 0}%)`; } else if (currentType.includes('track')) { progressText = `Tracks: ${tracks.matched || 0} / ${tracks.total || 0} (${tracks.percent || 0}%)`; } else if (!artistsComplete) { progressText = `Artists: ${artists.matched || 0} / ${artists.total || 0} (${artists.percent || 0}%)`; } else if (!albumsComplete) { progressText = `Albums: ${albums.matched || 0} / ${albums.total || 0} (${albums.percent || 0}%)`; } else { progressText = `Tracks: ${tracks.matched || 0} / ${tracks.total || 0} (${tracks.percent || 0}%)`; } tooltipProgress.textContent = progressText; } } } async function toggleSpotifyEnrichment() { try { const button = document.getElementById('spotify-enrich-button'); if (!button) return; const isRunning = button.classList.contains('active'); const endpoint = isRunning ? '/api/enrichment/spotify/pause' : '/api/enrichment/spotify/resume'; const response = await fetch(endpoint, { method: 'POST' }); if (!response.ok) { const data = await response.json().catch(() => ({})); if (data.rate_limited) { showToast('Cannot resume — Spotify is rate limited', 'warning'); return; } throw new Error(`Failed to ${isRunning ? 'pause' : 'resume'} Spotify enrichment`); } await updateSpotifyEnrichmentStatus(); console.log(`Spotify enrichment ${isRunning ? 'paused' : 'resumed'}`); } catch (error) { console.error('Error toggling Spotify enrichment:', error); showToast(`Error: ${error.message}`, 'error'); } } // Initialize Spotify Enrichment UI on page load if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', () => { const button = document.getElementById('spotify-enrich-button'); if (button) { button.addEventListener('click', toggleSpotifyEnrichment); updateSpotifyEnrichmentStatus(); setInterval(updateSpotifyEnrichmentStatus, 2000); } }); } else { const button = document.getElementById('spotify-enrich-button'); if (button) { button.addEventListener('click', toggleSpotifyEnrichment); updateSpotifyEnrichmentStatus(); setInterval(updateSpotifyEnrichmentStatus, 2000); } } // =================================================================== // ITUNES ENRICHMENT STATUS // =================================================================== async function updateiTunesEnrichmentStatus() { if (socketConnected) return; // WebSocket handles this if (document.hidden) return; // Skip polling when tab is not visible try { const response = await fetch('/api/enrichment/itunes/status'); if (!response.ok) { console.warn('iTunes enrichment status endpoint unavailable'); return; } const data = await response.json(); updateiTunesEnrichmentStatusFromData(data); } catch (error) { console.error('Error updating iTunes enrichment status:', error); } } function updateiTunesEnrichmentStatusFromData(data) { const button = document.getElementById('itunes-enrich-button'); if (!button) return; button.classList.remove('active', 'paused', 'complete'); if (data.idle) { button.classList.add('complete'); } else if (data.running && !data.paused) { button.classList.add('active'); } else if (data.paused) { button.classList.add('paused'); } const tooltipStatus = document.getElementById('itunes-enrich-tooltip-status'); const tooltipCurrent = document.getElementById('itunes-enrich-tooltip-current'); const tooltipProgress = document.getElementById('itunes-enrich-tooltip-progress'); if (tooltipStatus) { if (data.idle) { tooltipStatus.textContent = 'Complete'; } else if (data.running && !data.paused) { tooltipStatus.textContent = 'Running'; } else if (data.paused) { tooltipStatus.textContent = data.yield_reason === 'downloads' ? 'Yielding for downloads' : 'Paused'; } else { tooltipStatus.textContent = 'Idle'; } } if (tooltipCurrent) { if (data.idle) { tooltipCurrent.textContent = 'All items processed'; } else if (data.current_item && data.current_item.name) { tooltipCurrent.textContent = `Now: ${data.current_item.name}`; } } if (data.progress && tooltipProgress) { const artists = data.progress.artists || {}; const albums = data.progress.albums || {}; const tracks = data.progress.tracks || {}; const currentType = data.current_item?.type || ''; let progressText = ''; const artistsComplete = artists.matched >= artists.total; const albumsComplete = albums.matched >= albums.total; if (currentType === 'artist') { progressText = `Artists: ${artists.matched || 0} / ${artists.total || 0} (${artists.percent || 0}%)`; } else if (currentType.includes('album')) { progressText = `Albums: ${albums.matched || 0} / ${albums.total || 0} (${albums.percent || 0}%)`; } else if (currentType.includes('track')) { progressText = `Tracks: ${tracks.matched || 0} / ${tracks.total || 0} (${tracks.percent || 0}%)`; } else if (!artistsComplete) { progressText = `Artists: ${artists.matched || 0} / ${artists.total || 0} (${artists.percent || 0}%)`; } else if (!albumsComplete) { progressText = `Albums: ${albums.matched || 0} / ${albums.total || 0} (${albums.percent || 0}%)`; } else { progressText = `Tracks: ${tracks.matched || 0} / ${tracks.total || 0} (${tracks.percent || 0}%)`; } tooltipProgress.textContent = progressText; } } async function toggleiTunesEnrichment() { try { const button = document.getElementById('itunes-enrich-button'); if (!button) return; const isRunning = button.classList.contains('active'); const endpoint = isRunning ? '/api/enrichment/itunes/pause' : '/api/enrichment/itunes/resume'; const response = await fetch(endpoint, { method: 'POST' }); if (!response.ok) { throw new Error(`Failed to ${isRunning ? 'pause' : 'resume'} iTunes enrichment`); } await updateiTunesEnrichmentStatus(); console.log(`iTunes enrichment ${isRunning ? 'paused' : 'resumed'}`); } catch (error) { console.error('Error toggling iTunes enrichment:', error); showToast(`Error: ${error.message}`, 'error'); } } // Initialize iTunes Enrichment UI on page load if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', () => { const button = document.getElementById('itunes-enrich-button'); if (button) { button.addEventListener('click', toggleiTunesEnrichment); updateiTunesEnrichmentStatus(); setInterval(updateiTunesEnrichmentStatus, 2000); } }); } else { const button = document.getElementById('itunes-enrich-button'); if (button) { button.addEventListener('click', toggleiTunesEnrichment); updateiTunesEnrichmentStatus(); setInterval(updateiTunesEnrichmentStatus, 2000); } } // =================================================================== // LAST.FM ENRICHMENT STATUS // =================================================================== async function updateLastFMEnrichmentStatus() { if (socketConnected) return; if (document.hidden) return; try { const response = await fetch('/api/enrichment/lastfm/status'); if (!response.ok) { console.warn('Last.fm status endpoint unavailable'); return; } const data = await response.json(); updateLastFMEnrichmentStatusFromData(data); } catch (error) { console.error('Error updating Last.fm status:', error); } } function updateLastFMEnrichmentStatusFromData(data) { const button = document.getElementById('lastfm-enrich-button'); if (!button) return; const notAuthenticated = data.authenticated === false; button.classList.remove('active', 'paused', 'complete', 'no-auth'); if (data.paused) { button.classList.add('paused'); } else if (notAuthenticated) { button.classList.add('no-auth'); } else if (data.idle) { button.classList.add('complete'); } else if (data.running && !data.paused) { button.classList.add('active'); } const tooltipStatus = document.getElementById('lastfm-enrich-tooltip-status'); const tooltipCurrent = document.getElementById('lastfm-enrich-tooltip-current'); const tooltipProgress = document.getElementById('lastfm-enrich-tooltip-progress'); if (tooltipStatus) { if (data.paused) { tooltipStatus.textContent = 'Paused'; } else if (notAuthenticated) { tooltipStatus.textContent = 'Not Authenticated'; } else if (data.idle) { tooltipStatus.textContent = 'Complete'; } else if (data.running) { tooltipStatus.textContent = 'Running'; } else { tooltipStatus.textContent = 'Idle'; } } if (tooltipCurrent) { if (data.paused) { tooltipCurrent.textContent = notAuthenticated ? 'Add Last.fm API key in Settings to enrich' : 'Click to resume'; } else if (notAuthenticated) { tooltipCurrent.textContent = 'Add Last.fm API key in Settings to enrich'; } else if (data.idle) { tooltipCurrent.textContent = 'All items processed'; } else if (data.current_item && data.current_item.name) { tooltipCurrent.textContent = `Now: ${data.current_item.name}`; } } if (data.progress && tooltipProgress) { if (notAuthenticated) { tooltipProgress.textContent = `Pending: ${data.stats?.pending || 0} items`; } else { const artists = data.progress.artists || {}; const albums = data.progress.albums || {}; const tracks = data.progress.tracks || {}; const currentType = data.current_item?.type; let progressText = ''; const artistsComplete = artists.matched >= artists.total; const albumsComplete = albums.matched >= albums.total; if (currentType === 'artist' || (!artistsComplete && !currentType)) { progressText = `Artists: ${artists.matched || 0} / ${artists.total || 0} (${artists.percent || 0}%)`; } else if (currentType === 'album' || (artistsComplete && !albumsComplete)) { progressText = `Albums: ${albums.matched || 0} / ${albums.total || 0} (${albums.percent || 0}%)`; } else if (currentType === 'track' || (artistsComplete && albumsComplete)) { progressText = `Tracks: ${tracks.matched || 0} / ${tracks.total || 0} (${tracks.percent || 0}%)`; } else { progressText = `Artists: ${artists.matched || 0} / ${artists.total || 0} (${artists.percent || 0}%)`; } tooltipProgress.textContent = progressText; } } } async function toggleLastFMEnrichment() { try { const button = document.getElementById('lastfm-enrich-button'); if (!button) return; const isRunning = button.classList.contains('active'); const endpoint = isRunning ? '/api/enrichment/lastfm/pause' : '/api/enrichment/lastfm/resume'; const response = await fetch(endpoint, { method: 'POST' }); if (!response.ok) { throw new Error(`Failed to ${isRunning ? 'pause' : 'resume'} Last.fm enrichment`); } await updateLastFMEnrichmentStatus(); console.log(`Last.fm enrichment ${isRunning ? 'paused' : 'resumed'}`); } catch (error) { console.error('Error toggling Last.fm enrichment:', error); showToast(`Error: ${error.message}`, 'error'); } } if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', () => { const button = document.getElementById('lastfm-enrich-button'); if (button) { button.addEventListener('click', toggleLastFMEnrichment); updateLastFMEnrichmentStatus(); setInterval(updateLastFMEnrichmentStatus, 2000); } }); } else { const button = document.getElementById('lastfm-enrich-button'); if (button) { button.addEventListener('click', toggleLastFMEnrichment); updateLastFMEnrichmentStatus(); setInterval(updateLastFMEnrichmentStatus, 2000); } } // =================================================================== // GENIUS ENRICHMENT STATUS // =================================================================== async function updateGeniusEnrichmentStatus() { if (socketConnected) return; if (document.hidden) return; try { const response = await fetch('/api/enrichment/genius/status'); if (!response.ok) { console.warn('Genius status endpoint unavailable'); return; } const data = await response.json(); updateGeniusEnrichmentStatusFromData(data); } catch (error) { console.error('Error updating Genius status:', error); } } function updateGeniusEnrichmentStatusFromData(data) { const button = document.getElementById('genius-enrich-button'); if (!button) return; const notAuthenticated = data.authenticated === false; button.classList.remove('active', 'paused', 'complete', 'no-auth'); if (data.paused) { button.classList.add('paused'); } else if (notAuthenticated) { button.classList.add('no-auth'); } else if (data.idle) { button.classList.add('complete'); } else if (data.running && !data.paused) { button.classList.add('active'); } const tooltipStatus = document.getElementById('genius-enrich-tooltip-status'); const tooltipCurrent = document.getElementById('genius-enrich-tooltip-current'); const tooltipProgress = document.getElementById('genius-enrich-tooltip-progress'); if (tooltipStatus) { if (data.paused) { tooltipStatus.textContent = 'Paused'; } else if (notAuthenticated) { tooltipStatus.textContent = 'Not Authenticated'; } else if (data.idle) { tooltipStatus.textContent = 'Complete'; } else if (data.running) { tooltipStatus.textContent = 'Running'; } else { tooltipStatus.textContent = 'Idle'; } } if (tooltipCurrent) { if (data.paused) { tooltipCurrent.textContent = notAuthenticated ? 'Add Genius access token in Settings to enrich' : 'Click to resume'; } else if (notAuthenticated) { tooltipCurrent.textContent = 'Add Genius access token in Settings to enrich'; } else if (data.idle) { tooltipCurrent.textContent = 'All items processed'; } else if (data.current_item && data.current_item.name) { tooltipCurrent.textContent = `Now: ${data.current_item.name}`; } } if (data.progress && tooltipProgress) { if (notAuthenticated) { tooltipProgress.textContent = `Pending: ${data.stats?.pending || 0} items`; } else { const artists = data.progress.artists || {}; const tracks = data.progress.tracks || {}; const currentType = data.current_item?.type; let progressText = ''; const artistsComplete = artists.matched >= artists.total; if (currentType === 'artist' || (!artistsComplete && !currentType)) { progressText = `Artists: ${artists.matched || 0} / ${artists.total || 0} (${artists.percent || 0}%)`; } else { progressText = `Tracks: ${tracks.matched || 0} / ${tracks.total || 0} (${tracks.percent || 0}%)`; } tooltipProgress.textContent = progressText; } } } async function toggleGeniusEnrichment() { try { const button = document.getElementById('genius-enrich-button'); if (!button) return; const isRunning = button.classList.contains('active'); const endpoint = isRunning ? '/api/enrichment/genius/pause' : '/api/enrichment/genius/resume'; const response = await fetch(endpoint, { method: 'POST' }); if (!response.ok) { throw new Error(`Failed to ${isRunning ? 'pause' : 'resume'} Genius enrichment`); } await updateGeniusEnrichmentStatus(); console.log(`Genius enrichment ${isRunning ? 'paused' : 'resumed'}`); } catch (error) { console.error('Error toggling Genius enrichment:', error); showToast(`Error: ${error.message}`, 'error'); } } if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', () => { const button = document.getElementById('genius-enrich-button'); if (button) { button.addEventListener('click', toggleGeniusEnrichment); updateGeniusEnrichmentStatus(); setInterval(updateGeniusEnrichmentStatus, 2000); } }); } else { const button = document.getElementById('genius-enrich-button'); if (button) { button.addEventListener('click', toggleGeniusEnrichment); updateGeniusEnrichmentStatus(); setInterval(updateGeniusEnrichmentStatus, 2000); } } // =================================================================== // TIDAL ENRICHMENT WORKER // =================================================================== async function updateTidalEnrichmentStatus() { if (socketConnected) return; if (document.hidden) return; try { const response = await fetch('/api/enrichment/tidal/status'); if (!response.ok) { console.warn('Tidal status endpoint unavailable'); return; } const data = await response.json(); updateTidalEnrichmentStatusFromData(data); } catch (error) { console.error('Error updating Tidal status:', error); } } function updateTidalEnrichmentStatusFromData(data) { const button = document.getElementById('tidal-enrich-button'); if (!button) return; const notAuthenticated = data.authenticated === false; button.classList.remove('active', 'paused', 'complete', 'no-auth'); if (data.paused) { button.classList.add('paused'); } else if (notAuthenticated) { button.classList.add('no-auth'); } else if (data.idle) { button.classList.add('complete'); } else if (data.running && !data.paused) { button.classList.add('active'); } const tooltipStatus = document.getElementById('tidal-enrich-tooltip-status'); const tooltipCurrent = document.getElementById('tidal-enrich-tooltip-current'); const tooltipProgress = document.getElementById('tidal-enrich-tooltip-progress'); if (tooltipStatus) { if (data.paused) { tooltipStatus.textContent = 'Paused'; } else if (notAuthenticated) { tooltipStatus.textContent = 'Not Authenticated'; } else if (data.idle) { tooltipStatus.textContent = 'Complete'; } else if (data.running) { tooltipStatus.textContent = 'Running'; } else { tooltipStatus.textContent = 'Idle'; } } if (tooltipCurrent) { if (data.paused) { tooltipCurrent.textContent = notAuthenticated ? 'Connect Tidal in Settings to enrich' : 'Click to resume'; } else if (notAuthenticated) { tooltipCurrent.textContent = 'Connect Tidal in Settings to enrich'; } else if (data.idle) { tooltipCurrent.textContent = 'All items processed'; } else if (data.current_item && data.current_item.name) { tooltipCurrent.textContent = `Now: ${data.current_item.name}`; } } if (data.progress && tooltipProgress) { if (notAuthenticated) { tooltipProgress.textContent = `Pending: ${data.stats?.pending || 0} items`; } else { const artists = data.progress.artists || {}; const albums = data.progress.albums || {}; const tracks = data.progress.tracks || {}; const currentType = data.current_item?.type; let progressText = ''; const artistsComplete = artists.matched >= artists.total; const albumsComplete = albums.matched >= albums.total; if (currentType === 'artist' || (!artistsComplete && !currentType)) { progressText = `Artists: ${artists.matched || 0} / ${artists.total || 0} (${artists.percent || 0}%)`; } else if (currentType === 'album' || (!albumsComplete && !currentType)) { progressText = `Albums: ${albums.matched || 0} / ${albums.total || 0} (${albums.percent || 0}%)`; } else { progressText = `Tracks: ${tracks.matched || 0} / ${tracks.total || 0} (${tracks.percent || 0}%)`; } tooltipProgress.textContent = progressText; } } } async function toggleTidalEnrichment() { try { const button = document.getElementById('tidal-enrich-button'); if (!button) return; const isRunning = button.classList.contains('active'); const endpoint = isRunning ? '/api/enrichment/tidal/pause' : '/api/enrichment/tidal/resume'; const response = await fetch(endpoint, { method: 'POST' }); if (!response.ok) { throw new Error(`Failed to ${isRunning ? 'pause' : 'resume'} Tidal enrichment`); } await updateTidalEnrichmentStatus(); console.log(`Tidal enrichment ${isRunning ? 'paused' : 'resumed'}`); } catch (error) { console.error('Error toggling Tidal enrichment:', error); showToast(`Error: ${error.message}`, 'error'); } } if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', () => { const button = document.getElementById('tidal-enrich-button'); if (button) { button.addEventListener('click', toggleTidalEnrichment); updateTidalEnrichmentStatus(); setInterval(updateTidalEnrichmentStatus, 2000); } }); } else { const button = document.getElementById('tidal-enrich-button'); if (button) { button.addEventListener('click', toggleTidalEnrichment); updateTidalEnrichmentStatus(); setInterval(updateTidalEnrichmentStatus, 2000); } } // =================================================================== // QOBUZ ENRICHMENT WORKER // =================================================================== async function updateQobuzEnrichmentStatus() { if (socketConnected) return; if (document.hidden) return; try { const response = await fetch('/api/enrichment/qobuz/status'); if (!response.ok) { console.warn('Qobuz status endpoint unavailable'); return; } const data = await response.json(); updateQobuzEnrichmentStatusFromData(data); } catch (error) { console.error('Error updating Qobuz status:', error); } } function updateQobuzEnrichmentStatusFromData(data) { const button = document.getElementById('qobuz-enrich-button'); if (!button) return; const notAuthenticated = data.authenticated === false; button.classList.remove('active', 'paused', 'complete', 'no-auth'); if (data.paused) { button.classList.add('paused'); } else if (notAuthenticated) { button.classList.add('no-auth'); } else if (data.idle) { button.classList.add('complete'); } else if (data.running && !data.paused) { button.classList.add('active'); } const tooltipStatus = document.getElementById('qobuz-enrich-tooltip-status'); const tooltipCurrent = document.getElementById('qobuz-enrich-tooltip-current'); const tooltipProgress = document.getElementById('qobuz-enrich-tooltip-progress'); if (tooltipStatus) { if (data.paused) { tooltipStatus.textContent = 'Paused'; } else if (notAuthenticated) { tooltipStatus.textContent = 'Not Authenticated'; } else if (data.idle) { tooltipStatus.textContent = 'Complete'; } else if (data.running) { tooltipStatus.textContent = 'Running'; } else { tooltipStatus.textContent = 'Idle'; } } if (tooltipCurrent) { if (data.paused) { tooltipCurrent.textContent = notAuthenticated ? 'Connect Qobuz in Settings to enrich' : 'Click to resume'; } else if (notAuthenticated) { tooltipCurrent.textContent = 'Connect Qobuz in Settings to enrich'; } else if (data.idle) { tooltipCurrent.textContent = 'All items processed'; } else if (data.current_item && data.current_item.name) { tooltipCurrent.textContent = `Now: ${data.current_item.name}`; } } if (data.progress && tooltipProgress) { if (notAuthenticated) { tooltipProgress.textContent = `Pending: ${data.stats?.pending || 0} items`; } else { const artists = data.progress.artists || {}; const albums = data.progress.albums || {}; const tracks = data.progress.tracks || {}; const currentType = data.current_item?.type; let progressText = ''; const artistsComplete = artists.matched >= artists.total; const albumsComplete = albums.matched >= albums.total; if (currentType === 'artist' || (!artistsComplete && !currentType)) { progressText = `Artists: ${artists.matched || 0} / ${artists.total || 0} (${artists.percent || 0}%)`; } else if (currentType === 'album' || (!albumsComplete && !currentType)) { progressText = `Albums: ${albums.matched || 0} / ${albums.total || 0} (${albums.percent || 0}%)`; } else { progressText = `Tracks: ${tracks.matched || 0} / ${tracks.total || 0} (${tracks.percent || 0}%)`; } tooltipProgress.textContent = progressText; } } } async function toggleQobuzEnrichment() { try { const button = document.getElementById('qobuz-enrich-button'); if (!button) return; const isRunning = button.classList.contains('active'); const endpoint = isRunning ? '/api/enrichment/qobuz/pause' : '/api/enrichment/qobuz/resume'; const response = await fetch(endpoint, { method: 'POST' }); if (!response.ok) { throw new Error(`Failed to ${isRunning ? 'pause' : 'resume'} Qobuz enrichment`); } await updateQobuzEnrichmentStatus(); console.log(`Qobuz enrichment ${isRunning ? 'paused' : 'resumed'}`); } catch (error) { console.error('Error toggling Qobuz enrichment:', error); showToast(`Error: ${error.message}`, 'error'); } } if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', () => { const button = document.getElementById('qobuz-enrich-button'); if (button) { button.addEventListener('click', toggleQobuzEnrichment); updateQobuzEnrichmentStatus(); setInterval(updateQobuzEnrichmentStatus, 2000); } }); } else { const button = document.getElementById('qobuz-enrich-button'); if (button) { button.addEventListener('click', toggleQobuzEnrichment); updateQobuzEnrichmentStatus(); setInterval(updateQobuzEnrichmentStatus, 2000); } } // =================================================================== // AMAZON MUSIC ENRICHMENT WORKER // =================================================================== async function updateAmazonEnrichmentStatus() { if (socketConnected) return; if (document.hidden) return; try { const response = await fetch('/api/enrichment/amazon/status'); if (!response.ok) { console.warn('Amazon enrichment status endpoint unavailable'); return; } const data = await response.json(); updateAmazonEnrichmentStatusFromData(data); } catch (error) { console.error('Error updating Amazon enrichment status:', error); } } function updateAmazonEnrichmentStatusFromData(data) { const button = document.getElementById('amazon-enrich-button'); if (!button) return; button.classList.remove('active', 'paused', 'complete'); if (data.paused) { button.classList.add('paused'); } else if (data.idle) { button.classList.add('complete'); } else if (data.running && !data.paused) { button.classList.add('active'); } const tooltipStatus = document.getElementById('amazon-enrich-tooltip-status'); const tooltipCurrent = document.getElementById('amazon-enrich-tooltip-current'); const tooltipProgress = document.getElementById('amazon-enrich-tooltip-progress'); if (tooltipStatus) { if (data.paused) { tooltipStatus.textContent = data.yield_reason === 'downloads' ? 'Yielding for downloads' : 'Paused'; } else if (data.idle) { tooltipStatus.textContent = 'Complete'; } else if (data.running) { tooltipStatus.textContent = 'Running'; } else { tooltipStatus.textContent = 'Idle'; } } if (tooltipCurrent) { if (data.idle) { tooltipCurrent.textContent = 'All items processed'; } else if (data.current_item && data.current_item.name) { tooltipCurrent.textContent = `Now: ${data.current_item.name}`; } else { tooltipCurrent.textContent = 'No active matches'; } } if (data.progress && tooltipProgress) { const artists = data.progress.artists || {}; const albums = data.progress.albums || {}; const tracks = data.progress.tracks || {}; const currentType = data.current_item?.type; let progressText = ''; const artistsComplete = artists.matched >= artists.total; const albumsComplete = albums.matched >= albums.total; if (currentType === 'artist' || (!artistsComplete && !currentType)) { progressText = `Artists: ${artists.matched || 0} / ${artists.total || 0} (${artists.percent || 0}%)`; } else if (currentType === 'album' || (!albumsComplete && !currentType)) { progressText = `Albums: ${albums.matched || 0} / ${albums.total || 0} (${albums.percent || 0}%)`; } else { progressText = `Tracks: ${tracks.matched || 0} / ${tracks.total || 0} (${tracks.percent || 0}%)`; } tooltipProgress.textContent = progressText; } } async function toggleAmazonEnrichment() { try { const button = document.getElementById('amazon-enrich-button'); if (!button) return; const isRunning = button.classList.contains('active'); const endpoint = isRunning ? '/api/enrichment/amazon/pause' : '/api/enrichment/amazon/resume'; const response = await fetch(endpoint, { method: 'POST' }); if (!response.ok) { throw new Error(`Failed to ${isRunning ? 'pause' : 'resume'} Amazon enrichment`); } await updateAmazonEnrichmentStatus(); console.log(`Amazon enrichment ${isRunning ? 'paused' : 'resumed'}`); } catch (error) { console.error('Error toggling Amazon enrichment:', error); showToast(`Error: ${error.message}`, 'error'); } } if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', () => { const button = document.getElementById('amazon-enrich-button'); if (button) { button.addEventListener('click', toggleAmazonEnrichment); updateAmazonEnrichmentStatus(); setInterval(updateAmazonEnrichmentStatus, 2000); } }); } else { const button = document.getElementById('amazon-enrich-button'); if (button) { button.addEventListener('click', toggleAmazonEnrichment); updateAmazonEnrichmentStatus(); setInterval(updateAmazonEnrichmentStatus, 2000); } } // =================================================================== // SIMILAR ARTISTS (MUSICMAP) WORKER — dashboard bubble // =================================================================== async function updateSimilarArtistsEnrichmentStatus() { if (socketConnected) return; if (document.hidden) return; try { const response = await fetch('/api/enrichment/similar_artists/status'); if (!response.ok) return; updateSimilarArtistsEnrichmentStatusFromData(await response.json()); } catch (error) { console.error('Error updating Similar Artists enrichment status:', error); } } function updateSimilarArtistsEnrichmentStatusFromData(data) { const button = document.getElementById('similar-artists-enrich-button'); if (!button) return; button.classList.remove('active', 'paused', 'complete'); if (data.paused) button.classList.add('paused'); else if (data.idle) button.classList.add('complete'); else if (data.running && !data.paused) button.classList.add('active'); const tStatus = document.getElementById('similar-artists-enrich-tooltip-status'); const tCurrent = document.getElementById('similar-artists-enrich-tooltip-current'); const tProgress = document.getElementById('similar-artists-enrich-tooltip-progress'); if (tStatus) { if (data.paused) tStatus.textContent = 'Paused'; else if (data.idle) tStatus.textContent = 'Complete'; else if (data.running) tStatus.textContent = 'Running'; else tStatus.textContent = 'Idle'; } if (tCurrent) { if (data.idle) tCurrent.textContent = 'All library artists processed'; else if (data.current_item) tCurrent.textContent = `Now: ${data.current_item}`; else tCurrent.textContent = 'No active matches'; } // DB-backed artist progress (matches the Manage modal exactly). if (tProgress) { const a = (data.progress && data.progress.artists) || {}; tProgress.textContent = `Artists: ${a.matched || 0} / ${a.total || 0} (${a.percent || 0}%)`; } } async function toggleSimilarArtistsEnrichment() { // Identical logic to the other orbs (e.g. toggleAmazonEnrichment): if the orb // is 'active' (running) → pause, otherwise → resume. A paused orb isn't // 'active', so this resumes it. try { const button = document.getElementById('similar-artists-enrich-button'); if (!button) return; const isRunning = button.classList.contains('active'); const endpoint = isRunning ? '/api/enrichment/similar_artists/pause' : '/api/enrichment/similar_artists/resume'; const response = await fetch(endpoint, { method: 'POST' }); if (!response.ok) throw new Error(`Failed to ${isRunning ? 'pause' : 'resume'} Similar Artists enrichment`); await updateSimilarArtistsEnrichmentStatus(); } catch (error) { console.error('Error toggling Similar Artists enrichment:', error); showToast(`Error: ${error.message}`, 'error'); } } // Initialize Similar Artists UI on page load — identical pattern to AudioDB/Deezer // (addEventListener for the click; no inline onclick on the button). if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', () => { const button = document.getElementById('similar-artists-enrich-button'); if (button) { button.addEventListener('click', toggleSimilarArtistsEnrichment); updateSimilarArtistsEnrichmentStatus(); setInterval(updateSimilarArtistsEnrichmentStatus, 2000); } }); } else { const button = document.getElementById('similar-artists-enrich-button'); if (button) { button.addEventListener('click', toggleSimilarArtistsEnrichment); updateSimilarArtistsEnrichmentStatus(); setInterval(updateSimilarArtistsEnrichmentStatus, 2000); } } // =================================================================== // HYDRABASE P2P MIRROR WORKER // =================================================================== async function updateHydrabaseStatus() { if (socketConnected) return; // WebSocket handles this if (document.hidden) return; // Skip polling when tab is not visible try { const response = await fetch('/api/hydrabase-worker/status'); if (!response.ok) return; const data = await response.json(); updateHydrabaseStatusFromData(data); } catch (error) { // Silently ignore — worker may not be available } } function updateHydrabaseStatusFromData(data) { const button = document.getElementById('hydrabase-button'); if (!button) return; button.classList.remove('active', 'paused'); if (data.running && !data.paused) { button.classList.add('active'); } else if (data.paused) { button.classList.add('paused'); } const statusEl = document.getElementById('hydrabase-tooltip-status'); if (statusEl) { if (data.paused) { statusEl.textContent = 'Paused'; statusEl.style.color = '#ffc107'; } else if (data.running) { statusEl.textContent = 'Active'; statusEl.style.color = '#ffffff'; } else { statusEl.textContent = 'Stopped'; statusEl.style.color = '#ff5252'; } } } async function toggleHydrabaseWorker() { const button = document.getElementById('hydrabase-button'); if (!button) return; const isRunning = button.classList.contains('active'); const endpoint = isRunning ? '/api/hydrabase-worker/pause' : '/api/hydrabase-worker/resume'; try { await fetch(endpoint, { method: 'POST' }); await updateHydrabaseStatus(); } catch (error) { console.error('Error toggling Hydrabase worker:', error); } } // Initialize Hydrabase UI on page load if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', () => { const button = document.getElementById('hydrabase-button'); if (button) { button.addEventListener('click', toggleHydrabaseWorker); updateHydrabaseStatus(); setInterval(updateHydrabaseStatus, 2000); } }); } else { const button = document.getElementById('hydrabase-button'); if (button) { button.addEventListener('click', toggleHydrabaseWorker); updateHydrabaseStatus(); setInterval(updateHydrabaseStatus, 2000); } } // =================================================================== // LIBRARY REPAIR WORKER // =================================================================== async function updateRepairStatus() { if (socketConnected) return; // WebSocket handles this if (document.hidden) return; // Skip polling when tab is not visible try { const response = await fetch('/api/repair/status'); if (!response.ok) { console.warn('Repair status endpoint unavailable'); return; } const data = await response.json(); updateRepairStatusFromData(data); } catch (error) { console.error('Error updating repair status:', error); } } function updateRepairStatusFromData(data) { const button = document.getElementById('repair-button'); if (!button) return; button.classList.remove('active', 'paused', 'complete'); if (data.idle) { button.classList.add('complete'); } else if (data.running && !data.paused) { button.classList.add('active'); } else if (data.paused) { button.classList.add('paused'); } const tooltipStatus = document.getElementById('repair-tooltip-status'); const tooltipCurrent = document.getElementById('repair-tooltip-current'); const tooltipProgress = document.getElementById('repair-tooltip-progress'); if (tooltipStatus) { if (data.idle) { tooltipStatus.textContent = 'Complete'; } else if (data.running && !data.paused) { tooltipStatus.textContent = 'Running'; } else if (data.paused) { tooltipStatus.textContent = data.yield_reason === 'downloads' ? 'Yielding for downloads' : 'Paused'; } else { tooltipStatus.textContent = 'Idle'; } } if (tooltipCurrent) { if (data.idle) { tooltipCurrent.textContent = 'All jobs complete — waiting for next schedule'; } else if (data.current_job && data.current_job.display_name) { const jobName = data.current_job.display_name; const jobProgress = data.progress && data.progress.current_job; if (jobProgress && jobProgress.total > 0) { tooltipCurrent.textContent = `${jobName}: ${jobProgress.scanned} / ${jobProgress.total} (${jobProgress.percent}%)`; } else { tooltipCurrent.textContent = `Running: ${jobName}`; } } else if (data.current_item && data.current_item.name) { tooltipCurrent.textContent = `Running: ${data.current_item.name}`; } else { tooltipCurrent.textContent = 'No active repairs'; } } if (tooltipProgress && data.progress) { const tracks = data.progress.tracks || {}; const parts = []; if (tracks.total > 0) parts.push(`Checked: ${tracks.checked || 0} / ${tracks.total || 0}`); if (tracks.repaired > 0) parts.push(`Repaired: ${tracks.repaired}`); const pending = data.findings_pending || 0; if (pending > 0) parts.push(`Findings: ${pending}`); tooltipProgress.textContent = parts.length ? parts.join(' · ') : 'No items processed yet'; } // Update findings badge const badge = document.getElementById('repair-findings-badge'); const findingsPending = data.findings_pending || 0; if (badge) { badge.textContent = findingsPending; badge.style.display = findingsPending > 0 ? '' : 'none'; } const tabBadge = document.getElementById('repair-findings-tab-badge'); if (tabBadge) { tabBadge.textContent = findingsPending; tabBadge.style.display = findingsPending > 0 ? '' : 'none'; } // Update master toggle in modal if open const masterToggle = document.getElementById('repair-master-toggle'); const masterLabel = document.getElementById('repair-master-label'); if (masterToggle) masterToggle.checked = data.enabled || false; if (masterLabel) masterLabel.textContent = data.enabled ? 'Enabled' : 'Disabled'; // Update button state if (!data.enabled) { button.classList.add('paused'); button.classList.remove('active', 'complete'); } } // ── SoulID Worker Status ── function updateSoulIDStatusFromData(data) { const button = document.getElementById('soulid-button'); if (!button) return; button.classList.remove('active', 'complete'); if (data.idle) { button.classList.add('complete'); } else if (data.running && !data.paused) { button.classList.add('active'); } const tooltipStatus = document.getElementById('soulid-tooltip-status'); const tooltipCurrent = document.getElementById('soulid-tooltip-current'); const tooltipProgress = document.getElementById('soulid-tooltip-progress'); if (tooltipStatus) { if (data.idle) tooltipStatus.textContent = 'Complete'; else if (data.running && !data.paused) tooltipStatus.textContent = 'Running'; else if (data.paused) tooltipStatus.textContent = 'Paused'; else tooltipStatus.textContent = 'Idle'; } if (tooltipCurrent) { if (data.current_item) { tooltipCurrent.textContent = data.current_item; } else if (data.idle) { tooltipCurrent.textContent = 'All entities have soul IDs'; } else { tooltipCurrent.textContent = 'No items processing'; } } if (tooltipProgress && data.stats) { const s = data.stats; const parts = []; if (s.artists_processed) parts.push(`Artists: ${s.artists_processed}`); if (s.albums_processed) parts.push(`Albums: ${s.albums_processed}`); if (s.tracks_processed) parts.push(`Tracks: ${s.tracks_processed}`); if (s.pending > 0) parts.push(`Pending: ${s.pending}`); tooltipProgress.textContent = parts.length ? parts.join(' · ') : 'No items processed yet'; } } // ── Repair Modal State ── let _repairCurrentTab = 'jobs'; let _repairFindingsPage = 0; let _repairSelectedFindings = new Set(); let _repairFindingsTotal = 0; let _repairFindingsAutoSwitched = false; // Set after auto-switching to "All Status" so we don't loop const REPAIR_FINDINGS_PAGE_SIZE = 30; let _repairJobsCache = {}; // Cache job data for help modal /** * Open the Library Maintenance modal */ async function openRepairModal() { navigateToPage('tools'); // Scroll to maintenance section setTimeout(() => { const section = document.querySelector('.tools-maintenance-section'); if (section) section.scrollIntoView({ behavior: 'smooth' }); }, 100); _repairCurrentTab = 'jobs'; switchRepairTab('jobs'); // Load master toggle state updateRepairStatus(); // Load any active job progress try { const resp = await fetch('/api/repair/progress'); if (resp.ok) { const data = await resp.json(); if (Object.keys(data).length > 0) { // Brief delay so job cards are rendered first setTimeout(() => updateRepairJobProgressFromData(data), 300); } } } catch (e) { /* ignore */ } } function closeRepairModal() { // No-op — repair content now lives on the tools page, no modal to close } async function toggleRepairMaster() { try { const response = await fetch('/api/repair/toggle', { method: 'POST' }); if (!response.ok) throw new Error('Failed to toggle'); const data = await response.json(); const label = document.getElementById('repair-master-label'); const toggle = document.getElementById('repair-master-toggle'); if (label) label.textContent = data.enabled ? 'Enabled' : 'Disabled'; if (toggle) toggle.checked = data.enabled; await updateRepairStatus(); } catch (error) { console.error('Error toggling repair master:', error); showToast('Error toggling maintenance worker', 'error'); } } function switchRepairTab(tab) { _repairCurrentTab = tab; document.querySelectorAll('.repair-tab').forEach(t => { t.classList.toggle('active', t.dataset.tab === tab); }); document.querySelectorAll('.repair-tab-content').forEach(c => { c.style.display = 'none'; }); const content = document.getElementById(`repair-tab-${tab}`); if (content) content.style.display = ''; if (tab === 'jobs') loadRepairJobs(); else if (tab === 'findings') { loadRepairFindingsDashboard(); loadRepairFindings(); } else if (tab === 'history') loadRepairHistory(); } // Turn a snake_case setting key into a human label. Handles acronym fix-ups // (EP, ID, URL, MB, AC, OS) that the naive Title-Case would otherwise botch. function _prettifyRepairSettingKey(key) { const words = key.replace(/^_+/, '').split('_'); const acronyms = { 'eps': 'EPs', 'id': 'ID', 'url': 'URL', 'mb': 'MB', 'ac': 'AC', 'os': 'OS', 'api': 'API', 'mp3': 'MP3', 'flac': 'FLAC', 'cd': 'CD' }; return words.map(w => acronyms[w.toLowerCase()] || (w.charAt(0).toUpperCase() + w.slice(1))).join(' '); } async function loadRepairJobs() { const container = document.getElementById('repair-jobs-list'); if (!container) return; try { const response = await fetch('/api/repair/jobs'); if (!response.ok) throw new Error('Failed to fetch jobs'); const data = await response.json(); const jobs = data.jobs || []; // Cache job data for help modal _repairJobsCache = {}; jobs.forEach(j => { _repairJobsCache[j.job_id] = j; }); if (jobs.length === 0) { container.innerHTML = `
🔧
No Maintenance Jobs
Library maintenance jobs will appear here once available.
`; return; } // Populate findings job filter dropdown const jobFilter = document.getElementById('repair-findings-job-filter'); if (jobFilter && jobFilter.options.length <= 1) { jobs.forEach(job => { const opt = document.createElement('option'); opt.value = job.job_id; opt.textContent = job.display_name; jobFilter.appendChild(opt); }); } container.innerHTML = jobs.map(job => { const lastRunText = job.last_run ? formatCacheAge(job.last_run.finished_at) : 'Never'; const nextRunText = job.next_run ? formatCacheAge(job.next_run) : (job.enabled ? 'Pending' : '-'); const statusClass = job.is_running ? 'running' : (job.enabled ? 'idle' : 'disabled'); const dotClass = job.is_running ? 'running' : (job.enabled ? 'enabled' : 'disabled'); const cardClass = job.is_running ? 'running' : (!job.enabled ? 'disabled' : ''); // Build flow badges const flowParts = []; flowParts.push(`${job.is_running ? '▶ Running' : 'Scan'}`); if (job.auto_fix) { flowParts.push(''); const isDryRun = job.settings && job.settings.dry_run === true; if (isDryRun) { flowParts.push('Dry Run'); } else { flowParts.push('Auto-fix'); } } // Badge: prefer the CURRENT pending count from the API // (matches what the Findings tab actually shows); fall // back to the historical ``findings_created`` from the // last run when pending = 0 but the last scan did find // something — that way users still see a hint that a scan // happened recently. Without this, a scan that finds 372 // duplicates and then has them all bulk-fixed shows // "372 findings" on the badge while the Findings tab // Pending filter is empty, which reads as a bug. const pendingCount = job.pending_findings_count || 0; const lastScanCount = job.last_run ? (job.last_run.findings_created || 0) : 0; if (pendingCount > 0) { flowParts.push(''); flowParts.push(`${pendingCount} pending`); } else if (lastScanCount > 0) { // Show historical count with a clear "found in last scan" // qualifier so users don't expect them on the Pending tab. flowParts.push(''); flowParts.push(`${lastScanCount} found in last scan`); } // Build meta parts const metaParts = []; metaParts.push('Last: ' + lastRunText); metaParts.push('Next: ' + nextRunText); if (job.last_run) { metaParts.push(`Scanned: ${(job.last_run.items_scanned || 0).toLocaleString()}`); if (job.last_run.auto_fixed) metaParts.push(`Fixed: ${job.last_run.auto_fixed}`); } if (job.last_run && job.last_run.duration_seconds) { metaParts.push(`${job.last_run.duration_seconds.toFixed(1)}s`); } // Build settings HTML let settingsHtml = ''; if (job.settings && Object.keys(job.settings).length > 0) { const settingsRows = Object.entries(job.settings).map(([key, val]) => { // Section header: keys starting with `_section_` render as a // group divider + title instead of a setting row. The value // is the human-readable title. if (key.startsWith('_section_')) { return `
${val}
`; } const label = _prettifyRepairSettingKey(key); // Dropdown when the job declares allowed values for this key. const opts = job.setting_options && job.setting_options[key]; if (Array.isArray(opts) && opts.length) { const optionsHtml = opts.map(o => `` ).join(''); return `
`; } const inputType = typeof val === 'boolean' ? 'checkbox' : typeof val === 'number' ? 'number' : 'text'; const inputVal = inputType === 'checkbox' ? (val ? ' checked' : '') : ` value="${val}"`; return `
`; }).join(''); settingsHtml = ` `; } return `
${job.display_name}
${job.description || ''}
${flowParts.join('')}
${metaParts.join(' · ')}
${Object.keys(job.settings || {}).length > 0 ? `` : ''}
${settingsHtml}
`; }).join(''); } catch (error) { console.error('Error loading repair jobs:', error); container.innerHTML = '
Error loading jobs
'; } } async function toggleRepairJob(jobId, enabled) { try { await fetch(`/api/repair/jobs/${jobId}/toggle`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ enabled }) }); // Update card visuals immediately const card = document.querySelector(`.repair-job-card[data-job-id="${jobId}"]`); if (card) { card.classList.toggle('disabled', !enabled); const dot = card.querySelector('.repair-job-status'); if (dot) dot.className = 'repair-job-status ' + (enabled ? 'enabled' : 'disabled'); } } catch (error) { console.error('Error toggling job:', error); showToast('Error toggling job', 'error'); } } function expandRepairJobSettings(jobId) { const el = document.getElementById(`repair-settings-${jobId}`); if (el) el.style.display = el.style.display === 'none' ? '' : 'none'; } function showRepairJobHelp(jobId) { const job = _repairJobsCache[jobId]; if (!job) return; // Remove existing overlay if present let overlay = document.getElementById('repair-help-overlay'); if (overlay) overlay.remove(); // Build settings summary (skip `_section_` group-header sentinels) let settingsHtml = ''; if (job.settings && Object.keys(job.settings).length > 0) { const rows = Object.entries(job.settings) .filter(([key]) => !key.startsWith('_section_')) .map(([key, val]) => { const label = _prettifyRepairSettingKey(key); const display = typeof val === 'boolean' ? (val ? 'Yes' : 'No') : val; return `
${label}${display}
`; }).join(''); settingsHtml = `
Current Settings
${rows}
`; } // Build info badges const badges = []; if (job.auto_fix) { const isDryRun = job.settings && job.settings.dry_run === true; badges.push(isDryRun ? 'Dry Run' : 'Auto-fix'); } else { badges.push('Scan Only'); } badges.push(`Every ${job.interval_hours}h`); if (job.enabled) { badges.push('Enabled'); } else { badges.push('Disabled'); } // Format help text paragraphs const helpBody = (job.help_text || job.description || '').split('\n\n').map(p => { if (p.startsWith('Settings:\n')) { const lines = p.split('\n').slice(1); return '
' + lines.map(l => `
${l.replace(/^- /, '')}
`).join('') + '
'; } return `

${p.replace(/\n/g, '
')}

`; }).join(''); overlay = document.createElement('div'); overlay.id = 'repair-help-overlay'; overlay.className = 'repair-help-overlay'; overlay.onclick = (e) => { if (e.target === overlay) overlay.remove(); }; overlay.innerHTML = `

${job.display_name}

${badges.join('')}
${helpBody}
${settingsHtml}
`; document.body.appendChild(overlay); } async function saveRepairJobSettings(jobId) { try { const inputs = document.querySelectorAll(`.repair-setting-input[data-job="${jobId}"]`); let intervalHours = null; const settings = {}; inputs.forEach(input => { const key = input.dataset.key; if (key === '_interval_hours') { intervalHours = parseInt(input.value) || 24; } else { if (input.type === 'checkbox') settings[key] = input.checked; else if (input.type === 'number') settings[key] = parseFloat(input.value); else settings[key] = input.value; } }); await fetch(`/api/repair/jobs/${jobId}/settings`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ interval_hours: intervalHours, settings }) }); showToast('Settings saved', 'success'); } catch (error) { console.error('Error saving job settings:', error); showToast('Error saving settings', 'error'); } } async function runRepairJobNow(jobId) { try { await fetch(`/api/repair/jobs/${jobId}/run`, { method: 'POST' }); showToast('Job started', 'success'); setTimeout(() => loadRepairJobs(), 1000); } catch (error) { console.error('Error running job:', error); showToast('Error starting job', 'error'); } } // ── Repair Job Live Progress ── const _repairProgressLogCounts = {}; const _repairProgressHideTimers = {}; function updateRepairJobProgressFromData(data) { for (const [jobId, state] of Object.entries(data)) { const card = document.querySelector(`.repair-job-card[data-job-id="${jobId}"]`); if (!card) continue; // Update status dot const statusDot = card.querySelector('.repair-job-status'); if (statusDot) { if (state.status === 'running') statusDot.className = 'repair-job-status running'; else if (state.status === 'finished') statusDot.className = 'repair-job-status enabled'; else if (state.status === 'error') statusDot.className = 'repair-job-status enabled'; } // Update flow badge to show running state const firstBadge = card.querySelector('.repair-flow-badge.scan'); if (firstBadge) { if (state.status === 'running') firstBadge.innerHTML = '▶ Running'; else if (state.status === 'finished') firstBadge.innerHTML = '✓ Complete'; else if (state.status === 'error') firstBadge.innerHTML = '✗ Error'; } // Add/update card running class card.classList.toggle('running', state.status === 'running'); card.classList.remove('disabled'); // Create or find progress panel (bar-first layout like automation) let panel = card.querySelector('.repair-job-progress'); if (!panel) { panel = document.createElement('div'); panel.className = 'repair-job-progress'; panel.innerHTML = `
`; card.appendChild(panel); } // Show panel panel.classList.add('visible'); panel.classList.toggle('finished', state.status === 'finished'); panel.classList.toggle('error', state.status === 'error'); if (state.status === 'running') { panel.classList.remove('finished', 'error'); if (_repairProgressHideTimers[jobId]) { clearTimeout(_repairProgressHideTimers[jobId]); delete _repairProgressHideTimers[jobId]; } // Reset log for re-run if (_repairProgressLogCounts[jobId] > 0 && state.log && state.log.length < _repairProgressLogCounts[jobId]) { const existingLog = panel.querySelector('.repair-progress-log'); if (existingLog) existingLog.innerHTML = ''; _repairProgressLogCounts[jobId] = 0; } } // Update progress bar const bar = panel.querySelector('.repair-progress-bar'); if (bar) bar.style.width = (state.progress || 0) + '%'; // Update phase const phaseEl = panel.querySelector('.repair-progress-phase'); if (phaseEl && state.phase) phaseEl.textContent = state.phase; // Update log const logEl = panel.querySelector('.repair-progress-log'); if (logEl && state.log) { const prevCount = _repairProgressLogCounts[jobId] || 0; if (state.log.length > prevCount) { const newLines = state.log.slice(prevCount); for (const line of newLines) { const div = document.createElement('div'); div.className = 'repair-log-line ' + (line.type || 'info'); div.textContent = line.text; logEl.appendChild(div); } logEl.scrollTop = logEl.scrollHeight; } _repairProgressLogCounts[jobId] = state.log.length; } // Auto-hide panel after completion if (state.status === 'finished' || state.status === 'error') { if (!_repairProgressHideTimers[jobId]) { _repairProgressHideTimers[jobId] = setTimeout(() => { panel.classList.remove('visible'); card.classList.remove('running'); delete _repairProgressHideTimers[jobId]; delete _repairProgressLogCounts[jobId]; // Reload to get updated stats loadRepairJobs(); }, 30000); } } else { // Clear any existing hide timer if job restarts if (_repairProgressHideTimers[jobId]) { clearTimeout(_repairProgressHideTimers[jobId]); delete _repairProgressHideTimers[jobId]; } } } } async function loadRepairFindingsDashboard() { const dashboard = document.getElementById('repair-findings-dashboard'); if (!dashboard) return; try { const response = await fetch('/api/repair/findings/counts'); if (!response.ok) throw new Error('Failed to fetch counts'); const data = await response.json(); const pending = data.pending || 0; const resolved = data.resolved || 0; const dismissed = data.dismissed || 0; const autoFixed = data.auto_fixed || 0; const byJob = data.by_job || {}; // Summary stats row let html = '
'; html += `
${pending.toLocaleString()} pending
`; html += `
${resolved.toLocaleString()} resolved
`; html += `
${dismissed.toLocaleString()} dismissed
`; if (autoFixed > 0) { html += `
${autoFixed.toLocaleString()} auto-fixed
`; } html += '
'; // Per-job chips (only if there are pending findings) const jobIds = Object.keys(byJob).sort((a, b) => byJob[b].total - byJob[a].total); if (jobIds.length > 0) { html += '
'; const jobFilter = document.getElementById('repair-findings-job-filter'); const activeJob = jobFilter ? jobFilter.value : ''; for (const jid of jobIds) { const job = byJob[jid]; const isActive = activeJob === jid; const severityDots = []; if (job.warning > 0) severityDots.push(``); if (job.info > 0) severityDots.push(``); html += `
${job.total.toLocaleString()} ${_escFinding(job.display_name || jid.replace(/_/g, ' '))} ${severityDots.length ? `${severityDots.join('')}` : ''}
`; } html += '
'; } dashboard.innerHTML = html; // Load cache health stats _loadCacheHealthStats(dashboard); } catch (error) { console.error('Error loading findings dashboard:', error); dashboard.innerHTML = ''; } } async function _loadCacheHealthStats(dashboard) { try { const response = await fetch('/api/repair/cache-health'); if (!response.ok) return; const stats = await response.json(); if (!stats.total_entities && !stats.total_searches) return; const healthScore = stats.junk_entities === 0 && stats.stale_mb_nulls === 0 ? 'healthy' : stats.junk_entities > 50 ? 'poor' : 'fair'; const healthLabel = healthScore === 'healthy' ? 'Healthy' : healthScore === 'fair' ? 'Needs Cleanup' : 'Needs Attention'; // Remove any existing cache-health bar before appending — prevents // stacking when multiple dashboard refreshes race and each resolved // fetch appends its own section. dashboard.querySelectorAll('.repair-cache-health').forEach(el => el.remove()); const section = document.createElement('div'); section.className = 'repair-cache-health'; section.innerHTML = `
Metadata Cache ${stats.total_entities.toLocaleString()} entities · ${healthLabel} View Details ›
`; dashboard.appendChild(section); } catch (error) { console.error('Error loading cache health:', error); } } async function openCacheHealthModal() { if (document.getElementById('cache-health-modal-overlay')) return; const overlay = document.createElement('div'); overlay.id = 'cache-health-modal-overlay'; overlay.className = 'modal-overlay'; overlay.onclick = (e) => { if (e.target === overlay) overlay.remove(); }; overlay.innerHTML = `
📊

Cache Health

Metadata cache status across all sources

Loading cache stats...
`; document.body.appendChild(overlay); try { const response = await fetch('/api/repair/cache-health'); if (!response.ok) throw new Error('Failed to load'); const s = await response.json(); const body = overlay.querySelector('.cache-health-body'); const healthScore = s.junk_entities === 0 && s.stale_mb_nulls === 0 ? 'healthy' : s.junk_entities > 50 ? 'poor' : 'fair'; const healthEmoji = healthScore === 'healthy' ? '✓' : healthScore === 'fair' ? '⚠' : '❌'; const healthLabel = healthScore === 'healthy' ? 'Cache is healthy' : healthScore === 'fair' ? 'Minor issues detected' : 'Cleanup recommended'; body.innerHTML = `
${healthEmoji}
${healthLabel}
${s.total_entities.toLocaleString()}
Total Entities
${s.total_searches.toLocaleString()}
Search Results
${s.junk_entities}
Junk Entries
0 ? 'onclick="openFailedMBLookupsModal()"' : ''}>
${s.stale_mb_nulls}
Failed MB Lookups
${s.stale_mb_nulls > 0 ? '
Manage ›
' : ''}
By Source
${(() => { const allSources = { ...(s.by_source || {}) }; if (s.total_musicbrainz) allSources['musicbrainz'] = s.total_musicbrainz; const maxCount = Math.max(...Object.values(allSources), 1); return Object.entries(allSources).map(([src, count]) => { const pct = Math.round(count / maxCount * 100); const color = src === 'spotify' ? '#1DB954' : src === 'itunes' ? '#FC3C44' : src === 'deezer' ? '#A238FF' : src === 'musicbrainz' ? '#BA478F' : '#666'; return `
${src === 'musicbrainz' ? 'MusicBrainz' : src}
${count.toLocaleString()}
`; }).join(''); })()}
By Type
${Object.entries(s.by_type || {}).map(([type, count]) => `${type}s ${count.toLocaleString()}`).join('')}
Metrics
Average Age${s.avg_age_days} days
Total Cache Hits${s.total_access_hits.toLocaleString()}
Expiring in 24h${s.expiring_24h}
Expiring in 7 days${s.expiring_7d}
`; } catch (error) { const body = overlay.querySelector('.cache-health-body'); body.innerHTML = '
Failed to load cache stats
'; } } // ── Failed MB Lookups Management Modal ── let _failedMBState = { items: [], total: 0, page: 1, filter: '', typeFilter: '', typeCounts: {} }; async function openFailedMBLookupsModal() { if (document.getElementById('failed-mb-modal-overlay')) return; const overlay = document.createElement('div'); overlay.id = 'failed-mb-modal-overlay'; overlay.className = 'modal-overlay'; overlay.onclick = (e) => { if (e.target === overlay) overlay.remove(); }; overlay.innerHTML = `

Failed MusicBrainz Lookups

Tracks, albums, and artists that couldn't be matched automatically

Loading...
`; document.body.appendChild(overlay); // Search debounce const searchInput = overlay.querySelector('#failed-mb-search'); let searchTimer = null; searchInput.addEventListener('input', () => { clearTimeout(searchTimer); searchTimer = setTimeout(() => { _failedMBState.filter = searchInput.value; _failedMBState.page = 1; _loadFailedMBLookups(); }, 300); }); _failedMBState = { items: [], total: 0, page: 1, filter: '', typeFilter: '', typeCounts: {} }; await _loadFailedMBLookups(); } async function _loadFailedMBLookups() { const body = document.getElementById('failed-mb-body'); if (!body) return; // Only fetch type_counts on first load — cache them for tab switches const needCounts = Object.keys(_failedMBState.typeCounts).length === 0; const params = new URLSearchParams({ page: _failedMBState.page, limit: 50, }); if (needCounts) params.set('counts', 'true'); if (_failedMBState.typeFilter) params.set('entity_type', _failedMBState.typeFilter); if (_failedMBState.filter) params.set('search', _failedMBState.filter); try { const resp = await fetch(`/api/metadata-cache/failed-mb-lookups?${params}`); if (!resp.ok) throw new Error('Failed to load'); const data = await resp.json(); _failedMBState.items = data.items; _failedMBState.total = data.total; if (data.type_counts) _failedMBState.typeCounts = data.type_counts; // Render type filter tabs const tabsEl = document.getElementById('failed-mb-tabs'); if (tabsEl) { const allCount = Object.values(_failedMBState.typeCounts).reduce((a, b) => a + b, 0); let tabsHTML = ``; const typeLabels = { artist: 'Artists', release: 'Albums', recording: 'Tracks' }; for (const [type, count] of Object.entries(_failedMBState.typeCounts)) { tabsHTML += ``; } tabsEl.innerHTML = tabsHTML; } // Render items if (data.items.length === 0) { body.innerHTML = `
${_failedMBState.filter ? 'No matches for your search' : 'No failed lookups — cache is clean!'}
`; } else { const typeIcons = { artist: '🎤', release: '💿', recording: '🎵' }; body.innerHTML = data.items.map(item => `
${typeIcons[item.entity_type] || '?'}
${escapeHtml(item.entity_name)}
${item.artist_name ? `
${escapeHtml(item.artist_name)}
` : ''}
${item.entity_type} ${item.last_updated ? new Date(item.last_updated).toLocaleDateString() : ''}
`).join(''); } // Pagination footer const footer = document.getElementById('failed-mb-footer'); if (footer) { const totalPages = Math.ceil(data.total / 50); footer.innerHTML = totalPages > 1 ? `
Page ${_failedMBState.page} of ${totalPages} (${data.total} total)
` : `
${data.total} entries
`; } } catch (err) { body.innerHTML = '
Failed to load data
'; } } function _failedMBSetType(type) { _failedMBState.typeFilter = type; _failedMBState.page = 1; _loadFailedMBLookups(); } function _failedMBPage(page) { _failedMBState.page = page; _loadFailedMBLookups(); } async function _failedMBDelete(entryId) { try { const resp = await fetch(`/api/metadata-cache/mb-entry/${entryId}`, { method: 'DELETE' }); if (resp.ok) { const row = document.querySelector(`.failed-mb-item[data-id="${entryId}"]`); if (row) { row.style.opacity = '0'; setTimeout(() => { row.remove(); _failedMBState.typeCounts = {}; // Force refresh counts _loadFailedMBLookups(); }, 200); } } } catch (err) { showToast('Failed to delete entry', 'error'); } } async function _failedMBClearAll() { if (!confirm(`Clear all ${_failedMBState.total} failed lookups? They will be retried on next enrichment run.`)) return; try { const resp = await fetch('/api/metadata-cache/clear-musicbrainz?failed_only=true', { method: 'DELETE' }); const data = await resp.json(); if (data.success) { showToast(`Cleared ${data.cleared} failed lookups`, 'success'); _failedMBState.page = 1; _failedMBState.typeCounts = {}; // Force refresh counts _loadFailedMBLookups(); } } catch (err) { showToast('Failed to clear lookups', 'error'); } } // ── MusicBrainz Search Sub-Modal ── async function _failedMBSearch(entryId, entityType, entityName, artistName) { // Remove existing search modal if any const existing = document.getElementById('mb-search-modal-overlay'); if (existing) existing.remove(); const overlay = document.createElement('div'); overlay.id = 'mb-search-modal-overlay'; overlay.className = 'modal-overlay'; overlay.style.zIndex = '10001'; overlay.onclick = (e) => { if (e.target === overlay) overlay.remove(); }; const typeLabels = { artist: 'Artist', release: 'Album', recording: 'Track' }; overlay.innerHTML = `

Search MusicBrainz

Find a match for: ${escapeHtml(entityName)}${artistName ? ` by ${escapeHtml(artistName)}` : ''}

Enter a search query and click Search
`; document.body.appendChild(overlay); // Toggle artist row visibility based on type const typeSelect = overlay.querySelector('#mb-search-type'); typeSelect.addEventListener('change', () => { const artistRow = overlay.querySelector('#mb-search-artist-row'); artistRow.style.display = typeSelect.value === 'artist' ? 'none' : ''; }); // Enter to search overlay.querySelectorAll('.mb-search-input').forEach(input => { input.addEventListener('keydown', (e) => { if (e.key === 'Enter') _runMBSearch(entryId); }); }); // Auto-search on open _runMBSearch(entryId); } async function _runMBSearch(entryId) { const resultsEl = document.getElementById('mb-search-results'); const typeEl = document.getElementById('mb-search-type'); const queryEl = document.getElementById('mb-search-query'); const artistEl = document.getElementById('mb-search-artist'); const goBtn = document.getElementById('mb-search-go-btn'); if (!resultsEl || !queryEl) return; const type = typeEl.value; const query = queryEl.value.trim(); const artist = artistEl ? artistEl.value.trim() : ''; if (!query) return; goBtn.disabled = true; goBtn.textContent = 'Searching...'; resultsEl.innerHTML = '
Searching MusicBrainz...
'; try { const params = new URLSearchParams({ type, q: query, limit: 10 }); if (artist && type !== 'artist') params.set('artist', artist); const resp = await fetch(`/api/musicbrainz/search?${params}`); if (!resp.ok) throw new Error('Search failed'); const data = await resp.json(); if (!data.results || data.results.length === 0) { resultsEl.innerHTML = '
No results found. Try adjusting your search.
'; return; } resultsEl.innerHTML = data.results.map((r, i) => { const scoreColor = r.score >= 90 ? '#4ade80' : r.score >= 70 ? '#fbbf24' : '#f87171'; let detail = ''; if (type === 'release') detail = [r.artist, r.date, r.track_count ? `${r.track_count} tracks` : ''].filter(Boolean).join(' · '); else if (type === 'recording') detail = [r.artist, r.album].filter(Boolean).join(' · '); else detail = [r.type, r.country].filter(Boolean).join(' · '); return `
${r.score}%
${escapeHtml(r.name)}
${r.disambiguation ? `
${escapeHtml(r.disambiguation)}
` : ''} ${detail ? `
${escapeHtml(detail)}
` : ''}
${r.mbid.substring(0, 8)}...
`; }).join(''); } catch (err) { resultsEl.innerHTML = `
Search error: ${err.message}
`; } finally { goBtn.disabled = false; goBtn.textContent = 'Search'; } } async function _selectMBMatch(entryId, mbid, mbName) { try { const resp = await fetch('/api/metadata-cache/mb-match', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ entry_id: entryId, mbid, mb_name: mbName }) }); const data = await resp.json(); if (data.success) { showToast(`Matched to: ${mbName}`, 'success'); // Close search modal, refresh list with fresh counts const searchOverlay = document.getElementById('mb-search-modal-overlay'); if (searchOverlay) searchOverlay.remove(); _failedMBState.typeCounts = {}; _loadFailedMBLookups(); } else { showToast(data.error || 'Failed to save match', 'error'); } } catch (err) { showToast('Failed to save match', 'error'); } } function filterFindingsByJob(jobId) { const jobFilter = document.getElementById('repair-findings-job-filter'); if (!jobFilter) return; // Toggle: click same chip again to clear filter if (jobFilter.value === jobId) { jobFilter.value = ''; } else { jobFilter.value = jobId; } _repairFindingsPage = 0; loadRepairFindingsDashboard(); loadRepairFindings(); } async function loadRepairFindings() { const container = document.getElementById('repair-findings-list'); if (!container) return; const jobFilter = document.getElementById('repair-findings-job-filter'); const severityFilter = document.getElementById('repair-findings-severity-filter'); const statusFilter = document.getElementById('repair-findings-status-filter'); const params = new URLSearchParams(); if (jobFilter && jobFilter.value) params.set('job_id', jobFilter.value); if (severityFilter && severityFilter.value) params.set('severity', severityFilter.value); if (statusFilter && statusFilter.value) params.set('status', statusFilter.value); params.set('page', _repairFindingsPage); params.set('limit', REPAIR_FINDINGS_PAGE_SIZE); try { const response = await fetch(`/api/repair/findings?${params}`); if (!response.ok) throw new Error('Failed to fetch findings'); const data = await response.json(); const items = data.items || []; _repairSelectedFindings.clear(); _repairFindingsTotal = data.total || 0; const bulkBar = document.getElementById('repair-findings-bulk'); if (bulkBar) bulkBar.style.display = 'none'; const selectAllCb = document.getElementById('repair-select-all-cb'); if (selectAllCb) { selectAllCb.checked = false; selectAllCb.indeterminate = false; } if (items.length === 0) { // If the user is on the default "pending" filter and there are // ZERO pending rows but other statuses (dismissed/resolved) do // have rows, auto-switch the filter to "All Status" so the user // sees the carry-over findings instead of an empty pane. Common // case: scanner re-found same issues that were dismissed // previously — dedup-skip means no new pending row, so the // default-filtered tab looks empty even though the badge count // referenced existing dismissed rows. if (statusFilter && statusFilter.value === 'pending' && !_repairFindingsAutoSwitched) { try { const countsResp = await fetch('/api/repair/findings/counts'); if (countsResp.ok) { const counts = await countsResp.json(); const otherTotal = (counts.resolved || 0) + (counts.dismissed || 0) + (counts.auto_fixed || 0); if (otherTotal > 0) { _repairFindingsAutoSwitched = true; statusFilter.value = ''; _repairFindingsPage = 0; await loadRepairFindings(); const list = document.getElementById('repair-findings-list'); if (list && !list.querySelector('.repair-auto-switch-notice')) { const notice = document.createElement('div'); notice.className = 'repair-auto-switch-notice'; notice.style.cssText = 'background:rgba(96,165,250,0.08);border:1px solid rgba(96,165,250,0.25);border-radius:8px;padding:10px 14px;margin-bottom:12px;font-size:13px;color:#cbd5e1;'; notice.innerHTML = `No pending findings, but ${otherTotal.toLocaleString()} carry-over (resolved/dismissed/auto-fixed). Showing All Status — change the filter above to switch back.`; list.parentNode.insertBefore(notice, list); } return; } } } catch (e) { /* fall through to empty state */ } } container.innerHTML = `
All Clear
No findings match your filters. Your library is looking good!
`; document.getElementById('repair-findings-pagination').innerHTML = ''; return; } const severityIcons = { info: 'ℹ️', warning: '⚠️', critical: '🔴' }; const typeLabels = { dead_file: 'Dead File', orphan_file: 'Orphan', acoustid_mismatch: 'Wrong Song', acoustid_no_match: 'No Match', fake_lossless: 'Fake Lossless', duplicate_tracks: 'Duplicate', incomplete_album: 'Incomplete', path_mismatch: 'Path Mismatch', metadata_gap: 'Missing Metadata', missing_cover_art: 'Missing Art', track_number_mismatch: 'Track Number', missing_lyrics: 'Missing Lyrics', expired_download: 'Expired', missing_replaygain: 'No ReplayGain', empty_folder: 'Empty Folder', missing_lossy_copy: 'No Lossy Copy', library_retag: 'Re-tag' }; // Finding types that have an automated fix action const fixableTypes = { dead_file: 'Re-download', orphan_file: 'Resolve', track_number_mismatch: 'Fix', missing_cover_art: 'Apply Art', missing_lyrics: 'Apply Lyrics', missing_replaygain: 'Apply RG', empty_folder: 'Delete Folder', expired_download: 'Delete', metadata_gap: 'Apply', duplicate_tracks: 'Keep Best', incomplete_album: 'Auto-Fill', missing_lossy_copy: 'Convert', acoustid_mismatch: 'Fix', missing_discography_track: 'Add to Wishlist', library_retag: 'Apply Tags', }; container.innerHTML = items.map(f => { const icon = severityIcons[f.severity] || 'ℹ️'; const age = formatCacheAge(f.created_at); const actionLabels = { removed_db_entry: 'Entry Removed', added_to_wishlist: 'Wishlisted', deleted_file: 'File Deleted', already_gone: 'Already Gone', fixed_track_number: 'Track # Fixed', applied_cover_art: 'Art Applied', applied_metadata: 'Metadata Applied', applied_lyrics: 'Lyrics Applied', deleted_expired: 'Deleted', removed_duplicates: 'Duplicates Removed', }; let statusBadge = ''; if (f.status !== 'pending') { const actionText = actionLabels[f.user_action] || f.status; statusBadge = `${actionText}`; } const typeLabel = typeLabels[f.finding_type] || f.finding_type.replace(/_/g, ' '); const d = f.details || {}; const filePath = f.file_path || d.original_path || d.file_path || ''; const fixLabel = fixableTypes[f.finding_type]; return `
${icon} ${_escFinding(f.title)} ${typeLabel} ${statusBadge}
${_escFinding(f.description || '')}
${filePath ? `
${_escFinding(filePath)}
` : ''}
${f.job_id.replace(/_/g, ' ')} · ${f.entity_type || 'file'} ${f.entity_id ? `·ID: ${f.entity_id}` : ''} · ${age}
${f.status === 'pending' ? ` ${fixLabel ? `` : ''} ` : ''}
${_renderFindingDetail(f)}
`; }).join(''); // Pagination renderRepairFindingsPagination(data.total, data.page); } catch (error) { console.error('Error loading findings:', error); container.innerHTML = '
Error loading findings
'; } } function _escFinding(s) { if (!s) return ''; return s.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); } function _renderScoreBar(value, label) { const pct = Math.round((value || 0) * 100); const cls = pct >= 80 ? 'good' : pct >= 50 ? 'warn' : 'bad'; return `
${label}
${pct}%
`; } function _formatFileSize(bytes) { if (!bytes) return '-'; if (bytes < 1024) return bytes + ' B'; if (bytes < 1048576) return (bytes / 1024).toFixed(1) + ' KB'; return (bytes / 1048576).toFixed(1) + ' MB'; } function _renderPlayButton(f) { const d = f.details || {}; const filePath = f.file_path || d.file_path || d.original_path; if (!filePath) return ''; const title = d.expected_title || d.title || d.file_title || d.matched_title || ''; const artist = d.expected_artist || d.artist || d.artist_name || ''; const album = d.album || d.album_title || ''; const albumArt = d.album_thumb_url || ''; return ``; } function playFindingTrack(btn) { const track = { file_path: btn.dataset.path, title: btn.dataset.title || 'Unknown Track', id: btn.dataset.entityId || null }; const albumTitle = btn.dataset.album || ''; const artistName = btn.dataset.artist || ''; playLibraryTrack(track, albumTitle, artistName); } function _renderFindingMedia(d) { const albumUrl = d.album_thumb_url; const artistUrl = d.artist_thumb_url; if (!albumUrl && !artistUrl) return ''; let html = '
'; if (albumUrl) { const albumLabel = d.album_title || 'Album'; html += `
Album art ${_escFinding(albumLabel)}
`; } if (artistUrl) { const artistLabel = d.artist_name || d.artist || 'Artist'; html += `
Artist ${_escFinding(artistLabel)}
`; } html += '
'; return html; } function _renderFindingDetail(f) { const d = f.details || {}; const rows = []; const media = _renderFindingMedia(d); switch (f.finding_type) { case 'dead_file': if (d.artist) rows.push(['Artist', d.artist]); if (d.album) rows.push(['Album', d.album]); if (d.title) rows.push(['Title', d.title]); if (d.track_id) rows.push(['Track ID', d.track_id]); if (d.original_path) rows.push(['Original Path', d.original_path, 'path']); return media + _gridRows(rows) + _renderPlayButton(f); case 'orphan_file': if (d.folder) rows.push(['Folder', d.folder, 'path']); if (d.format) rows.push(['Format', d.format.toUpperCase()]); if (d.file_size) rows.push(['File Size', _formatFileSize(d.file_size)]); if (d.modified) rows.push(['Last Modified', d.modified]); if (f.file_path) rows.push(['Full Path', f.file_path, 'path']); return _gridRows(rows) + _renderPlayButton(f); case 'library_retag': { const tracks = Array.isArray(d.tracks) ? d.tracks : []; const changed = tracks.filter(t => t.changes && Object.keys(t.changes).length); const meta = []; if (d.source) meta.push(`Source: ${d.source}`); if (d.mode) meta.push(`Mode: ${d.mode}`); if (d.cover_action) meta.push(`Cover: ${d.cover_action}`); let html = ''; if (meta.length) { html += `
${_escFinding(meta.join(' · '))}
`; } if (!changed.length && d.cover_action) { html += `
Tags already correct — this would refresh cover art only.
`; } // Per-track old → new diff (cap the rendered list so huge albums stay sane). changed.slice(0, 40).forEach(t => { const fname = (t.file_path || '').split(/[\\/]/).pop(); const label = t.title || fname || 'Unknown'; const rows = Object.entries(t.changes).map(([field, c]) => [ field.replace(/_/g, ' '), `${(c.old === '' || c.old == null) ? '∅' : c.old} → ${c.new}`, 'highlight', ]); html += `
${_escFinding(label)}
`; // Show the physical filename so a wrong match is easy to spot before // applying (skip when the label already IS the filename, i.e. no title). if (fname && fname !== label) { html += `
📄 ${_escFinding(fname)}
`; } html += _gridRows(rows); }); if (changed.length > 40) { html += `
…and ${changed.length - 40} more track(s)
`; } if (Array.isArray(d.unmatched) && d.unmatched.length) { html += `
Unmatched (left untouched): ${_escFinding(d.unmatched.join(', '))}
`; } return html || '
No changes.
'; } case 'acoustid_mismatch': { let html = media + '
'; html += _renderScoreBar(d.fingerprint_score, 'Fingerprint'); html += _renderScoreBar(d.title_similarity, 'Title Match'); html += _renderScoreBar(d.artist_similarity, 'Artist Match'); html += '
'; rows.push(['Expected Title', d.expected_title || '-']); rows.push(['Expected Artist', d.expected_artist || '-']); rows.push(['AcoustID Title', d.acoustid_title || '-', 'highlight']); rows.push(['AcoustID Artist', d.acoustid_artist || '-', 'highlight']); if (f.file_path) rows.push(['File', f.file_path, 'path']); return html + _gridRows(rows) + _renderPlayButton(f); } case 'acoustid_no_match': if (d.expected_title) rows.push(['Expected Title', d.expected_title]); if (d.expected_artist) rows.push(['Expected Artist', d.expected_artist]); if (f.file_path) rows.push(['File', f.file_path, 'path']); return media + _gridRows(rows) + _renderPlayButton(f); case 'fake_lossless': { const cutoff = d.detected_cutoff_khz || 0; const expectedMin = d.expected_min_khz || 0; const nyquist = d.nyquist_khz || (d.sample_rate ? d.sample_rate / 2000 : 22.05); let flHtml = ''; if (cutoff && expectedMin) { const cutoffPct = Math.min(100, Math.round((cutoff / nyquist) * 100)); const expectedPct = Math.min(100, Math.round((expectedMin / nyquist) * 100)); flHtml += `
Spectral Analysis
${cutoff} kHz detected ${expectedMin} kHz expected min
`; } if (d.format) rows.push(['Format', d.format.toUpperCase()]); if (d.sample_rate) rows.push(['Sample Rate', `${d.sample_rate} Hz`]); if (d.bit_depth) rows.push(['Bit Depth', `${d.bit_depth}-bit`]); if (d.bitrate) rows.push(['Bitrate', `${d.bitrate} kbps`]); if (d.file_size) rows.push(['File Size', _formatFileSize(d.file_size)]); if (f.file_path) rows.push(['File', f.file_path, 'path']); return flHtml + _gridRows(rows) + _renderPlayButton(f); } case 'duplicate_tracks': if (!d.tracks || !d.tracks.length) return _gridRows([['Count', d.count || '?']]); // Determine best copy — same logic as the backend // (core/library/duplicate_keep.py): lossless format first, so a FLAC // beats an MP3 even when the FLAC's bitrate is missing, then bitrate, // duration, track number. const _dupFmtRank = (fp) => { const r = { flac: 10, wav: 9, aiff: 9, aif: 9, ape: 8, m4a: 7, ogg: 6, opus: 6, mp3: 5, aac: 5, wma: 3 }; return r[String(fp || '').split('.').pop().toLowerCase()] || 1; }; const _dupKey = (t) => [_dupFmtRank(t.file_path), t.bitrate || 0, t.duration || 0, t.track_number || 0]; const bestDup = d.tracks.reduce((best, t) => { const bk = _dupKey(best), tk = _dupKey(t); for (let i = 0; i < bk.length; i++) { if (tk[i] !== bk[i]) return tk[i] > bk[i] ? t : best; } return best; }, d.tracks[0]); const findingId = f.id; return media + `
${d.tracks.map((t, i) => { const tid = t.track_id || t.id; const isBest = (t.id === bestDup.id); return `
${isBest ? 'KEEP' : 'REMOVE'} ${_escFinding(t.title)} by ${_escFinding(t.artist)} Album: ${_escFinding(t.album || 'Unknown')}${t.bitrate ? ` · ${t.bitrate} kbps` : ''}${t.duration ? ` · ${Math.round(t.duration)}s` : ''}${t.track_number ? ` · Track #${t.track_number}` : ''} ${t.file_path ? `${_escFinding(t.file_path)}` : ''}
`; }).join('')}
Click on a version to keep it, or use "Keep Best" for auto-selection
`; case 'incomplete_album': if (d.artist) rows.push(['Artist', d.artist]); if (d.album_title) rows.push(['Album', d.album_title]); if (d.primary_source && d.primary_album_id) { const primaryLabel = d.primary_source.charAt(0).toUpperCase() + d.primary_source.slice(1); rows.push([`${primaryLabel} ID`, d.primary_album_id]); if (d.spotify_album_id && d.primary_source !== 'spotify') { rows.push(['Spotify ID', d.spotify_album_id]); } } else if (d.spotify_album_id) { rows.push(['Spotify ID', d.spotify_album_id]); } let incHtml = media + _gridRows(rows); const actual = d.actual_tracks || 0, expected = d.expected_tracks || 0; if (expected > 0) { const pct = Math.round((actual / expected) * 100); incHtml += `
${actual} of ${expected} tracks (${pct}%)
`; } if (d.missing_tracks && d.missing_tracks.length) { incHtml += `
${d.missing_tracks.map(t => `
#${t.track_number || '?'} ${_escFinding(t.name || t.title || 'Unknown')} ${t.source && t.source !== 'spotify' ? `Source: ${_escFinding(t.source)}${t.source_track_id ? ` · ID: ${_escFinding(t.source_track_id)}` : ''}` : ''} ${t.duration_ms ? `Duration: ${Math.round(t.duration_ms / 1000)}s` : ''}
`).join('')}
`; } return incHtml; case 'path_mismatch': if (d.from) rows.push(['Current Path', d.from, 'path']); if (d.to) rows.push(['Expected Path', d.to, 'success']); return _gridRows(rows); case 'metadata_gap': if (d.artist) rows.push(['Artist', d.artist]); if (d.album) rows.push(['Album', d.album]); if (d.title) rows.push(['Title', d.title]); if (d.spotify_track_id) rows.push(['Spotify ID', d.spotify_track_id]); if (d.resolved_source) rows.push(['Resolved Source', d.resolved_source]); if (d.resolved_track_id) rows.push(['Resolved Track ID', d.resolved_track_id]); if (d.found_fields && typeof d.found_fields === 'object') { Object.entries(d.found_fields).forEach(([k, v]) => { rows.push([`Found: ${k}`, String(v), 'success']); }); } return media + _gridRows(rows); case 'missing_cover_art': if (d.artist) rows.push(['Artist', d.artist]); if (d.album_title) rows.push(['Album', d.album_title]); if (d.spotify_album_id) rows.push(['Spotify ID', d.spotify_album_id]); let artHtml = ''; // Each found image is independently applyable (Pache711: "fix one, // dismiss the other"). Per-image Apply buttons let the user take the // correct album art and skip a wrong artist image, or vice versa. if (d.artist_thumb_url || d.found_artwork_url || d.found_artist_url) { artHtml += '
'; if (d.found_artwork_url) { artHtml += `
Found album art Found Album Art
`; } // Current artist image (context) — no apply, it's what's there now. if (d.artist_thumb_url) { artHtml += `
Current artist art ${_escFinding(d.artist || 'Artist')} (current)
`; } // Found artist image — the applyable replacement. if (d.found_artist_url) { artHtml += `
Found artist art Found Artist Art
`; } artHtml += '
'; } artHtml += _gridRows(rows); return artHtml; case 'missing_lyrics': if (d.track_title) rows.push(['Track', d.track_title]); if (d.artist) rows.push(['Artist', d.artist]); if (d.album_title) rows.push(['Album', d.album_title]); return _gridRows(rows); case 'missing_replaygain': if (d.track_title) rows.push(['Track', d.track_title]); if (d.artist) rows.push(['Artist', d.artist]); return _gridRows(rows); case 'empty_folder': if (d.folder_path) rows.push(['Folder', d.folder_path, 'path']); if (d.junk_files && d.junk_files.length) rows.push(['Junk files', d.junk_files.join(', ')]); return _gridRows(rows); case 'expired_download': if (d.title) rows.push(['Track', d.title]); if (d.artist) rows.push(['Artist', d.artist]); if (d.origin) rows.push(['Source', `${d.origin}${d.origin_context ? ' — ' + d.origin_context : ''}`]); if (d.file_path) rows.push(['File', d.file_path.split(/[\\/]/).pop()]); return _gridRows(rows); case 'track_number_mismatch': if (d.album_title) rows.push(['Album', d.album_title]); if (d.artist_name) rows.push(['Artist', d.artist_name]); if (d.matched_title) rows.push(['Matched To', d.matched_title]); if (d.file_title) rows.push(['File Title', d.file_title]); if (d.current_track_num !== undefined) rows.push(['Current Track #', String(d.current_track_num)]); if (d.correct_track_num !== undefined) rows.push(['Correct Track #', String(d.correct_track_num), 'success']); if (f.file_path) rows.push(['File', f.file_path, 'path']); let tnHtml = media; if (d.match_score) { tnHtml += '
'; tnHtml += _renderScoreBar(d.match_score, 'Title Match'); tnHtml += '
'; } tnHtml += _gridRows(rows); if (d.changes && d.changes.length) { tnHtml += `
${d.changes.map(c => `
${_escFinding(c)}
`).join('')}
`; } tnHtml += _renderPlayButton(f); return tnHtml; default: // Generic: render all detail keys Object.entries(d).forEach(([k, v]) => { if (typeof v !== 'object' && !k.endsWith('_thumb_url')) { rows.push([k.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase()), String(v)]); } }); if (f.file_path) rows.push(['File', f.file_path, 'path']); return (media || '') + (rows.length ? _gridRows(rows) : 'No additional details available'); } } function _gridRows(rows) { if (!rows.length) return ''; return `
${rows.map(([k, v, cls]) => `${_escFinding(k)}${_escFinding(v)}` ).join('')}
`; } function toggleFindingDetail(id) { const panel = document.getElementById(`repair-detail-${id}`); const btn = document.querySelector(`.repair-finding-expand-btn[data-finding="${id}"]`); if (!panel) return; const isOpen = panel.classList.toggle('open'); if (btn) btn.classList.toggle('open', isOpen); } function toggleFindingSelect(id, checked) { if (checked) _repairSelectedFindings.add(id); else _repairSelectedFindings.delete(id); _updateFindingsBulkBar(); } function _updateFindingsBulkBar() { const bulkBar = document.getElementById('repair-findings-bulk'); const count = _repairSelectedFindings.size; if (bulkBar) bulkBar.style.display = count > 0 ? '' : 'none'; const countEl = document.getElementById('repair-bulk-count'); if (countEl) countEl.textContent = count > 0 ? `${count} selected` : ''; // Show "Fix All (N)" when all on page are selected and there are more pages const fixAllBtn = document.getElementById('repair-fix-all-btn'); if (fixAllBtn && _repairFindingsTotal > 0) { const allPageSelected = count > 0 && count >= document.querySelectorAll('.repair-finding-card').length; fixAllBtn.style.display = (allPageSelected && _repairFindingsTotal > count) ? '' : 'none'; fixAllBtn.textContent = `Fix All ${_repairFindingsTotal}`; } // Sync "Select All" checkbox const selectAllCb = document.getElementById('repair-select-all-cb'); if (selectAllCb) { const totalOnPage = document.querySelectorAll('.repair-finding-card').length; selectAllCb.checked = totalOnPage > 0 && count >= totalOnPage; selectAllCb.indeterminate = count > 0 && count < totalOnPage; } } function toggleSelectAllFindings(checked) { const checkboxes = document.querySelectorAll('.repair-finding-select input[type="checkbox"]'); checkboxes.forEach(cb => { cb.checked = checked; const card = cb.closest('.repair-finding-card'); if (card) { const id = parseInt(card.dataset.id); if (checked) _repairSelectedFindings.add(id); else _repairSelectedFindings.delete(id); } }); _updateFindingsBulkBar(); } async function fixAllMatchingFindings() { const jobFilter = document.getElementById('repair-findings-job-filter'); const severityFilter = document.getElementById('repair-findings-severity-filter'); const jobId = jobFilter ? jobFilter.value : ''; const severity = severityFilter ? severityFilter.value : ''; // If fixing orphan files or dead files, prompt for action FIRST let fixAction = null; // Discography backfill: 3-option prompt (Add to Wishlist / Just Clear / Cancel). // "Just Clear" bypasses bulk-fix entirely and goes through the clear endpoint, // which is why it's handled inline and returns early. if (jobId === 'discography_backfill') { const choice = await _promptDiscographyBackfillAction(_repairFindingsTotal); if (!choice) return; if (choice === 'dismiss') { if (!await showConfirmDialog({ title: 'Clear All Discography Findings', message: `Clear all ${_repairFindingsTotal} discography backfill findings without adding any to the wishlist? Tracks can be re-detected next scan.`, confirmText: 'Clear All', destructive: false })) return; showToast(`Clearing ${_repairFindingsTotal} findings...`, 'info'); try { const resp = await fetch('/api/repair/findings/clear', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ job_id: 'discography_backfill', status: 'pending' }) }); const result = await resp.json(); if (result.success) { showToast(`Cleared ${result.deleted} findings`, 'success'); } else { showToast(result.error || 'Clear failed', 'error'); } } catch (err) { console.error('Error clearing findings:', err); showToast('Error clearing findings', 'error'); } _repairSelectedFindings.clear(); loadRepairFindingsDashboard(); loadRepairFindings(); updateRepairStatus(); return; } // 'add_to_wishlist' falls through to bulk-fix. No destructive warning — // the backend handler only adds tracks to the wishlist. } else if (jobId === 'dead_file_cleaner') { fixAction = await _promptDeadFileAction(); if (!fixAction) return; } else if (jobId === 'orphan_file_detector' || _isMassOrphanFix(jobId, _repairFindingsTotal)) { fixAction = await _promptOrphanAction(); if (!fixAction) return; // Confirm before proceeding if (fixAction === 'delete' && _repairFindingsTotal > 50) { if (!await showWitnessMeDialog(_repairFindingsTotal)) return; } else if (fixAction === 'delete') { if (!await showConfirmDialog({ title: 'Delete Orphan Files', message: `Permanently delete ${_repairFindingsTotal} orphan files from disk? This cannot be undone.`, confirmText: 'Delete', destructive: true })) return; } else if (fixAction === 'staging') { if (!await showConfirmDialog({ title: 'Move to Staging', message: `Move ${_repairFindingsTotal} orphan files to the import folder? Files are NOT deleted — you can review and import them.`, confirmText: 'Move All to Staging', destructive: false })) return; } } else { const scopeLabel = jobId ? jobId.replace(/_/g, ' ') : 'all jobs'; if (!await showConfirmDialog({ title: 'Fix All Findings', message: `Apply fixes to all ${_repairFindingsTotal} pending fixable findings for ${scopeLabel}? This may delete files or remove database entries depending on finding type.`, confirmText: 'Fix All', destructive: true })) return; } showToast(`Fixing ${_repairFindingsTotal} findings...`, 'info'); try { const body = {}; if (jobId) body.job_id = jobId; if (severity) body.severity = severity; if (fixAction) body.fix_action = fixAction; const response = await fetch('/api/repair/findings/bulk-fix', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }); const result = await response.json(); if (result.success) { let msg = `Fixed ${result.fixed}${result.failed ? `, ${result.failed} failed` : ''} of ${result.total}`; if (result.errors && result.errors.length > 0) { msg += `: ${result.errors[0].error}`; } showToast(msg, result.fixed > 0 ? 'success' : 'error'); } else { showToast(result.error || 'Bulk fix failed', 'error'); } } catch (error) { console.error('Error in bulk fix:', error); showToast('Error applying bulk fix', 'error'); } _repairSelectedFindings.clear(); loadRepairFindingsDashboard(); loadRepairFindings(); updateRepairStatus(); } function renderRepairFindingsPagination(total, currentPage) { const container = document.getElementById('repair-findings-pagination'); if (!container) return; const totalPages = Math.ceil(total / REPAIR_FINDINGS_PAGE_SIZE); if (totalPages <= 1) { container.innerHTML = ''; return; } let html = ''; if (currentPage > 0) { html += ``; } // Smart page range let startPage = Math.max(0, currentPage - 3); let endPage = Math.min(totalPages, startPage + 7); if (endPage - startPage < 7) startPage = Math.max(0, endPage - 7); if (startPage > 0) { html += ``; if (startPage > 1) html += '...'; } for (let i = startPage; i < endPage; i++) { html += ``; } if (endPage < totalPages) { if (endPage < totalPages - 1) html += '...'; html += ``; } if (currentPage < totalPages - 1) { html += ``; } html += `${total.toLocaleString()} total`; container.innerHTML = html; } async function selectDuplicateToKeep(findingId, keepTrackId) { if (!await showConfirmDialog({ title: 'Keep This Version', message: 'Keep this version and remove the other duplicate(s)?', confirmText: 'Keep', destructive: true })) return; try { const response = await fetch(`/api/repair/findings/${findingId}/fix`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ fix_action: keepTrackId }), }); const result = await response.json(); if (result.success) { showToast(result.message || 'Duplicate resolved', 'success'); } else { showToast(result.error || 'Failed to resolve duplicate', 'error'); } loadRepairFindingsDashboard(); loadRepairFindings(); updateRepairStatus(); } catch (error) { console.error('Error fixing duplicate:', error); showToast('Error resolving duplicate', 'error'); } } async function applyCoverArtTarget(id, target) { // Per-image apply for a cover-art finding (Pache711). target: 'album' | // 'artist'. Applying either resolves the finding, so picking the correct // image and ignoring the wrong one = "fix one, dismiss the other". try { const response = await fetch(`/api/repair/findings/${id}/fix`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ fix_action: target }), }); const result = await response.json(); if (result.success) { showToast(result.message || `Applied ${target} art`, 'success'); } else { showToast(result.error || `Failed to apply ${target} art`, 'error'); } loadRepairFindingsDashboard(); loadRepairFindings(); updateRepairStatus(); } catch (error) { console.error('Error applying cover art target:', error); showToast('Error applying art', 'error'); } } async function fixRepairFinding(id, findingType) { // Orphan files require user to choose an action let fixAction = null; if (findingType === 'orphan_file') { fixAction = await _promptOrphanAction(); if (!fixAction) return; // User cancelled } // Dead files: re-download or just remove from DB if (findingType === 'dead_file') { fixAction = await _promptDeadFileAction(); if (!fixAction) return; } // AcoustID mismatch: retag, redownload, or delete if (findingType === 'acoustid_mismatch') { fixAction = await _promptAcoustidAction(); if (!fixAction) return; } // Discography backfill: add to wishlist or just clear the finding if (findingType === 'missing_discography_track') { const choice = await _promptDiscographyBackfillAction(1); if (!choice) return; // cancel if (choice === 'dismiss') { // User just wants to remove the finding without adding to wishlist await dismissRepairFinding(id); return; } // 'add_to_wishlist' — fall through to the fix endpoint. The handler // already defaults to adding to wishlist, so no fix_action is needed. } const card = document.querySelector(`.repair-finding-card[data-id="${id}"]`); const fixBtn = card ? card.querySelector('.repair-finding-btn.fix') : null; let originalText = ''; if (fixBtn) { originalText = fixBtn.textContent; fixBtn.disabled = true; fixBtn.textContent = '...'; } try { const body = fixAction ? { fix_action: fixAction } : {}; const response = await fetch(`/api/repair/findings/${id}/fix`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), }); const result = await response.json(); if (result.success) { showToast(result.message || 'Fixed successfully', 'success'); } else { showToast(result.error || 'Fix failed', 'error'); } loadRepairFindingsDashboard(); loadRepairFindings(); updateRepairStatus(); } catch (error) { console.error('Error fixing finding:', error); showToast('Error applying fix', 'error'); if (fixBtn) { fixBtn.disabled = false; fixBtn.textContent = originalText; } } } function _promptOrphanAction() { return new Promise(resolve => { const overlay = document.createElement('div'); overlay.className = 'modal-overlay'; overlay.style.cssText = 'display:flex;align-items:center;justify-content:center;z-index:10000;'; overlay.innerHTML = `
Orphan File Action
Choose how to handle orphan files. Staging is safe and reversible.
`; document.body.appendChild(overlay); overlay.querySelector('#_orphan-staging').onclick = () => { overlay.remove(); resolve('staging'); }; overlay.querySelector('#_orphan-delete').onclick = () => { overlay.remove(); resolve('delete'); }; overlay.querySelector('#_orphan-cancel').onclick = () => { overlay.remove(); resolve(null); }; overlay.onclick = (e) => { if (e.target === overlay) { overlay.remove(); resolve(null); } }; }); } function _promptDeadFileAction() { return new Promise(resolve => { const overlay = document.createElement('div'); overlay.className = 'modal-overlay'; overlay.style.cssText = 'display:flex;align-items:center;justify-content:center;z-index:10000;'; overlay.innerHTML = `
Dead File Action
This track's file no longer exists on disk. Choose how to handle it.
`; document.body.appendChild(overlay); overlay.querySelector('#_dead-redownload').onclick = () => { overlay.remove(); resolve('redownload'); }; overlay.querySelector('#_dead-remove').onclick = () => { overlay.remove(); resolve('remove'); }; overlay.querySelector('#_dead-cancel').onclick = () => { overlay.remove(); resolve(null); }; overlay.onclick = (e) => { if (e.target === overlay) { overlay.remove(); resolve(null); } }; }); } function _promptAcoustidAction() { return new Promise(resolve => { const overlay = document.createElement('div'); overlay.className = 'modal-overlay'; overlay.style.cssText = 'display:flex;align-items:center;justify-content:center;z-index:10000;'; overlay.innerHTML = `
AcoustID Mismatch
The audio fingerprint doesn't match the expected track. Choose how to fix it.
Retag = update metadata in place • Relocate = retag + move to Staging so it's re-imported into the correct artist/album • Re-download = add correct track to wishlist & delete wrong file • Delete = remove file and DB entry
`; document.body.appendChild(overlay); overlay.querySelector('#_acid-retag').onclick = () => { overlay.remove(); resolve('retag'); }; overlay.querySelector('#_acid-relocate').onclick = () => { overlay.remove(); resolve('relocate'); }; overlay.querySelector('#_acid-redownload').onclick = () => { overlay.remove(); resolve('redownload'); }; overlay.querySelector('#_acid-delete').onclick = () => { overlay.remove(); resolve('delete'); }; overlay.querySelector('#_acid-cancel').onclick = () => { overlay.remove(); resolve(null); }; overlay.onclick = (e) => { if (e.target === overlay) { overlay.remove(); resolve(null); } }; }); } function _promptDiscographyBackfillAction(count = 1) { const isSingle = count <= 1; const headerText = isSingle ? 'Missing Discography Track' : `Missing Discography Tracks (${count})`; const bodyText = isSingle ? 'Add this track to the wishlist for automatic download, or just clear the finding?' : `Add all ${count} selected tracks to the wishlist for automatic download, or just clear the findings?`; const addLabel = isSingle ? 'Add to Wishlist' : `Add All ${count} to Wishlist`; const clearLabel = isSingle ? 'Just Clear Finding' : 'Just Clear Findings'; return new Promise(resolve => { const overlay = document.createElement('div'); overlay.className = 'modal-overlay'; overlay.style.cssText = 'display:flex;align-items:center;justify-content:center;z-index:10000;'; overlay.innerHTML = `
`; // Assign text content (avoids HTML-escaping gotchas with dynamic values) overlay.querySelector('#_dbf-header').textContent = headerText; overlay.querySelector('#_dbf-body').textContent = bodyText; overlay.querySelector('#_dbf-add').textContent = addLabel; overlay.querySelector('#_dbf-dismiss').textContent = clearLabel; document.body.appendChild(overlay); overlay.querySelector('#_dbf-add').onclick = () => { overlay.remove(); resolve('add_to_wishlist'); }; overlay.querySelector('#_dbf-dismiss').onclick = () => { overlay.remove(); resolve('dismiss'); }; overlay.querySelector('#_dbf-cancel').onclick = () => { overlay.remove(); resolve(null); }; overlay.onclick = (e) => { if (e.target === overlay) { overlay.remove(); resolve(null); } }; }); } async function resolveRepairFinding(id) { try { await fetch(`/api/repair/findings/${id}/resolve`, { method: 'POST' }); loadRepairFindingsDashboard(); loadRepairFindings(); updateRepairStatus(); } catch (error) { console.error('Error resolving finding:', error); } } async function dismissRepairFinding(id) { try { await fetch(`/api/repair/findings/${id}/dismiss`, { method: 'POST' }); loadRepairFindingsDashboard(); loadRepairFindings(); updateRepairStatus(); } catch (error) { console.error('Error dismissing finding:', error); } } async function bulkRepairAction(action) { if (_repairSelectedFindings.size === 0) return; try { await fetch('/api/repair/findings/bulk', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ids: Array.from(_repairSelectedFindings), action }) }); showToast(`${_repairSelectedFindings.size} findings ${action === 'dismiss' ? 'dismissed' : 'resolved'}`, 'success'); _repairSelectedFindings.clear(); loadRepairFindingsDashboard(); loadRepairFindings(); updateRepairStatus(); } catch (error) { console.error('Error bulk updating findings:', error); showToast('Error updating findings', 'error'); } } async function bulkFixFindings() { if (_repairSelectedFindings.size === 0) return; const ids = Array.from(_repairSelectedFindings); // If any selected findings are orphan files, prompt for action FIRST const selectedOrphanCards = ids.filter(id => { const card = document.querySelector(`.repair-finding-card[data-id="${id}"]`); return card && card.dataset.jobId === 'orphan_file_detector'; }); let orphanFixAction = null; if (selectedOrphanCards.length > 0) { orphanFixAction = await _promptOrphanAction(); if (!orphanFixAction) return; // Only show scary dialog for mass deletion, not staging if (orphanFixAction === 'delete' && selectedOrphanCards.length > MASS_ORPHAN_THRESHOLD) { const hasMassFlag = ids.some(id => { const card = document.querySelector(`.repair-finding-card[data-id="${id}"]`); return card && card.dataset.massOrphan === 'true'; }); if (hasMassFlag && !await showWitnessMeDialog(selectedOrphanCards.length)) return; } } // If any selected findings are dead files, prompt for action const selectedDeadCards = ids.filter(id => { const card = document.querySelector(`.repair-finding-card[data-id="${id}"]`); return card && card.dataset.jobId === 'dead_file_cleaner'; }); let deadFixAction = null; if (selectedDeadCards.length > 0) { deadFixAction = await _promptDeadFileAction(); if (!deadFixAction) return; } // If any selected findings are AcoustID mismatches, prompt for action const selectedAcoustidCards = ids.filter(id => { const card = document.querySelector(`.repair-finding-card[data-id="${id}"]`); return card && card.dataset.jobId === 'acoustid_scanner'; }); let acoustidFixAction = null; if (selectedAcoustidCards.length > 0) { acoustidFixAction = await _promptAcoustidAction(); if (!acoustidFixAction) return; } // If any selected findings are discography backfill, prompt once (add-to-wishlist vs clear) const selectedBackfillCards = ids.filter(id => { const card = document.querySelector(`.repair-finding-card[data-id="${id}"]`); return card && card.dataset.jobId === 'discography_backfill'; }); let backfillAction = null; if (selectedBackfillCards.length > 0) { backfillAction = await _promptDiscographyBackfillAction(selectedBackfillCards.length); if (!backfillAction) return; } let fixed = 0, failed = 0, lastError = ''; showToast(`Fixing ${ids.length} findings...`, 'info'); for (const id of ids) { try { // Determine if this finding needs a specific action const card = document.querySelector(`.repair-finding-card[data-id="${id}"]`); const isOrphan = card && card.dataset.jobId === 'orphan_file_detector'; const isDead = card && card.dataset.jobId === 'dead_file_cleaner'; const isAcoustid = card && card.dataset.jobId === 'acoustid_scanner'; const isBackfill = card && card.dataset.jobId === 'discography_backfill'; // Discography backfill "Just Clear" path uses the dismiss endpoint, // not the fix endpoint — so handle it inline before the fix call. if (isBackfill && backfillAction === 'dismiss') { try { const resp = await fetch(`/api/repair/findings/${id}/dismiss`, { method: 'POST' }); if (resp.ok) fixed++; else { failed++; lastError = 'dismiss failed'; } } catch { failed++; } continue; } let body = {}; if (isOrphan && orphanFixAction) body = { fix_action: orphanFixAction }; else if (isDead && deadFixAction) body = { fix_action: deadFixAction }; else if (isAcoustid && acoustidFixAction) body = { fix_action: acoustidFixAction }; // Discography backfill "Add to Wishlist" falls through with empty body // — the fix handler already adds to wishlist by default. const response = await fetch(`/api/repair/findings/${id}/fix`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), }); const result = await response.json(); if (result.success) fixed++; else { failed++; lastError = result.error || 'unknown error'; } } catch { failed++; } } _repairSelectedFindings.clear(); let fixMsg = `Fixed ${fixed}${failed ? `, ${failed} failed` : ''}`; if (failed && lastError) fixMsg += `: ${lastError}`; showToast(fixMsg, fixed > 0 ? 'success' : 'error'); loadRepairFindingsDashboard(); loadRepairFindings(); updateRepairStatus(); } async function clearRepairFindings() { const jobFilter = document.getElementById('repair-findings-job-filter'); const statusFilter = document.getElementById('repair-findings-status-filter'); const jobId = jobFilter ? jobFilter.value : ''; const status = statusFilter ? statusFilter.value : ''; const scopeLabel = jobId ? jobId.replace(/_/g, ' ') : 'all jobs'; const statusLabel = status ? ` (${status})` : ''; if (!await showConfirmDialog({ title: 'Clear Findings', message: `Delete all findings for ${scopeLabel}${statusLabel}? This cannot be undone.`, confirmText: 'Clear', destructive: true })) return; try { const body = {}; if (jobId) body.job_id = jobId; if (status) body.status = status; const response = await fetch('/api/repair/findings/clear', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }); const result = await response.json(); if (result.success) { showToast(`Cleared ${result.deleted} findings`, 'success'); } else { showToast(result.error || 'Failed to clear findings', 'error'); } _repairSelectedFindings.clear(); loadRepairFindingsDashboard(); loadRepairFindings(); updateRepairStatus(); } catch (error) { console.error('Error clearing findings:', error); showToast('Error clearing findings', 'error'); } } async function loadRepairHistory() { const container = document.getElementById('repair-history-list'); if (!container) return; try { const response = await fetch('/api/repair/history?limit=50'); if (!response.ok) throw new Error('Failed to fetch history'); const data = await response.json(); const runs = data.runs || []; if (runs.length === 0) { container.innerHTML = `
🕑
No History Yet
Job run history will appear here after maintenance jobs complete their first scan.
`; return; } container.innerHTML = runs.map(run => { const duration = run.duration_seconds ? `${run.duration_seconds.toFixed(1)}s` : '-'; const age = formatCacheAge(run.started_at); const statusClass = run.status === 'completed' ? 'success' : run.status === 'failed' ? 'error' : 'running'; // Build stat pills const stats = []; stats.push(`${(run.items_scanned || 0).toLocaleString()} scanned`); if (run.findings_created) stats.push(`${run.findings_created} findings`); if (run.auto_fixed) stats.push(`${run.auto_fixed} fixed`); if (run.errors) stats.push(`${run.errors} errors`); // Format timestamps const startTime = run.started_at ? new Date(run.started_at).toLocaleString() : '-'; const endTime = run.finished_at ? new Date(run.finished_at).toLocaleString() : 'In progress'; return `
${run.display_name || run.job_id} ${run.status} ${duration}
${stats.join('')}
${age} · ${startTime} → ${endTime}
`; }).join(''); } catch (error) { console.error('Error loading repair history:', error); container.innerHTML = '
Error loading history
'; } } // Initialize Repair Worker UI on page load if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', () => { const button = document.getElementById('repair-button'); if (button) { button.addEventListener('click', openRepairModal); updateRepairStatus(); setInterval(updateRepairStatus, 5000); } }); } else { const button = document.getElementById('repair-button'); if (button) { button.addEventListener('click', openRepairModal); updateRepairStatus(); setInterval(updateRepairStatus, 5000); } } // ===================================================================