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