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) => ` + + `).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.

+ + 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()