Add backup recovery center

This commit is contained in:
Philip Guzman
2026-07-01 15:34:58 -07:00
parent c4f1b4535d
commit da72419ce9
9 changed files with 184 additions and 5 deletions
+4 -4
View File
@@ -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
+30
View File
@@ -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.
+52
View File
@@ -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
+28
View File
@@ -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()
+51
View File
@@ -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) => `
<article class="backup-item">
<div><span class="backup-status ${backup.status}">${backup.status === 'restored' ? 'Restored' : 'Ready to restore'}</span><strong>${formatDate(backup.created_at)}</strong><small>Kept: ${escapeHtml(backup.keeper)}</small><small>${backup.replaced.length} original file${backup.replaced.length === 1 ? '' : 's'} · ${formatBytes(backup.size_bytes)}</small></div>
<button type="button" data-restore-backup="${escapeHtml(backup.path)}" ${backup.status === 'restored' ? 'disabled' : ''}>${backup.status === 'restored' ? 'Already restored' : 'Restore'}</button>
</article>
`).join('') : '<div class="empty-detail">No repair backups found for this library yet.</div>';
} 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) => `<li>${escapeHtml(file)}</li>`).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; }
});
+9
View File
@@ -6,6 +6,7 @@
<meta name="color-scheme" content="dark">
<title>Serato Doctor</title>
<link rel="stylesheet" href="/app.css">
<link rel="stylesheet" href="/recovery.css">
</head>
<body>
<div class="ambient ambient-one"></div>
@@ -20,6 +21,7 @@
<a class="nav-item active" href="#dashboard"><span></span> Dashboard</a>
<a class="nav-item" href="#scan"><span></span> New analysis</a>
<a class="nav-item" href="#diagnostics"><span></span> Diagnostics</a>
<a class="nav-item" href="#recovery"><span></span> Recovery</a>
</nav>
<div class="safety-card">
<span class="safety-icon"></span>
@@ -55,6 +57,13 @@
<div id="error-message" class="error" role="alert" hidden></div>
</section>
<section id="recovery" class="recovery-panel panel">
<div class="section-heading"><div><p class="eyebrow">Safety net</p><h2>Backup recovery</h2></div><button id="load-backups" type="button">Check backups</button></div>
<p>Review every repair snapshot saved for this Serato library. Older backups remain available after restarting Serato Doctor.</p>
<div id="backup-summary" class="backup-summary">Enter your Serato folder above, then check backups.</div>
<div id="backup-list" class="backup-list"></div>
</section>
<section id="dashboard" class="results" aria-live="polite" hidden>
<div class="section-heading"><div><p class="eyebrow">Latest analysis</p><h2>Library health</h2></div><span id="analysis-time"></span></div>
<div class="hero-grid">
+1
View File
@@ -0,0 +1 @@
.recovery-panel{margin-top:18px;padding:25px}.recovery-panel>p{color:var(--muted);font-size:12px;line-height:1.6}.recovery-panel .section-heading button{border:1px solid rgba(155,135,245,.35);border-radius:10px;padding:9px 12px;background:rgba(155,135,245,.12);color:#ddd8fa;font:700 11px inherit;cursor:pointer}.recovery-panel button:disabled{opacity:.5;cursor:not-allowed}.backup-summary{padding:11px 13px;border-radius:11px;background:rgba(255,255,255,.03);color:#bfc4cf;font-size:11px;margin:15px 0 10px}.backup-list{display:grid;gap:9px}.backup-item{display:flex;align-items:center;justify-content:space-between;gap:18px;padding:14px;border:1px solid var(--line);border-radius:13px;background:rgba(255,255,255,.025)}.backup-item>div{min-width:0}.backup-item strong,.backup-item small{display:block}.backup-item strong{font-size:12px;margin:7px 0}.backup-item small{font-size:10px;color:var(--muted);line-height:1.5;overflow-wrap:anywhere}.backup-item>button{flex:0 0 auto;border:0;border-radius:10px;padding:10px 13px;background:#6f5bd0;color:#fff;font:700 11px inherit;cursor:pointer}.backup-status{display:inline-block;padding:4px 7px;border-radius:999px;font-size:9px;text-transform:uppercase;letter-spacing:.08em;color:var(--cyan);background:rgba(102,217,232,.1)}.backup-status.restored{color:#aeb4c2;background:rgba(255,255,255,.06)}@media(max-width:520px){.backup-item{align-items:stretch;flex-direction:column}.backup-item>button{width:100%}}
+7
View File
@@ -5,6 +5,7 @@ import pytest
from serato_doctor.repair import (
BACKUP_FOLDER,
apply_duplicate_repair,
list_backups,
plan_duplicate_repair,
restore_backup,
rotate_backups,
@@ -39,10 +40,16 @@ def test_duplicate_repair_backs_up_then_preserves_old_path_as_link(tmp_path):
assert any((receipt.backup / "serato-metadata").rglob("House.crate"))
assert any((receipt.backup / "serato-metadata").rglob("database V2"))
history = list_backups(serato)
assert len(history) == 1
assert history[0].status == "ready"
assert history[0].size_bytes > 0
restored = restore_backup(receipt.backup)
assert restored == (duplicate.resolve(),)
assert not duplicate.is_symlink()
assert duplicate.read_bytes() == b"duplicate"
assert list_backups(serato)[0].status == "restored"
def test_plan_rejects_keeper_outside_duplicate_group(tmp_path):
+2 -1
View File
@@ -40,12 +40,13 @@ def test_web_analysis_rejects_missing_folders(tmp_path):
def test_web_static_assets_are_declared_and_packaged():
asset_root = Path(__file__).parents[1] / "serato_doctor" / "webui"
assert set(STATIC_FILES) == {"/", "/app.css", "/app.js"}
assert set(STATIC_FILES) == {"/", "/app.css", "/recovery.css", "/app.js"}
assert all((asset_root / filename).is_file() for filename, _ in STATIC_FILES.values())
html = (asset_root / "index.html").read_text(encoding="utf-8")
assert "Missing tracks in Serato" in html
assert "Old crate references" in html
assert "Choose a diagnostic" in html
assert "Backup recovery" in html
assert 'data-detail="database_missing_tracks"' in html
assert 'data-detail="old_crate_references"' in html
assert html.count('class="info-button"') >= 10