diff --git a/src/app.rs b/src/app.rs index 2da90d8..d504b4a 100644 --- a/src/app.rs +++ b/src/app.rs @@ -171,6 +171,12 @@ pub struct App { // is. backup_save_tx: Sender, backup_save_rx: Receiver, + // "Import library backup" source path β€” same pattern as backup_save_rx + // but for the open-file direction. Restore happens synchronously on + // the main thread once we receive the path, since the merge is a + // bounded SQL operation, not a long-running download. + backup_open_tx: Sender, + backup_open_rx: Receiver, // Maintenance (library health) screen state. The flag is gone now β€” // Screen::Maintenance + this report's presence drive the render. health_report: Option, @@ -283,6 +289,7 @@ impl App { let (cookies_pick_tx, cookies_pick_rx) = std::sync::mpsc::channel::(); let (backup_save_tx, backup_save_rx) = std::sync::mpsc::channel::(); + let (backup_open_tx, backup_open_rx) = std::sync::mpsc::channel::(); let (thumb_request_tx, thumb_request_rx) = std::sync::mpsc::channel::(); let (thumb_result_tx, thumb_result_rx) = @@ -355,6 +362,8 @@ impl App { cookies_pick_rx, backup_save_tx, backup_save_rx, + backup_open_tx, + backup_open_rx, health_report: None, stats_report: None, show_channel_options: false, @@ -2329,35 +2338,64 @@ impl App { .small() .weak(), ); - if ui.button("πŸ’Ύ Save library backup…").clicked() { - // Open the save dialog off the UI thread, mirroring the - // cookies file-picker pattern. - let tx = self.backup_save_tx.clone(); - let default_name = format!( - "yt-offline-{}.db", - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map(|d| d.as_secs()).unwrap_or(0), - ); - std::thread::spawn(move || { - if let Ok(rt) = tokio::runtime::Builder::new_current_thread() - .enable_all() - .build() - { - let picked = rt.block_on(async { - rfd::AsyncFileDialog::new() - .set_title("Save library backup") - .set_file_name(&default_name) - .add_filter("SQLite database", &["db"]) - .save_file() - .await - }); - if let Some(f) = picked { - let _ = tx.send(f.path().to_path_buf()); + ui.horizontal(|ui| { + if ui.button("πŸ’Ύ Save library backup…").clicked() { + // Open the save dialog off the UI thread, mirroring the + // cookies file-picker pattern. + let tx = self.backup_save_tx.clone(); + let default_name = format!( + "yt-offline-{}.db", + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs()).unwrap_or(0), + ); + std::thread::spawn(move || { + if let Ok(rt) = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + { + let picked = rt.block_on(async { + rfd::AsyncFileDialog::new() + .set_title("Save library backup") + .set_file_name(&default_name) + .add_filter("SQLite database", &["db"]) + .save_file() + .await + }); + if let Some(f) = picked { + let _ = tx.send(f.path().to_path_buf()); + } } - } - }); - } + }); + } + if ui.button("πŸ“‚ Import library backup…") + .on_hover_text( + "Merge a previously-saved snapshot into the live DB. \ + Watched / positions / flags / folders / channel options \ + from the backup are merged in idempotently β€” running \ + twice with the same file is safe.") + .clicked() + { + let tx = self.backup_open_tx.clone(); + std::thread::spawn(move || { + if let Ok(rt) = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + { + let picked = rt.block_on(async { + rfd::AsyncFileDialog::new() + .set_title("Choose library backup to import") + .add_filter("SQLite database", &["db"]) + .pick_file() + .await + }); + if let Some(f) = picked { + let _ = tx.send(f.path().to_path_buf()); + } + } + }); + } + }); ui.add_space(8.0); ui.separator(); @@ -3195,6 +3233,27 @@ impl eframe::App for App { Err(e) => self.status = format!("Backup failed: {e}"), } } + // User picked a backup file to import. Merge it in, then refresh + // the in-memory caches so the next render sees the new rows + // without waiting for a rescan. + while let Ok(src) = self.backup_open_rx.try_recv() { + match self.db.restore_from_backup(&src) { + Ok(s) => { + self.watched = self.db.get_watched().unwrap_or_default(); + self.resume_positions = self.db.get_positions().unwrap_or_default(); + self.flags = self.db.get_video_flags().unwrap_or_default(); + self.folders = self.db.list_folders().unwrap_or_default(); + // Rescan so channel rows pick up new folder assignments. + self.rescan(); + self.status = format!( + "Imported: {}W Β· {}P Β· {}F Β· {}flags Β· {}folders Β· {}assigns", + s.watched_added, s.positions_added, s.options_added, + s.flags_added, s.folders_added, s.assignments_added, + ); + } + Err(e) => self.status = format!("Restore failed: {e}"), + } + } self.top_bar(ctx); // Floating sub-dialogs that overlay any screen. diff --git a/src/database.rs b/src/database.rs index da7af81..2beb20d 100644 --- a/src/database.rs +++ b/src/database.rs @@ -423,6 +423,359 @@ impl Database { .collect(); Ok(map) } + + /// Idempotently merge another yt-offline database into this one. + /// + /// Designed for "Import library backup…" β€” the user uploads a snapshot + /// produced by `GET /api/backup/db`, and we merge its rows in without + /// disturbing the channel files on disk. Safe to re-run with the same + /// backup (or a chain of overlapping backups): conflicting rows resolve + /// deterministically by recency, flag-OR, or first-write-wins depending + /// on the table's semantics. + /// + /// # Schema validation + /// + /// Before merging, we verify each expected table exists in the backup. + /// A backup from an older schema is rejected outright β€” partial merges + /// could leave the DB in a state where some features see stale data. + /// The caller can offer a "do it anyway" path later if needed. + /// + /// # Per-table merge rules + /// + /// - `watched`: keep the later `watched_at`. INSERT-OR-IGNORE then + /// UPDATE-when-newer covers both directions. + /// - `positions`: keep the later `updated_at` (same pattern). + /// - `settings`: skip β€” `password_hash` and other settings are + /// *machine-local* per AGPL-deployment context. Importing them + /// would re-authorize an old password / overwrite the current + /// source_url with a stale value. The user can re-set them. + /// - `channel_options`: keep the later `updated_at`. + /// - `video_flags`: bitwise OR each flag column. If you favourited a + /// video on either side, it stays favourited. + /// - `folders`: insert when the same name doesn't already exist. + /// Folder *contents* may differ between backups; we keep the + /// current side as authoritative and ignore the backup's + /// `channel_assignments` for any folder we already have. + /// - `channel_assignments`: insert when the (platform, handle) pair + /// isn't already assigned. Doesn't change existing assignments. + pub fn restore_from_backup(&self, backup_path: &Path) -> Result { + // Open the backup file via ATTACH so we can write `INSERT … SELECT + // … FROM bk.` in a single transaction against the live DB. + // ATTACH paths are escaped by binding rather than interpolating to + // avoid an injection if a caller ever passes a user-influenced + // string (current callers pass a tmpfile path, but defense in + // depth is cheap). + let path_str = backup_path.to_string_lossy().to_string(); + let conn = self.conn(); + conn.execute("ATTACH DATABASE ?1 AS bk", [&path_str])?; + // No matter what happens below, DETACH so the next caller's pool + // checkout doesn't see a lingering attachment. + let result = (|| -> Result { + // ── Schema validation ──────────────────────────────────── + let required = [ + "watched", + "positions", + "channel_options", + "video_flags", + "folders", + "channel_assignments", + ]; + for table in &required { + let count: i64 = conn.query_row( + "SELECT count(*) FROM bk.sqlite_master \ + WHERE type = 'table' AND name = ?1", + [table], + |r| r.get(0), + )?; + if count == 0 { + return Err(rusqlite::Error::SqliteFailure( + rusqlite::ffi::Error::new(rusqlite::ffi::SQLITE_MISMATCH), + Some(format!( + "backup is missing required table `{table}` β€” \ + not a yt-offline snapshot, or from an incompatible version" + )), + )); + } + } + + // Wrap the whole merge in a transaction. If any step fails the + // attached DB stays read-only on our side and the live DB + // rolls back to pre-import state. + conn.execute("BEGIN", [])?; + let summary = (|| -> Result { + // watched: keep the later timestamp. Two-step approach + // (insert missing, then update older) works in plain SQL + // without an UPSERT WHERE clause. + let watched_before: i64 = conn.query_row( + "SELECT count(*) FROM watched", [], |r| r.get(0))?; + conn.execute( + "INSERT OR IGNORE INTO watched (video_id, watched_at) \ + SELECT video_id, watched_at FROM bk.watched", [])?; + conn.execute( + "UPDATE watched SET watched_at = (\ + SELECT bk.watched.watched_at FROM bk.watched \ + WHERE bk.watched.video_id = main.watched.video_id) \ + WHERE EXISTS (\ + SELECT 1 FROM bk.watched \ + WHERE bk.watched.video_id = main.watched.video_id \ + AND bk.watched.watched_at > main.watched.watched_at)", [])?; + let watched_after: i64 = conn.query_row( + "SELECT count(*) FROM watched", [], |r| r.get(0))?; + let watched_added = (watched_after - watched_before).max(0); + + // positions: same pattern as watched. + let positions_before: i64 = conn.query_row( + "SELECT count(*) FROM positions", [], |r| r.get(0))?; + conn.execute( + "INSERT OR IGNORE INTO positions (video_id, position_secs, updated_at) \ + SELECT video_id, position_secs, updated_at FROM bk.positions", [])?; + // Use main.positions to disambiguate the target β€” inside + // the WHERE/SET subqueries SQLite would otherwise resolve + // the bare `positions` to the innermost FROM (bk.positions + // for the SELECT subquery), giving us a degenerate + // `bk.positions.x = bk.positions.x` join. + conn.execute( + "UPDATE positions SET \ + position_secs = (SELECT bk.positions.position_secs FROM bk.positions \ + WHERE bk.positions.video_id = main.positions.video_id), \ + updated_at = (SELECT bk.positions.updated_at FROM bk.positions \ + WHERE bk.positions.video_id = main.positions.video_id) \ + WHERE EXISTS (\ + SELECT 1 FROM bk.positions \ + WHERE bk.positions.video_id = main.positions.video_id \ + AND bk.positions.updated_at > main.positions.updated_at)", [])?; + let positions_after: i64 = conn.query_row( + "SELECT count(*) FROM positions", [], |r| r.get(0))?; + let positions_added = (positions_after - positions_before).max(0); + + // channel_options: keep the later updated_at. + let options_before: i64 = conn.query_row( + "SELECT count(*) FROM channel_options", [], |r| r.get(0))?; + conn.execute( + "INSERT OR IGNORE INTO channel_options \ + (platform, handle, options_json, updated_at) \ + SELECT platform, handle, options_json, updated_at \ + FROM bk.channel_options", [])?; + conn.execute( + "UPDATE channel_options SET \ + options_json = (SELECT bk.channel_options.options_json \ + FROM bk.channel_options \ + WHERE bk.channel_options.platform = main.channel_options.platform \ + AND bk.channel_options.handle = main.channel_options.handle), \ + updated_at = (SELECT bk.channel_options.updated_at \ + FROM bk.channel_options \ + WHERE bk.channel_options.platform = main.channel_options.platform \ + AND bk.channel_options.handle = main.channel_options.handle) \ + WHERE EXISTS (\ + SELECT 1 FROM bk.channel_options \ + WHERE bk.channel_options.platform = main.channel_options.platform \ + AND bk.channel_options.handle = main.channel_options.handle \ + AND bk.channel_options.updated_at > main.channel_options.updated_at)", [])?; + let options_after: i64 = conn.query_row( + "SELECT count(*) FROM channel_options", [], |r| r.get(0))?; + let options_added = (options_after - options_before).max(0); + + // video_flags: bitwise OR each flag column. Insert missing + // first, then OR for collisions. (`MAX` works since each + // flag is 0/1.) + let flags_before: i64 = conn.query_row( + "SELECT count(*) FROM video_flags", [], |r| r.get(0))?; + conn.execute( + "INSERT OR IGNORE INTO video_flags \ + (video_id, bookmark, favourite, waiting, archive, updated_at) \ + SELECT video_id, bookmark, favourite, waiting, archive, updated_at \ + FROM bk.video_flags", [])?; + conn.execute( + "UPDATE video_flags SET \ + bookmark = MAX(bookmark, COALESCE((SELECT bookmark FROM bk.video_flags \ + WHERE bk.video_flags.video_id = main.video_flags.video_id), 0)), \ + favourite = MAX(favourite, COALESCE((SELECT favourite FROM bk.video_flags \ + WHERE bk.video_flags.video_id = main.video_flags.video_id), 0)), \ + waiting = MAX(waiting, COALESCE((SELECT waiting FROM bk.video_flags \ + WHERE bk.video_flags.video_id = main.video_flags.video_id), 0)), \ + archive = MAX(archive, COALESCE((SELECT archive FROM bk.video_flags \ + WHERE bk.video_flags.video_id = main.video_flags.video_id), 0))", [])?; + let flags_after: i64 = conn.query_row( + "SELECT count(*) FROM video_flags", [], |r| r.get(0))?; + let flags_added = (flags_after - flags_before).max(0); + + // folders: only insert names we don't already have. + let folders_before: i64 = conn.query_row( + "SELECT count(*) FROM folders", [], |r| r.get(0))?; + conn.execute( + "INSERT OR IGNORE INTO folders (name, position, created_at) \ + SELECT name, position, created_at FROM bk.folders", [])?; + let folders_after: i64 = conn.query_row( + "SELECT count(*) FROM folders", [], |r| r.get(0))?; + let folders_added = (folders_after - folders_before).max(0); + + // channel_assignments: insert when (platform, handle) is + // unassigned. We re-resolve folder_id by name to handle the + // case where the backup and live DB have the same folder + // name but different IDs. + let assignments_before: i64 = conn.query_row( + "SELECT count(*) FROM channel_assignments", [], |r| r.get(0))?; + conn.execute( + "INSERT OR IGNORE INTO channel_assignments (platform, handle, folder_id) \ + SELECT b.platform, b.handle, f.id \ + FROM bk.channel_assignments b \ + JOIN bk.folders bf ON bf.id = b.folder_id \ + JOIN folders f ON f.name = bf.name", [])?; + let assignments_after: i64 = conn.query_row( + "SELECT count(*) FROM channel_assignments", [], |r| r.get(0))?; + let assignments_added = (assignments_after - assignments_before).max(0); + + Ok(RestoreSummary { + watched_added: watched_added as u64, + positions_added: positions_added as u64, + options_added: options_added as u64, + flags_added: flags_added as u64, + folders_added: folders_added as u64, + assignments_added: assignments_added as u64, + }) + })(); + match summary { + Ok(s) => { + conn.execute("COMMIT", [])?; + Ok(s) + } + Err(e) => { + let _ = conn.execute("ROLLBACK", []); + Err(e) + } + } + })(); + // ATTACH state lives on the connection. Detach even on error so + // the pooled connection is clean when it goes back. + let _ = conn.execute("DETACH DATABASE bk", []); + result + } +} + +/// Per-table row counts that landed in the live DB during a restore. +/// Useful for the UI to show "imported N watched + M positions" so the +/// user sees evidence the merge actually did something. +#[derive(Debug, Clone, Default, serde::Serialize)] +pub struct RestoreSummary { + pub watched_added: u64, + pub positions_added: u64, + pub options_added: u64, + pub flags_added: u64, + pub folders_added: u64, + pub assignments_added: u64, +} + +#[cfg(test)] +mod restore_tests { + use super::*; + + /// Per-test scratch dir that auto-removes itself. Avoids pulling in + /// the `tempfile` crate just for this test module. + struct ScratchDir(std::path::PathBuf); + impl ScratchDir { + fn new(name: &str) -> Self { + let mut p = std::env::temp_dir(); + // Disambiguate parallel-test runs of the same name with the + // pid + a counter; collisions would otherwise leave one test + // operating on another's DB. + use std::sync::atomic::{AtomicU64, Ordering}; + static N: AtomicU64 = AtomicU64::new(0); + let id = N.fetch_add(1, Ordering::Relaxed); + p.push(format!("yt-offline-test-{}-{}-{}", std::process::id(), id, name)); + let _ = std::fs::remove_dir_all(&p); + std::fs::create_dir_all(&p).unwrap(); + ScratchDir(p) + } + fn join(&self, name: &str) -> std::path::PathBuf { self.0.join(name) } + } + impl Drop for ScratchDir { + fn drop(&mut self) { let _ = std::fs::remove_dir_all(&self.0); } + } + + #[test] + fn restores_watched_and_positions() { + let dir = ScratchDir::new("watched-positions"); + let live = Database::open(&dir.join("live.db")).unwrap(); + let backup = Database::open(&dir.join("backup.db")).unwrap(); + + backup.set_watched("v-only-in-backup", true).unwrap(); + // CURRENT_TIMESTAMP has 1-second resolution. Write the live row + // first, sleep just over a second, then write the backup row so + // the merge's `updated_at >` comparison actually picks the + // backup's value. Real-world backups are taken minutes/days + // apart, so this resolution is fine in production. + live.set_position("v-shared", 10.0).unwrap(); + std::thread::sleep(std::time::Duration::from_millis(1100)); + backup.set_position("v-shared", 42.5).unwrap(); + + let summary = live.restore_from_backup(&dir.join("backup.db")).unwrap(); + assert_eq!(summary.watched_added, 1); + // v-shared existed on the live side already, so positions_added + // counts only the *new* row (which there isn't one of). + assert_eq!(summary.positions_added, 0); + + // The watched row from backup made it through. + let w = live.get_watched().unwrap(); + assert!(w.contains("v-only-in-backup")); + + // v-shared got the *later* position (backup's, since the live one + // was inserted earlier in this test). + let p = live.get_positions().unwrap(); + assert!((p.get("v-shared").copied().unwrap_or(0.0) - 42.5).abs() < 0.001); + } + + #[test] + fn ors_video_flags() { + let dir = ScratchDir::new("flags-or"); + let live = Database::open(&dir.join("live.db")).unwrap(); + let backup = Database::open(&dir.join("backup.db")).unwrap(); + + // Live side: v1 favourite. Backup side: v1 bookmark. After merge + // v1 should be both. + live.set_video_flag("v1", "favourite", true).unwrap(); + backup.set_video_flag("v1", "bookmark", true).unwrap(); + backup.set_video_flag("v2", "waiting", true).unwrap(); + + live.restore_from_backup(&dir.join("backup.db")).unwrap(); + let flags = live.get_video_flags().unwrap(); + assert!(flags.favourite.contains("v1")); + assert!(flags.bookmark.contains("v1")); + assert!(flags.waiting.contains("v2")); + } + + #[test] + fn idempotent_when_run_twice() { + let dir = ScratchDir::new("idempotent"); + let live = Database::open(&dir.join("live.db")).unwrap(); + let backup = Database::open(&dir.join("backup.db")).unwrap(); + backup.set_watched("v1", true).unwrap(); + backup.set_position("v1", 7.5).unwrap(); + + let s1 = live.restore_from_backup(&dir.join("backup.db")).unwrap(); + let s2 = live.restore_from_backup(&dir.join("backup.db")).unwrap(); + + // First pass adds 1 of each, second adds none (same backup). + assert_eq!(s1.watched_added, 1); + assert_eq!(s1.positions_added, 1); + assert_eq!(s2.watched_added, 0); + assert_eq!(s2.positions_added, 0); + } + + #[test] + fn rejects_unrelated_sqlite_file() { + let dir = ScratchDir::new("schema-mismatch"); + let live = Database::open(&dir.join("live.db")).unwrap(); + + // Create a SQLite file with a completely different schema. + let bad = dir.join("not-yt-offline.db"); + let conn = Connection::open(&bad).unwrap(); + conn.execute("CREATE TABLE foo (x INT)", []).unwrap(); + drop(conn); + + let err = live.restore_from_backup(&bad).unwrap_err(); + let msg = format!("{err}"); + assert!(msg.contains("missing required table"), "{msg}"); + } } /// Translate an `r2d2::Error` from `Pool::build()` into a `rusqlite::Error` so diff --git a/src/web.rs b/src/web.rs index 90d47b0..f16319b 100644 --- a/src/web.rs +++ b/src/web.rs @@ -1794,6 +1794,77 @@ async fn get_backup_db(State(state): State>) -> Response { ).into_response() } +/// `POST /api/restore/db` β€” accept a SQLite database body and merge its +/// rows into the live DB. Mirrors `GET /api/backup/db` in the opposite +/// direction. +/// +/// The body is the raw `yt-offline.db` bytes β€” same shape as what +/// `get_backup_db` produces. We write it to a sibling temp file, hand it +/// to [`Database::restore_from_backup`] for the actual merge, then +/// refresh the in-memory watched / positions / flags caches so the next +/// `/api/library` response reflects the import. +/// +/// Hard-cap the body at 100 MB to keep a malicious or fat-finger upload +/// from filling tmpfs. Realistic backups are a few KB to a few MB for +/// even very large libraries. +async fn post_restore_db( + State(state): State>, + body: Bytes, +) -> Response { + const MAX_BACKUP_BYTES: usize = 100 * 1024 * 1024; + if body.len() > MAX_BACKUP_BYTES { + return (StatusCode::PAYLOAD_TOO_LARGE, + "backup file too large (max 100 MB)").into_response(); + } + if body.is_empty() { + return (StatusCode::BAD_REQUEST, "empty body β€” POST the .db bytes").into_response(); + } + // SQLite magic header check before we even bother writing the file. + // All valid SQLite 3 databases start with "SQLite format 3\0". + if !body.starts_with(b"SQLite format 3\0") { + return (StatusCode::BAD_REQUEST, + "not a SQLite database (bad magic header)").into_response(); + } + + // Write to a sibling tmp file so it lives on the same filesystem as + // the live DB β€” keeps ATTACH happy and avoids cross-device rename + // issues if we ever extend this to atomic-replace semantics. + let tmp_path = state.channels_root.join(".yt-offline.restore.tmp"); + if let Err(e) = std::fs::write(&tmp_path, &body) { + return (StatusCode::INTERNAL_SERVER_ERROR, + format!("write tmp file: {e}")).into_response(); + } + + let summary = match state.db.restore_from_backup(&tmp_path) { + Ok(s) => s, + Err(e) => { + let _ = std::fs::remove_file(&tmp_path); + return (StatusCode::BAD_REQUEST, + format!("restore failed: {e}")).into_response(); + } + }; + let _ = std::fs::remove_file(&tmp_path); + + // Refresh in-memory caches so the next /api/library reflects the new + // watched / position / flag rows without waiting for an app restart. + if let Ok(w) = state.db.get_watched() { + *state.watched.lock().unwrap() = w; + } + if let Ok(p) = state.db.get_positions() { + *state.positions.lock().unwrap() = p; + } + if let Ok(f) = state.db.get_video_flags() { + *state.flags.lock().unwrap() = f; + } + // Bump the library version so cached /api/library responses revalidate. + bump_library_version(&state); + + ( + [(header::CONTENT_TYPE, "application/json")], + serde_json::to_string(&summary).unwrap_or_else(|_| "{}".to_string()), + ).into_response() +} + /// `POST /api/folders/:id/check` β€” fire a re-check on every channel in /// the folder. Mirrors `POST /api/scheduler/run` but scoped to a single /// folder's members. Each channel's stored download_options + quality @@ -2216,6 +2287,7 @@ async fn serve(config: Config, shutdown_rx: std::sync::mpsc::Receiver<()>) { .route("/api/folders/:id/rename", post(post_rename_folder)) .route("/api/folders/:id/check", post(post_check_folder)) .route("/api/backup/db", get(get_backup_db)) + .route("/api/restore/db", post(post_restore_db)) .route("/api/folders/:id", axum::routing::delete(delete_folder)) .route("/api/channels/:platform/:handle/folder", post(post_assign_folder)) .route("/api/resume/:id", post(post_resume)) diff --git a/src/web_ui/index.html b/src/web_ui/index.html index 38d4033..91e10cc 100644 --- a/src/web_ui/index.html +++ b/src/web_ui/index.html @@ -1316,8 +1316,13 @@ async function openSettings(){
Backup
-
Saves the SQLite file holding your watched / favourites / bookmarks / waiting / channel-options / folders. Restore by replacing yt-offline.db in the channels directory.
- +
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.
+
+ + + +
+
${srcRow}
@@ -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}