Add backup-first duplicate repair
This commit is contained in:
+83
-7
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user