const form = document.querySelector('#analysis-form'); const button = document.querySelector('#analyze-button'); const errorBox = document.querySelector('#error-message'); const results = document.querySelector('#dashboard'); const infoButtons = document.querySelectorAll('.info-button'); const drillTriggers = document.querySelectorAll('[data-detail]'); const drilldownTitle = document.querySelector('#drilldown-title'); const drilldownCount = document.querySelector('#drilldown-count'); const drilldownSummary = document.querySelector('#drilldown-summary'); const drilldownList = document.querySelector('#drilldown-list'); const repairPanel = document.querySelector('#duplicate-repair'); const repairChoice = document.querySelector('#repair-choice'); const repairPreview = document.querySelector('#repair-preview'); const repairMessage = document.querySelector('#repair-message'); const previewRepairButton = document.querySelector('#preview-repair'); const applyRepairButton = document.querySelector('#apply-repair'); const restoreRepairButton = document.querySelector('#restore-repair'); const loadBackupsButton = document.querySelector('#load-backups'); const backupSummary = document.querySelector('#backup-summary'); const backupList = document.querySelector('#backup-list'); const audioPreview = document.querySelector('#audio-preview'); const audioPreviewName = document.querySelector('#audio-preview-name'); const audioPlayer = document.querySelector('#audio-player'); const closeAudioPreview = document.querySelector('#close-audio-preview'); const batchPreviewModal = document.querySelector('#batch-preview-modal'); const batchPreviewSummary = document.querySelector('#batch-preview-summary'); const batchPreviewList = document.querySelector('#batch-preview-list'); const batchPreviewSafety = document.querySelector('#batch-preview-safety'); const closeBatchPreview = document.querySelector('#close-batch-preview'); const continueReviewing = document.querySelector('#continue-reviewing'); const acceptPreview = document.querySelector('#accept-preview'); let latestAnalysis = null; let selectedDuplicateGroup = null; let previewedRepair = null; let latestBackup = null; let reviewState = null; function expandHome(path) { return path.trim(); } function escapeHtml(value) { return String(value ?? '').replace(/[&<>"']/g, (character) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''', }[character])); } function formatBytes(bytes) { if (!bytes) return '0 B'; const units = ['B', 'KB', 'MB', 'GB', 'TB']; const unit = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1); return `${(bytes / (1024 ** unit)).toFixed(unit ? 1 : 0)} ${units[unit]}`; } function formatDate(value) { const date = new Date(value); return Number.isNaN(date.valueOf()) ? value : date.toLocaleString([], {dateStyle: 'medium', timeStyle: 'short'}); } async function loadBackups() { loadBackupsButton.disabled = true; backupSummary.textContent = 'Looking for recovery snapshots…'; try { const response = await fetch('/api/backups', {method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({serato: expandHome(document.querySelector('#serato-path').value)})}); const data = await response.json(); if (!response.ok) throw new Error(data.error || 'Could not load backups'); backupSummary.textContent = `${data.total} backup${data.total === 1 ? '' : 's'} · ${formatBytes(data.size_bytes)} on disk`; backupList.innerHTML = data.items.length ? data.items.map((backup) => `
${backup.status === 'restored' ? 'Restored' : 'Ready to restore'}${formatDate(backup.created_at)}Kept: ${escapeHtml(backup.keeper)}${backup.replaced.length} original file${backup.replaced.length === 1 ? '' : 's'} · ${formatBytes(backup.size_bytes)}
`).join('') : '
No repair backups found for this library yet.
'; } catch (error) { backupSummary.textContent = error.message; backupList.innerHTML = ''; } finally { loadBackupsButton.disabled = false; } } loadBackupsButton.addEventListener('click', loadBackups); backupList.addEventListener('click', async (event) => { const button = event.target.closest('[data-restore-backup]'); if (!button || button.disabled) return; if (!window.confirm('Restore the original duplicate files from this backup? Existing real files will never be overwritten.')) return; button.disabled = true; button.textContent = 'Restoring…'; try { const response = await fetch('/api/backups/restore', {method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({serato: expandHome(document.querySelector('#serato-path').value), backup: button.dataset.restoreBackup})}); const result = await response.json(); if (!response.ok) throw new Error(result.error || 'Restore failed'); await loadBackups(); } catch (error) { backupSummary.textContent = error.message; button.disabled = false; button.textContent = 'Restore'; } }); function detailLines(item) { if (item.file_previews) { return item.file_previews.map((file) => `
  • ${escapeHtml(file.path)}
  • `).join(''); } const lines = []; if (item.artist || item.title) lines.push(`${item.artist || 'Unknown artist'} — ${item.title || item.filename}`); if (item.crate) lines.push(`Crate: ${item.crate}`); if (item.saved_path) lines.push(`Saved path: ${item.saved_path}`); if (item.candidate) lines.push(`Candidate: ${item.candidate}`); if (item.path) lines.push(`File: ${item.path}`); if (item.target) lines.push(`Target: ${item.target}`); if (item.score || item.reason) lines.push(`${item.score || 'Match'} · ${item.reason || 'Candidate found'}`); if (item.repeated_filename) lines.push('Same filename appears more than once in Serato’s missing list.'); return lines.map((line) => `
  • ${escapeHtml(line)}
  • `).join(''); } function renderDetail(key) { const detail = latestAnalysis?.details?.[key]; if (!detail) return; drillTriggers.forEach((trigger) => { trigger.classList.toggle('selected', trigger.dataset.detail === key); }); drilldownTitle.textContent = detail.title; drilldownCount.textContent = `${detail.total ?? 0} found`; drilldownSummary.textContent = detail.summary; repairPanel.hidden = true; selectedDuplicateGroup = null; previewedRepair = null; latestBackup = null; restoreRepairButton.hidden = true; if (!detail.items?.length) { drilldownList.innerHTML = '
    Nothing to review here. Tiny victory parade, very tasteful.
    '; return; } if (detail.items[0].file_previews) { reviewState = {key, index: 0, choices: new Map()}; repairPanel.hidden = false; renderReviewGroup(); return; } drilldownList.innerHTML = detail.items.map((item, index) => `
    ${escapeHtml(item.filename || item.path || 'Untitled item')}
    `).join(''); } function renderReviewGroup() { const groups = latestAnalysis.details[reviewState.key].items; const group = groups[reviewState.index]; const chosen = reviewState.choices.get(reviewState.index); drilldownCount.textContent = `${reviewState.index + 1} of ${groups.length} · ${reviewState.choices.size} approved`; drilldownSummary.textContent = 'Listen to each candidate, choose the keeper, and we’ll move to the next group. Skip anything uncertain.'; drilldownList.innerHTML = `
    Comparing now${escapeHtml(group.filename)}
    ${reviewState.choices.size} selected
    ${group.file_previews.map((file, index) => `
    Option ${index + 1} ${escapeHtml(file.path.split('/').pop())} ${escapeHtml(file.path)}
    `).join('')}
    `; updateBatchSummary(); } function updateBatchSummary() { if (!reviewState) return; const groups = latestAnalysis.details[reviewState.key].items; repairChoice.innerHTML = `
    ${reviewState.choices.size} group${reviewState.choices.size === 1 ? '' : 's'} approved${groups.length - reviewState.choices.size} skipped or still awaiting a decision
    `; previewRepairButton.disabled = reviewState.choices.size === 0; previewedRepair = null; applyRepairButton.disabled = true; repairPreview.hidden = true; } function repairPayload() { if (!reviewState?.choices.size) throw new Error('Choose at least one keeper'); const groups = latestAnalysis.details[reviewState.key].items; const choices = Array.from(reviewState.choices, ([index, keeper]) => ({keeper, group_files: groups[index].files})); return {serato: expandHome(document.querySelector('#serato-path').value), music: expandHome(document.querySelector('#music-path').value), choices, backup_limit: document.querySelector('#keep-all-backups').checked ? null : Number(document.querySelector('#backup-limit').value)}; } async function requestRepair(endpoint) { const response = await fetch(endpoint, {method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(repairPayload())}); const data = await response.json(); if (!response.ok) throw new Error(data.error || 'Duplicate cleanup failed'); return data; } previewRepairButton.addEventListener('click', async () => { repairMessage.textContent = 'Checking the plan…'; applyRepairButton.disabled = true; try { previewedRepair = await requestRepair('/api/duplicates/batch/preview'); batchPreviewSummary.textContent = `${previewedRepair.choice_count} keeper decision(s) · ${previewedRepair.replaced.length} duplicate file(s) consolidated`; batchPreviewList.innerHTML = previewedRepair.decisions.map((decision, index) => `
    Decision ${index + 1}
    Keep${escapeHtml(decision.keeper.split('/').pop())}${escapeHtml(decision.keeper)}
    Replace with a shortcut${decision.replaced.map((path) => `${escapeHtml(path)}`).join('')}
    `).join(''); batchPreviewSafety.textContent = `${previewedRepair.metadata_backups} Serato metadata file(s) and every replaced audio file will be backed up. Database V2 will not be modified.`; repairPreview.innerHTML = `Preview approved

    ${previewedRepair.choice_count} keeper decision(s) are ready for one backed-up apply.

    `; repairPreview.hidden = false; applyRepairButton.disabled = false; repairMessage.textContent = 'Preview complete. Nothing has changed yet.'; batchPreviewModal.showModal(); } catch (error) { repairMessage.textContent = error.message; } }); function dismissBatchPreview() { batchPreviewModal.close(); } closeBatchPreview.addEventListener('click', dismissBatchPreview); continueReviewing.addEventListener('click', dismissBatchPreview); acceptPreview.addEventListener('click', dismissBatchPreview); applyRepairButton.addEventListener('click', async () => { if (!previewedRepair) return; applyRepairButton.disabled = true; repairMessage.textContent = 'Creating the backup before making changes…'; try { const result = await requestRepair('/api/duplicates/batch/apply'); latestBackup = result.backup; repairMessage.textContent = `Cleanup complete. Restore backup: ${result.backup}`; previewRepairButton.disabled = true; restoreRepairButton.hidden = false; await loadBackups(); } catch (error) { repairMessage.textContent = error.message; applyRepairButton.disabled = false; } }); restoreRepairButton.addEventListener('click', async () => { if (!latestBackup) return; restoreRepairButton.disabled = true; repairMessage.textContent = 'Restoring the duplicate files…'; try { const response = await fetch('/api/backups/restore', {method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({serato: expandHome(document.querySelector('#serato-path').value), backup: latestBackup})}); const result = await response.json(); if (!response.ok) throw new Error(result.error || 'Restore failed'); repairMessage.textContent = `Restore complete. ${result.restored.length} original file(s) returned.`; restoreRepairButton.hidden = true; await loadBackups(); } catch (error) { repairMessage.textContent = error.message; restoreRepairButton.disabled = false; } }); async function handleFileAction(event) { const previewButton = event.target.closest('[data-audio-url]'); const revealButton = event.target.closest('[data-reveal-token]'); if (!previewButton && !revealButton) return false; event.preventDefault(); event.stopPropagation(); if (previewButton) { audioPlayer.src = previewButton.dataset.audioUrl; audioPreviewName.textContent = previewButton.dataset.audioName.split('/').pop(); audioPreview.hidden = false; try { await audioPlayer.play(); } catch (_) { /* Native controls remain available. */ } } else { const original = revealButton.textContent; revealButton.disabled = true; revealButton.textContent = 'Opening…'; try { const response = await fetch('/api/reveal', {method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({token: revealButton.dataset.revealToken})}); const result = await response.json(); if (!response.ok) throw new Error(result.error || 'Could not open Finder'); revealButton.textContent = 'Shown in Finder'; } catch (error) { revealButton.textContent = error.message; } setTimeout(() => { revealButton.disabled = false; revealButton.textContent = original; }, 1800); } return true; } closeAudioPreview.addEventListener('click', () => { audioPlayer.pause(); audioPlayer.removeAttribute('src'); audioPlayer.load(); audioPreview.hidden = true; }); repairChoice.addEventListener('click', handleFileAction); drilldownList.addEventListener('click', async (event) => { if (await handleFileAction(event) || !reviewState) return; const groups = latestAnalysis.details[reviewState.key].items; const winner = event.target.closest('[data-choose-winner]'); if (winner) { const fileIndex = Number(winner.dataset.chooseWinner); reviewState.choices.set(reviewState.index, groups[reviewState.index].files[fileIndex]); if (reviewState.index < groups.length - 1) reviewState.index += 1; renderReviewGroup(); return; } if (event.target.closest('[data-review-skip]')) { reviewState.choices.delete(reviewState.index); if (reviewState.index < groups.length - 1) reviewState.index += 1; renderReviewGroup(); return; } if (event.target.closest('[data-review-previous]') && reviewState.index > 0) { reviewState.index -= 1; renderReviewGroup(); } }); function render(data) { latestAnalysis = data; document.querySelectorAll('[data-field]').forEach((element) => { const value = data[element.dataset.field]; element.textContent = value ?? '—'; }); const score = data.score; document.querySelector('#health-score').textContent = score == null ? '—' : `${score}%`; document.querySelector('#score-ring').style.setProperty('--score', score ?? 0); document.querySelector('#health-message').textContent = score == null ? 'Not enough data yet' : score >= 95 ? 'Looking excellent' : score >= 80 ? 'A few things need attention' : 'Review recommended'; document.querySelector('#score-basis').textContent = data.score_basis; document.querySelector('#analysis-time').textContent = `Completed ${new Date().toLocaleTimeString([], {hour: '2-digit', minute: '2-digit'})}`; renderDetail(data.database_missing_paths > 0 ? 'database_missing_tracks' : 'old_crate_references'); results.hidden = false; results.scrollIntoView({behavior: 'smooth', block: 'start'}); } function closeInfoButtons(except = null) { infoButtons.forEach((infoButton) => { if (infoButton !== except) { infoButton.classList.remove('open'); infoButton.setAttribute('aria-expanded', 'false'); } }); } infoButtons.forEach((infoButton) => { infoButton.addEventListener('click', (event) => { event.stopPropagation(); const willOpen = !infoButton.classList.contains('open'); closeInfoButtons(infoButton); infoButton.classList.toggle('open', willOpen); infoButton.setAttribute('aria-expanded', String(willOpen)); }); }); drillTriggers.forEach((trigger) => { trigger.addEventListener('click', () => renderDetail(trigger.dataset.detail)); trigger.addEventListener('keydown', (event) => { if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); renderDetail(trigger.dataset.detail); } }); }); document.addEventListener('click', () => closeInfoButtons()); document.addEventListener('keydown', (event) => { if (event.key === 'Escape') closeInfoButtons(); }); form.addEventListener('submit', async (event) => { event.preventDefault(); errorBox.hidden = true; button.disabled = true; button.querySelector('span').textContent = 'Analyzing safely…'; const roots = document.querySelector('#reference-roots').value .split('\n').map((value) => value.trim()).filter(Boolean); try { const response = await fetch('/api/analyze', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({ serato: expandHome(document.querySelector('#serato-path').value), music: expandHome(document.querySelector('#music-path').value), reference_roots: roots, }), }); const data = await response.json(); if (!response.ok) throw new Error(data.error || 'Analysis failed'); render(data); } catch (error) { errorBox.textContent = error.message; errorBox.hidden = false; } finally { button.disabled = false; button.querySelector('span').textContent = 'Analyze library'; } });