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:
parent
d9a7007f34
commit
55b90a22b6
4 changed files with 546 additions and 30 deletions
|
|
@ -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}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue