mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-22 06:25:41 +00:00
5377f3b737
Complete the Dependabot #910 bump: update Cargo.toml and migrate backup archive writes to SimpleFileOptions (zip 4.x API). Fixes lockfile drift where cargo downgraded zip back to 0.6.6 on every dev build.
450 lines
17 KiB
Rust
450 lines
17 KiB
Rust
use std::fs;
|
|
use std::io::{self, Write};
|
|
use std::path::{Path, PathBuf};
|
|
|
|
use rusqlite::{Connection, OpenFlags};
|
|
use serde_json::Value;
|
|
use tauri::{AppHandle, Manager};
|
|
use zip::write::SimpleFileOptions;
|
|
use zip::{CompressionMethod, ZipArchive, ZipWriter};
|
|
|
|
const LIBRARY_ARCHIVE_ENTRY: &str = "library.sqlite";
|
|
const ANALYSIS_ARCHIVE_ENTRY: &str = "audio-analysis.sqlite";
|
|
const FULL_ARCHIVE_SETTINGS_ENTRY: &str = "settings.json";
|
|
const FULL_ARCHIVE_VERSION: u64 = 1;
|
|
|
|
#[derive(Debug, serde::Serialize, serde::Deserialize)]
|
|
struct FullBackupPayload {
|
|
version: u64,
|
|
app_version: String,
|
|
stores: Value,
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub(crate) async fn backup_export_library_db(
|
|
app: AppHandle,
|
|
destination_path: String,
|
|
) -> Result<(), String> {
|
|
tauri::async_runtime::spawn_blocking(move || {
|
|
backup_export_library_db_blocking(&app, destination_path)
|
|
})
|
|
.await
|
|
.map_err(|e| e.to_string())?
|
|
}
|
|
|
|
fn backup_export_library_db_blocking(app: &AppHandle, destination_path: String) -> Result<(), String> {
|
|
let destination = PathBuf::from(destination_path);
|
|
if let Some(parent) = destination.parent() {
|
|
fs::create_dir_all(parent).map_err(|e| e.to_string())?;
|
|
}
|
|
let source_library = library_db_path(app)?;
|
|
let source_analysis = analysis_db_path(app)?;
|
|
if !source_library.exists() {
|
|
return Err("library database does not exist".to_string());
|
|
}
|
|
if !source_analysis.exists() {
|
|
return Err("analysis database does not exist".to_string());
|
|
}
|
|
remove_if_exists(&destination)?;
|
|
|
|
let snapshot_library_tmp = source_library.with_file_name("library-export.sqlite");
|
|
let snapshot_analysis_tmp = source_analysis.with_file_name("audio-analysis-export.sqlite");
|
|
remove_db_with_sidecars(&snapshot_library_tmp)?;
|
|
remove_db_with_sidecars(&snapshot_analysis_tmp)?;
|
|
vacuum_copy(&source_library, &snapshot_library_tmp)?;
|
|
vacuum_copy(&source_analysis, &snapshot_analysis_tmp)?;
|
|
let result = write_databases_archive(
|
|
&snapshot_library_tmp,
|
|
&snapshot_analysis_tmp,
|
|
&destination,
|
|
);
|
|
remove_db_with_sidecars(&snapshot_library_tmp).ok();
|
|
remove_db_with_sidecars(&snapshot_analysis_tmp).ok();
|
|
result
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub(crate) async fn backup_import_library_db(app: AppHandle, source_path: String) -> Result<(), String> {
|
|
tauri::async_runtime::spawn_blocking(move || {
|
|
backup_import_library_db_blocking(&app, source_path)
|
|
})
|
|
.await
|
|
.map_err(|e| e.to_string())?
|
|
}
|
|
|
|
fn backup_import_library_db_blocking(app: &AppHandle, source_path: String) -> Result<(), String> {
|
|
let source = PathBuf::from(source_path);
|
|
if !source.exists() {
|
|
return Err("backup file not found".to_string());
|
|
}
|
|
|
|
let active_library = library_db_path(app)?;
|
|
let active_analysis = analysis_db_path(app)?;
|
|
let import_library_tmp = active_library.with_file_name("library-import.sqlite");
|
|
let import_analysis_tmp = active_analysis.with_file_name("audio-analysis-import.sqlite");
|
|
remove_db_with_sidecars(&import_library_tmp)?;
|
|
remove_db_with_sidecars(&import_analysis_tmp)?;
|
|
extract_databases_archive(&source, &import_library_tmp, &import_analysis_tmp)?;
|
|
validate_sqlite_file(&import_library_tmp)?;
|
|
validate_sqlite_file(&import_analysis_tmp)?;
|
|
import_databases_from_sqlite(app, &import_library_tmp, &import_analysis_tmp)
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub(crate) async fn backup_export_full(
|
|
app: AppHandle,
|
|
destination_path: String,
|
|
stores: Value,
|
|
app_version: String,
|
|
) -> Result<(), String> {
|
|
tauri::async_runtime::spawn_blocking(move || {
|
|
backup_export_full_blocking(&app, destination_path, stores, app_version)
|
|
})
|
|
.await
|
|
.map_err(|e| e.to_string())?
|
|
}
|
|
|
|
fn backup_export_full_blocking(
|
|
app: &AppHandle,
|
|
destination_path: String,
|
|
stores: Value,
|
|
app_version: String,
|
|
) -> Result<(), String> {
|
|
if !stores.is_object() {
|
|
return Err("stores payload must be an object".to_string());
|
|
}
|
|
let destination = PathBuf::from(destination_path);
|
|
if let Some(parent) = destination.parent() {
|
|
fs::create_dir_all(parent).map_err(|e| e.to_string())?;
|
|
}
|
|
remove_if_exists(&destination)?;
|
|
|
|
let source_library = library_db_path(app)?;
|
|
let source_analysis = analysis_db_path(app)?;
|
|
if !source_library.exists() {
|
|
return Err("library database does not exist".to_string());
|
|
}
|
|
if !source_analysis.exists() {
|
|
return Err("analysis database does not exist".to_string());
|
|
}
|
|
let snapshot_library_tmp = source_library.with_file_name("library-export.sqlite");
|
|
let snapshot_analysis_tmp = source_analysis.with_file_name("audio-analysis-export.sqlite");
|
|
remove_db_with_sidecars(&snapshot_library_tmp)?;
|
|
remove_db_with_sidecars(&snapshot_analysis_tmp)?;
|
|
vacuum_copy(&source_library, &snapshot_library_tmp)?;
|
|
vacuum_copy(&source_analysis, &snapshot_analysis_tmp)?;
|
|
|
|
let payload = FullBackupPayload {
|
|
version: FULL_ARCHIVE_VERSION,
|
|
app_version,
|
|
stores,
|
|
};
|
|
let result = write_full_archive(
|
|
&snapshot_library_tmp,
|
|
&snapshot_analysis_tmp,
|
|
&destination,
|
|
&payload,
|
|
);
|
|
remove_db_with_sidecars(&snapshot_library_tmp).ok();
|
|
remove_db_with_sidecars(&snapshot_analysis_tmp).ok();
|
|
result
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub(crate) async fn backup_import_full(app: AppHandle, source_path: String) -> Result<Value, String> {
|
|
tauri::async_runtime::spawn_blocking(move || backup_import_full_blocking(&app, source_path))
|
|
.await
|
|
.map_err(|e| e.to_string())?
|
|
}
|
|
|
|
fn backup_import_full_blocking(app: &AppHandle, source_path: String) -> Result<Value, String> {
|
|
let source = PathBuf::from(source_path);
|
|
if !source.exists() {
|
|
return Err("backup file not found".to_string());
|
|
}
|
|
|
|
let active_library = library_db_path(app)?;
|
|
let active_analysis = analysis_db_path(app)?;
|
|
let import_library_tmp = active_library.with_file_name("library-import.sqlite");
|
|
let import_analysis_tmp = active_analysis.with_file_name("audio-analysis-import.sqlite");
|
|
remove_db_with_sidecars(&import_library_tmp)?;
|
|
remove_db_with_sidecars(&import_analysis_tmp)?;
|
|
let payload = extract_full_archive(&source, &import_library_tmp, &import_analysis_tmp)?;
|
|
validate_sqlite_file(&import_library_tmp)?;
|
|
validate_sqlite_file(&import_analysis_tmp)?;
|
|
let stores = payload.stores;
|
|
if !stores.is_object() {
|
|
remove_db_with_sidecars(&import_library_tmp).ok();
|
|
remove_db_with_sidecars(&import_analysis_tmp).ok();
|
|
return Err("backup payload stores must be an object".to_string());
|
|
}
|
|
|
|
import_databases_from_sqlite(app, &import_library_tmp, &import_analysis_tmp)?;
|
|
Ok(stores)
|
|
}
|
|
|
|
fn library_db_path(app: &AppHandle) -> Result<PathBuf, String> {
|
|
let base = app.path().app_data_dir().map_err(|e| e.to_string())?;
|
|
let dir = base.join("databases").join("library");
|
|
fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
|
|
Ok(dir.join("library.sqlite"))
|
|
}
|
|
|
|
fn analysis_db_path(app: &AppHandle) -> Result<PathBuf, String> {
|
|
let base = app.path().app_data_dir().map_err(|e| e.to_string())?;
|
|
let dir = base.join("databases").join("analysis");
|
|
fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
|
|
Ok(dir.join("audio-analysis.sqlite"))
|
|
}
|
|
|
|
fn vacuum_copy(source: &Path, destination: &Path) -> Result<(), String> {
|
|
let conn = Connection::open_with_flags(source, OpenFlags::SQLITE_OPEN_READ_WRITE)
|
|
.map_err(|e| e.to_string())?;
|
|
let escaped = destination.to_string_lossy().replace('\'', "''");
|
|
let sql = format!("VACUUM INTO '{escaped}';");
|
|
conn.execute_batch(&sql).map_err(|e| e.to_string())
|
|
}
|
|
|
|
fn write_databases_archive(
|
|
library_snapshot: &Path,
|
|
analysis_snapshot: &Path,
|
|
destination_archive: &Path,
|
|
) -> Result<(), String> {
|
|
let file = fs::File::create(destination_archive).map_err(|e| e.to_string())?;
|
|
let mut zip = ZipWriter::new(file);
|
|
let options =
|
|
SimpleFileOptions::default().compression_method(CompressionMethod::Deflated);
|
|
zip.start_file(LIBRARY_ARCHIVE_ENTRY, options)
|
|
.map_err(|e| e.to_string())?;
|
|
let mut src = fs::File::open(library_snapshot).map_err(|e| e.to_string())?;
|
|
io::copy(&mut src, &mut zip).map_err(|e| e.to_string())?;
|
|
zip.start_file(ANALYSIS_ARCHIVE_ENTRY, options)
|
|
.map_err(|e| e.to_string())?;
|
|
let mut analysis_src = fs::File::open(analysis_snapshot).map_err(|e| e.to_string())?;
|
|
io::copy(&mut analysis_src, &mut zip).map_err(|e| e.to_string())?;
|
|
zip.finish().map_err(|e| e.to_string())?;
|
|
Ok(())
|
|
}
|
|
|
|
fn write_full_archive(
|
|
library_snapshot: &Path,
|
|
analysis_snapshot: &Path,
|
|
destination_archive: &Path,
|
|
payload: &FullBackupPayload,
|
|
) -> Result<(), String> {
|
|
let file = fs::File::create(destination_archive).map_err(|e| e.to_string())?;
|
|
let mut zip = ZipWriter::new(file);
|
|
let options =
|
|
SimpleFileOptions::default().compression_method(CompressionMethod::Deflated);
|
|
|
|
zip.start_file(FULL_ARCHIVE_SETTINGS_ENTRY, options)
|
|
.map_err(|e| e.to_string())?;
|
|
let settings = serde_json::to_vec_pretty(payload).map_err(|e| e.to_string())?;
|
|
zip.write_all(&settings).map_err(|e| e.to_string())?;
|
|
|
|
zip.start_file(LIBRARY_ARCHIVE_ENTRY, options)
|
|
.map_err(|e| e.to_string())?;
|
|
let mut src = fs::File::open(library_snapshot).map_err(|e| e.to_string())?;
|
|
io::copy(&mut src, &mut zip).map_err(|e| e.to_string())?;
|
|
|
|
zip.start_file(ANALYSIS_ARCHIVE_ENTRY, options)
|
|
.map_err(|e| e.to_string())?;
|
|
let mut analysis_src = fs::File::open(analysis_snapshot).map_err(|e| e.to_string())?;
|
|
io::copy(&mut analysis_src, &mut zip).map_err(|e| e.to_string())?;
|
|
zip.finish().map_err(|e| e.to_string())?;
|
|
Ok(())
|
|
}
|
|
|
|
fn extract_databases_archive(
|
|
source_archive: &Path,
|
|
library_destination_sqlite: &Path,
|
|
analysis_destination_sqlite: &Path,
|
|
) -> Result<(), String> {
|
|
let file = fs::File::open(source_archive).map_err(|e| e.to_string())?;
|
|
let mut archive = ZipArchive::new(file).map_err(|e| e.to_string())?;
|
|
let library_target_index = archive
|
|
.file_names()
|
|
.enumerate()
|
|
.find_map(|(i, name)| (name == LIBRARY_ARCHIVE_ENTRY).then_some(i))
|
|
.ok_or_else(|| "archive does not contain library.sqlite".to_string())?;
|
|
let analysis_target_index = archive
|
|
.file_names()
|
|
.enumerate()
|
|
.find_map(|(i, name)| (name == ANALYSIS_ARCHIVE_ENTRY).then_some(i))
|
|
.ok_or_else(|| "archive does not contain audio-analysis.sqlite".to_string())?;
|
|
|
|
if let Some(parent) = library_destination_sqlite.parent() {
|
|
fs::create_dir_all(parent).map_err(|e| e.to_string())?;
|
|
}
|
|
{
|
|
let mut library_entry = archive.by_index(library_target_index).map_err(|e| e.to_string())?;
|
|
let mut out = fs::File::create(library_destination_sqlite).map_err(|e| e.to_string())?;
|
|
io::copy(&mut library_entry, &mut out).map_err(|e| e.to_string())?;
|
|
}
|
|
|
|
if let Some(parent) = analysis_destination_sqlite.parent() {
|
|
fs::create_dir_all(parent).map_err(|e| e.to_string())?;
|
|
}
|
|
{
|
|
let mut analysis_entry = archive.by_index(analysis_target_index).map_err(|e| e.to_string())?;
|
|
let mut analysis_out = fs::File::create(analysis_destination_sqlite).map_err(|e| e.to_string())?;
|
|
io::copy(&mut analysis_entry, &mut analysis_out).map_err(|e| e.to_string())?;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn extract_full_archive(
|
|
source_archive: &Path,
|
|
library_destination_sqlite: &Path,
|
|
analysis_destination_sqlite: &Path,
|
|
) -> Result<FullBackupPayload, String> {
|
|
let file = fs::File::open(source_archive).map_err(|e| e.to_string())?;
|
|
let mut archive = ZipArchive::new(file).map_err(|e| e.to_string())?;
|
|
|
|
let payload = {
|
|
let mut entry = archive
|
|
.by_name(FULL_ARCHIVE_SETTINGS_ENTRY)
|
|
.map_err(|_| "archive does not contain settings.json".to_string())?;
|
|
let mut buf = Vec::new();
|
|
io::copy(&mut entry, &mut buf).map_err(|e| e.to_string())?;
|
|
serde_json::from_slice::<FullBackupPayload>(&buf).map_err(|e| e.to_string())?
|
|
};
|
|
if payload.version != FULL_ARCHIVE_VERSION {
|
|
return Err("unsupported full backup version".to_string());
|
|
}
|
|
|
|
extract_databases_archive(
|
|
source_archive,
|
|
library_destination_sqlite,
|
|
analysis_destination_sqlite,
|
|
)?;
|
|
Ok(payload)
|
|
}
|
|
|
|
fn validate_sqlite_file(path: &Path) -> Result<(), String> {
|
|
let conn = Connection::open_with_flags(path, OpenFlags::SQLITE_OPEN_READ_ONLY)
|
|
.map_err(|e| e.to_string())?;
|
|
let integrity: String = conn
|
|
.query_row("PRAGMA integrity_check;", [], |row| row.get(0))
|
|
.map_err(|e| e.to_string())?;
|
|
if integrity != "ok" {
|
|
return Err("backup file integrity check failed".to_string());
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn library_health_check(active_path: &Path) -> Result<(), String> {
|
|
let conn = Connection::open_with_flags(active_path, OpenFlags::SQLITE_OPEN_READ_ONLY)
|
|
.map_err(|e| e.to_string())?;
|
|
conn.query_row("SELECT COUNT(*) FROM track", [], |_row| Ok(()))
|
|
.map_err(|e| e.to_string())
|
|
}
|
|
|
|
fn analysis_health_check(active_path: &Path) -> Result<(), String> {
|
|
let conn = Connection::open_with_flags(active_path, OpenFlags::SQLITE_OPEN_READ_ONLY)
|
|
.map_err(|e| e.to_string())?;
|
|
conn.query_row("SELECT COUNT(*) FROM analysis_track", [], |_row| Ok(()))
|
|
.map_err(|e| e.to_string())
|
|
}
|
|
|
|
fn import_databases_from_sqlite(
|
|
app: &AppHandle,
|
|
import_library_tmp: &Path,
|
|
import_analysis_tmp: &Path,
|
|
) -> Result<(), String> {
|
|
let active_path = library_db_path(app)?;
|
|
let analysis_active_path = analysis_db_path(app)?;
|
|
let Some(runtime) = app.try_state::<psysonic_library::LibraryRuntime>() else {
|
|
remove_db_with_sidecars(import_library_tmp).ok();
|
|
remove_db_with_sidecars(import_analysis_tmp).ok();
|
|
return Err("library runtime unavailable".to_string());
|
|
};
|
|
let Some(cache) = app.try_state::<psysonic_analysis::analysis_cache::AnalysisCache>() else {
|
|
remove_db_with_sidecars(import_library_tmp).ok();
|
|
remove_db_with_sidecars(import_analysis_tmp).ok();
|
|
return Err("analysis runtime unavailable".to_string());
|
|
};
|
|
|
|
let library_backup = runtime
|
|
.store
|
|
.swap_database_file(&active_path, import_library_tmp)?
|
|
.ok_or_else(|| "import switch failed".to_string())?;
|
|
let analysis_backup = match cache.swap_database_file(&analysis_active_path, import_analysis_tmp) {
|
|
Ok(Some(backup)) => backup,
|
|
Ok(None) => {
|
|
let _ = runtime.store.restore_database_backup(&library_backup, &active_path);
|
|
let _ = remove_db_with_sidecars(&library_backup);
|
|
let _ = remove_db_with_sidecars(import_library_tmp);
|
|
let _ = remove_db_with_sidecars(import_analysis_tmp);
|
|
return Err("analysis import switch failed".to_string());
|
|
}
|
|
Err(err) => {
|
|
let _ = runtime.store.restore_database_backup(&library_backup, &active_path);
|
|
let _ = remove_db_with_sidecars(&library_backup);
|
|
let _ = remove_db_with_sidecars(import_library_tmp);
|
|
let _ = remove_db_with_sidecars(import_analysis_tmp);
|
|
return Err(err);
|
|
}
|
|
};
|
|
|
|
if let Err(err) = library_health_check(&active_path)
|
|
.and_then(|_| analysis_health_check(&analysis_active_path))
|
|
{
|
|
let _ = runtime.store.restore_database_backup(&library_backup, &active_path);
|
|
let _ = cache.restore_database_backup(&analysis_backup, &analysis_active_path);
|
|
let _ = remove_db_with_sidecars(&library_backup);
|
|
let _ = remove_db_with_sidecars(&analysis_backup);
|
|
let _ = remove_db_with_sidecars(import_library_tmp);
|
|
let _ = remove_db_with_sidecars(import_analysis_tmp);
|
|
return Err(err);
|
|
}
|
|
|
|
let library_bak_path = active_path.with_file_name("library.sqlite.import.bak");
|
|
remove_db_with_sidecars(&library_bak_path).ok();
|
|
if library_backup.exists() {
|
|
fs::rename(&library_backup, &library_bak_path).map_err(|e| e.to_string())?;
|
|
move_sidecar(&library_backup, &library_bak_path, "-wal")?;
|
|
move_sidecar(&library_backup, &library_bak_path, "-shm")?;
|
|
}
|
|
|
|
let analysis_bak_path = analysis_active_path.with_file_name("audio-analysis.sqlite.import.bak");
|
|
remove_db_with_sidecars(&analysis_bak_path).ok();
|
|
if analysis_backup.exists() {
|
|
fs::rename(&analysis_backup, &analysis_bak_path).map_err(|e| e.to_string())?;
|
|
move_sidecar(&analysis_backup, &analysis_bak_path, "-wal")?;
|
|
move_sidecar(&analysis_backup, &analysis_bak_path, "-shm")?;
|
|
}
|
|
|
|
remove_db_with_sidecars(import_library_tmp).ok();
|
|
remove_db_with_sidecars(import_analysis_tmp).ok();
|
|
Ok(())
|
|
}
|
|
|
|
fn remove_db_with_sidecars(path: &Path) -> Result<(), String> {
|
|
remove_if_exists(path)?;
|
|
for suffix in ["-wal", "-shm"] {
|
|
let sidecar = PathBuf::from(format!("{}{}", path.to_string_lossy(), suffix));
|
|
remove_if_exists(&sidecar)?;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn move_sidecar(from_base: &Path, to_base: &Path, suffix: &str) -> Result<(), String> {
|
|
let from = PathBuf::from(format!("{}{}", from_base.display(), suffix));
|
|
if !from.exists() {
|
|
return Ok(());
|
|
}
|
|
let to = PathBuf::from(format!("{}{}", to_base.display(), suffix));
|
|
if let Some(parent) = to.parent() {
|
|
fs::create_dir_all(parent).map_err(|e| e.to_string())?;
|
|
}
|
|
fs::rename(from, to).map_err(|e| e.to_string())
|
|
}
|
|
|
|
fn remove_if_exists(path: &Path) -> Result<(), String> {
|
|
if path.exists() {
|
|
fs::remove_file(path).map_err(|e| e.to_string())?;
|
|
}
|
|
Ok(())
|
|
}
|