diff --git a/docs/design/duplicate-repair.md b/docs/design/duplicate-repair.md
new file mode 100644
index 0000000..3499a45
--- /dev/null
+++ b/docs/design/duplicate-repair.md
@@ -0,0 +1,39 @@
+# Duplicate repair
+
+## Problem
+
+Duplicate and cloud-conflict files waste space, but deleting either path can
+unmap tracks in crates or Serato's database. DJs need to choose the authoritative
+copy and understand every change before it happens.
+
+## Design
+
+The web interface requires an analysis, an explicit keeper selection, and a
+dry-run preview. Applying the plan first creates a timestamped backup beneath
+`_Serato_/.serato-doctor-backups/`. The snapshot contains every replaced audio
+file, loaded crate/smart-crate metadata, database V2, and a JSON restore manifest.
+
+The non-kept audio path is then replaced with a symbolic link to the keeper.
+This removes the extra audio payload while preserving every existing saved path.
+Crate files and database V2 are never rewritten. The UI exposes immediate
+restore using the manifest.
+
+Users may retain all backups or set a positive rotation limit. Rotation occurs
+only after a repair succeeds.
+
+## Edge cases
+
+- The duplicate group is rescanned and validated immediately before preview and
+ apply.
+- Existing symlinks cannot be selected as disposable duplicate files.
+- A partial failure restores already-changed files before reporting the error.
+- Restore refuses to overwrite a real file.
+- Healthy symlink aliases are excluded from future duplicate counts.
+
+## Tests
+
+- Backup creation includes audio and Serato metadata.
+- The old path resolves to the selected keeper after repair.
+- Restore returns the original file contents.
+- Invalid keeper choices are rejected.
+- Limited and unlimited retention behave deterministically.
diff --git a/serato_doctor/duplicates.py b/serato_doctor/duplicates.py
index afdf6e9..fedbc24 100644
--- a/serato_doctor/duplicates.py
+++ b/serato_doctor/duplicates.py
@@ -11,7 +11,9 @@ def find_duplicate_groups(
) -> Tuple[DuplicateGroup, ...]:
"""Find exact-name duplicates and suspected numeric conflict copies."""
- track_tuple = tuple(tracks)
+ # Healthy symlinks preserve legacy Serato paths without consuming another
+ # copy of the audio, so they are aliases rather than duplicate files.
+ track_tuple = tuple(track for track in tracks if not track.path.is_symlink())
by_name: DefaultDict[str, List[DiskTrack]] = defaultdict(list)
by_conflict_name: DefaultDict[str, List[DiskTrack]] = defaultdict(list)
diff --git a/serato_doctor/repair.py b/serato_doctor/repair.py
index e69de29..d4855e5 100644
--- a/serato_doctor/repair.py
+++ b/serato_doctor/repair.py
@@ -0,0 +1,160 @@
+"""Backup-first duplicate consolidation.
+
+Serato's binary metadata is deliberately not rewritten. Removed duplicate files
+are replaced with symbolic links, so every existing path continues to resolve.
+"""
+
+import json
+import shutil
+from dataclasses import dataclass
+from datetime import datetime, timezone
+from pathlib import Path
+from typing import Iterable, Optional, Tuple
+from uuid import uuid4
+
+
+BACKUP_FOLDER = ".serato-doctor-backups"
+
+
+@dataclass(frozen=True)
+class DuplicateRepairPlan:
+ keeper: Path
+ replaced: Tuple[Path, ...]
+ metadata_files: Tuple[Path, ...]
+
+ @property
+ def changes(self) -> int:
+ return len(self.replaced)
+
+
+@dataclass(frozen=True)
+class RepairReceipt:
+ backup: Path
+ keeper: Path
+ replaced: Tuple[Path, ...]
+
+
+def plan_duplicate_repair(
+ keeper: Path, duplicates: Iterable[Path], serato_root: Path
+) -> DuplicateRepairPlan:
+ keeper = keeper.expanduser().resolve()
+ candidates = tuple(path.expanduser().resolve() for path in duplicates)
+ if keeper not in candidates:
+ raise ValueError("The file to keep must belong to this duplicate group")
+ if not keeper.is_file():
+ raise ValueError(f"The file to keep no longer exists: {keeper}")
+ replaced = tuple(path for path in candidates if path != keeper)
+ if not replaced:
+ raise ValueError("Choose a duplicate group containing at least two files")
+ if any(not path.is_file() or path.is_symlink() for path in replaced):
+ raise ValueError("A duplicate changed since the analysis; analyze again")
+ metadata = tuple(
+ sorted(
+ path
+ for path in serato_root.expanduser().resolve().rglob("*")
+ if path.is_file()
+ and BACKUP_FOLDER not in path.parts
+ and (
+ path.name == "database V2"
+ or path.suffix.casefold() in {".crate", ".scrate"}
+ )
+ )
+ )
+ return DuplicateRepairPlan(keeper, replaced, metadata)
+
+
+def apply_duplicate_repair(
+ plan: DuplicateRepairPlan,
+ serato_root: Path,
+ backup_limit: Optional[int] = 10,
+) -> RepairReceipt:
+ """Create a complete rollback snapshot, then replace extras with symlinks."""
+ if backup_limit is not None and backup_limit < 1:
+ raise ValueError("Backup limit must be at least 1, or unlimited")
+ backup_root = serato_root.expanduser().resolve() / BACKUP_FOLDER
+ backup = backup_root / _backup_name()
+ files_root = backup / "files"
+ metadata_root = backup / "serato-metadata"
+ backup.mkdir(parents=True)
+
+ entries = []
+ try:
+ for path in plan.replaced:
+ destination = files_root / _safe_backup_path(path)
+ destination.parent.mkdir(parents=True, exist_ok=True)
+ shutil.copy2(path, destination)
+ entries.append({"original": str(path), "backup": str(destination)})
+ for path in plan.metadata_files:
+ relative = path.relative_to(serato_root.expanduser().resolve())
+ destination = metadata_root / relative
+ destination.parent.mkdir(parents=True, exist_ok=True)
+ shutil.copy2(path, destination)
+ manifest = {
+ "created_at": datetime.now(timezone.utc).isoformat(),
+ "keeper": str(plan.keeper),
+ "replaced": entries,
+ "strategy": "symlink",
+ }
+ (backup / "manifest.json").write_text(
+ json.dumps(manifest, indent=2), encoding="utf-8"
+ )
+ for path in plan.replaced:
+ path.unlink()
+ path.symlink_to(plan.keeper)
+ except Exception:
+ _rollback_entries(entries)
+ shutil.rmtree(backup, ignore_errors=True)
+ raise
+
+ rotate_backups(backup_root, backup_limit)
+ return RepairReceipt(backup, plan.keeper, plan.replaced)
+
+
+def restore_backup(backup: Path) -> Tuple[Path, ...]:
+ manifest_path = backup.expanduser().resolve() / "manifest.json"
+ manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
+ restored = []
+ for entry in manifest["replaced"]:
+ original = Path(entry["original"])
+ saved = Path(entry["backup"])
+ if original.exists() and not original.is_symlink():
+ raise ValueError(f"Restore would overwrite a real file: {original}")
+ if original.is_symlink():
+ original.unlink()
+ original.parent.mkdir(parents=True, exist_ok=True)
+ shutil.copy2(saved, original)
+ restored.append(original)
+ return tuple(restored)
+
+
+def rotate_backups(backup_root: Path, limit: Optional[int]) -> None:
+ if limit is None or not backup_root.is_dir():
+ return
+ backups = sorted(
+ (path for path in backup_root.iterdir() if (path / "manifest.json").is_file()),
+ key=lambda path: path.name,
+ reverse=True,
+ )
+ for expired in backups[limit:]:
+ shutil.rmtree(expired)
+
+
+def _backup_name() -> str:
+ stamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
+ return f"{stamp}-{uuid4().hex[:8]}"
+
+
+def _safe_backup_path(path: Path) -> Path:
+ anchorless = path.as_posix().lstrip("/").replace(":", "_")
+ return Path(anchorless)
+
+
+def _rollback_entries(entries: Iterable[dict]) -> None:
+ for entry in entries:
+ original = Path(entry["original"])
+ saved = Path(entry["backup"])
+ if original.is_symlink():
+ original.unlink()
+ if not original.exists() and saved.is_file():
+ original.parent.mkdir(parents=True, exist_ok=True)
+ shutil.copy2(saved, original)
diff --git a/serato_doctor/web.py b/serato_doctor/web.py
index 31fe2e6..4d21bdf 100644
--- a/serato_doctor/web.py
+++ b/serato_doctor/web.py
@@ -5,7 +5,7 @@ from dataclasses import asdict
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from importlib import resources
from pathlib import Path
-from typing import Iterable
+from typing import Iterable, Optional
from serato_doctor.crate_parser import load_library_crates
from serato_doctor.database_parser import parse_database
@@ -16,6 +16,12 @@ from serato_doctor.models.crate import CrateKind
from serato_doctor.models.duplicate import DuplicateKind
from serato_doctor.models.library import Library
from serato_doctor.scanner import scan_filesystem
+from serato_doctor.repair import (
+ BACKUP_FOLDER,
+ apply_duplicate_repair,
+ plan_duplicate_repair,
+ restore_backup,
+)
MAX_REQUEST_BYTES = 64 * 1024
@@ -225,6 +231,44 @@ def analyze_paths(
return result
+def duplicate_repair(
+ serato: Path,
+ music: Path,
+ keeper: Path,
+ group_files: Iterable[Path],
+ backup_limit: Optional[int],
+ apply: bool = False,
+) -> dict:
+ """Validate a current duplicate group and preview or apply consolidation."""
+ serato = serato.expanduser().resolve()
+ music = music.expanduser().resolve()
+ keeper = keeper.expanduser().resolve()
+ requested = tuple(path.expanduser().resolve() for path in group_files)
+ if not serato.is_dir() or not music.is_dir():
+ raise ValueError("Analyze the library again before repairing duplicates")
+ tracks = scan_filesystem(music).tracks
+ groups = find_duplicate_groups(tracks)
+ valid_groups = [
+ {track.path.resolve() for track in group.tracks} for group in groups
+ ]
+ if set(requested) not in valid_groups:
+ raise ValueError("This duplicate group changed; analyze the library again")
+ plan = plan_duplicate_repair(keeper, requested, serato)
+ result = {
+ "keeper": str(plan.keeper),
+ "replaced": [str(path) for path in plan.replaced],
+ "metadata_backups": len(plan.metadata_files),
+ "strategy": "shortcut",
+ "database_v2_modified": False,
+ }
+ if apply:
+ receipt = apply_duplicate_repair(plan, serato, backup_limit)
+ result.update({"applied": True, "backup": str(receipt.backup)})
+ else:
+ result["applied"] = False
+ return result
+
+
class SeratoDoctorHandler(BaseHTTPRequestHandler):
def do_GET(self) -> None:
asset = STATIC_FILES.get(self.path)
@@ -244,7 +288,13 @@ class SeratoDoctorHandler(BaseHTTPRequestHandler):
self.wfile.write(content)
def do_POST(self) -> None:
- if self.path != "/api/analyze":
+ allowed = {
+ "/api/analyze",
+ "/api/duplicates/preview",
+ "/api/duplicates/apply",
+ "/api/backups/restore",
+ }
+ if self.path not in allowed:
self._json_response(404, {"error": "Not found"})
return
try:
@@ -254,11 +304,37 @@ class SeratoDoctorHandler(BaseHTTPRequestHandler):
payload = json.loads(self.rfile.read(length))
if not isinstance(payload, dict):
raise ValueError("Request body must be a JSON object")
- roots = [Path(value) for value in payload.get("reference_roots", [])]
- result = analyze_paths(
- Path(payload["serato"]), Path(payload["music"]), roots
- )
- except (KeyError, TypeError, json.JSONDecodeError, ValueError) as error:
+ if self.path == "/api/analyze":
+ roots = [Path(value) for value in payload.get("reference_roots", [])]
+ result = analyze_paths(
+ Path(payload["serato"]), Path(payload["music"]), roots
+ )
+ elif self.path == "/api/backups/restore":
+ serato = Path(payload["serato"]).expanduser().resolve()
+ backup = Path(payload["backup"]).expanduser().resolve()
+ backup_root = (serato / BACKUP_FOLDER).resolve()
+ if backup.parent != backup_root:
+ raise ValueError("That backup does not belong to this library")
+ restored = restore_backup(backup)
+ result = {"restored": [str(path) for path in restored]}
+ else:
+ raw_limit = payload.get("backup_limit", 10)
+ backup_limit = None if raw_limit is None else int(raw_limit)
+ result = duplicate_repair(
+ Path(payload["serato"]),
+ Path(payload["music"]),
+ Path(payload["keeper"]),
+ (Path(value) for value in payload["group_files"]),
+ backup_limit,
+ apply=self.path.endswith("/apply"),
+ )
+ except (
+ KeyError,
+ TypeError,
+ OSError,
+ json.JSONDecodeError,
+ ValueError,
+ ) as error:
self._json_response(400, {"error": str(error)})
return
self._json_response(200, result)
diff --git a/serato_doctor/webui/app.css b/serato_doctor/webui/app.css
index fa92a90..c893051 100644
--- a/serato_doctor/webui/app.css
+++ b/serato_doctor/webui/app.css
@@ -1,3 +1,3 @@
:root{--bg:#090b10;--panel:rgba(22,25,34,.86);--panel-2:#171a23;--line:rgba(255,255,255,.08);--text:#f4f5f7;--muted:#9298a7;--violet:#9b87f5;--cyan:#66d9e8;--amber:#f5b94c;--red:#f17878;--radius:22px;font-family:Inter,ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;color:var(--text);background:var(--bg)}*{box-sizing:border-box}html{scroll-behavior:smooth}body{margin:0;min-height:100vh;background:radial-gradient(circle at 75% -10%,rgba(98,79,181,.16),transparent 32%),var(--bg)}.ambient{position:fixed;border-radius:999px;filter:blur(90px);pointer-events:none;opacity:.16}.ambient-one{width:420px;height:420px;background:#674ce0;top:10%;right:5%}.ambient-two{width:320px;height:320px;background:#1b8a9b;bottom:0;left:20%}.shell{display:grid;grid-template-columns:250px 1fr;min-height:100vh}.sidebar{position:sticky;top:0;height:100vh;padding:28px 22px;border-right:1px solid var(--line);background:rgba(10,12,17,.72);backdrop-filter:blur(24px);display:flex;flex-direction:column;z-index:2}.brand{display:flex;align-items:center;gap:12px;color:var(--text);text-decoration:none;margin:0 8px 42px}.brand-mark{display:grid;place-items:center;width:42px;height:42px;border-radius:13px;background:linear-gradient(145deg,var(--violet),#6651ce);font-weight:800;letter-spacing:-1px;box-shadow:0 10px 30px rgba(126,103,235,.28)}.brand strong,.brand small{display:block}.brand small{color:var(--muted);letter-spacing:.14em;text-transform:uppercase;font-size:9px;margin-top:2px}.sidebar nav{display:grid;gap:8px}.nav-item{padding:12px 14px;border-radius:12px;color:var(--muted);text-decoration:none;font-size:14px;display:flex;gap:11px;align-items:center}.nav-item:hover,.nav-item.active{color:var(--text);background:rgba(255,255,255,.06)}.nav-item.active{box-shadow:inset 2px 0 var(--violet)}.safety-card{margin-top:auto;display:flex;gap:10px;padding:14px;border:1px solid rgba(102,217,232,.18);border-radius:16px;background:rgba(31,79,87,.18)}.safety-icon{display:grid;place-items:center;width:25px;height:25px;border-radius:50%;background:rgba(102,217,232,.16);color:var(--cyan);font-weight:700;flex:0 0 auto}.safety-card strong{font-size:12px}.safety-card p{font-size:11px;color:var(--muted);line-height:1.45;margin:4px 0 0}.sidebar-foot{font-size:10px;color:#656b79;margin:18px 10px 0}.shell main{padding:36px clamp(24px,5vw,72px) 70px;max-width:1500px;width:100%;margin:0 auto}.topbar{display:flex;align-items:center;justify-content:space-between;margin-bottom:32px}.eyebrow{text-transform:uppercase;letter-spacing:.16em;color:var(--violet);font-size:10px;font-weight:750;margin:0 0 7px}.topbar h1{font-size:clamp(26px,4vw,38px);letter-spacing:-.04em;margin:0}.status-pill,.read-only-tag{font-size:11px;color:#b7bdc9;border:1px solid var(--line);border-radius:999px;padding:9px 12px;background:rgba(255,255,255,.03)}.status-pill span{display:inline-block;width:7px;height:7px;border-radius:50%;background:#54d49a;margin-right:7px;box-shadow:0 0 12px #54d49a}.panel{background:linear-gradient(145deg,rgba(28,31,42,.93),rgba(18,21,29,.88));border:1px solid var(--line);border-radius:var(--radius);box-shadow:0 24px 80px rgba(0,0,0,.2)}.scan-panel{padding:clamp(24px,4vw,40px);display:grid;grid-template-columns:minmax(220px,.7fr) minmax(360px,1.3fr);gap:42px;align-items:center}.panel-copy h2,.section-heading h2{font-size:24px;letter-spacing:-.03em;margin:0 0 11px}.panel-copy>p:last-child{color:var(--muted);line-height:1.65;font-size:13px;max-width:440px}.scan-panel form{display:grid;grid-template-columns:1fr 1fr;gap:14px}.scan-panel label{font-size:11px;color:#c7cad2;font-weight:650}.scan-panel label span{color:#696f7e;font-weight:400}.scan-panel input,.scan-panel textarea{display:block;width:100%;margin-top:7px;border:1px solid var(--line);background:rgba(6,8,12,.56);color:var(--text);padding:12px 13px;border-radius:11px;outline:none;font:inherit;font-size:12px;resize:vertical}.scan-panel input:focus,.scan-panel textarea:focus{border-color:rgba(155,135,245,.7);box-shadow:0 0 0 3px rgba(155,135,245,.08)}.wide{grid-column:1/-1}.scan-panel button{grid-column:1/-1;border:0;border-radius:12px;padding:13px 16px;background:linear-gradient(135deg,#a08cf5,#7763dc);color:#fff;font:inherit;font-size:13px;font-weight:750;display:flex;justify-content:space-between;cursor:pointer;box-shadow:0 12px 30px rgba(115,91,213,.25)}.scan-panel button:hover{filter:brightness(1.08);transform:translateY(-1px)}.scan-panel button:disabled{opacity:.6;cursor:wait}.error{grid-column:1/-1;margin-top:14px;padding:12px 14px;color:#ffc5c5;background:rgba(160,48,48,.18);border:1px solid rgba(241,120,120,.25);border-radius:12px;font-size:12px}.results{margin-top:42px}.section-heading{display:flex;align-items:flex-end;justify-content:space-between;margin:0 2px 18px}.section-heading h2{margin:0}.section-heading>span{color:var(--muted);font-size:11px}.hero-grid{display:grid;grid-template-columns:1fr 1.35fr;gap:18px}.score-card{padding:28px;display:flex;gap:25px;align-items:center}.score-ring{--score:0;display:grid;place-items:center;flex:0 0 auto;width:140px;height:140px;border-radius:50%;background:conic-gradient(var(--violet) calc(var(--score)*1%),rgba(255,255,255,.06) 0);position:relative}.score-ring:before{content:"";position:absolute;inset:9px;border-radius:50%;background:#151821}.score-ring>div{position:relative;text-align:center}.score-ring strong{font-size:28px;letter-spacing:-.05em;display:block}.score-ring span{font-size:10px;text-transform:uppercase;letter-spacing:.13em;color:var(--muted)}.score-label{color:var(--violet);font-size:11px;font-weight:700;margin:0 0 7px}.score-card h3{font-size:20px;margin:0 0 7px}.score-card p:last-child{font-size:11px;color:var(--muted);line-height:1.5;margin:0}.metrics-grid{display:grid;grid-template-columns:1fr 1fr;gap:12px}.metric{padding:20px}.metric span{display:block;font-size:11px;color:var(--muted)}.metric strong{font-size:28px;letter-spacing:-.04em;display:block;margin:9px 0 2px}.metric small{font-size:10px;color:#656b79}.metric.warning strong{color:var(--amber)}.diagnostics{margin-top:18px;padding:25px}.diagnostic-list{display:grid;grid-template-columns:1fr 1fr;gap:10px}.diagnostic-list>div{display:flex;gap:13px;align-items:center;padding:14px;border-radius:14px;background:rgba(255,255,255,.025);border:1px solid rgba(255,255,255,.045)}.diag-icon{display:grid;place-items:center;width:34px;height:34px;border-radius:10px;flex:0 0 auto}.diag-icon.violet{color:var(--violet);background:rgba(155,135,245,.11)}.diag-icon.amber{color:var(--amber);background:rgba(245,185,76,.1)}.diag-icon.blue{color:var(--cyan);background:rgba(102,217,232,.1)}.diag-icon.red{color:var(--red);background:rgba(241,120,120,.1)}.diagnostic-list p{margin:0}.diagnostic-list strong{display:block;font-size:12px}.diagnostic-list small{display:block;color:var(--muted);font-size:10px;margin-top:5px}.diagnostic-list b{color:#cdd1d9;font-weight:650}@media(max-width:980px){.shell{grid-template-columns:82px 1fr}.sidebar{padding:25px 13px}.brand{margin-left:6px}.brand>span:last-child,.nav-item:not(.active){font-size:0}.nav-item{justify-content:center}.nav-item span{font-size:17px}.safety-card div,.sidebar-foot{display:none}.safety-card{padding:12px;justify-content:center}.scan-panel{grid-template-columns:1fr}.hero-grid{grid-template-columns:1fr}}@media(max-width:680px){.shell{display:block}.sidebar{position:static;width:100%;height:auto;flex-direction:row;align-items:center;padding:14px 18px}.brand{margin:0 auto 0 0}.brand>span:last-child{display:block}.sidebar nav{display:flex}.nav-item{padding:10px}.nav-item:not(.active),.safety-card,.sidebar-foot{display:none}.shell main{padding:24px 16px 50px}.topbar{margin-bottom:22px}.status-pill{display:none}.scan-panel{padding:22px;gap:20px}.scan-panel form{grid-template-columns:1fr}.scan-panel label,.wide,.scan-panel button{grid-column:1}.hero-grid{grid-template-columns:1fr}.score-card{flex-direction:column;text-align:center}.metrics-grid,.diagnostic-list{grid-template-columns:1fr 1fr}.section-heading{align-items:flex-start}}@media(max-width:440px){.metrics-grid,.diagnostic-list{grid-template-columns:1fr}.score-ring{width:126px;height:126px}}
.metric,.score-card{position:relative}.info-button{position:relative;display:grid;place-items:center;width:22px;height:22px;flex:0 0 auto;margin-left:auto;border:1px solid rgba(255,255,255,.14);border-radius:50%;background:rgba(255,255,255,.04);color:#aeb4c2;font:700 11px/1 Georgia,serif;cursor:pointer;z-index:3}.metric>.info-button,.score-card>.info-button{position:absolute;top:14px;right:14px}.info-button:hover,.info-button:focus-visible,.info-button.open{color:#fff;border-color:rgba(155,135,245,.75);background:rgba(155,135,245,.16);outline:none}.info-button:after{content:attr(data-info);position:absolute;right:0;bottom:calc(100% + 10px);width:min(280px,70vw);padding:11px 12px;border:1px solid rgba(155,135,245,.3);border-radius:11px;background:#202431;color:#e8eaf0;font:500 11px/1.5 Inter,ui-sans-serif,system-ui,sans-serif;text-align:left;box-shadow:0 14px 45px rgba(0,0,0,.42);opacity:0;visibility:hidden;transform:translateY(4px);transition:.15s ease;pointer-events:none}.info-button:hover:after,.info-button:focus-visible:after,.info-button.open:after{opacity:1;visibility:visible;transform:translateY(0)}.diagnostic-list p{min-width:0}.diagnostic-list .info-button{margin-left:auto}@media(max-width:680px){.info-button:after{position:fixed;left:16px;right:16px;bottom:18px;width:auto;font-size:12px;z-index:20}}
-.drill-trigger{cursor:pointer;transition:border-color .15s ease,background .15s ease,transform .15s ease}.drill-trigger:hover,.drill-trigger:focus-visible,.drill-trigger.selected{border-color:rgba(155,135,245,.35);background:linear-gradient(145deg,rgba(155,135,245,.11),rgba(255,255,255,.025));outline:none}.drill-trigger:hover{transform:translateY(-1px)}.drilldowns{margin-top:18px;padding:25px}.drilldowns>p{max-width:760px;color:var(--muted);font-size:12px;line-height:1.6;margin:0 0 16px}.detail-list{display:grid;gap:10px;max-height:520px;overflow:auto;padding-right:4px}.detail-item,.empty-detail{padding:14px;border:1px solid rgba(255,255,255,.055);border-radius:14px;background:rgba(255,255,255,.025)}.detail-item strong{display:block;font-size:12px;margin:0 0 8px;color:#f7f7fa;overflow-wrap:anywhere}.detail-item ul{list-style:none;padding:0;margin:0;display:grid;gap:5px}.detail-item li{font-size:10px;line-height:1.45;color:var(--muted);overflow-wrap:anywhere}.empty-detail{color:var(--muted);font-size:12px}@media(max-width:440px){.detail-list{max-height:420px}}
+.drill-trigger{cursor:pointer;transition:border-color .15s ease,background .15s ease,transform .15s ease}.drill-trigger:hover,.drill-trigger:focus-visible,.drill-trigger.selected{border-color:rgba(155,135,245,.35);background:linear-gradient(145deg,rgba(155,135,245,.11),rgba(255,255,255,.025));outline:none}.drill-trigger:hover{transform:translateY(-1px)}.drilldowns{margin-top:18px;padding:25px}.drilldowns>p{max-width:760px;color:var(--muted);font-size:12px;line-height:1.6;margin:0 0 16px}.detail-list{display:grid;gap:10px;max-height:520px;overflow:auto;padding-right:4px}.detail-item,.empty-detail{padding:14px;border:1px solid rgba(255,255,255,.055);border-radius:14px;background:rgba(255,255,255,.025)}.detail-item strong{display:block;font-size:12px;margin:0 0 8px;color:#f7f7fa;overflow-wrap:anywhere}.detail-item ul{list-style:none;padding:0;margin:0;display:grid;gap:5px}.detail-item li{font-size:10px;line-height:1.45;color:var(--muted);overflow-wrap:anywhere}.empty-detail{color:var(--muted);font-size:12px}.selectable-duplicate{cursor:pointer}.selectable-duplicate:hover,.selectable-duplicate:focus-visible{border-color:rgba(102,217,232,.35);outline:none}.selectable-duplicate>small{display:block;margin-top:10px;color:var(--cyan);font-size:10px}.repair-panel{margin-top:18px;padding:25px}.repair-panel>p{color:var(--muted);font-size:12px;line-height:1.6;max-width:820px}.repair-tag{color:var(--amber)!important}.repair-choice{display:grid;gap:9px;margin:18px 0}.keeper-option{display:flex;gap:12px;align-items:center;padding:13px;border:1px solid var(--line);border-radius:13px;background:rgba(255,255,255,.025);cursor:pointer}.keeper-option:has(input:checked){border-color:rgba(102,217,232,.45);background:rgba(102,217,232,.07)}.keeper-option span{min-width:0}.keeper-option strong,.keeper-option small{display:block}.keeper-option strong{font-size:11px}.keeper-option small{color:var(--muted);font-size:10px;margin-top:4px;overflow-wrap:anywhere}.backup-options{display:flex;align-items:end;gap:24px;margin:14px 0}.backup-options label{font-size:11px;color:var(--muted)}.backup-options input[type=number]{display:block;width:100px;margin-top:6px;padding:9px;border-radius:9px;border:1px solid var(--line);background:#0d1016;color:var(--text)}.check-label{padding-bottom:8px}.repair-preview{padding:14px;border:1px solid rgba(102,217,232,.25);border-radius:13px;background:rgba(102,217,232,.06);font-size:11px}.repair-preview p{color:var(--muted);line-height:1.55;margin:6px 0 0}.repair-actions{display:flex;gap:10px;margin-top:16px}.repair-actions button{border:0;border-radius:11px;padding:11px 15px;background:#6f5bd0;color:white;font:700 11px inherit;cursor:pointer}.repair-actions .danger-action{background:#bd5b5b}.repair-actions button:disabled{opacity:.4;cursor:not-allowed}.repair-message{color:var(--muted);font-size:11px;margin-top:12px;overflow-wrap:anywhere}@media(max-width:440px){.detail-list{max-height:420px}.backup-options,.repair-actions{align-items:stretch;flex-direction:column}}
diff --git a/serato_doctor/webui/app.js b/serato_doctor/webui/app.js
index 8475ac9..73c6f51 100644
--- a/serato_doctor/webui/app.js
+++ b/serato_doctor/webui/app.js
@@ -8,7 +8,17 @@ 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');
let latestAnalysis = null;
+let selectedDuplicateGroup = null;
+let previewedRepair = null;
+let latestBackup = null;
function expandHome(path) {
return path.trim();
@@ -51,19 +61,91 @@ function renderDetail(key) {
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;
}
- drilldownList.innerHTML = detail.items.map((item) => `
-
+ drilldownList.innerHTML = detail.items.map((item, index) => `
+
${escapeHtml(item.filename || item.path || 'Untitled item')}
+ ${item.files ? 'Choose this group to review a safe cleanup → ' : ''}
`).join('');
}
+function chooseDuplicate(detailKey, index) {
+ const group = latestAnalysis?.details?.[detailKey]?.items?.[index];
+ if (!group?.files) return;
+ selectedDuplicateGroup = group;
+ previewedRepair = null;
+ applyRepairButton.disabled = true;
+ repairPreview.hidden = true;
+ repairMessage.textContent = '';
+ repairChoice.innerHTML = group.files.map((file, fileIndex) => `
+ ${fileIndex === 0 ? 'Keep this file' : 'Keep instead'} ${escapeHtml(file)}
+ `).join('');
+ repairPanel.hidden = false;
+ repairPanel.scrollIntoView({behavior: 'smooth', block: 'start'});
+}
+
+function repairPayload() {
+ const keeper = document.querySelector('input[name="keeper"]:checked')?.value;
+ if (!selectedDuplicateGroup || !keeper) throw new Error('Choose a file to keep');
+ 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)};
+}
+
+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/preview');
+ repairPreview.innerHTML = `Ready to protect and consolidate ${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.
`;
+ 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/apply');
+ latestBackup = result.backup;
+ repairMessage.textContent = `Cleanup complete. Restore backup: ${result.backup}`;
+ previewRepairButton.disabled = true;
+ restoreRepairButton.hidden = false;
+ } 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;
+ } 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.'; });
+drilldownList.addEventListener('click', (event) => { const item = event.target.closest('[data-duplicate-index]'); if (item) chooseDuplicate(document.querySelector('.drill-trigger.selected')?.dataset.detail, Number(item.dataset.duplicateIndex)); });
+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)); } });
+
function render(data) {
latestAnalysis = data;
document.querySelectorAll('[data-field]').forEach((element) => {
diff --git a/serato_doctor/webui/index.html b/serato_doctor/webui/index.html
index 6fe2b31..0108134 100644
--- a/serato_doctor/webui/index.html
+++ b/serato_doctor/webui/index.html
@@ -23,7 +23,7 @@
✓
-
Read-only mode Your library will not be modified.
+
Protected mode Analysis is read-only. Repairs require a preview and backup.
@@ -88,6 +88,23 @@
Click a metric above to see example files and saved paths behind that number.
+
+
+
Backup-first cleanup
Safely consolidate duplicates Preview required
+
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.
+
+
+ Backups to keep
+ Keep every backup
+
+
+
+ Preview changes
+ Apply backed-up cleanup
+ Restore this backup
+
+
+
diff --git a/tests/test_repair.py b/tests/test_repair.py
new file mode 100644
index 0000000..9bf19c7
--- /dev/null
+++ b/tests/test_repair.py
@@ -0,0 +1,67 @@
+from pathlib import Path
+
+import pytest
+
+from serato_doctor.repair import (
+ BACKUP_FOLDER,
+ apply_duplicate_repair,
+ plan_duplicate_repair,
+ restore_backup,
+ rotate_backups,
+)
+
+
+def library(tmp_path):
+ serato = tmp_path / "_Serato_"
+ crate = serato / "Subcrates" / "House.crate"
+ crate.parent.mkdir(parents=True)
+ crate.write_bytes(b"crate-data")
+ (serato / "database V2").write_bytes(b"database-data")
+ music = tmp_path / "Music"
+ keeper = music / "Main" / "Track.mp3"
+ duplicate = music / "Old" / "Track.mp3"
+ keeper.parent.mkdir(parents=True)
+ duplicate.parent.mkdir(parents=True)
+ keeper.write_bytes(b"keeper")
+ duplicate.write_bytes(b"duplicate")
+ return serato, keeper, duplicate
+
+
+def test_duplicate_repair_backs_up_then_preserves_old_path_as_link(tmp_path):
+ serato, keeper, duplicate = library(tmp_path)
+ plan = plan_duplicate_repair(keeper, (keeper, duplicate), serato)
+
+ receipt = apply_duplicate_repair(plan, serato, backup_limit=3)
+
+ assert duplicate.is_symlink()
+ assert duplicate.resolve() == keeper.resolve()
+ assert (receipt.backup / "manifest.json").is_file()
+ assert any((receipt.backup / "serato-metadata").rglob("House.crate"))
+ assert any((receipt.backup / "serato-metadata").rglob("database V2"))
+
+ restored = restore_backup(receipt.backup)
+ assert restored == (duplicate.resolve(),)
+ assert not duplicate.is_symlink()
+ assert duplicate.read_bytes() == b"duplicate"
+
+
+def test_plan_rejects_keeper_outside_duplicate_group(tmp_path):
+ serato, keeper, duplicate = library(tmp_path)
+ outsider = tmp_path / "outsider.mp3"
+ outsider.write_bytes(b"other")
+
+ with pytest.raises(ValueError, match="must belong"):
+ plan_duplicate_repair(outsider, (keeper, duplicate), serato)
+
+
+def test_backup_rotation_can_be_limited_or_unlimited(tmp_path):
+ root = tmp_path / BACKUP_FOLDER
+ for name in ("001", "002", "003"):
+ backup = root / name
+ backup.mkdir(parents=True)
+ (backup / "manifest.json").write_text("{}", encoding="utf-8")
+
+ rotate_backups(root, None)
+ assert len(list(root.iterdir())) == 3
+ rotate_backups(root, 2)
+ assert {path.name for path in root.iterdir()} == {"002", "003"}
diff --git a/tests/test_web.py b/tests/test_web.py
index e9ac50b..a4b2c85 100644
--- a/tests/test_web.py
+++ b/tests/test_web.py
@@ -3,7 +3,7 @@ from pathlib import Path
import pytest
-from serato_doctor.web import STATIC_FILES, analyze_paths
+from serato_doctor.web import STATIC_FILES, analyze_paths, duplicate_repair
def test_web_analysis_uses_production_health_pipeline(tmp_path):
@@ -49,3 +49,24 @@ def test_web_static_assets_are_declared_and_packaged():
assert 'data-detail="database_missing_tracks"' in html
assert 'data-detail="old_crate_references"' in html
assert html.count('class="info-button"') >= 10
+
+
+def test_duplicate_repair_preview_does_not_change_files(tmp_path):
+ serato = tmp_path / "_Serato_"
+ serato.mkdir()
+ music = tmp_path / "Music"
+ first = music / "A" / "Track.mp3"
+ second = music / "B" / "Track.mp3"
+ first.parent.mkdir(parents=True)
+ second.parent.mkdir(parents=True)
+ first.write_bytes(b"first")
+ second.write_bytes(b"second")
+
+ result = duplicate_repair(
+ serato, music, first, (first, second), backup_limit=10
+ )
+
+ assert result["applied"] is False
+ assert result["database_v2_modified"] is False
+ assert second.read_bytes() == b"second"
+ assert not second.is_symlink()