// 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 isRateLimited = data.rate_limited === true;
// The real API is unauthed/banned but the worker is still matching via the
// no-creds Spotify Free source — treat it as running, not stuck (#798/#887).
const bridgingFree = data.using_free === true;
// #887: a no-auth user whose enrichment runs on Spotify Free is NOT "not
// authenticated" for status purposes — the worker IS enriching. Only flag
// Not Authenticated when Free isn't carrying it.
const notAuthenticated = data.authenticated === false && !bridgingFree;
const rateLimitedStuck = isRateLimited && !bridgingFree;
// Budget is a real-API cap; when bridging to free it no longer applies, so
// only treat the budget as a stop when we're NOT serving via free (#798).
const budgetStuck = (data.daily_budget && data.daily_budget.exhausted) && !bridgingFree;
button.classList.remove('active', 'paused', 'complete', 'no-auth');
if (data.paused) {
button.classList.add('paused');
} else if (notAuthenticated) {
button.classList.add('no-auth');
} else if (rateLimitedStuck || budgetStuck) {
button.classList.add('paused');
} else if (data.idle) {
button.classList.add('complete');
} else if (data.running && !data.paused) {
button.classList.add('active');
}
const tooltipStatus = document.getElementById('spotify-enrich-tooltip-status');
const tooltipCurrent = document.getElementById('spotify-enrich-tooltip-current');
const tooltipProgress = document.getElementById('spotify-enrich-tooltip-progress');
if (tooltipStatus) {
if (data.paused) { tooltipStatus.textContent = 'Paused'; }
else if (notAuthenticated) { tooltipStatus.textContent = 'Not Authenticated'; }
else if (rateLimitedStuck) { tooltipStatus.textContent = 'Rate Limited'; }
else if (bridgingFree) { tooltipStatus.textContent = 'Running (Spotify Free)'; }
else if (budgetStuck) { tooltipStatus.textContent = 'Daily Limit Reached'; }
else if (data.idle) { tooltipStatus.textContent = 'Complete'; }
else if (data.running) { tooltipStatus.textContent = 'Running'; }
else { tooltipStatus.textContent = 'Idle'; }
}
if (tooltipCurrent) {
if (data.paused) {
tooltipCurrent.textContent = notAuthenticated ? 'Connect Spotify in Settings to enrich' : 'Click to resume';
} else if (notAuthenticated) {
tooltipCurrent.textContent = 'Connect Spotify in Settings to enrich';
} else if (rateLimitedStuck) {
const info = data.rate_limit || {};
const remaining = info.remaining_seconds || 0;
tooltipCurrent.textContent = remaining > 0 ? `Waiting ${Math.ceil(remaining / 60)}m for rate limit to clear` : 'Waiting for rate limit to clear';
} else if (bridgingFree && data.current_item && data.current_item.name) {
tooltipCurrent.textContent = `Now: ${data.current_item.name} (via Spotify Free)`;
} else if (budgetStuck) {
const resets = data.daily_budget.resets_in_seconds || 0;
const hours = Math.floor(resets / 3600);
const mins = Math.floor((resets % 3600) / 60);
tooltipCurrent.textContent = `Resets in ${hours}h ${mins}m`;
} else if (data.idle) {
tooltipCurrent.textContent = 'All items processed';
} else if (data.current_item && data.current_item.name) {
tooltipCurrent.textContent = `Now: ${data.current_item.name}`;
} else {
tooltipCurrent.textContent = 'Waiting for next item...';
}
}
if (data.progress && tooltipProgress) {
if (notAuthenticated) {
tooltipProgress.textContent = `Pending: ${data.stats?.pending || 0} items`;
} else {
const artists = data.progress.artists || {};
const albums = data.progress.albums || {};
const tracks = data.progress.tracks || {};
const currentType = data.current_item?.type || '';
let progressText = '';
const artistsComplete = artists.matched >= artists.total;
const albumsComplete = albums.matched >= albums.total;
if (currentType === 'artist') {
progressText = `Artists: ${artists.matched || 0} / ${artists.total || 0} (${artists.percent || 0}%)`;
} else if (currentType.includes('album')) {
progressText = `Albums: ${albums.matched || 0} / ${albums.total || 0} (${albums.percent || 0}%)`;
} else if (currentType.includes('track')) {
progressText = `Tracks: ${tracks.matched || 0} / ${tracks.total || 0} (${tracks.percent || 0}%)`;
} else if (!artistsComplete) {
progressText = `Artists: ${artists.matched || 0} / ${artists.total || 0} (${artists.percent || 0}%)`;
} else if (!albumsComplete) {
progressText = `Albums: ${albums.matched || 0} / ${albums.total || 0} (${albums.percent || 0}%)`;
} else {
progressText = `Tracks: ${tracks.matched || 0} / ${tracks.total || 0} (${tracks.percent || 0}%)`;
}
tooltipProgress.textContent = progressText;
}
}
}
async function toggleSpotifyEnrichment() {
try {
const button = document.getElementById('spotify-enrich-button');
if (!button) return;
const isRunning = button.classList.contains('active');
const endpoint = isRunning ? '/api/enrichment/spotify/pause' : '/api/enrichment/spotify/resume';
const response = await fetch(endpoint, { method: 'POST' });
if (!response.ok) {
const data = await response.json().catch(() => ({}));
if (data.rate_limited) {
showToast('Cannot resume — Spotify is rate limited', 'warning');
return;
}
throw new Error(`Failed to ${isRunning ? 'pause' : 'resume'} Spotify enrichment`);
}
await updateSpotifyEnrichmentStatus();
console.log(`Spotify enrichment ${isRunning ? 'paused' : 'resumed'}`);
} catch (error) {
console.error('Error toggling Spotify enrichment:', error);
showToast(`Error: ${error.message}`, 'error');
}
}
// Initialize Spotify Enrichment UI on page load
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => {
const button = document.getElementById('spotify-enrich-button');
if (button) {
button.addEventListener('click', toggleSpotifyEnrichment);
updateSpotifyEnrichmentStatus();
setInterval(updateSpotifyEnrichmentStatus, 2000);
}
});
} else {
const button = document.getElementById('spotify-enrich-button');
if (button) {
button.addEventListener('click', toggleSpotifyEnrichment);
updateSpotifyEnrichmentStatus();
setInterval(updateSpotifyEnrichmentStatus, 2000);
}
}
// ===================================================================
// ITUNES ENRICHMENT STATUS
// ===================================================================
async function updateiTunesEnrichmentStatus() {
if (socketConnected) return; // WebSocket handles this
if (document.hidden) return; // Skip polling when tab is not visible
try {
const response = await fetch('/api/enrichment/itunes/status');
if (!response.ok) { console.warn('iTunes enrichment status endpoint unavailable'); return; }
const data = await response.json();
updateiTunesEnrichmentStatusFromData(data);
} catch (error) {
console.error('Error updating iTunes enrichment status:', error);
}
}
function updateiTunesEnrichmentStatusFromData(data) {
const button = document.getElementById('itunes-enrich-button');
if (!button) return;
button.classList.remove('active', 'paused', 'complete');
if (data.idle) {
button.classList.add('complete');
} else if (data.running && !data.paused) {
button.classList.add('active');
} else if (data.paused) {
button.classList.add('paused');
}
const tooltipStatus = document.getElementById('itunes-enrich-tooltip-status');
const tooltipCurrent = document.getElementById('itunes-enrich-tooltip-current');
const tooltipProgress = document.getElementById('itunes-enrich-tooltip-progress');
if (tooltipStatus) {
if (data.idle) { tooltipStatus.textContent = 'Complete'; }
else if (data.running && !data.paused) { tooltipStatus.textContent = 'Running'; }
else if (data.paused) { tooltipStatus.textContent = data.yield_reason === 'downloads' ? 'Yielding for downloads' : 'Paused'; }
else { tooltipStatus.textContent = 'Idle'; }
}
if (tooltipCurrent) {
if (data.idle) {
tooltipCurrent.textContent = 'All items processed';
} else if (data.current_item && data.current_item.name) {
tooltipCurrent.textContent = `Now: ${data.current_item.name}`;
}
}
if (data.progress && tooltipProgress) {
const artists = data.progress.artists || {};
const albums = data.progress.albums || {};
const tracks = data.progress.tracks || {};
const currentType = data.current_item?.type || '';
let progressText = '';
const artistsComplete = artists.matched >= artists.total;
const albumsComplete = albums.matched >= albums.total;
if (currentType === 'artist') {
progressText = `Artists: ${artists.matched || 0} / ${artists.total || 0} (${artists.percent || 0}%)`;
} else if (currentType.includes('album')) {
progressText = `Albums: ${albums.matched || 0} / ${albums.total || 0} (${albums.percent || 0}%)`;
} else if (currentType.includes('track')) {
progressText = `Tracks: ${tracks.matched || 0} / ${tracks.total || 0} (${tracks.percent || 0}%)`;
} else if (!artistsComplete) {
progressText = `Artists: ${artists.matched || 0} / ${artists.total || 0} (${artists.percent || 0}%)`;
} else if (!albumsComplete) {
progressText = `Albums: ${albums.matched || 0} / ${albums.total || 0} (${albums.percent || 0}%)`;
} else {
progressText = `Tracks: ${tracks.matched || 0} / ${tracks.total || 0} (${tracks.percent || 0}%)`;
}
tooltipProgress.textContent = progressText;
}
}
async function toggleiTunesEnrichment() {
try {
const button = document.getElementById('itunes-enrich-button');
if (!button) return;
const isRunning = button.classList.contains('active');
const endpoint = isRunning ? '/api/enrichment/itunes/pause' : '/api/enrichment/itunes/resume';
const response = await fetch(endpoint, { method: 'POST' });
if (!response.ok) {
throw new Error(`Failed to ${isRunning ? 'pause' : 'resume'} iTunes enrichment`);
}
await updateiTunesEnrichmentStatus();
console.log(`iTunes enrichment ${isRunning ? 'paused' : 'resumed'}`);
} catch (error) {
console.error('Error toggling iTunes enrichment:', error);
showToast(`Error: ${error.message}`, 'error');
}
}
// Initialize iTunes Enrichment UI on page load
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => {
const button = document.getElementById('itunes-enrich-button');
if (button) {
button.addEventListener('click', toggleiTunesEnrichment);
updateiTunesEnrichmentStatus();
setInterval(updateiTunesEnrichmentStatus, 2000);
}
});
} else {
const button = document.getElementById('itunes-enrich-button');
if (button) {
button.addEventListener('click', toggleiTunesEnrichment);
updateiTunesEnrichmentStatus();
setInterval(updateiTunesEnrichmentStatus, 2000);
}
}
// ===================================================================
// LAST.FM ENRICHMENT STATUS
// ===================================================================
async function updateLastFMEnrichmentStatus() {
if (socketConnected) return;
if (document.hidden) return;
try {
const response = await fetch('/api/enrichment/lastfm/status');
if (!response.ok) { console.warn('Last.fm status endpoint unavailable'); return; }
const data = await response.json();
updateLastFMEnrichmentStatusFromData(data);
} catch (error) {
console.error('Error updating Last.fm status:', error);
}
}
function updateLastFMEnrichmentStatusFromData(data) {
const button = document.getElementById('lastfm-enrich-button');
if (!button) return;
const notAuthenticated = data.authenticated === false;
button.classList.remove('active', 'paused', 'complete', 'no-auth');
if (data.paused) {
button.classList.add('paused');
} else if (notAuthenticated) {
button.classList.add('no-auth');
} else if (data.idle) {
button.classList.add('complete');
} else if (data.running && !data.paused) {
button.classList.add('active');
}
const tooltipStatus = document.getElementById('lastfm-enrich-tooltip-status');
const tooltipCurrent = document.getElementById('lastfm-enrich-tooltip-current');
const tooltipProgress = document.getElementById('lastfm-enrich-tooltip-progress');
if (tooltipStatus) {
if (data.paused) { tooltipStatus.textContent = 'Paused'; }
else if (notAuthenticated) { tooltipStatus.textContent = 'Not Authenticated'; }
else if (data.idle) { tooltipStatus.textContent = 'Complete'; }
else if (data.running) { tooltipStatus.textContent = 'Running'; }
else { tooltipStatus.textContent = 'Idle'; }
}
if (tooltipCurrent) {
if (data.paused) {
tooltipCurrent.textContent = notAuthenticated ? 'Add Last.fm API key in Settings to enrich' : 'Click to resume';
} else if (notAuthenticated) {
tooltipCurrent.textContent = 'Add Last.fm API key in Settings to enrich';
} else if (data.idle) {
tooltipCurrent.textContent = 'All items processed';
} else if (data.current_item && data.current_item.name) {
tooltipCurrent.textContent = `Now: ${data.current_item.name}`;
}
}
if (data.progress && tooltipProgress) {
if (notAuthenticated) {
tooltipProgress.textContent = `Pending: ${data.stats?.pending || 0} items`;
} else {
const artists = data.progress.artists || {};
const albums = data.progress.albums || {};
const tracks = data.progress.tracks || {};
const currentType = data.current_item?.type;
let progressText = '';
const artistsComplete = artists.matched >= artists.total;
const albumsComplete = albums.matched >= albums.total;
if (currentType === 'artist' || (!artistsComplete && !currentType)) {
progressText = `Artists: ${artists.matched || 0} / ${artists.total || 0} (${artists.percent || 0}%)`;
} else if (currentType === 'album' || (artistsComplete && !albumsComplete)) {
progressText = `Albums: ${albums.matched || 0} / ${albums.total || 0} (${albums.percent || 0}%)`;
} else if (currentType === 'track' || (artistsComplete && albumsComplete)) {
progressText = `Tracks: ${tracks.matched || 0} / ${tracks.total || 0} (${tracks.percent || 0}%)`;
} else {
progressText = `Artists: ${artists.matched || 0} / ${artists.total || 0} (${artists.percent || 0}%)`;
}
tooltipProgress.textContent = progressText;
}
}
}
async function toggleLastFMEnrichment() {
try {
const button = document.getElementById('lastfm-enrich-button');
if (!button) return;
const isRunning = button.classList.contains('active');
const endpoint = isRunning ? '/api/enrichment/lastfm/pause' : '/api/enrichment/lastfm/resume';
const response = await fetch(endpoint, { method: 'POST' });
if (!response.ok) {
throw new Error(`Failed to ${isRunning ? 'pause' : 'resume'} Last.fm enrichment`);
}
await updateLastFMEnrichmentStatus();
console.log(`Last.fm enrichment ${isRunning ? 'paused' : 'resumed'}`);
} catch (error) {
console.error('Error toggling Last.fm enrichment:', error);
showToast(`Error: ${error.message}`, 'error');
}
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => {
const button = document.getElementById('lastfm-enrich-button');
if (button) {
button.addEventListener('click', toggleLastFMEnrichment);
updateLastFMEnrichmentStatus();
setInterval(updateLastFMEnrichmentStatus, 2000);
}
});
} else {
const button = document.getElementById('lastfm-enrich-button');
if (button) {
button.addEventListener('click', toggleLastFMEnrichment);
updateLastFMEnrichmentStatus();
setInterval(updateLastFMEnrichmentStatus, 2000);
}
}
// ===================================================================
// GENIUS ENRICHMENT STATUS
// ===================================================================
async function updateGeniusEnrichmentStatus() {
if (socketConnected) return;
if (document.hidden) return;
try {
const response = await fetch('/api/enrichment/genius/status');
if (!response.ok) { console.warn('Genius status endpoint unavailable'); return; }
const data = await response.json();
updateGeniusEnrichmentStatusFromData(data);
} catch (error) {
console.error('Error updating Genius status:', error);
}
}
function updateGeniusEnrichmentStatusFromData(data) {
const button = document.getElementById('genius-enrich-button');
if (!button) return;
const notAuthenticated = data.authenticated === false;
button.classList.remove('active', 'paused', 'complete', 'no-auth');
if (data.paused) {
button.classList.add('paused');
} else if (notAuthenticated) {
button.classList.add('no-auth');
} else if (data.idle) {
button.classList.add('complete');
} else if (data.running && !data.paused) {
button.classList.add('active');
}
const tooltipStatus = document.getElementById('genius-enrich-tooltip-status');
const tooltipCurrent = document.getElementById('genius-enrich-tooltip-current');
const tooltipProgress = document.getElementById('genius-enrich-tooltip-progress');
if (tooltipStatus) {
if (data.paused) { tooltipStatus.textContent = 'Paused'; }
else if (notAuthenticated) { tooltipStatus.textContent = 'Not Authenticated'; }
else if (data.idle) { tooltipStatus.textContent = 'Complete'; }
else if (data.running) { tooltipStatus.textContent = 'Running'; }
else { tooltipStatus.textContent = 'Idle'; }
}
if (tooltipCurrent) {
if (data.paused) {
tooltipCurrent.textContent = notAuthenticated ? 'Add Genius access token in Settings to enrich' : 'Click to resume';
} else if (notAuthenticated) {
tooltipCurrent.textContent = 'Add Genius access token in Settings to enrich';
} else if (data.idle) {
tooltipCurrent.textContent = 'All items processed';
} else if (data.current_item && data.current_item.name) {
tooltipCurrent.textContent = `Now: ${data.current_item.name}`;
}
}
if (data.progress && tooltipProgress) {
if (notAuthenticated) {
tooltipProgress.textContent = `Pending: ${data.stats?.pending || 0} items`;
} else {
const artists = data.progress.artists || {};
const tracks = data.progress.tracks || {};
const currentType = data.current_item?.type;
let progressText = '';
const artistsComplete = artists.matched >= artists.total;
if (currentType === 'artist' || (!artistsComplete && !currentType)) {
progressText = `Artists: ${artists.matched || 0} / ${artists.total || 0} (${artists.percent || 0}%)`;
} else {
progressText = `Tracks: ${tracks.matched || 0} / ${tracks.total || 0} (${tracks.percent || 0}%)`;
}
tooltipProgress.textContent = progressText;
}
}
}
async function toggleGeniusEnrichment() {
try {
const button = document.getElementById('genius-enrich-button');
if (!button) return;
const isRunning = button.classList.contains('active');
const endpoint = isRunning ? '/api/enrichment/genius/pause' : '/api/enrichment/genius/resume';
const response = await fetch(endpoint, { method: 'POST' });
if (!response.ok) {
throw new Error(`Failed to ${isRunning ? 'pause' : 'resume'} Genius enrichment`);
}
await updateGeniusEnrichmentStatus();
console.log(`Genius enrichment ${isRunning ? 'paused' : 'resumed'}`);
} catch (error) {
console.error('Error toggling Genius enrichment:', error);
showToast(`Error: ${error.message}`, 'error');
}
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => {
const button = document.getElementById('genius-enrich-button');
if (button) {
button.addEventListener('click', toggleGeniusEnrichment);
updateGeniusEnrichmentStatus();
setInterval(updateGeniusEnrichmentStatus, 2000);
}
});
} else {
const button = document.getElementById('genius-enrich-button');
if (button) {
button.addEventListener('click', toggleGeniusEnrichment);
updateGeniusEnrichmentStatus();
setInterval(updateGeniusEnrichmentStatus, 2000);
}
}
// ===================================================================
// TIDAL ENRICHMENT WORKER
// ===================================================================
async function updateTidalEnrichmentStatus() {
if (socketConnected) return;
if (document.hidden) return;
try {
const response = await fetch('/api/enrichment/tidal/status');
if (!response.ok) { console.warn('Tidal status endpoint unavailable'); return; }
const data = await response.json();
updateTidalEnrichmentStatusFromData(data);
} catch (error) {
console.error('Error updating Tidal status:', error);
}
}
function updateTidalEnrichmentStatusFromData(data) {
const button = document.getElementById('tidal-enrich-button');
if (!button) return;
const notAuthenticated = data.authenticated === false;
button.classList.remove('active', 'paused', 'complete', 'no-auth');
if (data.paused) {
button.classList.add('paused');
} else if (notAuthenticated) {
button.classList.add('no-auth');
} else if (data.idle) {
button.classList.add('complete');
} else if (data.running && !data.paused) {
button.classList.add('active');
}
const tooltipStatus = document.getElementById('tidal-enrich-tooltip-status');
const tooltipCurrent = document.getElementById('tidal-enrich-tooltip-current');
const tooltipProgress = document.getElementById('tidal-enrich-tooltip-progress');
if (tooltipStatus) {
if (data.paused) { tooltipStatus.textContent = 'Paused'; }
else if (notAuthenticated) { tooltipStatus.textContent = 'Not Authenticated'; }
else if (data.idle) { tooltipStatus.textContent = 'Complete'; }
else if (data.running) { tooltipStatus.textContent = 'Running'; }
else { tooltipStatus.textContent = 'Idle'; }
}
if (tooltipCurrent) {
if (data.paused) {
tooltipCurrent.textContent = notAuthenticated ? 'Connect Tidal in Settings to enrich' : 'Click to resume';
} else if (notAuthenticated) {
tooltipCurrent.textContent = 'Connect Tidal in Settings to enrich';
} else if (data.idle) {
tooltipCurrent.textContent = 'All items processed';
} else if (data.current_item && data.current_item.name) {
tooltipCurrent.textContent = `Now: ${data.current_item.name}`;
}
}
if (data.progress && tooltipProgress) {
if (notAuthenticated) {
tooltipProgress.textContent = `Pending: ${data.stats?.pending || 0} items`;
} else {
const artists = data.progress.artists || {};
const albums = data.progress.albums || {};
const tracks = data.progress.tracks || {};
const currentType = data.current_item?.type;
let progressText = '';
const artistsComplete = artists.matched >= artists.total;
const albumsComplete = albums.matched >= albums.total;
if (currentType === 'artist' || (!artistsComplete && !currentType)) {
progressText = `Artists: ${artists.matched || 0} / ${artists.total || 0} (${artists.percent || 0}%)`;
} else if (currentType === 'album' || (!albumsComplete && !currentType)) {
progressText = `Albums: ${albums.matched || 0} / ${albums.total || 0} (${albums.percent || 0}%)`;
} else {
progressText = `Tracks: ${tracks.matched || 0} / ${tracks.total || 0} (${tracks.percent || 0}%)`;
}
tooltipProgress.textContent = progressText;
}
}
}
async function toggleTidalEnrichment() {
try {
const button = document.getElementById('tidal-enrich-button');
if (!button) return;
const isRunning = button.classList.contains('active');
const endpoint = isRunning ? '/api/enrichment/tidal/pause' : '/api/enrichment/tidal/resume';
const response = await fetch(endpoint, { method: 'POST' });
if (!response.ok) {
throw new Error(`Failed to ${isRunning ? 'pause' : 'resume'} Tidal enrichment`);
}
await updateTidalEnrichmentStatus();
console.log(`Tidal enrichment ${isRunning ? 'paused' : 'resumed'}`);
} catch (error) {
console.error('Error toggling Tidal enrichment:', error);
showToast(`Error: ${error.message}`, 'error');
}
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => {
const button = document.getElementById('tidal-enrich-button');
if (button) {
button.addEventListener('click', toggleTidalEnrichment);
updateTidalEnrichmentStatus();
setInterval(updateTidalEnrichmentStatus, 2000);
}
});
} else {
const button = document.getElementById('tidal-enrich-button');
if (button) {
button.addEventListener('click', toggleTidalEnrichment);
updateTidalEnrichmentStatus();
setInterval(updateTidalEnrichmentStatus, 2000);
}
}
// ===================================================================
// QOBUZ ENRICHMENT WORKER
// ===================================================================
async function updateQobuzEnrichmentStatus() {
if (socketConnected) return;
if (document.hidden) return;
try {
const response = await fetch('/api/enrichment/qobuz/status');
if (!response.ok) { console.warn('Qobuz status endpoint unavailable'); return; }
const data = await response.json();
updateQobuzEnrichmentStatusFromData(data);
} catch (error) {
console.error('Error updating Qobuz status:', error);
}
}
function updateQobuzEnrichmentStatusFromData(data) {
const button = document.getElementById('qobuz-enrich-button');
if (!button) return;
const notAuthenticated = data.authenticated === false;
button.classList.remove('active', 'paused', 'complete', 'no-auth');
if (data.paused) {
button.classList.add('paused');
} else if (notAuthenticated) {
button.classList.add('no-auth');
} else if (data.idle) {
button.classList.add('complete');
} else if (data.running && !data.paused) {
button.classList.add('active');
}
const tooltipStatus = document.getElementById('qobuz-enrich-tooltip-status');
const tooltipCurrent = document.getElementById('qobuz-enrich-tooltip-current');
const tooltipProgress = document.getElementById('qobuz-enrich-tooltip-progress');
if (tooltipStatus) {
if (data.paused) { tooltipStatus.textContent = 'Paused'; }
else if (notAuthenticated) { tooltipStatus.textContent = 'Not Authenticated'; }
else if (data.idle) { tooltipStatus.textContent = 'Complete'; }
else if (data.running) { tooltipStatus.textContent = 'Running'; }
else { tooltipStatus.textContent = 'Idle'; }
}
if (tooltipCurrent) {
if (data.paused) {
tooltipCurrent.textContent = notAuthenticated ? 'Connect Qobuz in Settings to enrich' : 'Click to resume';
} else if (notAuthenticated) {
tooltipCurrent.textContent = 'Connect Qobuz in Settings to enrich';
} else if (data.idle) {
tooltipCurrent.textContent = 'All items processed';
} else if (data.current_item && data.current_item.name) {
tooltipCurrent.textContent = `Now: ${data.current_item.name}`;
}
}
if (data.progress && tooltipProgress) {
if (notAuthenticated) {
tooltipProgress.textContent = `Pending: ${data.stats?.pending || 0} items`;
} else {
const artists = data.progress.artists || {};
const albums = data.progress.albums || {};
const tracks = data.progress.tracks || {};
const currentType = data.current_item?.type;
let progressText = '';
const artistsComplete = artists.matched >= artists.total;
const albumsComplete = albums.matched >= albums.total;
if (currentType === 'artist' || (!artistsComplete && !currentType)) {
progressText = `Artists: ${artists.matched || 0} / ${artists.total || 0} (${artists.percent || 0}%)`;
} else if (currentType === 'album' || (!albumsComplete && !currentType)) {
progressText = `Albums: ${albums.matched || 0} / ${albums.total || 0} (${albums.percent || 0}%)`;
} else {
progressText = `Tracks: ${tracks.matched || 0} / ${tracks.total || 0} (${tracks.percent || 0}%)`;
}
tooltipProgress.textContent = progressText;
}
}
}
async function toggleQobuzEnrichment() {
try {
const button = document.getElementById('qobuz-enrich-button');
if (!button) return;
const isRunning = button.classList.contains('active');
const endpoint = isRunning ? '/api/enrichment/qobuz/pause' : '/api/enrichment/qobuz/resume';
const response = await fetch(endpoint, { method: 'POST' });
if (!response.ok) {
throw new Error(`Failed to ${isRunning ? 'pause' : 'resume'} Qobuz enrichment`);
}
await updateQobuzEnrichmentStatus();
console.log(`Qobuz enrichment ${isRunning ? 'paused' : 'resumed'}`);
} catch (error) {
console.error('Error toggling Qobuz enrichment:', error);
showToast(`Error: ${error.message}`, 'error');
}
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => {
const button = document.getElementById('qobuz-enrich-button');
if (button) {
button.addEventListener('click', toggleQobuzEnrichment);
updateQobuzEnrichmentStatus();
setInterval(updateQobuzEnrichmentStatus, 2000);
}
});
} else {
const button = document.getElementById('qobuz-enrich-button');
if (button) {
button.addEventListener('click', toggleQobuzEnrichment);
updateQobuzEnrichmentStatus();
setInterval(updateQobuzEnrichmentStatus, 2000);
}
}
// ===================================================================
// AMAZON MUSIC ENRICHMENT WORKER
// ===================================================================
async function updateAmazonEnrichmentStatus() {
if (socketConnected) return;
if (document.hidden) return;
try {
const response = await fetch('/api/enrichment/amazon/status');
if (!response.ok) { console.warn('Amazon enrichment status endpoint unavailable'); return; }
const data = await response.json();
updateAmazonEnrichmentStatusFromData(data);
} catch (error) {
console.error('Error updating Amazon enrichment status:', error);
}
}
function updateAmazonEnrichmentStatusFromData(data) {
const button = document.getElementById('amazon-enrich-button');
if (!button) return;
button.classList.remove('active', 'paused', 'complete');
if (data.paused) {
button.classList.add('paused');
} else if (data.idle) {
button.classList.add('complete');
} else if (data.running && !data.paused) {
button.classList.add('active');
}
const tooltipStatus = document.getElementById('amazon-enrich-tooltip-status');
const tooltipCurrent = document.getElementById('amazon-enrich-tooltip-current');
const tooltipProgress = document.getElementById('amazon-enrich-tooltip-progress');
if (tooltipStatus) {
if (data.paused) { tooltipStatus.textContent = data.yield_reason === 'downloads' ? 'Yielding for downloads' : 'Paused'; }
else if (data.idle) { tooltipStatus.textContent = 'Complete'; }
else if (data.running) { tooltipStatus.textContent = 'Running'; }
else { tooltipStatus.textContent = 'Idle'; }
}
if (tooltipCurrent) {
if (data.idle) {
tooltipCurrent.textContent = 'All items processed';
} else if (data.current_item && data.current_item.name) {
tooltipCurrent.textContent = `Now: ${data.current_item.name}`;
} else {
tooltipCurrent.textContent = 'No active matches';
}
}
if (data.progress && tooltipProgress) {
const artists = data.progress.artists || {};
const albums = data.progress.albums || {};
const tracks = data.progress.tracks || {};
const currentType = data.current_item?.type;
let progressText = '';
const artistsComplete = artists.matched >= artists.total;
const albumsComplete = albums.matched >= albums.total;
if (currentType === 'artist' || (!artistsComplete && !currentType)) {
progressText = `Artists: ${artists.matched || 0} / ${artists.total || 0} (${artists.percent || 0}%)`;
} else if (currentType === 'album' || (!albumsComplete && !currentType)) {
progressText = `Albums: ${albums.matched || 0} / ${albums.total || 0} (${albums.percent || 0}%)`;
} else {
progressText = `Tracks: ${tracks.matched || 0} / ${tracks.total || 0} (${tracks.percent || 0}%)`;
}
tooltipProgress.textContent = progressText;
}
}
async function toggleAmazonEnrichment() {
try {
const button = document.getElementById('amazon-enrich-button');
if (!button) return;
const isRunning = button.classList.contains('active');
const endpoint = isRunning ? '/api/enrichment/amazon/pause' : '/api/enrichment/amazon/resume';
const response = await fetch(endpoint, { method: 'POST' });
if (!response.ok) {
throw new Error(`Failed to ${isRunning ? 'pause' : 'resume'} Amazon enrichment`);
}
await updateAmazonEnrichmentStatus();
console.log(`Amazon enrichment ${isRunning ? 'paused' : 'resumed'}`);
} catch (error) {
console.error('Error toggling Amazon enrichment:', error);
showToast(`Error: ${error.message}`, 'error');
}
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => {
const button = document.getElementById('amazon-enrich-button');
if (button) {
button.addEventListener('click', toggleAmazonEnrichment);
updateAmazonEnrichmentStatus();
setInterval(updateAmazonEnrichmentStatus, 2000);
}
});
} else {
const button = document.getElementById('amazon-enrich-button');
if (button) {
button.addEventListener('click', toggleAmazonEnrichment);
updateAmazonEnrichmentStatus();
setInterval(updateAmazonEnrichmentStatus, 2000);
}
}
// ===================================================================
// SIMILAR ARTISTS (MUSICMAP) WORKER — dashboard bubble
// ===================================================================
async function updateSimilarArtistsEnrichmentStatus() {
if (socketConnected) return;
if (document.hidden) return;
try {
const response = await fetch('/api/enrichment/similar_artists/status');
if (!response.ok) return;
updateSimilarArtistsEnrichmentStatusFromData(await response.json());
} catch (error) {
console.error('Error updating Similar Artists enrichment status:', error);
}
}
function updateSimilarArtistsEnrichmentStatusFromData(data) {
const button = document.getElementById('similar-artists-enrich-button');
if (!button) return;
button.classList.remove('active', 'paused', 'complete');
if (data.paused) button.classList.add('paused');
else if (data.idle) button.classList.add('complete');
else if (data.running && !data.paused) button.classList.add('active');
const tStatus = document.getElementById('similar-artists-enrich-tooltip-status');
const tCurrent = document.getElementById('similar-artists-enrich-tooltip-current');
const tProgress = document.getElementById('similar-artists-enrich-tooltip-progress');
if (tStatus) {
if (data.paused) tStatus.textContent = 'Paused';
else if (data.idle) tStatus.textContent = 'Complete';
else if (data.running) tStatus.textContent = 'Running';
else tStatus.textContent = 'Idle';
}
if (tCurrent) {
if (data.idle) tCurrent.textContent = 'All library artists processed';
else if (data.current_item) tCurrent.textContent = `Now: ${data.current_item}`;
else tCurrent.textContent = 'No active matches';
}
// DB-backed artist progress (matches the Manage modal exactly).
if (tProgress) {
const a = (data.progress && data.progress.artists) || {};
tProgress.textContent = `Artists: ${a.matched || 0} / ${a.total || 0} (${a.percent || 0}%)`;
}
}
async function toggleSimilarArtistsEnrichment() {
// Identical logic to the other orbs (e.g. toggleAmazonEnrichment): if the orb
// is 'active' (running) → pause, otherwise → resume. A paused orb isn't
// 'active', so this resumes it.
try {
const button = document.getElementById('similar-artists-enrich-button');
if (!button) return;
const isRunning = button.classList.contains('active');
const endpoint = isRunning ? '/api/enrichment/similar_artists/pause' : '/api/enrichment/similar_artists/resume';
const response = await fetch(endpoint, { method: 'POST' });
if (!response.ok) throw new Error(`Failed to ${isRunning ? 'pause' : 'resume'} Similar Artists enrichment`);
await updateSimilarArtistsEnrichmentStatus();
} catch (error) {
console.error('Error toggling Similar Artists enrichment:', error);
showToast(`Error: ${error.message}`, 'error');
}
}
// Initialize Similar Artists UI on page load — identical pattern to AudioDB/Deezer
// (addEventListener for the click; no inline onclick on the button).
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => {
const button = document.getElementById('similar-artists-enrich-button');
if (button) {
button.addEventListener('click', toggleSimilarArtistsEnrichment);
updateSimilarArtistsEnrichmentStatus();
setInterval(updateSimilarArtistsEnrichmentStatus, 2000);
}
});
} else {
const button = document.getElementById('similar-artists-enrich-button');
if (button) {
button.addEventListener('click', toggleSimilarArtistsEnrichment);
updateSimilarArtistsEnrichmentStatus();
setInterval(updateSimilarArtistsEnrichmentStatus, 2000);
}
}
// ===================================================================
// HYDRABASE P2P MIRROR WORKER
// ===================================================================
async function updateHydrabaseStatus() {
if (socketConnected) return; // WebSocket handles this
if (document.hidden) return; // Skip polling when tab is not visible
try {
const response = await fetch('/api/hydrabase-worker/status');
if (!response.ok) return;
const data = await response.json();
updateHydrabaseStatusFromData(data);
} catch (error) {
// Silently ignore — worker may not be available
}
}
function updateHydrabaseStatusFromData(data) {
const button = document.getElementById('hydrabase-button');
if (!button) return;
button.classList.remove('active', 'paused');
if (data.running && !data.paused) {
button.classList.add('active');
} else if (data.paused) {
button.classList.add('paused');
}
const statusEl = document.getElementById('hydrabase-tooltip-status');
if (statusEl) {
if (data.paused) {
statusEl.textContent = 'Paused';
statusEl.style.color = '#ffc107';
} else if (data.running) {
statusEl.textContent = 'Active';
statusEl.style.color = '#ffffff';
} else {
statusEl.textContent = 'Stopped';
statusEl.style.color = '#ff5252';
}
}
}
async function toggleHydrabaseWorker() {
const button = document.getElementById('hydrabase-button');
if (!button) return;
const isRunning = button.classList.contains('active');
const endpoint = isRunning ? '/api/hydrabase-worker/pause' : '/api/hydrabase-worker/resume';
try {
await fetch(endpoint, { method: 'POST' });
await updateHydrabaseStatus();
} catch (error) {
console.error('Error toggling Hydrabase worker:', error);
}
}
// Initialize Hydrabase UI on page load
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => {
const button = document.getElementById('hydrabase-button');
if (button) {
button.addEventListener('click', toggleHydrabaseWorker);
updateHydrabaseStatus();
setInterval(updateHydrabaseStatus, 2000);
}
});
} else {
const button = document.getElementById('hydrabase-button');
if (button) {
button.addEventListener('click', toggleHydrabaseWorker);
updateHydrabaseStatus();
setInterval(updateHydrabaseStatus, 2000);
}
}
// ===================================================================
// LIBRARY REPAIR WORKER
// ===================================================================
async function updateRepairStatus() {
if (socketConnected) return; // WebSocket handles this
if (document.hidden) return; // Skip polling when tab is not visible
try {
const response = await fetch('/api/repair/status');
if (!response.ok) { console.warn('Repair status endpoint unavailable'); return; }
const data = await response.json();
updateRepairStatusFromData(data);
} catch (error) {
console.error('Error updating repair status:', error);
}
}
function updateRepairStatusFromData(data) {
const button = document.getElementById('repair-button');
if (!button) return;
button.classList.remove('active', 'paused', 'complete');
if (data.idle) {
button.classList.add('complete');
} else if (data.running && !data.paused) {
button.classList.add('active');
} else if (data.paused) {
button.classList.add('paused');
}
const tooltipStatus = document.getElementById('repair-tooltip-status');
const tooltipCurrent = document.getElementById('repair-tooltip-current');
const tooltipProgress = document.getElementById('repair-tooltip-progress');
if (tooltipStatus) {
if (data.idle) { tooltipStatus.textContent = 'Complete'; }
else if (data.running && !data.paused) { tooltipStatus.textContent = 'Running'; }
else if (data.paused) { tooltipStatus.textContent = data.yield_reason === 'downloads' ? 'Yielding for downloads' : 'Paused'; }
else { tooltipStatus.textContent = 'Idle'; }
}
if (tooltipCurrent) {
if (data.idle) {
tooltipCurrent.textContent = 'All jobs complete — waiting for next schedule';
} else if (data.current_job && data.current_job.display_name) {
const jobName = data.current_job.display_name;
const jobProgress = data.progress && data.progress.current_job;
if (jobProgress && jobProgress.total > 0) {
tooltipCurrent.textContent = `${jobName}: ${jobProgress.scanned} / ${jobProgress.total} (${jobProgress.percent}%)`;
} else {
tooltipCurrent.textContent = `Running: ${jobName}`;
}
} else if (data.current_item && data.current_item.name) {
tooltipCurrent.textContent = `Running: ${data.current_item.name}`;
} else {
tooltipCurrent.textContent = 'No active repairs';
}
}
if (tooltipProgress && data.progress) {
const tracks = data.progress.tracks || {};
const parts = [];
if (tracks.total > 0) parts.push(`Checked: ${tracks.checked || 0} / ${tracks.total || 0}`);
if (tracks.repaired > 0) parts.push(`Repaired: ${tracks.repaired}`);
const pending = data.findings_pending || 0;
if (pending > 0) parts.push(`Findings: ${pending}`);
tooltipProgress.textContent = parts.length ? parts.join(' · ') : 'No items processed yet';
}
// Update findings badge
const badge = document.getElementById('repair-findings-badge');
const findingsPending = data.findings_pending || 0;
if (badge) {
badge.textContent = findingsPending;
badge.style.display = findingsPending > 0 ? '' : 'none';
}
const tabBadge = document.getElementById('repair-findings-tab-badge');
if (tabBadge) {
tabBadge.textContent = findingsPending;
tabBadge.style.display = findingsPending > 0 ? '' : 'none';
}
// Update master toggle in modal if open
const masterToggle = document.getElementById('repair-master-toggle');
const masterLabel = document.getElementById('repair-master-label');
if (masterToggle) masterToggle.checked = data.enabled || false;
if (masterLabel) masterLabel.textContent = data.enabled ? 'Enabled' : 'Disabled';
// Update button state
if (!data.enabled) {
button.classList.add('paused');
button.classList.remove('active', 'complete');
}
}
// ── SoulID Worker Status ──
function updateSoulIDStatusFromData(data) {
const button = document.getElementById('soulid-button');
if (!button) return;
button.classList.remove('active', 'complete');
if (data.idle) {
button.classList.add('complete');
} else if (data.running && !data.paused) {
button.classList.add('active');
}
const tooltipStatus = document.getElementById('soulid-tooltip-status');
const tooltipCurrent = document.getElementById('soulid-tooltip-current');
const tooltipProgress = document.getElementById('soulid-tooltip-progress');
if (tooltipStatus) {
if (data.idle) tooltipStatus.textContent = 'Complete';
else if (data.running && !data.paused) tooltipStatus.textContent = 'Running';
else if (data.paused) tooltipStatus.textContent = 'Paused';
else tooltipStatus.textContent = 'Idle';
}
if (tooltipCurrent) {
if (data.current_item) {
tooltipCurrent.textContent = data.current_item;
} else if (data.idle) {
tooltipCurrent.textContent = 'All entities have soul IDs';
} else {
tooltipCurrent.textContent = 'No items processing';
}
}
if (tooltipProgress && data.stats) {
const s = data.stats;
const parts = [];
if (s.artists_processed) parts.push(`Artists: ${s.artists_processed}`);
if (s.albums_processed) parts.push(`Albums: ${s.albums_processed}`);
if (s.tracks_processed) parts.push(`Tracks: ${s.tracks_processed}`);
if (s.pending > 0) parts.push(`Pending: ${s.pending}`);
tooltipProgress.textContent = parts.length ? parts.join(' · ') : 'No items processed yet';
}
}
// ── Repair Modal State ──
let _repairCurrentTab = 'jobs';
let _repairFindingsPage = 0;
let _repairSelectedFindings = new Set();
let _repairFindingsTotal = 0;
let _repairFindingsAutoSwitched = false; // Set after auto-switching to "All Status" so we don't loop
const REPAIR_FINDINGS_PAGE_SIZE = 30;
let _repairJobsCache = {}; // Cache job data for help modal
/**
* Open the Library Maintenance modal
*/
async function openRepairModal() {
navigateToPage('tools');
// Scroll to maintenance section
setTimeout(() => {
const section = document.querySelector('.tools-maintenance-section');
if (section) section.scrollIntoView({ behavior: 'smooth' });
}, 100);
_repairCurrentTab = 'jobs';
switchRepairTab('jobs');
// Load master toggle state
updateRepairStatus();
// Load any active job progress
try {
const resp = await fetch('/api/repair/progress');
if (resp.ok) {
const data = await resp.json();
if (Object.keys(data).length > 0) {
// Brief delay so job cards are rendered first
setTimeout(() => updateRepairJobProgressFromData(data), 300);
}
}
} catch (e) { /* ignore */ }
}
function closeRepairModal() {
// No-op — repair content now lives on the tools page, no modal to close
}
async function toggleRepairMaster() {
try {
const response = await fetch('/api/repair/toggle', { method: 'POST' });
if (!response.ok) throw new Error('Failed to toggle');
const data = await response.json();
const label = document.getElementById('repair-master-label');
const toggle = document.getElementById('repair-master-toggle');
if (label) label.textContent = data.enabled ? 'Enabled' : 'Disabled';
if (toggle) toggle.checked = data.enabled;
await updateRepairStatus();
} catch (error) {
console.error('Error toggling repair master:', error);
showToast('Error toggling maintenance worker', 'error');
}
}
function switchRepairTab(tab) {
_repairCurrentTab = tab;
document.querySelectorAll('.repair-tab').forEach(t => {
t.classList.toggle('active', t.dataset.tab === tab);
});
document.querySelectorAll('.repair-tab-content').forEach(c => {
c.style.display = 'none';
});
const content = document.getElementById(`repair-tab-${tab}`);
if (content) content.style.display = '';
if (tab === 'jobs') loadRepairJobs();
else if (tab === 'findings') { loadRepairFindingsDashboard(); loadRepairFindings(); }
else if (tab === 'history') loadRepairHistory();
}
// Turn a snake_case setting key into a human label. Handles acronym fix-ups
// (EP, ID, URL, MB, AC, OS) that the naive Title-Case would otherwise botch.
function _prettifyRepairSettingKey(key) {
// Full-key label overrides — for settings whose plain prettified name
// doesn't convey an important cost/behaviour (e.g. that it runs ffmpeg).
const fullKeyLabels = {
'deep_audio_verify': 'Deep Audio Verify (ffmpeg decode — CPU heavy)',
};
if (fullKeyLabels[key]) return fullKeyLabels[key];
const words = key.replace(/^_+/, '').split('_');
const acronyms = { 'eps': 'EPs', 'id': 'ID', 'url': 'URL', 'mb': 'MB',
'ac': 'AC', 'os': 'OS', 'api': 'API', 'mp3': 'MP3',
'flac': 'FLAC', 'cd': 'CD' };
return words.map(w => acronyms[w.toLowerCase()] || (w.charAt(0).toUpperCase() + w.slice(1))).join(' ');
}
async function loadRepairJobs() {
const container = document.getElementById('repair-jobs-list');
if (!container) return;
try {
const response = await fetch('/api/repair/jobs');
if (!response.ok) throw new Error('Failed to fetch jobs');
const data = await response.json();
const jobs = data.jobs || [];
// Cache job data for help modal
_repairJobsCache = {};
jobs.forEach(j => { _repairJobsCache[j.job_id] = j; });
if (jobs.length === 0) {
container.innerHTML = `
🔧
No Maintenance Jobs
Library maintenance jobs will appear here once available.
`;
return;
}
// Populate findings job filter dropdown
const jobFilter = document.getElementById('repair-findings-job-filter');
if (jobFilter && jobFilter.options.length <= 1) {
jobs.forEach(job => {
const opt = document.createElement('option');
opt.value = job.job_id;
opt.textContent = job.display_name;
jobFilter.appendChild(opt);
});
}
container.innerHTML = jobs.map(job => {
const lastRunText = job.last_run ? formatCacheAge(job.last_run.finished_at) : 'Never';
const nextRunText = job.next_run ? formatCacheAge(job.next_run) : (job.enabled ? 'Pending' : '-');
const statusClass = job.is_running ? 'running' : (job.enabled ? 'idle' : 'disabled');
const dotClass = job.is_running ? 'running' : (job.enabled ? 'enabled' : 'disabled');
const cardClass = job.is_running ? 'running' : (!job.enabled ? 'disabled' : '');
// Build flow badges
const flowParts = [];
flowParts.push(`${job.is_running ? '▶ Running' : 'Scan'}`);
if (job.auto_fix) {
flowParts.push('→');
const isDryRun = job.settings && job.settings.dry_run === true;
if (isDryRun) {
flowParts.push('Dry Run');
} else {
flowParts.push('Auto-fix');
}
}
// Badge: prefer the CURRENT pending count from the API
// (matches what the Findings tab actually shows); fall
// back to the historical ``findings_created`` from the
// last run when pending = 0 but the last scan did find
// something — that way users still see a hint that a scan
// happened recently. Without this, a scan that finds 372
// duplicates and then has them all bulk-fixed shows
// "372 findings" on the badge while the Findings tab
// Pending filter is empty, which reads as a bug.
const pendingCount = job.pending_findings_count || 0;
const lastScanCount = job.last_run ? (job.last_run.findings_created || 0) : 0;
if (pendingCount > 0) {
flowParts.push('→');
flowParts.push(`${pendingCount} pending`);
} else if (lastScanCount > 0) {
// Show historical count with a clear "found in last scan"
// qualifier so users don't expect them on the Pending tab.
flowParts.push('→');
flowParts.push(`${lastScanCount} found in last scan`);
}
// Build meta parts
const metaParts = [];
metaParts.push('Last: ' + lastRunText);
metaParts.push('Next: ' + nextRunText);
if (job.last_run) {
metaParts.push(`Scanned: ${(job.last_run.items_scanned || 0).toLocaleString()}`);
if (job.last_run.auto_fixed) metaParts.push(`Fixed: ${job.last_run.auto_fixed}`);
}
if (job.last_run && job.last_run.duration_seconds) {
metaParts.push(`${job.last_run.duration_seconds.toFixed(1)}s`);
}
// Build settings HTML
let settingsHtml = '';
if (job.settings && Object.keys(job.settings).length > 0) {
const settingsRows = Object.entries(job.settings).map(([key, val]) => {
// Section header: keys starting with `_section_` render as a
// group divider + title instead of a setting row. The value
// is the human-readable title.
if (key.startsWith('_section_')) {
return `
${val}
`;
}
const label = _prettifyRepairSettingKey(key);
// Dropdown when the job declares allowed values for this key.
const opts = job.setting_options && job.setting_options[key];
if (Array.isArray(opts) && opts.length) {
const optionsHtml = opts.map(o =>
``
).join('');
return `
`;
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 {
const v = input.value;
settings[key] = v === 'true' ? true : v === 'false' ? false : v;
}
}
});
await fetch(`/api/repair/jobs/${jobId}/settings`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ interval_hours: intervalHours, settings })
});
showToast('Settings saved', 'success');
} catch (error) {
console.error('Error saving job settings:', error);
showToast('Error saving settings', 'error');
}
}
async function runRepairJobNow(jobId) {
try {
await fetch(`/api/repair/jobs/${jobId}/run`, { method: 'POST' });
showToast('Job started', 'success');
setTimeout(() => loadRepairJobs(), 1000);
} catch (error) {
console.error('Error running job:', error);
showToast('Error starting job', 'error');
}
}
// ── Repair Job Live Progress ──
const _repairProgressLogCounts = {};
const _repairProgressHideTimers = {};
function updateRepairJobProgressFromData(data) {
for (const [jobId, state] of Object.entries(data)) {
const card = document.querySelector(`.repair-job-card[data-job-id="${jobId}"]`);
if (!card) continue;
// Update status dot
const statusDot = card.querySelector('.repair-job-status');
if (statusDot) {
if (state.status === 'running') statusDot.className = 'repair-job-status running';
else if (state.status === 'finished') statusDot.className = 'repair-job-status enabled';
else if (state.status === 'error') statusDot.className = 'repair-job-status enabled';
}
// Update flow badge to show running state
const firstBadge = card.querySelector('.repair-flow-badge.scan');
if (firstBadge) {
if (state.status === 'running') firstBadge.innerHTML = '▶ Running';
else if (state.status === 'finished') firstBadge.innerHTML = '✓ Complete';
else if (state.status === 'error') firstBadge.innerHTML = '✗ Error';
}
// Add/update card running class
card.classList.toggle('running', state.status === 'running');
card.classList.remove('disabled');
// Create or find progress panel (bar-first layout like automation)
let panel = card.querySelector('.repair-job-progress');
if (!panel) {
panel = document.createElement('div');
panel.className = 'repair-job-progress';
panel.innerHTML = `
`;
card.appendChild(panel);
}
// Show panel
panel.classList.add('visible');
panel.classList.toggle('finished', state.status === 'finished');
panel.classList.toggle('error', state.status === 'error');
if (state.status === 'running') {
panel.classList.remove('finished', 'error');
if (_repairProgressHideTimers[jobId]) {
clearTimeout(_repairProgressHideTimers[jobId]);
delete _repairProgressHideTimers[jobId];
}
// Reset log for re-run
if (_repairProgressLogCounts[jobId] > 0 && state.log && state.log.length < _repairProgressLogCounts[jobId]) {
const existingLog = panel.querySelector('.repair-progress-log');
if (existingLog) existingLog.innerHTML = '';
_repairProgressLogCounts[jobId] = 0;
}
}
// Update progress bar
const bar = panel.querySelector('.repair-progress-bar');
if (bar) bar.style.width = (state.progress || 0) + '%';
// Update phase
const phaseEl = panel.querySelector('.repair-progress-phase');
if (phaseEl && state.phase) phaseEl.textContent = state.phase;
// Update log
const logEl = panel.querySelector('.repair-progress-log');
if (logEl && state.log) {
const prevCount = _repairProgressLogCounts[jobId] || 0;
if (state.log.length > prevCount) {
const newLines = state.log.slice(prevCount);
for (const line of newLines) {
const div = document.createElement('div');
div.className = 'repair-log-line ' + (line.type || 'info');
div.textContent = line.text;
logEl.appendChild(div);
}
logEl.scrollTop = logEl.scrollHeight;
}
_repairProgressLogCounts[jobId] = state.log.length;
}
// Auto-hide panel after completion
if (state.status === 'finished' || state.status === 'error') {
if (!_repairProgressHideTimers[jobId]) {
_repairProgressHideTimers[jobId] = setTimeout(() => {
panel.classList.remove('visible');
card.classList.remove('running');
delete _repairProgressHideTimers[jobId];
delete _repairProgressLogCounts[jobId];
// Reload to get updated stats
loadRepairJobs();
}, 30000);
}
} else {
// Clear any existing hide timer if job restarts
if (_repairProgressHideTimers[jobId]) {
clearTimeout(_repairProgressHideTimers[jobId]);
delete _repairProgressHideTimers[jobId];
}
}
}
}
async function loadRepairFindingsDashboard() {
const dashboard = document.getElementById('repair-findings-dashboard');
if (!dashboard) return;
try {
const response = await fetch('/api/repair/findings/counts');
if (!response.ok) throw new Error('Failed to fetch counts');
const data = await response.json();
const pending = data.pending || 0;
const resolved = data.resolved || 0;
const dismissed = data.dismissed || 0;
const autoFixed = data.auto_fixed || 0;
const byJob = data.by_job || {};
// Summary stats row
let html = '
';
html += `
${pending.toLocaleString()} pending
`;
html += `
${resolved.toLocaleString()} resolved
`;
html += `
${dismissed.toLocaleString()} dismissed
`;
if (autoFixed > 0) {
html += `
${autoFixed.toLocaleString()} auto-fixed
`;
}
html += '
';
// Per-job chips (only if there are pending findings)
const jobIds = Object.keys(byJob).sort((a, b) => byJob[b].total - byJob[a].total);
if (jobIds.length > 0) {
html += '
';
const jobFilter = document.getElementById('repair-findings-job-filter');
const activeJob = jobFilter ? jobFilter.value : '';
for (const jid of jobIds) {
const job = byJob[jid];
const isActive = activeJob === jid;
const severityDots = [];
if (job.warning > 0) severityDots.push(``);
if (job.info > 0) severityDots.push(``);
html += `
';
if (albumUrl) {
const albumLabel = d.album_title || 'Album';
html += `
${_escFinding(albumLabel)}
`;
}
if (artistUrl) {
const artistLabel = d.artist_name || d.artist || 'Artist';
html += `
${_escFinding(artistLabel)}
`;
}
html += '
';
return html;
}
function _renderFindingDetail(f) {
const d = f.details || {};
const rows = [];
const media = _renderFindingMedia(d);
switch (f.finding_type) {
case 'dead_file':
if (d.artist) rows.push(['Artist', d.artist]);
if (d.album) rows.push(['Album', d.album]);
if (d.title) rows.push(['Title', d.title]);
if (d.track_id) rows.push(['Track ID', d.track_id]);
if (d.original_path) rows.push(['Original Path', d.original_path, 'path']);
return media + _gridRows(rows) + _renderPlayButton(f);
case 'orphan_file':
if (d.folder) rows.push(['Folder', d.folder, 'path']);
if (d.format) rows.push(['Format', d.format.toUpperCase()]);
if (d.file_size) rows.push(['File Size', _formatFileSize(d.file_size)]);
if (d.modified) rows.push(['Last Modified', d.modified]);
if (f.file_path) rows.push(['Full Path', f.file_path, 'path']);
return _gridRows(rows) + _renderPlayButton(f);
case 'library_retag': {
const tracks = Array.isArray(d.tracks) ? d.tracks : [];
const changed = tracks.filter(t => t.changes && Object.keys(t.changes).length);
const meta = [];
if (d.source) meta.push(`Source: ${d.source}`);
if (d.mode) meta.push(`Mode: ${d.mode}`);
if (d.cover_action) meta.push(`Cover: ${d.cover_action}`);
let html = '';
if (meta.length) {
html += `
${_escFinding(meta.join(' · '))}
`;
}
if (!changed.length && d.cover_action) {
html += `
Tags already correct — this would refresh cover art only.
`;
}
// Per-track old → new diff (cap the rendered list so huge albums stay sane).
changed.slice(0, 40).forEach(t => {
const fname = (t.file_path || '').split(/[\\/]/).pop();
const label = t.title || fname || 'Unknown';
const rows = Object.entries(t.changes).map(([field, c]) => [
field.replace(/_/g, ' '),
`${(c.old === '' || c.old == null) ? '∅' : c.old} → ${c.new}`,
'highlight',
]);
html += `
${_escFinding(label)}
`;
// Show the physical filename so a wrong match is easy to spot before
// applying (skip when the label already IS the filename, i.e. no title).
if (fname && fname !== label) {
html += `
📄 ${_escFinding(fname)}
`;
}
html += _gridRows(rows);
});
if (changed.length > 40) {
html += `
…and ${changed.length - 40} more track(s)
`;
}
if (Array.isArray(d.unmatched) && d.unmatched.length) {
html += `
';
}
case 'acoustid_mismatch': {
let html = media + '
';
html += _renderScoreBar(d.fingerprint_score, 'Fingerprint');
html += _renderScoreBar(d.title_similarity, 'Title Match');
html += _renderScoreBar(d.artist_similarity, 'Artist Match');
html += '
`;
}
return incHtml;
case 'path_mismatch':
if (d.from) rows.push(['Current Path', d.from, 'path']);
if (d.to) rows.push(['Expected Path', d.to, 'success']);
return _gridRows(rows);
case 'metadata_gap':
if (d.artist) rows.push(['Artist', d.artist]);
if (d.album) rows.push(['Album', d.album]);
if (d.title) rows.push(['Title', d.title]);
if (d.spotify_track_id) rows.push(['Spotify ID', d.spotify_track_id]);
if (d.resolved_source) rows.push(['Resolved Source', d.resolved_source]);
if (d.resolved_track_id) rows.push(['Resolved Track ID', d.resolved_track_id]);
if (d.found_fields && typeof d.found_fields === 'object') {
Object.entries(d.found_fields).forEach(([k, v]) => {
rows.push([`Found: ${k}`, String(v), 'success']);
});
}
return media + _gridRows(rows);
case 'missing_cover_art':
if (d.artist) rows.push(['Artist', d.artist]);
if (d.album_title) rows.push(['Album', d.album_title]);
if (d.spotify_album_id) rows.push(['Spotify ID', d.spotify_album_id]);
let artHtml = '';
// Each found image is independently applyable (Pache711: "fix one,
// dismiss the other"). Per-image Apply buttons let the user take the
// correct album art and skip a wrong artist image, or vice versa.
if (d.artist_thumb_url || d.found_artwork_url || d.found_artist_url) {
artHtml += '
';
if (d.found_artwork_url) {
artHtml += `
Found Album Art
`;
}
// Current artist image (context) — no apply, it's what's there now.
if (d.artist_thumb_url) {
artHtml += `
${_escFinding(d.artist || 'Artist')} (current)
`;
}
// Found artist image — the applyable replacement.
if (d.found_artist_url) {
artHtml += `
Found Artist Art
`;
}
artHtml += '
';
}
artHtml += _gridRows(rows);
return artHtml;
case 'missing_lyrics':
if (d.track_title) rows.push(['Track', d.track_title]);
if (d.artist) rows.push(['Artist', d.artist]);
if (d.album_title) rows.push(['Album', d.album_title]);
return _gridRows(rows);
case 'missing_replaygain':
if (d.track_title) rows.push(['Track', d.track_title]);
if (d.artist) rows.push(['Artist', d.artist]);
return _gridRows(rows);
case 'empty_folder':
if (d.folder_path) rows.push(['Folder', d.folder_path, 'path']);
if (d.junk_files && d.junk_files.length) rows.push(['Junk files', d.junk_files.join(', ')]);
return _gridRows(rows);
case 'expired_download':
if (d.title) rows.push(['Track', d.title]);
if (d.artist) rows.push(['Artist', d.artist]);
if (d.origin) rows.push(['Source', `${d.origin}${d.origin_context ? ' — ' + d.origin_context : ''}`]);
if (d.file_path) rows.push(['File', d.file_path.split(/[\\/]/).pop()]);
return _gridRows(rows);
case 'track_number_mismatch':
if (d.album_title) rows.push(['Album', d.album_title]);
if (d.artist_name) rows.push(['Artist', d.artist_name]);
if (d.matched_title) rows.push(['Matched To', d.matched_title]);
if (d.file_title) rows.push(['File Title', d.file_title]);
if (d.current_track_num !== undefined) rows.push(['Current Track #', String(d.current_track_num)]);
if (d.correct_track_num !== undefined) rows.push(['Correct Track #', String(d.correct_track_num), 'success']);
if (f.file_path) rows.push(['File', f.file_path, 'path']);
let tnHtml = media;
if (d.match_score) {
tnHtml += '
The audio fingerprint doesn't match the expected track. Choose how to fix it.
Retag = update metadata in place • Relocate = retag + move to Staging so it's re-imported into the correct artist/album • Re-download = add correct track to wishlist & delete wrong file • Delete = remove file and DB entry
This file is below your quality profile. Choose what to do.
Re-download = add to wishlist for a better-quality copy & delete this file • Delete = remove file and DB entry • Ignore = keep the file and dismiss this finding
`;
document.body.appendChild(overlay);
overlay.querySelector('#_qual-redownload').onclick = () => { overlay.remove(); resolve('redownload'); };
overlay.querySelector('#_qual-delete').onclick = () => { overlay.remove(); resolve('delete'); };
overlay.querySelector('#_qual-ignore').onclick = () => { overlay.remove(); resolve('ignore'); };
overlay.querySelector('#_qual-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;
}
// If any selected findings are quality upgrades, prompt once (redownload/delete/ignore)
const selectedQualityCards = ids.filter(id => {
const card = document.querySelector(`.repair-finding-card[data-id="${id}"]`);
return card && card.dataset.jobId === 'quality_upgrade_scanner';
});
let qualityFixAction = null;
if (selectedQualityCards.length > 0) {
qualityFixAction = await _promptQualityUpgradeAction();
if (!qualityFixAction) 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';
const isQuality = card && card.dataset.jobId === 'quality_upgrade_scanner';
// 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;
}
// Quality "Ignore" likewise dismisses rather than fixing.
if (isQuality && qualityFixAction === 'ignore') {
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 };
else if (isQuality && qualityFixAction) body = { fix_action: qualityFixAction };
// 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.