use actix_web::{HttpResponse, Responder, get, web::Data}; use chrono::Utc; use diesel::prelude::*; use diesel::sqlite::SqliteConnection; use log::{info, warn}; use std::path::{Path, PathBuf}; use crate::data::Claims; use crate::database::models::{InsertLibrary, LibraryRow}; use crate::database::schema::libraries; use crate::state::AppState; /// 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. #[allow(dead_code)] 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. #[allow(dead_code)] pub fn strip_root(&self, abs_path: &Path) -> Option { abs_path .strip_prefix(&self.root_path) .ok() .map(|p| p.to_string_lossy().replace('\\', "/")) } } impl From 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 { libraries::table .order(libraries::id.asc()) .load::(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), } } } /// Resolve a library request parameter (accepts numeric id as string or name) /// against the configured libraries. Returns `Ok(None)` when the param is /// absent, meaning "span all libraries". Returns `Err` when a value is /// provided but does not match any library. pub fn resolve_library_param<'a>( state: &'a AppState, param: Option<&str>, ) -> Result, String> { let Some(raw) = param.map(str::trim).filter(|s| !s.is_empty()) else { return Ok(None); }; if let Ok(id) = raw.parse::() { return state .library_by_id(id) .map(Some) .ok_or_else(|| format!("unknown library id: {}", id)); } state .library_by_name(raw) .map(Some) .ok_or_else(|| format!("unknown library name: {}", raw)) } #[derive(serde::Serialize)] pub struct LibrariesResponse { pub libraries: Vec, } #[get("/libraries")] pub async fn list_libraries(_claims: Claims, app_state: Data) -> impl Responder { HttpResponse::Ok().json(LibrariesResponse { libraries: app_state.libraries.clone(), }) } #[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()); } #[test] fn library_resolve_joins_under_root() { let lib = Library { id: 1, name: "main".into(), root_path: "/tmp/media".into(), }; let abs = lib.resolve("2024/photo.jpg"); assert_eq!(abs, PathBuf::from("/tmp/media/2024/photo.jpg")); } fn state_with_libraries(libs: Vec) -> AppState { let mut state = AppState::test_state(); state.libraries = libs; state } fn sample_libraries() -> Vec { vec![ Library { id: 1, name: "main".into(), root_path: "/tmp/main".into(), }, Library { id: 7, name: "archive".into(), root_path: "/tmp/archive".into(), }, ] } #[actix_rt::test] async fn resolve_library_param_absent_is_union() { let state = state_with_libraries(sample_libraries()); assert!(matches!(resolve_library_param(&state, None), Ok(None))); } #[actix_rt::test] async fn resolve_library_param_empty_or_whitespace_is_union() { let state = state_with_libraries(sample_libraries()); assert!(matches!(resolve_library_param(&state, Some("")), Ok(None))); assert!(matches!( resolve_library_param(&state, Some(" ")), Ok(None) )); } #[actix_rt::test] async fn resolve_library_param_numeric_id_matches() { let state = state_with_libraries(sample_libraries()); let lib = resolve_library_param(&state, Some("7")) .expect("valid id") .expect("some library"); assert_eq!(lib.id, 7); assert_eq!(lib.name, "archive"); } #[actix_rt::test] async fn resolve_library_param_name_matches() { let state = state_with_libraries(sample_libraries()); let lib = resolve_library_param(&state, Some("main")) .expect("valid name") .expect("some library"); assert_eq!(lib.id, 1); } #[actix_rt::test] async fn resolve_library_param_unknown_id_errs() { let state = state_with_libraries(sample_libraries()); let err = resolve_library_param(&state, Some("999")).unwrap_err(); assert!(err.contains("unknown library id")); } #[actix_rt::test] async fn resolve_library_param_unknown_name_errs() { let state = state_with_libraries(sample_libraries()); let err = resolve_library_param(&state, Some("missing")).unwrap_err(); assert!(err.contains("unknown library name")); } }