From 48e5de6eab23ab2b77f107176012529beefa8474 Mon Sep 17 00:00:00 2001 From: Cameron Date: Fri, 17 Apr 2026 15:35:14 -0400 Subject: [PATCH] feat: add GET /libraries and library query param plumbing 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 --- src/data/mod.rs | 4 ++++ src/files.rs | 16 ++++++++++++++++ src/libraries.rs | 40 ++++++++++++++++++++++++++++++++++++++++ src/main.rs | 1 + src/memories.rs | 35 +++++++++++++++++++++++++++++++---- 5 files changed, 92 insertions(+), 4 deletions(-) diff --git a/src/data/mod.rs b/src/data/mod.rs index 6935819..2aedbae 100644 --- a/src/data/mod.rs +++ b/src/data/mod.rs @@ -155,6 +155,10 @@ pub struct FilesRequest { // Pagination parameters (optional - backward compatible) pub limit: Option, pub offset: Option, + + /// Optional library filter. Accepts a library id (e.g. "1") or name + /// (e.g. "main"). When omitted, results span all libraries. + pub library: Option, } #[derive(Copy, Clone, Deserialize, PartialEq, Debug)] diff --git a/src/files.rs b/src/files.rs index 29a4d12..224c801 100644 --- a/src/files.rs +++ b/src/files.rs @@ -235,8 +235,24 @@ pub async fn list_photos( ) .to_string(), ), + KeyValue::new("library", req.library.clone().unwrap_or_default()), ]); + // Resolve the optional library filter. Unknown values return 400. + // For Phase 3 the filesystem walk still operates against a single + // library's root; Phase 4 introduces multi-root union scanning. + let library = match crate::libraries::resolve_library_param( + &app_state, + req.library.as_deref(), + ) { + Ok(lib) => lib, + Err(msg) => { + log::warn!("Rejecting /photos request: {}", msg); + return HttpResponse::BadRequest().body(msg); + } + }; + let scoped_library = library.unwrap_or_else(|| app_state.primary_library()); + let span_context = opentelemetry::Context::current_with_span(span); // Check if EXIF filtering is requested diff --git a/src/libraries.rs b/src/libraries.rs index a706507..22a4214 100644 --- a/src/libraries.rs +++ b/src/libraries.rs @@ -1,11 +1,14 @@ +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, @@ -116,6 +119,43 @@ pub fn seed_or_patch_from_env(conn: &mut SqliteConnection, base_path: &str) { } } +/// 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::*; diff --git a/src/main.rs b/src/main.rs index c440cc1..044d1a3 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1191,6 +1191,7 @@ fn main() -> std::io::Result<()> { .service(ai::get_available_models_handler) .service(ai::rate_insight_handler) .service(ai::export_training_data_handler) + .service(libraries::list_libraries) .add_feature(add_tag_services::<_, SqliteTagDao>) .add_feature(knowledge::add_knowledge_services::<_, SqliteKnowledgeDao>) .app_data(app_data.clone()) diff --git a/src/memories.rs b/src/memories.rs index c3754d3..89a7028 100644 --- a/src/memories.rs +++ b/src/memories.rs @@ -107,6 +107,9 @@ pub struct MemoriesRequest { pub span: Option, /// Client timezone offset in minutes from UTC (e.g., -480 for PST, 60 for CET) pub timezone_offset_minutes: Option, + /// Optional library filter. Accepts a library id (e.g. "1") or name + /// (e.g. "main"). When omitted, results span all libraries. + pub library: Option, } #[derive(Debug, Serialize, Clone)] @@ -114,6 +117,9 @@ pub struct MemoryItem { pub path: String, pub created: Option, pub modified: Option, + /// Id of the library this memory belongs to. Allows clients to show a + /// per-item source badge in union mode. + pub library_id: i32, } #[derive(Debug, Serialize)] @@ -417,6 +423,7 @@ fn collect_exif_memories( path: file_path.clone(), created, modified, + library_id: crate::libraries::PRIMARY_LIBRARY_ID, }, file_date, )) @@ -478,6 +485,7 @@ fn collect_filesystem_memories( path: path_relative, created, modified, + library_id: crate::libraries::PRIMARY_LIBRARY_ID, }, file_date, )) @@ -526,7 +534,23 @@ pub async fn list_memories( debug!("Now: {:?}", now); - let base = Path::new(&app_state.base_path); + // Resolve the optional library filter. Unknown values are a 400; None + // means "all libraries" — currently equivalent to the primary library + // while only one is configured. + let library = match crate::libraries::resolve_library_param( + &app_state, + q.library.as_deref(), + ) { + Ok(lib) => lib, + Err(msg) => { + warn!("Rejecting /memories request: {}", msg); + return HttpResponse::BadRequest().body(msg); + } + }; + // For Phase 3 the walker still operates against a single library's root. + // Multi-library union support for the filesystem walk comes in Phase 4. + let scoped_library = library.unwrap_or_else(|| app_state.primary_library()); + let base = Path::new(&scoped_library.root_path); // Build the path excluder from base and env-configured exclusions let path_excluder = PathExcluder::new(base, &app_state.excluded_dirs); @@ -535,7 +559,7 @@ pub async fn list_memories( let exif_memories = collect_exif_memories( &exif_dao, &span_context, - &app_state.base_path, + &scoped_library.root_path, now, span_mode, years_back, @@ -546,12 +570,12 @@ pub async fn list_memories( // Build HashSet for deduplication let exif_paths: HashSet = exif_memories .iter() - .map(|(item, _)| PathBuf::from(&app_state.base_path).join(&item.path)) + .map(|(item, _)| PathBuf::from(&scoped_library.root_path).join(&item.path)) .collect(); // Phase 2: File system scan (skip EXIF files) let fs_memories = collect_filesystem_memories( - &app_state.base_path, + &scoped_library.root_path, &path_excluder, &exif_paths, now, @@ -1098,6 +1122,7 @@ mod tests { path: "photo1.jpg".to_string(), created: Some(jan_15_2024_9am), modified: Some(jan_15_2024_9am), + library_id: crate::libraries::PRIMARY_LIBRARY_ID, }, NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(), ), @@ -1106,6 +1131,7 @@ mod tests { path: "photo2.jpg".to_string(), created: Some(jan_15_2020_10am), modified: Some(jan_15_2020_10am), + library_id: crate::libraries::PRIMARY_LIBRARY_ID, }, NaiveDate::from_ymd_opt(2020, 1, 15).unwrap(), ), @@ -1114,6 +1140,7 @@ mod tests { path: "photo3.jpg".to_string(), created: Some(jan_16_2021_8am), modified: Some(jan_16_2021_8am), + library_id: crate::libraries::PRIMARY_LIBRARY_ID, }, NaiveDate::from_ymd_opt(2021, 1, 16).unwrap(), ),