Show detailed batch repair preview

This commit is contained in:
Philip Guzman
2026-07-01 16:26:04 -07:00
parent f3cfed316d
commit ec646479d2
6 changed files with 201 additions and 2 deletions
+4
View File
@@ -18,6 +18,10 @@ backup containing every affected audio file plus the Serato metadata snapshot,
then installs all compatibility shortcuts as one rollback unit. Any validation
or filesystem failure prevents or rolls back the batch.
The dry-run opens in a closable modal that names every selected keeper and every
path that will become a compatibility shortcut. Closing the modal preserves the
queue position and all decisions so the DJ can continue reviewing before apply.
## Edge cases
- The same group cannot be submitted twice.
+7
View File
@@ -360,6 +360,13 @@ def duplicate_repair_batch(
result = {
"choice_count": len(plans),
"replaced": replaced,
"decisions": [
{
"keeper": str(plan.keeper),
"replaced": [str(path) for path in plan.replaced],
}
for plan in plans
],
"metadata_backups": len(plans[0].metadata_files),
"strategy": "shortcut",
"database_v2_modified": False,
+19 -1
View File
@@ -22,6 +22,13 @@ 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;
@@ -191,12 +198,23 @@ 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>`;
batchPreviewSummary.textContent = `${previewedRepair.choice_count} keeper decision(s) · ${previewedRepair.replaced.length} duplicate file(s) consolidated`;
batchPreviewList.innerHTML = previewedRepair.decisions.map((decision, index) => `
<article class="preview-decision"><span>Decision ${index + 1}</span><div class="winner-path"><b>Keep</b><strong>${escapeHtml(decision.keeper.split('/').pop())}</strong><small>${escapeHtml(decision.keeper)}</small></div><div class="replaced-paths"><b>Replace with a shortcut</b>${decision.replaced.map((path) => `<small>${escapeHtml(path)}</small>`).join('')}</div></article>
`).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 = `<strong>Preview approved</strong><p>${previewedRepair.choice_count} keeper decision(s) are ready for one backed-up apply.</p>`;
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…';
+8 -1
View File
@@ -121,8 +121,15 @@
<audio id="audio-player" controls preload="metadata"></audio>
<button id="close-audio-preview" type="button" aria-label="Close audio preview">×</button>
</div>
<dialog id="batch-preview-modal" class="batch-preview-modal">
<div class="modal-heading"><div><p class="eyebrow">Final dry-run</p><h2>Heres exactly what will happen</h2></div><button id="close-batch-preview" type="button" aria-label="Close preview">×</button></div>
<p id="batch-preview-summary"></p>
<div id="batch-preview-list" class="batch-preview-list"></div>
<div class="modal-safety"><span></span><p><strong>Backup happens first</strong><small id="batch-preview-safety"></small></p></div>
<div class="modal-actions"><button id="continue-reviewing" type="button">Close and continue reviewing</button><button id="accept-preview" type="button">Looks right</button></div>
</dialog>
</main>
</div>
<script src="/app.js?v=5" defer></script>
<script src="/app.js?v=6" defer></script>
</body>
</html>
+158
View File
@@ -139,6 +139,164 @@
}
}
.batch-preview-modal {
width: min(820px, calc(100vw - 32px));
max-height: min(82vh, 760px);
padding: 24px;
overflow: auto;
border: 1px solid rgba(155, 135, 245, .3);
border-radius: 20px;
background: #161923;
color: var(--text);
box-shadow: 0 30px 100px rgba(0, 0, 0, .65);
}
.batch-preview-modal::backdrop {
background: rgba(4, 5, 8, .78);
backdrop-filter: blur(7px);
}
.modal-heading,
.modal-actions,
.modal-safety {
display: flex;
align-items: center;
justify-content: space-between;
gap: 14px;
}
.modal-heading h2 {
margin: 0;
font-size: 23px;
letter-spacing: -.03em;
}
.modal-heading > button {
border: 0;
background: transparent;
color: var(--muted);
font-size: 28px;
cursor: pointer;
}
#batch-preview-summary {
color: var(--muted);
font-size: 12px;
}
.batch-preview-list {
display: grid;
gap: 10px;
max-height: 390px;
margin: 18px 0;
padding-right: 4px;
overflow: auto;
}
.preview-decision {
display: grid;
grid-template-columns: 80px 1fr 1fr;
gap: 13px;
padding: 14px;
border: 1px solid var(--line);
border-radius: 13px;
background: rgba(255, 255, 255, .025);
}
.preview-decision > span,
.preview-decision b {
color: var(--muted);
font-size: 9px;
text-transform: uppercase;
letter-spacing: .08em;
}
.winner-path,
.replaced-paths {
min-width: 0;
}
.winner-path strong,
.winner-path small,
.replaced-paths small {
display: block;
margin-top: 5px;
overflow-wrap: anywhere;
}
.winner-path strong {
color: #8ce4b8;
font-size: 11px;
}
.winner-path small,
.replaced-paths small {
color: var(--muted);
font-size: 9px;
line-height: 1.45;
}
.modal-safety {
justify-content: flex-start;
padding: 13px;
border: 1px solid rgba(102, 217, 232, .2);
border-radius: 12px;
background: rgba(102, 217, 232, .05);
}
.modal-safety > span {
color: var(--cyan);
}
.modal-safety p,
.modal-safety strong,
.modal-safety small {
display: block;
margin: 0;
}
.modal-safety strong {
font-size: 11px;
}
.modal-safety small {
margin-top: 4px;
color: var(--muted);
font-size: 9px;
line-height: 1.45;
}
.modal-actions {
justify-content: flex-end;
margin-top: 16px;
}
.modal-actions button {
border: 1px solid var(--line);
border-radius: 10px;
padding: 10px 13px;
background: rgba(255, 255, 255, .04);
color: var(--text);
font: 700 11px/1 inherit;
cursor: pointer;
}
#accept-preview {
border-color: transparent;
background: #6f5bd0;
}
@media (max-width: 620px) {
.preview-decision {
grid-template-columns: 1fr;
}
.modal-actions {
align-items: stretch;
flex-direction: column-reverse;
}
}
.file-compare-row {
display: grid;
gap: 8px;
+5
View File
@@ -60,6 +60,8 @@ def test_web_static_assets_are_declared_and_packaged():
assert "Old crate references" in html
assert "Choose a diagnostic" in html
assert "Backup recovery" in html
assert "Heres exactly what will happen" in html
assert 'id="batch-preview-modal"' in html
assert 'data-detail="database_missing_tracks"' in html
assert 'data-detail="old_crate_references"' in html
assert html.count('class="info-button"') >= 10
@@ -139,4 +141,7 @@ def test_batch_preview_combines_approved_groups_without_changes(tmp_path):
assert result["applied"] is False
assert result["choice_count"] == 2
assert len(result["replaced"]) == 2
assert len(result["decisions"]) == 2
assert result["decisions"][0]["keeper"].endswith("First.mp3")
assert len(result["decisions"][0]["replaced"]) == 1
assert all(not Path(choice["group_files"][1]).is_symlink() for choice in choices)