feat: multi-library foundation (schema + libraries module)
Adds a `libraries` registry table and threads library_id through per-instance metadata tables (image_exif, photo_insights, entity_photo_links, video_preview_clips). File-path columns renamed to rel_path to make the relative-to-root semantics explicit. Adds content_hash + size_bytes on image_exif to support future hash-keyed thumbnail/HLS dedup. Tags and favorites stay library-agnostic so they share across libraries by rel_path. Behavior is unchanged: a single primary library (id=1) is seeded from BASE_PATH on first boot; all handlers and DAOs route through it as a transitional shim until the API gains a library query param. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
159
src/libraries.rs
Normal file
159
src/libraries.rs
Normal file
@@ -0,0 +1,159 @@
|
||||
use chrono::Utc;
|
||||
use diesel::prelude::*;
|
||||
use diesel::sqlite::SqliteConnection;
|
||||
use log::{info, warn};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use crate::database::models::{InsertLibrary, LibraryRow};
|
||||
use crate::database::schema::libraries;
|
||||
|
||||
/// Id of the primary library row seeded by the multi-library migration.
|
||||
/// Used as the default `library_id` during the Phase 2 transitional shim,
|
||||
/// before handlers/callers are library-aware.
|
||||
pub const PRIMARY_LIBRARY_ID: i32 = 1;
|
||||
|
||||
/// Placeholder value written into `libraries.root_path` by the migration.
|
||||
/// Replaced on startup with the live `BASE_PATH` env var.
|
||||
pub const ROOT_PATH_PLACEHOLDER: &str = "BASE_PATH_PLACEHOLDER";
|
||||
|
||||
/// A media library mount point: its numeric id, logical name, and absolute
|
||||
/// root on disk. `rel_path` values stored in the DB are relative to this root.
|
||||
#[derive(Clone, Debug, serde::Serialize)]
|
||||
pub struct Library {
|
||||
pub id: i32,
|
||||
pub name: String,
|
||||
pub root_path: String,
|
||||
}
|
||||
|
||||
impl Library {
|
||||
/// Resolve a library-relative path into an absolute `PathBuf` under the
|
||||
/// library root. Does not validate traversal — use `is_valid_full_path`
|
||||
/// for untrusted input.
|
||||
pub fn resolve(&self, rel_path: &str) -> PathBuf {
|
||||
Path::new(&self.root_path).join(rel_path)
|
||||
}
|
||||
|
||||
/// Inverse of `resolve`: given an absolute path under this library's
|
||||
/// root, return the root-relative portion. Returns `None` if the path
|
||||
/// is not under the library.
|
||||
pub fn strip_root(&self, abs_path: &Path) -> Option<String> {
|
||||
abs_path
|
||||
.strip_prefix(&self.root_path)
|
||||
.ok()
|
||||
.map(|p| p.to_string_lossy().replace('\\', "/"))
|
||||
}
|
||||
}
|
||||
|
||||
impl From<LibraryRow> for Library {
|
||||
fn from(row: LibraryRow) -> Self {
|
||||
Library {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
root_path: row.root_path,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Load all library rows from the database into `Library` values.
|
||||
pub fn load_all(conn: &mut SqliteConnection) -> Vec<Library> {
|
||||
libraries::table
|
||||
.order(libraries::id.asc())
|
||||
.load::<LibraryRow>(conn)
|
||||
.unwrap_or_else(|e| {
|
||||
warn!("Failed to load libraries table: {:?}", e);
|
||||
Vec::new()
|
||||
})
|
||||
.into_iter()
|
||||
.map(Library::from)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Ensure at least one library exists and that the seeded placeholder row is
|
||||
/// patched with the live `BASE_PATH`. Safe to call on every startup; it only
|
||||
/// writes when the placeholder is still present.
|
||||
pub fn seed_or_patch_from_env(conn: &mut SqliteConnection, base_path: &str) {
|
||||
// Check whether the primary row still carries the placeholder from the
|
||||
// migration. If so, replace it with the live BASE_PATH.
|
||||
let placeholder_count: i64 = libraries::table
|
||||
.filter(libraries::root_path.eq(ROOT_PATH_PLACEHOLDER))
|
||||
.count()
|
||||
.get_result(conn)
|
||||
.unwrap_or(0);
|
||||
|
||||
if placeholder_count > 0 {
|
||||
diesel::update(libraries::table.filter(libraries::root_path.eq(ROOT_PATH_PLACEHOLDER)))
|
||||
.set(libraries::root_path.eq(base_path))
|
||||
.execute(conn)
|
||||
.map(|rows| {
|
||||
info!(
|
||||
"Patched {} library row(s) with BASE_PATH='{}'",
|
||||
rows, base_path
|
||||
);
|
||||
})
|
||||
.unwrap_or_else(|e| warn!("Failed to patch library root_path: {:?}", e));
|
||||
return;
|
||||
}
|
||||
|
||||
// If no rows exist at all (e.g. table created outside the seeded migration),
|
||||
// insert a primary library pointing at BASE_PATH.
|
||||
let total: i64 = libraries::table
|
||||
.count()
|
||||
.get_result(conn)
|
||||
.unwrap_or(0);
|
||||
if total == 0 {
|
||||
let now = Utc::now().timestamp();
|
||||
let result = diesel::insert_into(libraries::table)
|
||||
.values(InsertLibrary {
|
||||
name: "main",
|
||||
root_path: base_path,
|
||||
created_at: now,
|
||||
})
|
||||
.execute(conn);
|
||||
match result {
|
||||
Ok(_) => info!("Seeded primary library 'main' with BASE_PATH='{}'", base_path),
|
||||
Err(e) => warn!("Failed to seed primary library: {:?}", e),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::database::test::in_memory_db_connection;
|
||||
|
||||
#[test]
|
||||
fn seed_patches_placeholder() {
|
||||
let mut conn = in_memory_db_connection();
|
||||
// Migration seeds one row with the placeholder.
|
||||
seed_or_patch_from_env(&mut conn, "/tmp/media");
|
||||
let libs = load_all(&mut conn);
|
||||
assert_eq!(libs.len(), 1);
|
||||
assert_eq!(libs[0].id, 1);
|
||||
assert_eq!(libs[0].name, "main");
|
||||
assert_eq!(libs[0].root_path, "/tmp/media");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn seed_is_idempotent() {
|
||||
let mut conn = in_memory_db_connection();
|
||||
seed_or_patch_from_env(&mut conn, "/tmp/media");
|
||||
seed_or_patch_from_env(&mut conn, "/tmp/other");
|
||||
// Second call should not overwrite an already-patched row.
|
||||
let libs = load_all(&mut conn);
|
||||
assert_eq!(libs.len(), 1);
|
||||
assert_eq!(libs[0].root_path, "/tmp/media");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn library_strip_root() {
|
||||
let lib = Library {
|
||||
id: 1,
|
||||
name: "main".into(),
|
||||
root_path: "/tmp/media".into(),
|
||||
};
|
||||
let rel = lib.strip_root(Path::new("/tmp/media/2024/photo.jpg"));
|
||||
assert_eq!(rel.as_deref(), Some("2024/photo.jpg"));
|
||||
let outside = lib.strip_root(Path::new("/etc/passwd"));
|
||||
assert!(outside.is_none());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user