From da72419ce90b36696559dd96a850b42d6c889d86 Mon Sep 17 00:00:00 2001 From: Philip Guzman Date: Wed, 1 Jul 2026 15:34:58 -0700 Subject: [PATCH] Add backup recovery center --- ROADMAP.md | 8 ++--- docs/design/backup-recovery-center.md | 30 ++++++++++++++++ serato_doctor/repair.py | 52 +++++++++++++++++++++++++++ serato_doctor/web.py | 28 +++++++++++++++ serato_doctor/webui/app.js | 51 ++++++++++++++++++++++++++ serato_doctor/webui/index.html | 9 +++++ serato_doctor/webui/recovery.css | 1 + tests/test_repair.py | 7 ++++ tests/test_web.py | 3 +- 9 files changed, 184 insertions(+), 5 deletions(-) create mode 100644 docs/design/backup-recovery-center.md create mode 100644 serato_doctor/webui/recovery.css 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) => ` +
+
${backup.status === 'restored' ? 'Restored' : 'Ready to restore'}${formatDate(backup.created_at)}Kept: ${escapeHtml(backup.keeper)}${backup.replaced.length} original file${backup.replaced.length === 1 ? '' : 's'} · ${formatBytes(backup.size_bytes)}
+ +
+ `).join('') : '
No repair backups found for this library yet.
'; + } catch (error) { + backupSummary.textContent = error.message; + backupList.innerHTML = ''; + } finally { loadBackupsButton.disabled = false; } +} + +loadBackupsButton.addEventListener('click', loadBackups); +backupList.addEventListener('click', async (event) => { + const button = event.target.closest('[data-restore-backup]'); + if (!button || button.disabled) return; + if (!window.confirm('Restore the original duplicate files from this backup? Existing real files will never be overwritten.')) return; + button.disabled = true; button.textContent = 'Restoring…'; + 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: button.dataset.restoreBackup})}); + const result = await response.json(); + if (!response.ok) throw new Error(result.error || 'Restore failed'); + await loadBackups(); + } catch (error) { backupSummary.textContent = error.message; button.disabled = false; button.textContent = 'Restore'; } +}); + function detailLines(item) { if (item.files) { return item.files.map((file) => `
  • ${escapeHtml(file)}
  • `).join(''); @@ -127,6 +176,7 @@ applyRepairButton.addEventListener('click', async () => { repairMessage.textContent = `Cleanup complete. Restore backup: ${result.backup}`; previewRepairButton.disabled = true; restoreRepairButton.hidden = false; + await loadBackups(); } catch (error) { repairMessage.textContent = error.message; applyRepairButton.disabled = false; } }); @@ -139,6 +189,7 @@ restoreRepairButton.addEventListener('click', async () => { if (!response.ok) throw new Error(result.error || 'Restore failed'); repairMessage.textContent = `Restore complete. ${result.restored.length} original file(s) returned.`; restoreRepairButton.hidden = true; + await loadBackups(); } catch (error) { repairMessage.textContent = error.message; restoreRepairButton.disabled = false; } }); diff --git a/serato_doctor/webui/index.html b/serato_doctor/webui/index.html index 0108134..e14f3c0 100644 --- a/serato_doctor/webui/index.html +++ b/serato_doctor/webui/index.html @@ -6,6 +6,7 @@ Serato Doctor +
    @@ -20,6 +21,7 @@ Dashboard New analysis Diagnostics + Recovery
    @@ -55,6 +57,13 @@ +
    +

    Safety net

    Backup recovery

    +

    Review every repair snapshot saved for this Serato library. Older backups remain available after restarting Serato Doctor.

    +
    Enter your Serato folder above, then check backups.
    +
    +
    +