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

@ -171,6 +171,12 @@ pub struct App {
// is. // is.
backup_save_tx: Sender<PathBuf>, backup_save_tx: Sender<PathBuf>,
backup_save_rx: Receiver<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 — // Maintenance (library health) screen state. The flag is gone now —
// Screen::Maintenance + this report's presence drive the render. // Screen::Maintenance + this report's presence drive the render.
health_report: Option<crate::maintenance::HealthReport>, 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 (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_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_request_tx, thumb_request_rx) = std::sync::mpsc::channel::<PathBuf>();
let (thumb_result_tx, thumb_result_rx) = let (thumb_result_tx, thumb_result_rx) =
@ -355,6 +362,8 @@ impl App {
cookies_pick_rx, cookies_pick_rx,
backup_save_tx, backup_save_tx,
backup_save_rx, backup_save_rx,
backup_open_tx,
backup_open_rx,
health_report: None, health_report: None,
stats_report: None, stats_report: None,
show_channel_options: false, show_channel_options: false,
@ -2329,6 +2338,7 @@ impl App {
.small() .small()
.weak(), .weak(),
); );
ui.horizontal(|ui| {
if ui.button("💾 Save library backup…").clicked() { if ui.button("💾 Save library backup…").clicked() {
// Open the save dialog off the UI thread, mirroring the // Open the save dialog off the UI thread, mirroring the
// cookies file-picker pattern. // cookies file-picker pattern.
@ -2358,6 +2368,34 @@ impl App {
} }
}); });
} }
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.add_space(8.0);
ui.separator(); ui.separator();
@ -3195,6 +3233,27 @@ impl eframe::App for App {
Err(e) => self.status = format!("Backup failed: {e}"), 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); self.top_bar(ctx);
// Floating sub-dialogs that overlay any screen. // Floating sub-dialogs that overlay any screen.

View file

@ -423,6 +423,359 @@ impl Database {
.collect(); .collect();
Ok(map) 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 /// Translate an `r2d2::Error` from `Pool::build()` into a `rusqlite::Error` so

View file

@ -1794,6 +1794,77 @@ async fn get_backup_db(State(state): State<Arc<WebState>>) -> Response {
).into_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 /// `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 /// the folder. Mirrors `POST /api/scheduler/run` but scoped to a single
/// folder's members. Each channel's stored download_options + quality /// 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/rename", post(post_rename_folder))
.route("/api/folders/:id/check", post(post_check_folder)) .route("/api/folders/:id/check", post(post_check_folder))
.route("/api/backup/db", get(get_backup_db)) .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/folders/:id", axum::routing::delete(delete_folder))
.route("/api/channels/:platform/:handle/folder", post(post_assign_folder)) .route("/api/channels/:platform/:handle/folder", post(post_assign_folder))
.route("/api/resume/:id", post(post_resume)) .route("/api/resume/:id", post(post_resume))

View file

@ -1316,8 +1316,13 @@ async function openSettings(){
<hr style="border-color:var(--border);margin:12px 0"> <hr style="border-color:var(--border);margin:12px 0">
<div style="font-weight:700;margin-bottom:8px">Backup</div> <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-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> <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="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> </div>
${srcRow} ${srcRow}
<div style="display:flex;gap:8px;justify-content:flex-end;margin-top:12px"> <div style="display:flex;gap:8px;justify-content:flex-end;margin-top:12px">
@ -1366,6 +1371,33 @@ function downloadBackupDb(){
// rendering the SQLite bytes inline. // rendering the SQLite bytes inline.
window.open('/api/backup/db','_blank'); 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){ async function generatePlex(btn){
const path=document.getElementById('cf-plex-path')?.value.trim(); const path=document.getElementById('cf-plex-path')?.value.trim();
if(!path){document.getElementById('plex-status').textContent='Set a path first';return} if(!path){document.getElementById('plex-status').textContent='Set a path first';return}