diff --git a/ROADMAP.md b/ROADMAP.md
index 2556449..c6e9bc2 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -28,12 +28,12 @@
## v0.3 — Safe Repair
-- [ ] Dry-run repair plan
-- [ ] Backup before repair
-- [ ] Compatibility symlink creation
+- [x] Dry-run repair plan
+- [x] Backup before repair
+- [x] Compatibility symlink creation
- [ ] Compatibility copy creation
- [ ] Rename repair
-- [ ] Rollback log
+- [x] Rollback log and recovery center
## v0.4 — Migration Wizard
diff --git a/docs/design/backup-recovery-center.md b/docs/design/backup-recovery-center.md
new file mode 100644
index 0000000..ffb5a4c
--- /dev/null
+++ b/docs/design/backup-recovery-center.md
@@ -0,0 +1,30 @@
+# Backup recovery center
+
+## Problem
+
+An immediate undo button is not enough. A DJ may restart Serato Doctor, notice
+an issue later, or need to understand how much disk space safety snapshots use.
+
+## Architecture
+
+The recovery center reads the existing JSON manifests beneath
+`_Serato_/.serato-doctor-backups/`. It reports creation time, selected keeper,
+affected paths, restore status, and snapshot size. Invalid or incomplete backup
+folders are ignored rather than presented as restorable.
+
+Restore remains conservative: it only replaces a symbolic-link alias and
+refuses to overwrite a real file. A successful restore records its timestamp in
+the manifest so the UI cannot accidentally offer the same rollback twice.
+
+## Edge cases
+
+- No backup directory is treated as an empty history.
+- Malformed manifests do not prevent valid backups from loading.
+- Restored snapshots remain visible for audit purposes and disk accounting.
+- Backups can only be restored through the Serato library that owns them.
+
+## Tests
+
+- Backup history reports size and ready status.
+- Restore changes the persistent status to restored.
+- Static recovery assets are included in the package.
diff --git a/serato_doctor/repair.py b/serato_doctor/repair.py
index d4855e5..bccf41c 100644
--- a/serato_doctor/repair.py
+++ b/serato_doctor/repair.py
@@ -34,6 +34,20 @@ class RepairReceipt:
replaced: Tuple[Path, ...]
+@dataclass(frozen=True)
+class BackupSummary:
+ path: Path
+ created_at: str
+ keeper: Path
+ replaced: Tuple[Path, ...]
+ size_bytes: int
+ restored_at: Optional[str]
+
+ @property
+ def status(self) -> str:
+ return "restored" if self.restored_at else "ready"
+
+
def plan_duplicate_repair(
keeper: Path, duplicates: Iterable[Path], serato_root: Path
) -> DuplicateRepairPlan:
@@ -124,9 +138,47 @@ def restore_backup(backup: Path) -> Tuple[Path, ...]:
original.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(saved, original)
restored.append(original)
+ manifest["restored_at"] = datetime.now(timezone.utc).isoformat()
+ manifest_path.write_text(json.dumps(manifest, indent=2), encoding="utf-8")
return tuple(restored)
+def list_backups(serato_root: Path) -> Tuple[BackupSummary, ...]:
+ backup_root = serato_root.expanduser().resolve() / BACKUP_FOLDER
+ if not backup_root.is_dir():
+ return ()
+ summaries = []
+ for backup in backup_root.iterdir():
+ manifest_path = backup / "manifest.json"
+ if not manifest_path.is_file():
+ continue
+ try:
+ manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
+ replaced = tuple(
+ Path(entry["original"]) for entry in manifest["replaced"]
+ )
+ size = sum(
+ path.stat().st_size
+ for path in backup.rglob("*")
+ if path.is_file()
+ )
+ summaries.append(
+ BackupSummary(
+ path=backup.resolve(),
+ created_at=manifest["created_at"],
+ keeper=Path(manifest["keeper"]),
+ replaced=replaced,
+ size_bytes=size,
+ restored_at=manifest.get("restored_at"),
+ )
+ )
+ except (KeyError, OSError, TypeError, json.JSONDecodeError):
+ continue
+ return tuple(
+ sorted(summaries, key=lambda item: item.created_at, reverse=True)
+ )
+
+
def rotate_backups(backup_root: Path, limit: Optional[int]) -> None:
if limit is None or not backup_root.is_dir():
return
diff --git a/serato_doctor/web.py b/serato_doctor/web.py
index 4d21bdf..57b1918 100644
--- a/serato_doctor/web.py
+++ b/serato_doctor/web.py
@@ -19,6 +19,7 @@ from serato_doctor.scanner import scan_filesystem
from serato_doctor.repair import (
BACKUP_FOLDER,
apply_duplicate_repair,
+ list_backups,
plan_duplicate_repair,
restore_backup,
)
@@ -29,6 +30,7 @@ DETAIL_LIMIT = 50
STATIC_FILES = {
"/": ("index.html", "text/html; charset=utf-8"),
"/app.css": ("app.css", "text/css; charset=utf-8"),
+ "/recovery.css": ("recovery.css", "text/css; charset=utf-8"),
"/app.js": ("app.js", "text/javascript; charset=utf-8"),
}
@@ -269,6 +271,29 @@ def duplicate_repair(
return result
+def backup_history(serato: Path) -> dict:
+ serato = serato.expanduser().resolve()
+ if not serato.is_dir():
+ raise ValueError(f"Serato folder does not exist: {serato}")
+ backups = list_backups(serato)
+ return {
+ "total": len(backups),
+ "size_bytes": sum(backup.size_bytes for backup in backups),
+ "items": [
+ {
+ "path": str(backup.path),
+ "created_at": backup.created_at,
+ "keeper": str(backup.keeper),
+ "replaced": [str(path) for path in backup.replaced],
+ "size_bytes": backup.size_bytes,
+ "status": backup.status,
+ "restored_at": backup.restored_at,
+ }
+ for backup in backups
+ ],
+ }
+
+
class SeratoDoctorHandler(BaseHTTPRequestHandler):
def do_GET(self) -> None:
asset = STATIC_FILES.get(self.path)
@@ -293,6 +318,7 @@ class SeratoDoctorHandler(BaseHTTPRequestHandler):
"/api/duplicates/preview",
"/api/duplicates/apply",
"/api/backups/restore",
+ "/api/backups",
}
if self.path not in allowed:
self._json_response(404, {"error": "Not found"})
@@ -309,6 +335,8 @@ class SeratoDoctorHandler(BaseHTTPRequestHandler):
result = analyze_paths(
Path(payload["serato"]), Path(payload["music"]), roots
)
+ elif self.path == "/api/backups":
+ result = backup_history(Path(payload["serato"]))
elif self.path == "/api/backups/restore":
serato = Path(payload["serato"]).expanduser().resolve()
backup = Path(payload["backup"]).expanduser().resolve()
diff --git a/serato_doctor/webui/app.js b/serato_doctor/webui/app.js
index 73c6f51..1791763 100644
--- a/serato_doctor/webui/app.js
+++ b/serato_doctor/webui/app.js
@@ -15,6 +15,9 @@ const repairMessage = document.querySelector('#repair-message');
const previewRepairButton = document.querySelector('#preview-repair');
const applyRepairButton = document.querySelector('#apply-repair');
const restoreRepairButton = document.querySelector('#restore-repair');
+const loadBackupsButton = document.querySelector('#load-backups');
+const backupSummary = document.querySelector('#backup-summary');
+const backupList = document.querySelector('#backup-list');
let latestAnalysis = null;
let selectedDuplicateGroup = null;
let previewedRepair = null;
@@ -34,6 +37,52 @@ function escapeHtml(value) {
}[character]));
}
+function formatBytes(bytes) {
+ if (!bytes) return '0 B';
+ const units = ['B', 'KB', 'MB', 'GB', 'TB'];
+ const unit = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1);
+ return `${(bytes / (1024 ** unit)).toFixed(unit ? 1 : 0)} ${units[unit]}`;
+}
+
+function formatDate(value) {
+ const date = new Date(value);
+ return Number.isNaN(date.valueOf()) ? value : date.toLocaleString([], {dateStyle: 'medium', timeStyle: 'short'});
+}
+
+async function loadBackups() {
+ loadBackupsButton.disabled = true;
+ backupSummary.textContent = 'Looking for recovery snapshots…';
+ try {
+ const response = await fetch('/api/backups', {method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({serato: expandHome(document.querySelector('#serato-path').value)})});
+ const data = await response.json();
+ if (!response.ok) throw new Error(data.error || 'Could not load backups');
+ backupSummary.textContent = `${data.total} backup${data.total === 1 ? '' : 's'} · ${formatBytes(data.size_bytes)} on disk`;
+ backupList.innerHTML = data.items.length ? data.items.map((backup) => `
+
Safety net
Review every repair snapshot saved for this Serato library. Older backups remain available after restarting Serato Doctor.
+Latest analysis