Add backup-first duplicate repair

This commit is contained in:
Philip Guzman
2026-07-01 15:29:44 -07:00
parent d2f625ed18
commit c4f1b4535d
9 changed files with 477 additions and 13 deletions
+83 -7
View File
@@ -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)