You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
SoulSync/webui/static/enrichment.js

3586 lines
162 KiB

This file contains invisible Unicode characters!

This file contains invisible Unicode characters that may be processed differently from what appears below. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to reveal hidden characters.

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

// 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 = `<div class="repair-empty-state">
<div class="repair-empty-icon">🔧</div>
<div class="repair-empty-title">No Maintenance Jobs</div>
<div class="repair-empty-text">Library maintenance jobs will appear here once available.</div>
</div>`;
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(`<span class="repair-flow-badge scan">${job.is_running ? '&#9654; Running' : 'Scan'}</span>`);
if (job.auto_fix) {
flowParts.push('<span class="repair-flow-arrow">&rarr;</span>');
const isDryRun = job.settings && job.settings.dry_run === true;
if (isDryRun) {
flowParts.push('<span class="repair-flow-badge dryrun">Dry Run</span>');
} else {
flowParts.push('<span class="repair-flow-badge autofix">Auto-fix</span>');
}
}
// Show pending findings count
const findingsCount = job.last_run ? (job.last_run.findings_created || 0) : 0;
if (findingsCount > 0) {
flowParts.push('<span class="repair-flow-arrow">&rarr;</span>');
flowParts.push(`<span class="repair-flow-badge findings">${findingsCount} finding${findingsCount !== 1 ? 's' : ''}</span>`);
}
// 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 `<div class="repair-setting-section">${val}</div>`;
}
const label = _prettifyRepairSettingKey(key);
const inputType = typeof val === 'boolean' ? 'checkbox' :
typeof val === 'number' ? 'number' : 'text';
const inputVal = inputType === 'checkbox' ?
(val ? ' checked' : '') :
` value="${val}"`;
return `<div class="repair-setting-row">
<label>${label}</label>
<input type="${inputType}" class="repair-setting-input"
data-job="${job.job_id}" data-key="${key}"${inputVal}
${inputType === 'number' ? 'step="0.01" min="0"' : ''}>
</div>`;
}).join('');
settingsHtml = `
<div class="repair-job-settings" id="repair-settings-${job.job_id}" style="display:none;">
<div class="repair-setting-row">
<label>Interval (hours)</label>
<input type="number" class="repair-setting-input"
data-job="${job.job_id}" data-key="_interval_hours"
value="${job.interval_hours}" min="1" step="1">
</div>
${settingsRows}
<button class="repair-save-settings-btn" onclick="saveRepairJobSettings('${job.job_id}')">Save Settings</button>
</div>`;
}
return `<div class="repair-job-card ${cardClass}" data-job-id="${job.job_id}">
<div class="repair-job-main">
<div class="repair-job-status ${dotClass}"></div>
<div class="repair-job-info">
<div class="repair-job-name">${job.display_name}</div>
<div class="repair-job-desc">${job.description || ''}</div>
<div class="repair-job-flow">${flowParts.join('')}</div>
<div class="repair-job-meta">${metaParts.join(' &middot; ')}</div>
</div>
<div class="repair-job-actions">
<label class="repair-job-toggle">
<input type="checkbox" ${job.enabled ? 'checked' : ''}
onchange="toggleRepairJob('${job.job_id}', this.checked)">
<span class="repair-toggle-slider small"></span>
</label>
<button class="repair-run-btn" onclick="runRepairJobNow('${job.job_id}')"
title="Run now">&#9654;</button>
${Object.keys(job.settings || {}).length > 0 ?
`<button class="repair-settings-btn" onclick="expandRepairJobSettings('${job.job_id}')"
title="Settings">&#9881;</button>` : ''}
<button class="repair-help-btn" onclick="event.stopPropagation(); showRepairJobHelp('${job.job_id}')"
title="About this job">?</button>
</div>
</div>
${settingsHtml}
</div>`;
}).join('');
} catch (error) {
console.error('Error loading repair jobs:', error);
container.innerHTML = '<div class="repair-empty">Error loading jobs</div>';
}
}
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 `<div class="repair-help-setting"><span class="repair-help-setting-key">${label}</span><span class="repair-help-setting-val">${display}</span></div>`;
}).join('');
settingsHtml = `<div class="repair-help-settings-section">
<div class="repair-help-section-title">Current Settings</div>
${rows}
</div>`;
}
// Build info badges
const badges = [];
if (job.auto_fix) {
const isDryRun = job.settings && job.settings.dry_run === true;
badges.push(isDryRun
? '<span class="repair-flow-badge dryrun">Dry Run</span>'
: '<span class="repair-flow-badge autofix">Auto-fix</span>');
} else {
badges.push('<span class="repair-flow-badge scan">Scan Only</span>');
}
badges.push(`<span class="repair-flow-badge scan">Every ${job.interval_hours}h</span>`);
if (job.enabled) {
badges.push('<span class="repair-flow-badge" style="background:rgba(74,222,128,0.12);color:#4ade80;">Enabled</span>');
} else {
badges.push('<span class="repair-flow-badge" style="background:rgba(255,255,255,0.06);color:rgba(255,255,255,0.4);">Disabled</span>');
}
// 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 '<div class="repair-help-setting-list">' +
lines.map(l => `<div class="repair-help-setting-item">${l.replace(/^- /, '')}</div>`).join('') +
'</div>';
}
return `<p>${p.replace(/\n/g, '<br>')}</p>`;
}).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 = `
<div class="repair-help-modal">
<div class="repair-help-header">
<h3>${job.display_name}</h3>
<button class="repair-help-close" onclick="document.getElementById('repair-help-overlay').remove()">&times;</button>
</div>
<div class="repair-help-badges">${badges.join('')}</div>
<div class="repair-help-body">${helpBody}</div>
${settingsHtml}
</div>
`;
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 = '&#9654; Running';
else if (state.status === 'finished') firstBadge.innerHTML = '&#10003; Complete';
else if (state.status === 'error') firstBadge.innerHTML = '&#10007; 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 = `
<div class="repair-progress-bar-wrap">
<div class="repair-progress-bar" style="width:0%"></div>
</div>
<div class="repair-progress-phase"></div>
<div class="repair-progress-log"></div>
`;
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 = '<div class="repair-dashboard-summary">';
html += `<div class="repair-dashboard-stat pending">
<span class="stat-count">${pending.toLocaleString()}</span> pending
</div>`;
html += `<div class="repair-dashboard-stat resolved">
<span class="stat-count">${resolved.toLocaleString()}</span> resolved
</div>`;
html += `<div class="repair-dashboard-stat dismissed">
<span class="stat-count">${dismissed.toLocaleString()}</span> dismissed
</div>`;
if (autoFixed > 0) {
html += `<div class="repair-dashboard-stat auto-fixed">
<span class="stat-count">${autoFixed.toLocaleString()}</span> auto-fixed
</div>`;
}
html += '</div>';
// 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 += '<div class="repair-dashboard-jobs">';
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(`<span class="repair-dashboard-chip-severity warning" title="${job.warning} warnings"></span>`);
if (job.info > 0) severityDots.push(`<span class="repair-dashboard-chip-severity info" title="${job.info} info"></span>`);
html += `<div class="repair-dashboard-chip ${isActive ? 'active' : ''}" onclick="filterFindingsByJob('${jid}')">
<span class="repair-dashboard-chip-count">${job.total.toLocaleString()}</span>
<span class="repair-dashboard-chip-name">${_escFinding(job.display_name || jid.replace(/_/g, ' '))}</span>
${severityDots.length ? `<span class="repair-dashboard-chip-bar">${severityDots.join('')}</span>` : ''}
</div>`;
}
html += '</div>';
}
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 = `
<div class="repair-cache-health-bar" onclick="openCacheHealthModal()">
<span class="repair-cache-health-dot ${healthScore}"></span>
<span class="repair-cache-health-title">Metadata Cache</span>
<span class="repair-cache-health-summary">${stats.total_entities.toLocaleString()} entities · ${healthLabel}</span>
<span class="repair-cache-health-action">View Details </span>
</div>
`;
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 = `
<div class="cache-health-modal">
<div class="cache-health-header">
<div class="cache-health-header-content">
<div class="cache-health-header-icon">&#128202;</div>
<div>
<h2 class="cache-health-title">Cache Health</h2>
<p class="cache-health-subtitle">Metadata cache status across all sources</p>
</div>
</div>
<button class="watch-all-close" onclick="document.getElementById('cache-health-modal-overlay').remove()">&times;</button>
</div>
<div class="cache-health-body">
<div class="cache-health-loading">
<div class="watch-all-loading-spinner"></div>
<div>Loading cache stats...</div>
</div>
</div>
<div class="cache-health-footer">
<button class="watch-all-btn watch-all-btn-cancel" onclick="document.getElementById('cache-health-modal-overlay').remove()">Close</button>
</div>
</div>
`;
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' ? '&#10003;' : healthScore === 'fair' ? '&#9888;' : '&#10060;';
const healthLabel = healthScore === 'healthy' ? 'Cache is healthy' : healthScore === 'fair' ? 'Minor issues detected' : 'Cleanup recommended';
body.innerHTML = `
<div class="cache-health-status ${healthScore}">
<div class="cache-health-status-icon">${healthEmoji}</div>
<div class="cache-health-status-text">${healthLabel}</div>
</div>
<div class="cache-health-cards">
<div class="cache-health-card">
<div class="cache-health-card-value">${s.total_entities.toLocaleString()}</div>
<div class="cache-health-card-label">Total Entities</div>
</div>
<div class="cache-health-card">
<div class="cache-health-card-value">${s.total_searches.toLocaleString()}</div>
<div class="cache-health-card-label">Search Results</div>
</div>
<div class="cache-health-card">
<div class="cache-health-card-value ${s.junk_entities > 0 ? 'warn' : ''}">${s.junk_entities}</div>
<div class="cache-health-card-label">Junk Entries</div>
</div>
<div class="cache-health-card ${s.stale_mb_nulls > 0 ? 'clickable' : ''}" ${s.stale_mb_nulls > 0 ? 'onclick="openFailedMBLookupsModal()"' : ''}>
<div class="cache-health-card-value ${s.stale_mb_nulls > 10 ? 'warn' : ''}">${s.stale_mb_nulls}</div>
<div class="cache-health-card-label">Failed MB Lookups</div>
${s.stale_mb_nulls > 0 ? '<div class="cache-health-card-action">Manage </div>' : ''}
</div>
</div>
<div class="cache-health-section">
<div class="cache-health-section-title">By Source</div>
<div class="cache-health-source-bars">
${(() => {
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 `<div class="cache-health-source-row">
<span class="cache-health-source-name">${src === 'musicbrainz' ? 'MusicBrainz' : src}</span>
<div class="cache-health-source-track"><div class="cache-health-source-fill" style="width:${pct}%;background:${color}"></div></div>
<span class="cache-health-source-count">${count.toLocaleString()}</span>
</div>`;
}).join('');
})()}
</div>
</div>
<div class="cache-health-section">
<div class="cache-health-section-title">By Type</div>
<div class="cache-health-type-pills">
${Object.entries(s.by_type || {}).map(([type, count]) => `<span class="cache-health-pill">${type}s <strong>${count.toLocaleString()}</strong></span>`).join('')}
</div>
</div>
<div class="cache-health-section">
<div class="cache-health-section-title">Metrics</div>
<div class="cache-health-metrics">
<div class="cache-health-metric"><span class="cache-health-metric-label">Average Age</span><span class="cache-health-metric-value">${s.avg_age_days} days</span></div>
<div class="cache-health-metric"><span class="cache-health-metric-label">Total Cache Hits</span><span class="cache-health-metric-value">${s.total_access_hits.toLocaleString()}</span></div>
<div class="cache-health-metric"><span class="cache-health-metric-label">Expiring in 24h</span><span class="cache-health-metric-value">${s.expiring_24h}</span></div>
<div class="cache-health-metric"><span class="cache-health-metric-label">Expiring in 7 days</span><span class="cache-health-metric-value">${s.expiring_7d}</span></div>
</div>
</div>
`;
} catch (error) {
const body = overlay.querySelector('.cache-health-body');
body.innerHTML = '<div class="cache-health-loading">Failed to load cache stats</div>';
}
}
// ── 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 = `
<div class="failed-mb-modal">
<div class="failed-mb-header">
<div>
<h2 class="failed-mb-title">Failed MusicBrainz Lookups</h2>
<p class="failed-mb-subtitle">Tracks, albums, and artists that couldn't be matched automatically</p>
</div>
<button class="watch-all-close" onclick="document.getElementById('failed-mb-modal-overlay').remove()">&times;</button>
</div>
<div class="failed-mb-toolbar">
<div class="failed-mb-tabs" id="failed-mb-tabs"></div>
<div class="failed-mb-search-row">
<input type="text" id="failed-mb-search" class="failed-mb-search-input" placeholder="Filter by name...">
<button class="failed-mb-btn failed-mb-btn-danger" onclick="_failedMBClearAll()">Clear All Failed</button>
</div>
</div>
<div class="failed-mb-body" id="failed-mb-body">
<div class="cache-health-loading"><div class="watch-all-loading-spinner"></div><div>Loading...</div></div>
</div>
<div class="failed-mb-footer" id="failed-mb-footer"></div>
</div>
`;
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 = `<button class="failed-mb-tab ${!_failedMBState.typeFilter ? 'active' : ''}" onclick="_failedMBSetType('')">All (${allCount})</button>`;
const typeLabels = { artist: 'Artists', release: 'Albums', recording: 'Tracks' };
for (const [type, count] of Object.entries(_failedMBState.typeCounts)) {
tabsHTML += `<button class="failed-mb-tab ${_failedMBState.typeFilter === type ? 'active' : ''}" onclick="_failedMBSetType('${type}')">${typeLabels[type] || type} (${count})</button>`;
}
tabsEl.innerHTML = tabsHTML;
}
// Render items
if (data.items.length === 0) {
body.innerHTML = `<div class="failed-mb-empty">${_failedMBState.filter ? 'No matches for your search' : 'No failed lookups cache is clean!'}</div>`;
} else {
const typeIcons = { artist: '🎤', release: '💿', recording: '🎵' };
body.innerHTML = data.items.map(item => `
<div class="failed-mb-item" data-id="${item.id}">
<div class="failed-mb-item-icon">${typeIcons[item.entity_type] || '?'}</div>
<div class="failed-mb-item-info">
<div class="failed-mb-item-name">${escapeHtml(item.entity_name)}</div>
${item.artist_name ? `<div class="failed-mb-item-artist">${escapeHtml(item.artist_name)}</div>` : ''}
</div>
<div class="failed-mb-item-meta">
<span class="failed-mb-item-type">${item.entity_type}</span>
<span class="failed-mb-item-date">${item.last_updated ? new Date(item.last_updated).toLocaleDateString() : ''}</span>
</div>
<div class="failed-mb-item-actions">
<button class="failed-mb-btn-sm failed-mb-btn-primary" onclick="_failedMBSearch(${item.id}, '${item.entity_type}', '${escapeForInlineJs(item.entity_name)}', '${escapeForInlineJs(item.artist_name || '')}')">Search MB</button>
<button class="failed-mb-btn-sm failed-mb-btn-ghost" onclick="_failedMBDelete(${item.id})">Remove</button>
</div>
</div>
`).join('');
}
// Pagination footer
const footer = document.getElementById('failed-mb-footer');
if (footer) {
const totalPages = Math.ceil(data.total / 50);
footer.innerHTML = totalPages > 1 ? `
<div class="failed-mb-pagination">
<button class="failed-mb-btn-sm" ${_failedMBState.page <= 1 ? 'disabled' : ''} onclick="_failedMBPage(${_failedMBState.page - 1})">Prev</button>
<span>Page ${_failedMBState.page} of ${totalPages} (${data.total} total)</span>
<button class="failed-mb-btn-sm" ${_failedMBState.page >= totalPages ? 'disabled' : ''} onclick="_failedMBPage(${_failedMBState.page + 1})">Next</button>
</div>
` : `<div class="failed-mb-pagination"><span>${data.total} entries</span></div>`;
}
} catch (err) {
body.innerHTML = '<div class="failed-mb-empty">Failed to load data</div>';
}
}
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 = `
<div class="mb-search-modal">
<div class="mb-search-header">
<div>
<h2 class="mb-search-title">Search MusicBrainz</h2>
<p class="mb-search-subtitle">Find a match for: <strong>${escapeHtml(entityName)}</strong>${artistName ? ` by ${escapeHtml(artistName)}` : ''}</p>
</div>
<button class="watch-all-close" onclick="document.getElementById('mb-search-modal-overlay').remove()">&times;</button>
</div>
<div class="mb-search-inputs">
<div class="mb-search-input-row">
<label>Type</label>
<select id="mb-search-type" class="mb-search-select">
<option value="artist" ${entityType === 'artist' ? 'selected' : ''}>Artist</option>
<option value="release" ${entityType === 'release' ? 'selected' : ''}>Album / Release</option>
<option value="recording" ${entityType === 'recording' ? 'selected' : ''}>Track / Recording</option>
</select>
</div>
<div class="mb-search-input-row">
<label>Name</label>
<input type="text" id="mb-search-query" class="mb-search-input" value="${escapeHtml(entityName)}">
</div>
<div class="mb-search-input-row" id="mb-search-artist-row" ${entityType === 'artist' ? 'style="display:none"' : ''}>
<label>Artist</label>
<input type="text" id="mb-search-artist" class="mb-search-input" value="${escapeHtml(artistName)}">
</div>
<button class="failed-mb-btn failed-mb-btn-primary" id="mb-search-go-btn" onclick="_runMBSearch(${entryId})">Search</button>
</div>
<div class="mb-search-results" id="mb-search-results">
<div class="failed-mb-empty">Enter a search query and click Search</div>
</div>
</div>
`;
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 = '<div class="cache-health-loading"><div class="watch-all-loading-spinner"></div><div>Searching MusicBrainz...</div></div>';
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 = '<div class="failed-mb-empty">No results found. Try adjusting your search.</div>';
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 `
<div class="mb-search-result" onclick="_selectMBMatch(${entryId}, '${r.mbid}', '${escapeForInlineJs(r.name)}')">
<div class="mb-search-result-score" style="color:${scoreColor}">${r.score}%</div>
<div class="mb-search-result-info">
<div class="mb-search-result-name">${escapeHtml(r.name)}</div>
${r.disambiguation ? `<div class="mb-search-result-disambig">${escapeHtml(r.disambiguation)}</div>` : ''}
${detail ? `<div class="mb-search-result-detail">${escapeHtml(detail)}</div>` : ''}
</div>
<div class="mb-search-result-mbid" title="${r.mbid}">${r.mbid.substring(0, 8)}...</div>
</div>
`;
}).join('');
} catch (err) {
resultsEl.innerHTML = `<div class="failed-mb-empty">Search error: ${err.message}</div>`;
} 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 <b>pending</b> findings, but ${otherTotal.toLocaleString()} carry-over (resolved/dismissed/auto-fixed). Showing <b>All Status</b> — change the filter above to switch back.`;
list.parentNode.insertBefore(notice, list);
}
return;
}
}
} catch (e) { /* fall through to empty state */ }
}
container.innerHTML = `<div class="repair-empty-state">
<div class="repair-empty-icon">&#10003;</div>
<div class="repair-empty-title">All Clear</div>
<div class="repair-empty-text">No findings match your filters. Your library is looking good!</div>
</div>`;
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 = `<span class="repair-finding-status-badge ${f.status}">${actionText}</span>`;
}
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 `<div class="repair-finding-card ${f.severity}" data-id="${f.id}" data-job-id="${f.job_id}" data-mass-orphan="${!!(d.mass_orphan)}">
<div class="repair-finding-main" onclick="toggleFindingDetail(${f.id})">
<div class="repair-finding-select" onclick="event.stopPropagation()">
<input type="checkbox" onchange="toggleFindingSelect(${f.id}, this.checked)">
</div>
<div class="repair-finding-content">
<div class="repair-finding-title">
<span class="repair-finding-icon">${icon}</span>
${_escFinding(f.title)}
<span class="repair-finding-type-badge">${typeLabel}</span>
${statusBadge}
</div>
<div class="repair-finding-desc">${_escFinding(f.description || '')}</div>
${filePath ? `<div class="repair-finding-path">${_escFinding(filePath)}</div>` : ''}
<div class="repair-finding-meta">
<span>${f.job_id.replace(/_/g, ' ')}</span>
<span>&middot;</span>
<span>${f.entity_type || 'file'}</span>
${f.entity_id ? `<span>&middot;</span><span>ID: ${f.entity_id}</span>` : ''}
<span>&middot;</span>
<span>${age}</span>
</div>
</div>
<div class="repair-finding-actions" onclick="event.stopPropagation()">
${f.status === 'pending' ? `
${fixLabel ? `<button class="repair-finding-btn fix" onclick="fixRepairFinding(${f.id}, '${f.finding_type}')" title="${fixLabel}">${_escFinding(fixLabel)}</button>` : ''}
<button class="repair-finding-btn dismiss" onclick="dismissRepairFinding(${f.id})" title="Dismiss">&times;</button>
` : ''}
<button class="repair-finding-expand-btn" data-finding="${f.id}" title="Details">&#9660;</button>
</div>
</div>
<div class="repair-finding-detail" id="repair-detail-${f.id}">
<div class="repair-finding-detail-inner">
${_renderFindingDetail(f)}
</div>
</div>
</div>`;
}).join('');
// Pagination
renderRepairFindingsPagination(data.total, data.page);
} catch (error) {
console.error('Error loading findings:', error);
container.innerHTML = '<div class="repair-empty">Error loading findings</div>';
}
}
function _escFinding(s) {
if (!s) return '';
return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
function _renderScoreBar(value, label) {
const pct = Math.round((value || 0) * 100);
const cls = pct >= 80 ? 'good' : pct >= 50 ? 'warn' : 'bad';
return `<div class="repair-score-bar">
<span class="repair-detail-key">${label}</span>
<div class="repair-score-bar-track"><div class="repair-score-bar-fill ${cls}" style="width:${pct}%"></div></div>
<span class="repair-detail-val">${pct}%</span>
</div>`;
}
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 `<button class="repair-finding-play-btn" onclick="event.stopPropagation(); playFindingTrack(this)"
data-path="${_escFinding(filePath)}" data-title="${_escFinding(title)}"
data-artist="${_escFinding(artist)}" data-album="${_escFinding(album)}"
data-art="${_escFinding(albumArt)}" data-entity-id="${_escFinding(f.entity_id || '')}" title="Play this track">
<svg viewBox="0 0 24 24"><polygon points="5,3 19,12 5,21"/></svg> Play
</button>`;
}
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 = '<div class="repair-finding-media">';
if (albumUrl) {
const albumLabel = d.album_title || 'Album';
html += `<div class="repair-finding-media-card">
<img class="repair-finding-media-img" src="${_escFinding(albumUrl)}" alt="Album art"
onerror="this.parentElement.style.display='none'" />
<span class="repair-finding-media-label">${_escFinding(albumLabel)}</span>
</div>`;
}
if (artistUrl) {
const artistLabel = d.artist_name || d.artist || 'Artist';
html += `<div class="repair-finding-media-card">
<img class="repair-finding-media-img artist" src="${_escFinding(artistUrl)}" alt="Artist"
onerror="this.parentElement.style.display='none'" />
<span class="repair-finding-media-label">${_escFinding(artistLabel)}</span>
</div>`;
}
html += '</div>';
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 + '<div style="margin-bottom:8px">';
html += _renderScoreBar(d.fingerprint_score, 'Fingerprint');
html += _renderScoreBar(d.title_similarity, 'Title Match');
html += _renderScoreBar(d.artist_similarity, 'Artist Match');
html += '</div>';
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 += `<div class="repair-spectrum-bar">
<div class="repair-spectrum-label">Spectral Analysis</div>
<div class="repair-spectrum-track">
<div class="repair-spectrum-detected" style="width:${cutoffPct}%"></div>
<div class="repair-spectrum-expected" style="left:${expectedPct}%"></div>
</div>
<div class="repair-spectrum-legend">
<span class="repair-spectrum-legend-detected">${cutoff} kHz detected</span>
<span class="repair-spectrum-legend-expected">${expectedMin} kHz expected min</span>
</div>
</div>`;
}
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 + `<div class="repair-detail-sublist">${d.tracks.map((t, i) => {
const tid = t.track_id || t.id;
const isBest = (t.id === bestDup.id);
return `<div class="repair-detail-subitem ${isBest ? 'best' : 'removable'}" style="cursor:pointer;" onclick="selectDuplicateToKeep(${findingId}, '${tid}')" title="Click to keep this version">
<strong>
${isBest ? '<span class="repair-keep-badge">KEEP</span>' : '<span class="repair-remove-badge">REMOVE</span>'}
${_escFinding(t.title)} by ${_escFinding(t.artist)}
</strong>
<span>Album: ${_escFinding(t.album || 'Unknown')}${t.bitrate ? ` &middot; ${t.bitrate} kbps` : ''}${t.duration ? ` &middot; ${Math.round(t.duration)}s` : ''}${t.track_number ? ` &middot; Track #${t.track_number}` : ''}</span>
${t.file_path ? `<span class="mono">${_escFinding(t.file_path)}</span>` : ''}
</div>`;
}).join('')}</div>
<div style="color:rgba(255,255,255,0.3);font-size:11px;padding:4px 0;">Click on a version to keep it, or use "Keep Best" for auto-selection</div>`;
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 += `<div class="repair-completion-bar">
<div class="repair-completion-label">${actual} of ${expected} tracks (${pct}%)</div>
<div class="repair-completion-track"><div class="repair-completion-fill" style="width:${pct}%"></div></div>
</div>`;
}
if (d.missing_tracks && d.missing_tracks.length) {
incHtml += `<div class="repair-detail-sublist">${d.missing_tracks.map(t => `
<div class="repair-detail-subitem">
<strong>#${t.track_number || '?'} ${_escFinding(t.name || t.title || 'Unknown')}</strong>
${t.source && t.source !== 'spotify' ? `<span>Source: ${_escFinding(t.source)}${t.source_track_id ? ` · ID: ${_escFinding(t.source_track_id)}` : ''}</span>` : ''}
${t.duration_ms ? `<span>Duration: ${Math.round(t.duration_ms / 1000)}s</span>` : ''}
</div>`).join('')}</div>`;
}
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 += '<div class="repair-finding-media">';
if (d.artist_thumb_url) {
artHtml += `<div class="repair-finding-media-card">
<img class="repair-finding-media-img artist" src="${_escFinding(d.artist_thumb_url)}" alt="Artist"
onerror="this.parentElement.style.display='none'" />
<span class="repair-finding-media-label">${_escFinding(d.artist || 'Artist')}</span>
</div>`;
}
if (d.found_artwork_url) {
artHtml += `<div class="repair-finding-media-card">
<img class="repair-finding-media-img" src="${_escFinding(d.found_artwork_url)}" alt="Found artwork"
onerror="this.parentElement.style.display='none'" />
<span class="repair-finding-media-label">Found Artwork</span>
</div>`;
}
artHtml += '</div>';
}
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 += '<div style="margin-bottom:8px">';
tnHtml += _renderScoreBar(d.match_score, 'Title Match');
tnHtml += '</div>';
}
tnHtml += _gridRows(rows);
if (d.changes && d.changes.length) {
tnHtml += `<div class="repair-detail-sublist">${d.changes.map(c => `
<div class="repair-detail-subitem"><strong>${_escFinding(c)}</strong></div>`).join('')}</div>`;
}
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) : '<span style="color:rgba(255,255,255,0.3);font-size:12px;">No additional details available</span>');
}
}
function _gridRows(rows) {
if (!rows.length) return '';
return `<div class="repair-detail-grid">${rows.map(([k, v, cls]) =>
`<span class="repair-detail-key">${_escFinding(k)}</span><span class="repair-detail-val ${cls || ''}">${_escFinding(v)}</span>`
).join('')}</div>`;
}
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 += `<button class="repair-page-btn" onclick="_repairFindingsPage=${currentPage - 1};loadRepairFindings()">&larr;</button>`;
}
// 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 += `<button class="repair-page-btn" onclick="_repairFindingsPage=0;loadRepairFindings()">1</button>`;
if (startPage > 1) html += '<span class="repair-page-info">...</span>';
}
for (let i = startPage; i < endPage; i++) {
html += `<button class="repair-page-btn ${i === currentPage ? 'active' : ''}"
onclick="_repairFindingsPage=${i};loadRepairFindings()">${i + 1}</button>`;
}
if (endPage < totalPages) {
if (endPage < totalPages - 1) html += '<span class="repair-page-info">...</span>';
html += `<button class="repair-page-btn" onclick="_repairFindingsPage=${totalPages - 1};loadRepairFindings()">${totalPages}</button>`;
}
if (currentPage < totalPages - 1) {
html += `<button class="repair-page-btn" onclick="_repairFindingsPage=${currentPage + 1};loadRepairFindings()">&rarr;</button>`;
}
html += `<span class="repair-page-info">${total.toLocaleString()} total</span>`;
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 = `
<div style="background:#1e1e2e;border:1px solid rgba(255,255,255,0.1);border-radius:16px;padding:28px;max-width:380px;width:90%;text-align:center;">
<div style="font-size:1.1em;font-weight:600;color:#fff;margin-bottom:8px;">Orphan File Action</div>
<div style="font-size:0.88em;color:rgba(255,255,255,0.6);margin-bottom:20px;">
Choose how to handle orphan files. Staging is safe and reversible.
</div>
<div style="display:flex;gap:10px;justify-content:center;">
<button id="_orphan-staging" style="padding:10px 20px;border-radius:10px;border:1px solid rgba(29,185,84,0.4);background:rgba(29,185,84,0.15);color:#1db954;font-weight:600;cursor:pointer;font-family:inherit;">
Move to Staging
</button>
<button id="_orphan-delete" style="padding:10px 20px;border-radius:10px;border:1px solid rgba(239,68,68,0.4);background:rgba(239,68,68,0.1);color:#ef4444;font-weight:500;cursor:pointer;font-family:inherit;">
Delete
</button>
</div>
<button id="_orphan-cancel" style="margin-top:12px;padding:6px 16px;border:none;background:none;color:rgba(255,255,255,0.4);cursor:pointer;font-size:0.82em;font-family:inherit;">
Cancel
</button>
</div>
`;
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 = `
<div style="background:#1e1e2e;border:1px solid rgba(255,255,255,0.1);border-radius:16px;padding:28px;max-width:420px;width:90%;text-align:center;">
<div style="font-size:1.1em;font-weight:600;color:#fff;margin-bottom:8px;">Dead File Action</div>
<div style="font-size:0.88em;color:rgba(255,255,255,0.6);margin-bottom:20px;">
This track's file no longer exists on disk. Choose how to handle it.
</div>
<div style="display:flex;gap:10px;justify-content:center;">
<button id="_dead-redownload" style="padding:10px 20px;border-radius:10px;border:1px solid rgba(29,185,84,0.4);background:rgba(29,185,84,0.15);color:#1db954;font-weight:600;cursor:pointer;font-family:inherit;">
Re-download
</button>
<button id="_dead-remove" style="padding:10px 20px;border-radius:10px;border:1px solid rgba(239,68,68,0.4);background:rgba(239,68,68,0.1);color:#ef4444;font-weight:500;cursor:pointer;font-family:inherit;">
Remove from DB
</button>
</div>
<button id="_dead-cancel" style="margin-top:12px;padding:6px 16px;border:none;background:none;color:rgba(255,255,255,0.4);cursor:pointer;font-size:0.82em;font-family:inherit;">
Cancel
</button>
</div>
`;
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 = `
<div style="background:#1e1e2e;border:1px solid rgba(255,255,255,0.1);border-radius:16px;padding:28px;max-width:460px;width:90%;text-align:center;">
<div style="font-size:1.1em;font-weight:600;color:#fff;margin-bottom:8px;">AcoustID Mismatch</div>
<div style="font-size:0.88em;color:rgba(255,255,255,0.6);margin-bottom:20px;">
The audio fingerprint doesn't match the expected track. Choose how to fix it.
</div>
<div style="display:flex;gap:10px;justify-content:center;flex-wrap:wrap;">
<button id="_acid-retag" style="padding:10px 20px;border-radius:10px;border:1px solid rgba(102,126,234,0.4);background:rgba(102,126,234,0.15);color:#667eea;font-weight:600;cursor:pointer;font-family:inherit;">
Retag
</button>
<button id="_acid-redownload" style="padding:10px 20px;border-radius:10px;border:1px solid rgba(29,185,84,0.4);background:rgba(29,185,84,0.15);color:#1db954;font-weight:600;cursor:pointer;font-family:inherit;">
Re-download
</button>
<button id="_acid-delete" style="padding:10px 20px;border-radius:10px;border:1px solid rgba(239,68,68,0.4);background:rgba(239,68,68,0.1);color:#ef4444;font-weight:500;cursor:pointer;font-family:inherit;">
Delete
</button>
</div>
<div style="margin-top:12px;font-size:0.78em;color:rgba(255,255,255,0.35);line-height:1.4;">
Retag = update metadata to match actual audio &bull; Re-download = add correct track to wishlist &amp; delete wrong file &bull; Delete = remove file and DB entry
</div>
<button id="_acid-cancel" style="margin-top:12px;padding:6px 16px;border:none;background:none;color:rgba(255,255,255,0.4);cursor:pointer;font-size:0.82em;font-family:inherit;">
Cancel
</button>
</div>
`;
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 = `
<div style="background:#1e1e2e;border:1px solid rgba(255,255,255,0.1);border-radius:16px;padding:28px;max-width:460px;width:90%;text-align:center;">
<div id="_dbf-header" style="font-size:1.1em;font-weight:600;color:#fff;margin-bottom:8px;"></div>
<div id="_dbf-body" style="font-size:0.88em;color:rgba(255,255,255,0.6);margin-bottom:20px;"></div>
<div style="display:flex;gap:10px;justify-content:center;flex-wrap:wrap;">
<button id="_dbf-add" style="padding:10px 20px;border-radius:10px;border:1px solid rgba(29,185,84,0.4);background:rgba(29,185,84,0.15);color:#1db954;font-weight:600;cursor:pointer;font-family:inherit;"></button>
<button id="_dbf-dismiss" style="padding:10px 20px;border-radius:10px;border:1px solid rgba(102,126,234,0.4);background:rgba(102,126,234,0.15);color:#667eea;font-weight:500;cursor:pointer;font-family:inherit;"></button>
</div>
<button id="_dbf-cancel" style="margin-top:12px;padding:6px 16px;border:none;background:none;color:rgba(255,255,255,0.4);cursor:pointer;font-size:0.82em;font-family:inherit;">
Cancel
</button>
</div>
`;
// 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 = `<div class="repair-empty-state">
<div class="repair-empty-icon">&#128337;</div>
<div class="repair-empty-title">No History Yet</div>
<div class="repair-empty-text">Job run history will appear here after maintenance jobs complete their first scan.</div>
</div>`;
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(`<span class="repair-history-stat"><strong>${(run.items_scanned || 0).toLocaleString()}</strong> scanned</span>`);
if (run.findings_created) stats.push(`<span class="repair-history-stat findings"><strong>${run.findings_created}</strong> findings</span>`);
if (run.auto_fixed) stats.push(`<span class="repair-history-stat fixed"><strong>${run.auto_fixed}</strong> fixed</span>`);
if (run.errors) stats.push(`<span class="repair-history-stat errors"><strong>${run.errors}</strong> errors</span>`);
// 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 `<div class="repair-history-entry">
<div class="repair-history-header">
<div class="repair-history-dot ${statusClass}"></div>
<span class="repair-history-name">${run.display_name || run.job_id}</span>
<span class="repair-history-status ${statusClass}">${run.status}</span>
<span class="repair-history-duration">${duration}</span>
</div>
<div class="repair-history-stats">${stats.join('')}</div>
<div class="repair-history-meta">${age} &middot; ${startTime} &rarr; ${endTime}</div>
</div>`;
}).join('');
} catch (error) {
console.error('Error loading repair history:', error);
container.innerHTML = '<div class="repair-empty">Error loading history</div>';
}
}
// 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);
}
}
// ===================================================================