004 Multi-library Support #54
@@ -155,6 +155,10 @@ pub struct FilesRequest {
|
|||||||
// Pagination parameters (optional - backward compatible)
|
// Pagination parameters (optional - backward compatible)
|
||||||
pub limit: Option<i64>,
|
pub limit: Option<i64>,
|
||||||
pub offset: Option<i64>,
|
pub offset: Option<i64>,
|
||||||
|
|
||||||
|
/// Optional library filter. Accepts a library id (e.g. "1") or name
|
||||||
|
/// (e.g. "main"). When omitted, results span all libraries.
|
||||||
|
pub library: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Copy, Clone, Deserialize, PartialEq, Debug)]
|
#[derive(Copy, Clone, Deserialize, PartialEq, Debug)]
|
||||||
|
|||||||
16
src/files.rs
16
src/files.rs
@@ -235,8 +235,24 @@ pub async fn list_photos<TagD: TagDao, FS: FileSystemAccess>(
|
|||||||
)
|
)
|
||||||
.to_string(),
|
.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);
|
let span_context = opentelemetry::Context::current_with_span(span);
|
||||||
|
|
||||||
// Check if EXIF filtering is requested
|
// Check if EXIF filtering is requested
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
|
use actix_web::{HttpResponse, Responder, get, web::Data};
|
||||||
use chrono::Utc;
|
use chrono::Utc;
|
||||||
use diesel::prelude::*;
|
use diesel::prelude::*;
|
||||||
use diesel::sqlite::SqliteConnection;
|
use diesel::sqlite::SqliteConnection;
|
||||||
use log::{info, warn};
|
use log::{info, warn};
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
use crate::data::Claims;
|
||||||
use crate::database::models::{InsertLibrary, LibraryRow};
|
use crate::database::models::{InsertLibrary, LibraryRow};
|
||||||
use crate::database::schema::libraries;
|
use crate::database::schema::libraries;
|
||||||
|
use crate::state::AppState;
|
||||||
|
|
||||||
/// Id of the primary library row seeded by the multi-library migration.
|
/// Id of the primary library row seeded by the multi-library migration.
|
||||||
/// Used as the default `library_id` during the Phase 2 transitional shim,
|
/// 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<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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|||||||
@@ -1191,6 +1191,7 @@ fn main() -> std::io::Result<()> {
|
|||||||
.service(ai::get_available_models_handler)
|
.service(ai::get_available_models_handler)
|
||||||
.service(ai::rate_insight_handler)
|
.service(ai::rate_insight_handler)
|
||||||
.service(ai::export_training_data_handler)
|
.service(ai::export_training_data_handler)
|
||||||
|
.service(libraries::list_libraries)
|
||||||
.add_feature(add_tag_services::<_, SqliteTagDao>)
|
.add_feature(add_tag_services::<_, SqliteTagDao>)
|
||||||
.add_feature(knowledge::add_knowledge_services::<_, SqliteKnowledgeDao>)
|
.add_feature(knowledge::add_knowledge_services::<_, SqliteKnowledgeDao>)
|
||||||
.app_data(app_data.clone())
|
.app_data(app_data.clone())
|
||||||
|
|||||||
@@ -107,6 +107,9 @@ pub struct MemoriesRequest {
|
|||||||
pub span: Option<MemoriesSpan>,
|
pub span: Option<MemoriesSpan>,
|
||||||
/// Client timezone offset in minutes from UTC (e.g., -480 for PST, 60 for CET)
|
/// Client timezone offset in minutes from UTC (e.g., -480 for PST, 60 for CET)
|
||||||
pub timezone_offset_minutes: Option<i32>,
|
pub timezone_offset_minutes: Option<i32>,
|
||||||
|
/// Optional library filter. Accepts a library id (e.g. "1") or name
|
||||||
|
/// (e.g. "main"). When omitted, results span all libraries.
|
||||||
|
pub library: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Clone)]
|
#[derive(Debug, Serialize, Clone)]
|
||||||
@@ -114,6 +117,9 @@ pub struct MemoryItem {
|
|||||||
pub path: String,
|
pub path: String,
|
||||||
pub created: Option<i64>,
|
pub created: Option<i64>,
|
||||||
pub modified: Option<i64>,
|
pub modified: Option<i64>,
|
||||||
|
/// 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)]
|
#[derive(Debug, Serialize)]
|
||||||
@@ -417,6 +423,7 @@ fn collect_exif_memories(
|
|||||||
path: file_path.clone(),
|
path: file_path.clone(),
|
||||||
created,
|
created,
|
||||||
modified,
|
modified,
|
||||||
|
library_id: crate::libraries::PRIMARY_LIBRARY_ID,
|
||||||
},
|
},
|
||||||
file_date,
|
file_date,
|
||||||
))
|
))
|
||||||
@@ -478,6 +485,7 @@ fn collect_filesystem_memories(
|
|||||||
path: path_relative,
|
path: path_relative,
|
||||||
created,
|
created,
|
||||||
modified,
|
modified,
|
||||||
|
library_id: crate::libraries::PRIMARY_LIBRARY_ID,
|
||||||
},
|
},
|
||||||
file_date,
|
file_date,
|
||||||
))
|
))
|
||||||
@@ -526,7 +534,23 @@ pub async fn list_memories(
|
|||||||
|
|
||||||
debug!("Now: {:?}", now);
|
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
|
// Build the path excluder from base and env-configured exclusions
|
||||||
let path_excluder = PathExcluder::new(base, &app_state.excluded_dirs);
|
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(
|
let exif_memories = collect_exif_memories(
|
||||||
&exif_dao,
|
&exif_dao,
|
||||||
&span_context,
|
&span_context,
|
||||||
&app_state.base_path,
|
&scoped_library.root_path,
|
||||||
now,
|
now,
|
||||||
span_mode,
|
span_mode,
|
||||||
years_back,
|
years_back,
|
||||||
@@ -546,12 +570,12 @@ pub async fn list_memories(
|
|||||||
// Build HashSet for deduplication
|
// Build HashSet for deduplication
|
||||||
let exif_paths: HashSet<PathBuf> = exif_memories
|
let exif_paths: HashSet<PathBuf> = exif_memories
|
||||||
.iter()
|
.iter()
|
||||||
.map(|(item, _)| PathBuf::from(&app_state.base_path).join(&item.path))
|
.map(|(item, _)| PathBuf::from(&scoped_library.root_path).join(&item.path))
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
// Phase 2: File system scan (skip EXIF files)
|
// Phase 2: File system scan (skip EXIF files)
|
||||||
let fs_memories = collect_filesystem_memories(
|
let fs_memories = collect_filesystem_memories(
|
||||||
&app_state.base_path,
|
&scoped_library.root_path,
|
||||||
&path_excluder,
|
&path_excluder,
|
||||||
&exif_paths,
|
&exif_paths,
|
||||||
now,
|
now,
|
||||||
@@ -1098,6 +1122,7 @@ mod tests {
|
|||||||
path: "photo1.jpg".to_string(),
|
path: "photo1.jpg".to_string(),
|
||||||
created: Some(jan_15_2024_9am),
|
created: Some(jan_15_2024_9am),
|
||||||
modified: 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(),
|
NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
|
||||||
),
|
),
|
||||||
@@ -1106,6 +1131,7 @@ mod tests {
|
|||||||
path: "photo2.jpg".to_string(),
|
path: "photo2.jpg".to_string(),
|
||||||
created: Some(jan_15_2020_10am),
|
created: Some(jan_15_2020_10am),
|
||||||
modified: 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(),
|
NaiveDate::from_ymd_opt(2020, 1, 15).unwrap(),
|
||||||
),
|
),
|
||||||
@@ -1114,6 +1140,7 @@ mod tests {
|
|||||||
path: "photo3.jpg".to_string(),
|
path: "photo3.jpg".to_string(),
|
||||||
created: Some(jan_16_2021_8am),
|
created: Some(jan_16_2021_8am),
|
||||||
modified: 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(),
|
NaiveDate::from_ymd_opt(2021, 1, 16).unwrap(),
|
||||||
),
|
),
|
||||||
|
|||||||
Reference in New Issue
Block a user