Add batch duplicate review workflow
This commit is contained in:
@@ -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
@@ -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, ...]:
|
||||||
|
|||||||
@@ -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
@@ -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 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;
|
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;
|
||||||
|
|||||||
@@ -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. Serato’s database V2 is never edited.</p>
|
<p>Approved choices are combined into one plan. Skipped groups remain untouched, and Serato’s 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>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user