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
115
src/app.rs
115
src/app.rs
|
|
@ -171,6 +171,12 @@ pub struct App {
|
|||
// is.
|
||||
backup_save_tx: Sender<PathBuf>,
|
||||
backup_save_rx: Receiver<PathBuf>,
|
||||
// "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<PathBuf>,
|
||||
backup_open_rx: Receiver<PathBuf>,
|
||||
// Maintenance (library health) screen state. The flag is gone now —
|
||||
// Screen::Maintenance + this report's presence drive the render.
|
||||
health_report: Option<crate::maintenance::HealthReport>,
|
||||
|
|
@ -283,6 +289,7 @@ impl App {
|
|||
|
||||
let (cookies_pick_tx, cookies_pick_rx) = std::sync::mpsc::channel::<PathBuf>();
|
||||
let (backup_save_tx, backup_save_rx) = std::sync::mpsc::channel::<PathBuf>();
|
||||
let (backup_open_tx, backup_open_rx) = std::sync::mpsc::channel::<PathBuf>();
|
||||
|
||||
let (thumb_request_tx, thumb_request_rx) = std::sync::mpsc::channel::<PathBuf>();
|
||||
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.
|
||||
|
|
|
|||
353
src/database.rs
353
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<RestoreSummary> {
|
||||
// Open the backup file via ATTACH so we can write `INSERT … SELECT
|
||||
// … FROM bk.<table>` 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<RestoreSummary> {
|
||||
// ── 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<RestoreSummary> {
|
||||
// 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
|
||||
|
|
|
|||
72
src/web.rs
72
src/web.rs
|
|
@ -1794,6 +1794,77 @@ async fn get_backup_db(State(state): State<Arc<WebState>>) -> 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<Arc<WebState>>,
|
||||
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))
|
||||
|
|
|
|||
|
|
@ -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