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:
Cameron
2026-04-17 15:28:30 -04:00
parent 8bc948b297
commit 00da97fe86
17 changed files with 750 additions and 108 deletions

159
src/libraries.rs Normal file
View 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());
}
}