356 lines
18 KiB
JavaScript
356 lines
18 KiB
JavaScript
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');
|
||
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) => `
|
||
<article class="backup-item">
|
||
<div><span class="backup-status ${backup.status}">${backup.status === 'restored' ? 'Restored' : 'Ready to restore'}</span><strong>${formatDate(backup.created_at)}</strong><small>Kept: ${escapeHtml(backup.keeper)}</small><small>${backup.replaced.length} original file${backup.replaced.length === 1 ? '' : 's'} · ${formatBytes(backup.size_bytes)}</small></div>
|
||
<button type="button" data-restore-backup="${escapeHtml(backup.path)}" ${backup.status === 'restored' ? 'disabled' : ''}>${backup.status === 'restored' ? 'Already restored' : 'Restore'}</button>
|
||
</article>
|
||
`).join('') : '<div class="empty-detail">No repair backups found for this library yet.</div>';
|
||
} 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) => `<li class="file-compare-row"><span>${escapeHtml(file.path)}</span><div><button type="button" data-audio-url="${escapeHtml(file.audio_url)}" data-audio-name="${escapeHtml(file.path)}">▶ Play preview</button><button type="button" data-reveal-token="${escapeHtml(file.reveal_token)}">Show in Finder</button></div></li>`).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) => `<li>${escapeHtml(line)}</li>`).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 = '<div class="empty-detail">Nothing to review here. Tiny victory parade, very tasteful.</div>';
|
||
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) => `
|
||
<article class="detail-item">
|
||
<strong>${escapeHtml(item.filename || item.path || 'Untitled item')}</strong>
|
||
<ul>${detailLines(item)}</ul>
|
||
</article>
|
||
`).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 = `
|
||
<article class="review-workspace">
|
||
<div class="review-heading"><div><span>Comparing now</span><strong>${escapeHtml(group.filename)}</strong></div><span>${reviewState.choices.size} selected</span></div>
|
||
<div class="review-candidates">${group.file_previews.map((file, index) => `
|
||
<section class="review-candidate ${chosen === file.path ? 'winner' : ''}">
|
||
<span class="candidate-number">Option ${index + 1}</span>
|
||
<strong>${escapeHtml(file.path.split('/').pop())}</strong>
|
||
<small>${escapeHtml(file.path)}</small>
|
||
<div class="file-actions"><button type="button" data-audio-url="${escapeHtml(file.audio_url)}" data-audio-name="${escapeHtml(file.path)}">▶ Play preview</button><button type="button" data-reveal-token="${escapeHtml(file.reveal_token)}">Show in Finder</button></div>
|
||
<button class="choose-winner" type="button" data-choose-winner="${index}">${chosen === file.path ? '✓ Selected keeper' : 'Keep this one →'}</button>
|
||
</section>
|
||
`).join('')}</div>
|
||
<div class="review-navigation"><button type="button" data-review-previous ${reviewState.index === 0 ? 'disabled' : ''}>← Previous</button><button type="button" data-review-skip>Skip for now</button></div>
|
||
</article>`;
|
||
updateBatchSummary();
|
||
}
|
||
|
||
function updateBatchSummary() {
|
||
if (!reviewState) return;
|
||
const groups = latestAnalysis.details[reviewState.key].items;
|
||
repairChoice.innerHTML = `<div class="batch-summary"><strong>${reviewState.choices.size} group${reviewState.choices.size === 1 ? '' : 's'} approved</strong><span>${groups.length - reviewState.choices.size} skipped or still awaiting a decision</span></div>`;
|
||
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');
|
||
repairPreview.innerHTML = `<strong>One safe plan for ${previewedRepair.choice_count} approved group(s)</strong><p>${previewedRepair.replaced.length} duplicate file(s) will be backed up, then replaced with shortcuts to their selected keepers. ${previewedRepair.metadata_backups} Serato metadata file(s) will also be copied into the rollback snapshot. Database V2 will not be changed.</p>`;
|
||
repairPreview.hidden = false; applyRepairButton.disabled = false;
|
||
repairMessage.textContent = 'Preview complete. Nothing has changed yet.';
|
||
} catch (error) { repairMessage.textContent = error.message; }
|
||
});
|
||
|
||
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';
|
||
}
|
||
});
|