Library restore — idempotent backup import (2.4)

Backup direction has shipped for a while; this adds the import side
so users can actually round-trip their snapshot.

Database::restore_from_backup attaches the uploaded file and merges
each table with per-table semantics:
- watched / positions / channel_options: keep the later timestamp
- video_flags: bitwise-OR each flag (favourite + bookmark across two
  devices both stick)
- folders: insert names that don't exist yet
- channel_assignments: insert when (platform, handle) is unassigned;
  folder_id is re-resolved by name so backup/live ID drift is fine
- settings: deliberately skipped (password hash + source_url are
  per-deployment state, not user data)

Re-importing the same snapshot is a no-op. Schema validation rejects
non-yt-offline SQLite files up front with a 400.

POST /api/restore/db takes raw .db bytes (100 MB cap, magic-header
check) and returns a RestoreSummary with per-table added counts.
After success, the in-memory watched/positions/flags caches are
refreshed and the library version is bumped so cached /api/library
responses revalidate.

Desktop: 📂 Import library backup… button in Settings, mirrors the
existing 💾 save button. Triggers rescan after the merge so channel
folder assignments take effect.

Web UI: 📂 Import library snapshot… button next to the download
button. Hidden file input + confirm dialog + result summary.

Includes 4 new unit tests in src/database.rs covering the watched +
positions merge, flag OR-ing, idempotency on re-run, and schema
rejection.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Luna 2026-05-27 02:43:18 -07:00
parent d9a7007f34
commit 55b90a22b6
4 changed files with 546 additions and 30 deletions

View file

@ -1316,8 +1316,13 @@ async function openSettings(){
<hr style="border-color:var(--border);margin:12px 0">
<div style="font-weight:700;margin-bottom:8px">Backup</div>
<div class="settings-row" style="flex-direction:column;align-items:flex-start;gap:6px">
<div class="settings-hint">Saves the SQLite file holding your watched / favourites / bookmarks / waiting / channel-options / folders. Restore by replacing <code>yt-offline.db</code> in the channels directory.</div>
<button onclick="downloadBackupDb()">⬇ Download library snapshot</button>
<div class="settings-hint">Snapshots your watched / favourites / bookmarks / waiting / channel-options / folders. Import merges another snapshot in without touching the channel files on disk — re-running the same import is safe.</div>
<div style="display:flex;gap:8px;flex-wrap:wrap">
<button onclick="downloadBackupDb()">⬇ Download library snapshot</button>
<button onclick="document.getElementById('restore-input').click()">📂 Import library snapshot…</button>
<input type="file" id="restore-input" accept=".db,application/x-sqlite3" style="display:none" onchange="uploadRestoreDb(this)">
</div>
<div id="restore-status" class="settings-hint" style="display:none"></div>
</div>
${srcRow}
<div style="display:flex;gap:8px;justify-content:flex-end;margin-top:12px">
@ -1366,6 +1371,33 @@ function downloadBackupDb(){
// rendering the SQLite bytes inline.
window.open('/api/backup/db','_blank');
}
async function uploadRestoreDb(input){
// Stream the file's raw bytes to /api/restore/db. The server enforces a
// 100 MB cap; we mirror it client-side to give an instant error rather
// than blowing through the upload before getting a 413.
const f=input.files&&input.files[0];
if(!f){return}
const MAX=100*1024*1024;
const status=document.getElementById('restore-status');
status.style.display='';
if(f.size>MAX){status.textContent='File too large (max 100 MB)';input.value='';return}
if(!confirm(`Merge ${f.name} (${fmtSize(f.size)}) into the live database? Existing rows stay; conflicts resolve by the later timestamp.`)){input.value='';status.style.display='none';return}
status.textContent='Uploading…';
try{
const r=await fetch('/api/restore/db',{method:'POST',headers:{'Content-Type':'application/x-sqlite3'},body:f});
if(!r.ok){throw new Error(await r.text())}
const s=await r.json();
status.textContent=`Imported: ${s.watched_added}W · ${s.positions_added}P · ${s.options_added}opts · ${s.flags_added}flags · ${s.folders_added}folders · ${s.assignments_added}assigns`;
setStatus('Library snapshot imported');
// Refresh the sidebar / grid so the new flags / folders show up.
loadLibrary();
}catch(e){
status.textContent='Import failed: '+e.message;
setStatus('Import failed: '+e.message);
}finally{
input.value='';
}
}
async function generatePlex(btn){
const path=document.getElementById('cf-plex-path')?.value.trim();
if(!path){document.getElementById('plex-status').textContent='Set a path first';return}