New `/libraries` endpoint returns configured libraries so clients can discover them. `FilesRequest` and `MemoriesRequest` gain an optional `library` param (accepts name or numeric id). Unknown values are rejected with 400; absent values span all libraries. `/memories` now scopes its filesystem walk + EXIF query to the resolved library. `MemoryItem` carries `library_id` so union-mode clients can render a per-item source badge. Behavior is unchanged in single-library mode: omitting `library` still returns results from the primary library, which is the only one configured until a second row is added to the libraries table. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
200 lines
6.6 KiB
Rust
200 lines
6.6 KiB
Rust
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.
|
|
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),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// 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<Option<&'a Library>, String> {
|
|
let Some(raw) = param.map(str::trim).filter(|s| !s.is_empty()) else {
|
|
return Ok(None);
|
|
};
|
|
|
|
if let Ok(id) = raw.parse::<i32>() {
|
|
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<Library>,
|
|
}
|
|
|
|
#[get("/libraries")]
|
|
pub async fn list_libraries(_claims: Claims, app_state: Data<AppState>) -> 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());
|
|
}
|
|
}
|