Add batch duplicate review workflow

This commit is contained in:
Philip Guzman
2026-07-01 16:18:30 -07:00
parent 4315d8d4d4
commit f3cfed316d
8 changed files with 391 additions and 36 deletions
+33
View File
@@ -0,0 +1,33 @@
# Batch duplicate review
## Problem
Opening a diagnostic, opening each group again, scrolling to a separate repair
form, and applying one change at a time creates unnecessary friction and many
small backups.
## Design
Duplicate and cloud-conflict diagnostics now open as a sequential review queue.
Each group presents its candidates together with audio preview and Finder
controls. Choosing a keeper records the decision and advances immediately.
Uncertain groups can be skipped without changing them.
All approved decisions are submitted as one dry-run plan. Apply creates one
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.
## Edge cases
- The same group cannot be submitted twice.
- A file cannot be both a keeper and a disposable file across choices.
- Every group is rescanned and revalidated before preview and apply.
- Skipped and unreviewed groups remain untouched.
- Revisiting a group preserves and visibly marks its selected keeper.
## Tests
- Two groups produce one backup and one restorable receipt.
- Batch preview reports combined changes without modifying files.
- Existing single-group repair remains a wrapper around the batch engine.
+40 -10
View File
@@ -83,8 +83,28 @@ def apply_duplicate_repair(
backup_limit: Optional[int] = 10, backup_limit: Optional[int] = 10,
) -> RepairReceipt: ) -> RepairReceipt:
"""Create a complete rollback snapshot, then replace extras with symlinks.""" """Create a complete rollback snapshot, then replace extras with symlinks."""
return apply_duplicate_repair_batch((plan,), serato_root, backup_limit)
def apply_duplicate_repair_batch(
plans: Iterable[DuplicateRepairPlan],
serato_root: Path,
backup_limit: Optional[int] = 10,
) -> RepairReceipt:
"""Apply several approved duplicate choices as one atomic backup."""
plans = tuple(plans)
if not plans:
raise ValueError("Choose at least one duplicate group")
if backup_limit is not None and backup_limit < 1: if backup_limit is not None and backup_limit < 1:
raise ValueError("Backup limit must be at least 1, or unlimited") raise ValueError("Backup limit must be at least 1, or unlimited")
replaced_paths = tuple(
path for plan in plans for path in plan.replaced
)
if len(set(replaced_paths)) != len(replaced_paths):
raise ValueError("The same file appears in more than one repair choice")
keepers = {plan.keeper for plan in plans}
if keepers.intersection(replaced_paths):
raise ValueError("A selected keeper cannot be removed by another choice")
backup_root = serato_root.expanduser().resolve() / BACKUP_FOLDER backup_root = serato_root.expanduser().resolve() / BACKUP_FOLDER
backup = backup_root / _backup_name() backup = backup_root / _backup_name()
files_root = backup / "files" files_root = backup / "files"
@@ -93,35 +113,45 @@ def apply_duplicate_repair(
entries = [] entries = []
try: try:
for path in plan.replaced: for plan in plans:
destination = files_root / _safe_backup_path(path) for path in plan.replaced:
destination.parent.mkdir(parents=True, exist_ok=True) destination = files_root / _safe_backup_path(path)
shutil.copy2(path, destination) destination.parent.mkdir(parents=True, exist_ok=True)
entries.append({"original": str(path), "backup": str(destination)}) shutil.copy2(path, destination)
for path in plan.metadata_files: entries.append(
{
"original": str(path),
"backup": str(destination),
"keeper": str(plan.keeper),
}
)
for path in plans[0].metadata_files:
relative = path.relative_to(serato_root.expanduser().resolve()) relative = path.relative_to(serato_root.expanduser().resolve())
destination = metadata_root / relative destination = metadata_root / relative
destination.parent.mkdir(parents=True, exist_ok=True) destination.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(path, destination) shutil.copy2(path, destination)
manifest = { manifest = {
"created_at": datetime.now(timezone.utc).isoformat(), "created_at": datetime.now(timezone.utc).isoformat(),
"keeper": str(plan.keeper), "keeper": str(plans[0].keeper),
"keepers": [str(plan.keeper) for plan in plans],
"choice_count": len(plans),
"replaced": entries, "replaced": entries,
"strategy": "symlink", "strategy": "symlink",
} }
(backup / "manifest.json").write_text( (backup / "manifest.json").write_text(
json.dumps(manifest, indent=2), encoding="utf-8" json.dumps(manifest, indent=2), encoding="utf-8"
) )
for path in plan.replaced: for entry in entries:
path = Path(entry["original"])
path.unlink() path.unlink()
path.symlink_to(plan.keeper) path.symlink_to(Path(entry["keeper"]))
except Exception: except Exception:
_rollback_entries(entries) _rollback_entries(entries)
shutil.rmtree(backup, ignore_errors=True) shutil.rmtree(backup, ignore_errors=True)
raise raise
rotate_backups(backup_root, backup_limit) rotate_backups(backup_root, backup_limit)
return RepairReceipt(backup, plan.keeper, plan.replaced) return RepairReceipt(backup, plans[0].keeper, replaced_paths)
def restore_backup(backup: Path) -> Tuple[Path, ...]: def restore_backup(backup: Path) -> Tuple[Path, ...]:
+62
View File
@@ -26,6 +26,7 @@ from serato_doctor.scanner import scan_filesystem
from serato_doctor.repair import ( from serato_doctor.repair import (
BACKUP_FOLDER, BACKUP_FOLDER,
apply_duplicate_repair, apply_duplicate_repair,
apply_duplicate_repair_batch,
list_backups, list_backups,
plan_duplicate_repair, plan_duplicate_repair,
restore_backup, restore_backup,
@@ -324,6 +325,53 @@ def duplicate_repair(
return result return result
def duplicate_repair_batch(
serato: Path,
music: Path,
choices: Iterable[dict],
backup_limit: Optional[int],
apply: bool = False,
) -> dict:
"""Validate and preview or apply several keeper choices together."""
serato = serato.expanduser().resolve()
music = music.expanduser().resolve()
if not serato.is_dir() or not music.is_dir():
raise ValueError("Analyze the library again before repairing duplicates")
groups = find_duplicate_groups(scan_filesystem(music).tracks)
valid_groups = [
{track.path.resolve() for track in group.tracks} for group in groups
]
plans = []
selected_groups = set()
for choice in choices:
requested = tuple(Path(value).expanduser().resolve() for value in choice["group_files"])
group_key = frozenset(requested)
if set(requested) not in valid_groups:
raise ValueError("A duplicate group changed; analyze the library again")
if group_key in selected_groups:
raise ValueError("A duplicate group was selected more than once")
selected_groups.add(group_key)
plans.append(
plan_duplicate_repair(Path(choice["keeper"]), requested, serato)
)
if not plans:
raise ValueError("Choose at least one duplicate group")
replaced = [str(path) for plan in plans for path in plan.replaced]
result = {
"choice_count": len(plans),
"replaced": replaced,
"metadata_backups": len(plans[0].metadata_files),
"strategy": "shortcut",
"database_v2_modified": False,
}
if apply:
receipt = apply_duplicate_repair_batch(plans, serato, backup_limit)
result.update({"applied": True, "backup": str(receipt.backup)})
else:
result["applied"] = False
return result
def backup_history(serato: Path) -> dict: def backup_history(serato: Path) -> dict:
serato = serato.expanduser().resolve() serato = serato.expanduser().resolve()
if not serato.is_dir(): if not serato.is_dir():
@@ -354,6 +402,8 @@ class SeratoDoctorHandler(BaseHTTPRequestHandler):
try: try:
token = parse_qs(request.query)["token"][0] token = parse_qs(request.query)["token"][0]
self._audio_response(_verified_audio(token)) self._audio_response(_verified_audio(token))
except (BrokenPipeError, ConnectionResetError):
return
except (KeyError, IndexError, OSError, ValueError) as error: except (KeyError, IndexError, OSError, ValueError) as error:
self._json_response(404, {"error": str(error)}) self._json_response(404, {"error": str(error)})
return return
@@ -379,6 +429,8 @@ class SeratoDoctorHandler(BaseHTTPRequestHandler):
"/api/analyze", "/api/analyze",
"/api/duplicates/preview", "/api/duplicates/preview",
"/api/duplicates/apply", "/api/duplicates/apply",
"/api/duplicates/batch/preview",
"/api/duplicates/batch/apply",
"/api/backups/restore", "/api/backups/restore",
"/api/backups", "/api/backups",
"/api/reveal", "/api/reveal",
@@ -412,6 +464,16 @@ class SeratoDoctorHandler(BaseHTTPRequestHandler):
raise ValueError("That backup does not belong to this library") raise ValueError("That backup does not belong to this library")
restored = restore_backup(backup) restored = restore_backup(backup)
result = {"restored": [str(path) for path in restored]} result = {"restored": [str(path) for path in restored]}
elif self.path.startswith("/api/duplicates/batch/"):
raw_limit = payload.get("backup_limit", 10)
backup_limit = None if raw_limit is None else int(raw_limit)
result = duplicate_repair_batch(
Path(payload["serato"]),
Path(payload["music"]),
payload["choices"],
backup_limit,
apply=self.path.endswith("/apply"),
)
else: else:
raw_limit = payload.get("backup_limit", 10) raw_limit = payload.get("backup_limit", 10)
backup_limit = None if raw_limit is None else int(raw_limit) backup_limit = None if raw_limit is None else int(raw_limit)
+66 -21
View File
@@ -26,6 +26,7 @@ let latestAnalysis = null;
let selectedDuplicateGroup = null; let selectedDuplicateGroup = null;
let previewedRepair = null; let previewedRepair = null;
let latestBackup = null; let latestBackup = null;
let reviewState = null;
function expandHome(path) { function expandHome(path) {
return path.trim(); return path.trim();
@@ -124,34 +125,59 @@ function renderDetail(key) {
return; 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) => ` drilldownList.innerHTML = detail.items.map((item, index) => `
<article class="detail-item${item.files ? ' selectable-duplicate' : ''}" ${item.files ? `data-duplicate-index="${index}" role="button" tabindex="0"` : ''}> <article class="detail-item">
<strong>${escapeHtml(item.filename || item.path || 'Untitled item')}</strong> <strong>${escapeHtml(item.filename || item.path || 'Untitled item')}</strong>
<ul>${detailLines(item)}</ul> <ul>${detailLines(item)}</ul>
${item.files ? '<small>Choose this group to review a safe cleanup →</small>' : ''}
</article> </article>
`).join(''); `).join('');
} }
function chooseDuplicate(detailKey, index) { function renderReviewGroup() {
const group = latestAnalysis?.details?.[detailKey]?.items?.[index]; const groups = latestAnalysis.details[reviewState.key].items;
if (!group?.files) return; const group = groups[reviewState.index];
selectedDuplicateGroup = group; 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 well 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; previewedRepair = null;
applyRepairButton.disabled = true; applyRepairButton.disabled = true;
repairPreview.hidden = true; repairPreview.hidden = true;
repairMessage.textContent = '';
repairChoice.innerHTML = group.files.map((file, fileIndex) => `
<div class="keeper-option"><label><input type="radio" name="keeper" value="${escapeHtml(file)}" ${fileIndex === 0 ? 'checked' : ''}><span><strong>${fileIndex === 0 ? 'Keep this file' : 'Keep instead'}</strong><small>${escapeHtml(file)}</small></span></label><div class="file-actions"><button type="button" data-audio-url="${escapeHtml(group.file_previews[fileIndex].audio_url)}" data-audio-name="${escapeHtml(file)}">▶ Play preview</button><button type="button" data-reveal-token="${escapeHtml(group.file_previews[fileIndex].reveal_token)}">Show in Finder</button></div></div>
`).join('');
repairPanel.hidden = false;
repairPanel.scrollIntoView({behavior: 'smooth', block: 'start'});
} }
function repairPayload() { function repairPayload() {
const keeper = document.querySelector('input[name="keeper"]:checked')?.value; if (!reviewState?.choices.size) throw new Error('Choose at least one keeper');
if (!selectedDuplicateGroup || !keeper) throw new Error('Choose a file to keep'); const groups = latestAnalysis.details[reviewState.key].items;
return {serato: expandHome(document.querySelector('#serato-path').value), music: expandHome(document.querySelector('#music-path').value), keeper, group_files: selectedDuplicateGroup.files, backup_limit: document.querySelector('#keep-all-backups').checked ? null : Number(document.querySelector('#backup-limit').value)}; 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) { async function requestRepair(endpoint) {
@@ -164,8 +190,8 @@ async function requestRepair(endpoint) {
previewRepairButton.addEventListener('click', async () => { previewRepairButton.addEventListener('click', async () => {
repairMessage.textContent = 'Checking the plan…'; applyRepairButton.disabled = true; repairMessage.textContent = 'Checking the plan…'; applyRepairButton.disabled = true;
try { try {
previewedRepair = await requestRepair('/api/duplicates/preview'); previewedRepair = await requestRepair('/api/duplicates/batch/preview');
repairPreview.innerHTML = `<strong>Ready to protect and consolidate</strong><p>${previewedRepair.replaced.length} duplicate file(s) will be backed up, then replaced with shortcuts to the keeper. ${previewedRepair.metadata_backups} Serato metadata file(s) will also be copied into the rollback snapshot. Database V2 will not be changed.</p>`; 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; repairPreview.hidden = false; applyRepairButton.disabled = false;
repairMessage.textContent = 'Preview complete. Nothing has changed yet.'; repairMessage.textContent = 'Preview complete. Nothing has changed yet.';
} catch (error) { repairMessage.textContent = error.message; } } catch (error) { repairMessage.textContent = error.message; }
@@ -175,7 +201,7 @@ applyRepairButton.addEventListener('click', async () => {
if (!previewedRepair) return; if (!previewedRepair) return;
applyRepairButton.disabled = true; repairMessage.textContent = 'Creating the backup before making changes…'; applyRepairButton.disabled = true; repairMessage.textContent = 'Creating the backup before making changes…';
try { try {
const result = await requestRepair('/api/duplicates/apply'); const result = await requestRepair('/api/duplicates/batch/apply');
latestBackup = result.backup; latestBackup = result.backup;
repairMessage.textContent = `Cleanup complete. Restore backup: ${result.backup}`; repairMessage.textContent = `Cleanup complete. Restore backup: ${result.backup}`;
previewRepairButton.disabled = true; previewRepairButton.disabled = true;
@@ -197,7 +223,6 @@ restoreRepairButton.addEventListener('click', async () => {
} catch (error) { repairMessage.textContent = error.message; restoreRepairButton.disabled = false; } } catch (error) { repairMessage.textContent = error.message; restoreRepairButton.disabled = false; }
}); });
repairChoice.addEventListener('change', () => { previewedRepair = null; applyRepairButton.disabled = true; repairPreview.hidden = true; repairMessage.textContent = 'Keeper changed. Preview the plan again.'; });
async function handleFileAction(event) { async function handleFileAction(event) {
const previewButton = event.target.closest('[data-audio-url]'); const previewButton = event.target.closest('[data-audio-url]');
const revealButton = event.target.closest('[data-reveal-token]'); const revealButton = event.target.closest('[data-reveal-token]');
@@ -224,8 +249,28 @@ async function handleFileAction(event) {
closeAudioPreview.addEventListener('click', () => { audioPlayer.pause(); audioPlayer.removeAttribute('src'); audioPlayer.load(); audioPreview.hidden = true; }); closeAudioPreview.addEventListener('click', () => { audioPlayer.pause(); audioPlayer.removeAttribute('src'); audioPlayer.load(); audioPreview.hidden = true; });
repairChoice.addEventListener('click', handleFileAction); repairChoice.addEventListener('click', handleFileAction);
drilldownList.addEventListener('click', async (event) => { if (await handleFileAction(event)) return; const item = event.target.closest('[data-duplicate-index]'); if (item) chooseDuplicate(document.querySelector('.drill-trigger.selected')?.dataset.detail, Number(item.dataset.duplicateIndex)); }); drilldownList.addEventListener('click', async (event) => {
drilldownList.addEventListener('keydown', (event) => { if (event.key !== 'Enter' && event.key !== ' ') return; const item = event.target.closest('[data-duplicate-index]'); if (item) { event.preventDefault(); chooseDuplicate(document.querySelector('.drill-trigger.selected')?.dataset.detail, Number(item.dataset.duplicateIndex)); } }); 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) { function render(data) {
latestAnalysis = data; latestAnalysis = data;
+5 -5
View File
@@ -93,8 +93,8 @@
</div> </div>
<div id="duplicate-repair" class="repair-panel panel" hidden> <div id="duplicate-repair" class="repair-panel panel" hidden>
<div class="section-heading"><div><p class="eyebrow">Backup-first cleanup</p><h2>Safely consolidate duplicates</h2></div><span class="repair-tag">Preview required</span></div> <div class="section-heading"><div><p class="eyebrow">Batch cleanup</p><h2>Review all selected changes</h2></div><span class="repair-tag">One backup · one apply</span></div>
<p>Choose the real file to keep. Every other path will be backed up and replaced with a shortcut to it, so existing Serato crates remain mapped. Seratos database V2 is never edited.</p> <p>Approved choices are combined into one plan. Skipped groups remain untouched, and Seratos database V2 is never edited.</p>
<div id="repair-choice" class="repair-choice"></div> <div id="repair-choice" class="repair-choice"></div>
<div class="backup-options"> <div class="backup-options">
<label>Backups to keep <input id="backup-limit" type="number" min="1" value="10"></label> <label>Backups to keep <input id="backup-limit" type="number" min="1" value="10"></label>
@@ -102,8 +102,8 @@
</div> </div>
<div id="repair-preview" class="repair-preview" hidden></div> <div id="repair-preview" class="repair-preview" hidden></div>
<div class="repair-actions"> <div class="repair-actions">
<button id="preview-repair" type="button">Preview changes</button> <button id="preview-repair" type="button">Preview all selected changes</button>
<button id="apply-repair" class="danger-action" type="button" disabled>Apply backed-up cleanup</button> <button id="apply-repair" class="danger-action" type="button" disabled>Apply all selected changes</button>
<button id="restore-repair" type="button" hidden>Restore this backup</button> <button id="restore-repair" type="button" hidden>Restore this backup</button>
</div> </div>
<div id="repair-message" class="repair-message" role="status"></div> <div id="repair-message" class="repair-message" role="status"></div>
@@ -123,6 +123,6 @@
</div> </div>
</main> </main>
</div> </div>
<script src="/app.js?v=4" defer></script> <script src="/app.js?v=5" defer></script>
</body> </body>
</html> </html>
+131
View File
@@ -8,6 +8,137 @@
} }
} }
.review-workspace {
padding: clamp(16px, 3vw, 24px);
border: 1px solid rgba(155, 135, 245, .18);
border-radius: 16px;
background: rgba(255, 255, 255, .02);
}
.review-heading,
.review-navigation {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
}
.review-heading span,
.batch-summary span {
color: var(--muted);
font-size: 10px;
}
.review-heading strong {
display: block;
margin-top: 5px;
font-size: 14px;
}
.review-candidates {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
margin: 18px 0;
}
.review-candidate {
display: flex;
flex-direction: column;
gap: 9px;
min-width: 0;
padding: 16px;
border: 1px solid var(--line);
border-radius: 14px;
background: rgba(6, 8, 12, .28);
}
.review-candidate.winner {
border-color: rgba(84, 212, 154, .52);
background: rgba(84, 212, 154, .06);
}
.candidate-number {
color: var(--violet);
font-size: 9px;
font-weight: 750;
text-transform: uppercase;
letter-spacing: .1em;
}
.review-candidate > strong,
.review-candidate > small {
overflow-wrap: anywhere;
}
.review-candidate > strong {
font-size: 12px;
}
.review-candidate > small {
flex: 1;
color: var(--muted);
font-size: 9px;
line-height: 1.5;
}
.choose-winner {
margin-top: 4px;
border: 0;
border-radius: 10px;
padding: 10px 12px;
background: linear-gradient(135deg, #907be9, #6e59cf);
color: white;
font: 750 11px/1 inherit;
cursor: pointer;
}
.winner .choose-winner {
background: rgba(84, 212, 154, .18);
color: #8ce4b8;
}
.review-navigation button {
border: 1px solid var(--line);
border-radius: 9px;
padding: 8px 11px;
background: rgba(255, 255, 255, .04);
color: var(--muted);
font: 700 10px/1 inherit;
cursor: pointer;
}
.review-navigation button:disabled {
opacity: .35;
}
.batch-summary {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 14px;
border: 1px solid rgba(102, 217, 232, .2);
border-radius: 12px;
background: rgba(102, 217, 232, .05);
}
.batch-summary strong {
font-size: 12px;
}
@media (max-width: 760px) {
.review-candidates {
grid-template-columns: 1fr;
}
.review-heading,
.batch-summary {
align-items: flex-start;
flex-direction: column;
}
}
.file-compare-row { .file-compare-row {
display: grid; display: grid;
gap: 8px; gap: 8px;
+27
View File
@@ -5,6 +5,7 @@ import pytest
from serato_doctor.repair import ( from serato_doctor.repair import (
BACKUP_FOLDER, BACKUP_FOLDER,
apply_duplicate_repair, apply_duplicate_repair,
apply_duplicate_repair_batch,
list_backups, list_backups,
plan_duplicate_repair, plan_duplicate_repair,
restore_backup, restore_backup,
@@ -72,3 +73,29 @@ def test_backup_rotation_can_be_limited_or_unlimited(tmp_path):
assert len(list(root.iterdir())) == 3 assert len(list(root.iterdir())) == 3
rotate_backups(root, 2) rotate_backups(root, 2)
assert {path.name for path in root.iterdir()} == {"002", "003"} assert {path.name for path in root.iterdir()} == {"002", "003"}
def test_batch_repair_uses_one_backup_and_restores_every_group(tmp_path):
serato, first_keeper, first_duplicate = library(tmp_path)
second_keeper = tmp_path / "Music" / "Main" / "Other.mp3"
second_duplicate = tmp_path / "Music" / "Old" / "Other.mp3"
second_keeper.write_bytes(b"other-keeper")
second_duplicate.write_bytes(b"other-duplicate")
plans = (
plan_duplicate_repair(
first_keeper, (first_keeper, first_duplicate), serato
),
plan_duplicate_repair(
second_keeper, (second_keeper, second_duplicate), serato
),
)
receipt = apply_duplicate_repair_batch(plans, serato, backup_limit=10)
assert first_duplicate.is_symlink()
assert second_duplicate.is_symlink()
assert len(list((serato / BACKUP_FOLDER).iterdir())) == 1
assert len(receipt.replaced) == 2
restore_backup(receipt.backup)
assert first_duplicate.read_bytes() == b"duplicate"
assert second_duplicate.read_bytes() == b"other-duplicate"
+27
View File
@@ -9,6 +9,7 @@ from serato_doctor.web import (
_verified_audio, _verified_audio,
analyze_paths, analyze_paths,
duplicate_repair, duplicate_repair,
duplicate_repair_batch,
) )
@@ -113,3 +114,29 @@ def test_duplicate_details_include_preview_and_finder_controls(tmp_path):
assert len(group["file_previews"]) == 2 assert len(group["file_previews"]) == 2
assert group["file_previews"][0]["audio_url"].startswith("/api/audio?") assert group["file_previews"][0]["audio_url"].startswith("/api/audio?")
assert group["file_previews"][0]["reveal_token"] assert group["file_previews"][0]["reveal_token"]
def test_batch_preview_combines_approved_groups_without_changes(tmp_path):
serato = tmp_path / "_Serato_"
serato.mkdir()
music = tmp_path / "Music"
choices = []
for filename in ("First.mp3", "Second.mp3"):
keeper = music / "A" / filename
duplicate = music / "B" / filename
keeper.parent.mkdir(parents=True, exist_ok=True)
duplicate.parent.mkdir(parents=True, exist_ok=True)
keeper.write_bytes(b"keeper")
duplicate.write_bytes(b"duplicate")
choices.append(
{"keeper": str(keeper), "group_files": [str(keeper), str(duplicate)]}
)
result = duplicate_repair_batch(
serato, music, choices, backup_limit=10
)
assert result["applied"] is False
assert result["choice_count"] == 2
assert len(result["replaced"]) == 2
assert all(not Path(choice["group_files"][1]).is_symlink() for choice in choices)