Add backup recovery center
This commit is contained in:
+4
-4
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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; }
|
||||
});
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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%}}
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user