// 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;
const budgetExhausted = data.daily_budget && data.daily_budget.exhausted;
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 (isRateLimited || budgetExhausted) {
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 (isRateLimited) { tooltipStatus.textContent = 'Rate Limited'; }
else if (budgetExhausted) { 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 (isRateLimited) {
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 (budgetExhausted) {
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);
}
}
// ===================================================================
// 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 ');
}
}
// Show pending findings count
const findingsCount = job.last_run ? (job.last_run.findings_created || 0) : 0;
if (findingsCount > 0) {
flowParts.push('→ ');
flowParts.push(`${findingsCount} finding${findingsCount !== 1 ? 's' : ''} `);
}
// Build meta parts
const metaParts = [];
metaParts.push('Last: ' + lastRunText);
metaParts.push('Next: ' + nextRunText);
if (job.last_run) {
metaParts.push(`Scanned: ${(job.last_run.items_scanned || 0).toLocaleString()}`);
if (job.last_run.auto_fixed) metaParts.push(`Fixed: ${job.last_run.auto_fixed}`);
}
if (job.last_run && job.last_run.duration_seconds) {
metaParts.push(`${job.last_run.duration_seconds.toFixed(1)}s`);
}
// Build settings HTML
let settingsHtml = '';
if (job.settings && Object.keys(job.settings).length > 0) {
const settingsRows = Object.entries(job.settings).map(([key, val]) => {
// 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);
const inputType = typeof val === 'boolean' ? 'checkbox' :
typeof val === 'number' ? 'number' : 'text';
const inputVal = inputType === 'checkbox' ?
(val ? ' checked' : '') :
` value="${val}"`;
return `
${label}
`;
}).join('');
settingsHtml = `
`;
}
return `
${job.display_name}
${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 = ``;
}
// Build info badges
const badges = [];
if (job.auto_fix) {
const isDryRun = job.settings && job.settings.dry_run === true;
badges.push(isDryRun
? 'Dry Run '
: 'Auto-fix ');
} else {
badges.push('Scan Only ');
}
badges.push(`Every ${job.interval_hours}h `);
if (job.enabled) {
badges.push('Enabled ');
} else {
badges.push('Disabled ');
}
// Format help text paragraphs
const helpBody = (job.help_text || job.description || '').split('\n\n').map(p => {
if (p.startsWith('Settings:\n')) {
const lines = p.split('\n').slice(1);
return '' +
lines.map(l => `
${l.replace(/^- /, '')}
`).join('') +
'
';
}
return `${p.replace(/\n/g, ' ')}
`;
}).join('');
overlay = document.createElement('div');
overlay.id = 'repair-help-overlay';
overlay.className = 'repair-help-overlay';
overlay.onclick = (e) => { if (e.target === overlay) overlay.remove(); };
overlay.innerHTML = `
${badges.join('')}
${helpBody}
${settingsHtml}
`;
document.body.appendChild(overlay);
}
async function saveRepairJobSettings(jobId) {
try {
const inputs = document.querySelectorAll(`.repair-setting-input[data-job="${jobId}"]`);
let intervalHours = null;
const settings = {};
inputs.forEach(input => {
const key = input.dataset.key;
if (key === '_interval_hours') {
intervalHours = parseInt(input.value) || 24;
} else {
if (input.type === 'checkbox') settings[key] = input.checked;
else if (input.type === 'number') settings[key] = parseFloat(input.value);
else settings[key] = input.value;
}
});
await fetch(`/api/repair/jobs/${jobId}/settings`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ interval_hours: intervalHours, settings })
});
showToast('Settings saved', 'success');
} catch (error) {
console.error('Error saving job settings:', error);
showToast('Error saving settings', 'error');
}
}
async function runRepairJobNow(jobId) {
try {
await fetch(`/api/repair/jobs/${jobId}/run`, { method: 'POST' });
showToast('Job started', 'success');
setTimeout(() => loadRepairJobs(), 1000);
} catch (error) {
console.error('Error running job:', error);
showToast('Error starting job', 'error');
}
}
// ── Repair Job Live Progress ──
const _repairProgressLogCounts = {};
const _repairProgressHideTimers = {};
function updateRepairJobProgressFromData(data) {
for (const [jobId, state] of Object.entries(data)) {
const card = document.querySelector(`.repair-job-card[data-job-id="${jobId}"]`);
if (!card) continue;
// Update status dot
const statusDot = card.querySelector('.repair-job-status');
if (statusDot) {
if (state.status === 'running') statusDot.className = 'repair-job-status running';
else if (state.status === 'finished') statusDot.className = 'repair-job-status enabled';
else if (state.status === 'error') statusDot.className = 'repair-job-status enabled';
}
// Update flow badge to show running state
const firstBadge = card.querySelector('.repair-flow-badge.scan');
if (firstBadge) {
if (state.status === 'running') firstBadge.innerHTML = '▶ Running';
else if (state.status === 'finished') firstBadge.innerHTML = '✓ Complete';
else if (state.status === 'error') firstBadge.innerHTML = '✗ Error';
}
// Add/update card running class
card.classList.toggle('running', state.status === 'running');
card.classList.remove('disabled');
// Create or find progress panel (bar-first layout like automation)
let panel = card.querySelector('.repair-job-progress');
if (!panel) {
panel = document.createElement('div');
panel.className = 'repair-job-progress';
panel.innerHTML = `
`;
card.appendChild(panel);
}
// Show panel
panel.classList.add('visible');
panel.classList.toggle('finished', state.status === 'finished');
panel.classList.toggle('error', state.status === 'error');
if (state.status === 'running') {
panel.classList.remove('finished', 'error');
if (_repairProgressHideTimers[jobId]) {
clearTimeout(_repairProgressHideTimers[jobId]);
delete _repairProgressHideTimers[jobId];
}
// Reset log for re-run
if (_repairProgressLogCounts[jobId] > 0 && state.log && state.log.length < _repairProgressLogCounts[jobId]) {
const existingLog = panel.querySelector('.repair-progress-log');
if (existingLog) existingLog.innerHTML = '';
_repairProgressLogCounts[jobId] = 0;
}
}
// Update progress bar
const bar = panel.querySelector('.repair-progress-bar');
if (bar) bar.style.width = (state.progress || 0) + '%';
// Update phase
const phaseEl = panel.querySelector('.repair-progress-phase');
if (phaseEl && state.phase) phaseEl.textContent = state.phase;
// Update log
const logEl = panel.querySelector('.repair-progress-log');
if (logEl && state.log) {
const prevCount = _repairProgressLogCounts[jobId] || 0;
if (state.log.length > prevCount) {
const newLines = state.log.slice(prevCount);
for (const line of newLines) {
const div = document.createElement('div');
div.className = 'repair-log-line ' + (line.type || 'info');
div.textContent = line.text;
logEl.appendChild(div);
}
logEl.scrollTop = logEl.scrollHeight;
}
_repairProgressLogCounts[jobId] = state.log.length;
}
// Auto-hide panel after completion
if (state.status === 'finished' || state.status === 'error') {
if (!_repairProgressHideTimers[jobId]) {
_repairProgressHideTimers[jobId] = setTimeout(() => {
panel.classList.remove('visible');
card.classList.remove('running');
delete _repairProgressHideTimers[jobId];
delete _repairProgressLogCounts[jobId];
// Reload to get updated stats
loadRepairJobs();
}, 30000);
}
} else {
// Clear any existing hide timer if job restarts
if (_repairProgressHideTimers[jobId]) {
clearTimeout(_repairProgressHideTimers[jobId]);
delete _repairProgressHideTimers[jobId];
}
}
}
}
async function 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 = `
`;
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 = `
`;
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 = `All (${allCount}) `;
const typeLabels = { artist: 'Artists', release: 'Albums', recording: 'Tracks' };
for (const [type, count] of Object.entries(_failedMBState.typeCounts)) {
tabsHTML += `${typeLabels[type] || type} (${count}) `;
}
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() : ''}
Search MB
Remove
`).join('');
}
// Pagination footer
const footer = document.getElementById('failed-mb-footer');
if (footer) {
const totalPages = Math.ceil(data.total / 50);
footer.innerHTML = totalPages > 1 ? `
` : ``;
}
} 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 = `
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 = '';
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_lossy_copy: 'No Lossy Copy'
};
// 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',
metadata_gap: 'Apply',
duplicate_tracks: 'Keep Best',
incomplete_album: 'Auto-Fill',
missing_lossy_copy: 'Convert',
acoustid_mismatch: 'Fix',
missing_discography_track: 'Add to Wishlist',
};
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',
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 ? `${_escFinding(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 ``;
}
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 `
Play
`;
}
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 = '';
return html;
}
function _renderFindingDetail(f) {
const d = f.details || {};
const rows = [];
const media = _renderFindingMedia(d);
switch (f.finding_type) {
case 'dead_file':
if (d.artist) rows.push(['Artist', d.artist]);
if (d.album) rows.push(['Album', d.album]);
if (d.title) rows.push(['Title', d.title]);
if (d.track_id) rows.push(['Track ID', d.track_id]);
if (d.original_path) rows.push(['Original Path', d.original_path, 'path']);
return media + _gridRows(rows) + _renderPlayButton(f);
case 'orphan_file':
if (d.folder) rows.push(['Folder', d.folder, 'path']);
if (d.format) rows.push(['Format', d.format.toUpperCase()]);
if (d.file_size) rows.push(['File Size', _formatFileSize(d.file_size)]);
if (d.modified) rows.push(['Last Modified', d.modified]);
if (f.file_path) rows.push(['Full Path', f.file_path, 'path']);
return _gridRows(rows) + _renderPlayButton(f);
case 'acoustid_mismatch': {
let html = media + '';
html += _renderScoreBar(d.fingerprint_score, 'Fingerprint');
html += _renderScoreBar(d.title_similarity, 'Title Match');
html += _renderScoreBar(d.artist_similarity, 'Artist Match');
html += '
';
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 backend: highest bitrate, then duration, then track number)
const bestDup = d.tracks.reduce((best, t) => {
const bBr = best.bitrate || 0, tBr = t.bitrate || 0;
const bDur = best.duration || 0, tDur = t.duration || 0;
const bTn = best.track_number || 0, tTn = t.track_number || 0;
return (tBr > bBr || (tBr === bBr && tDur > bDur) || (tBr === bBr && tDur === bDur && tTn > bTn)) ? t : 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 = '';
// Show artist image + found artwork side by side
if (d.artist_thumb_url || d.found_artwork_url) {
artHtml += '';
}
artHtml += _gridRows(rows);
return artHtml;
case 'track_number_mismatch':
if (d.album_title) rows.push(['Album', d.album_title]);
if (d.artist_name) rows.push(['Artist', d.artist_name]);
if (d.matched_title) rows.push(['Matched To', d.matched_title]);
if (d.file_title) rows.push(['File Title', d.file_title]);
if (d.current_track_num !== undefined) rows.push(['Current Track #', String(d.current_track_num)]);
if (d.correct_track_num !== undefined) rows.push(['Correct Track #', String(d.correct_track_num), 'success']);
if (f.file_path) rows.push(['File', f.file_path, 'path']);
let tnHtml = media;
if (d.match_score) {
tnHtml += '';
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 += `1 `;
if (startPage > 1) html += '... ';
}
for (let i = startPage; i < endPage; i++) {
html += `${i + 1} `;
}
if (endPage < totalPages) {
if (endPage < totalPages - 1) html += '... ';
html += `${totalPages} `;
}
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 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.
Move to Staging
Delete
Cancel
`;
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.
Re-download
Remove from DB
Cancel
`;
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
Re-download
Delete
Retag = update metadata to match actual audio • Re-download = add correct track to wishlist & delete wrong file • Delete = remove file and DB entry
Cancel
`;
document.body.appendChild(overlay);
overlay.querySelector('#_acid-retag').onclick = () => { overlay.remove(); resolve('retag'); };
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 `
${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);
}
}
// ===================================================================