Pure mechanical cleanup of accumulated drift in files outside the
HLS-content-hash branch's main change set. No behavior change.
- `cargo fmt` on every previously-misformatted file
(`ai/insight_generator.rs`, `database/knowledge_dao.rs`,
`faces.rs`, `knowledge.rs`, `libraries.rs`).
- `cargo clippy --fix`:
- `needless_borrow`: `&library` → `library` in `handlers/image.rs`
(two sites in the photo-listing path).
- Manual clippy pass for warnings clippy emits but can't auto-apply:
- `field_reassign_with_default` in `database/reconcile.rs::run` —
consolidated into a struct-literal initializer.
- `needless_range_loop` in `database/knowledge_dao.rs::union_perceptual_tags`
— inner `for b in (a+1)..indices.len() { let ib = indices[b]; ... }`
becomes `for &ib in &indices[a + 1..] { ... }`.
- Doc-list indentation: continuation lines under nested bullets in
`database/mod.rs::get_memories_in_window` and
`database/knowledge_dao.rs::build_entity_graph` realigned to the
list-item content column.
Deliberately not touched (each deserves its own focused commit, with
testing, rather than getting bundled into a sweep):
- 4× `deprecated count_distinct` in `faces.rs` — diesel API migration
to `AggregateExpressionMethods::aggregate_distinct` may shift result
types; needs verification against the existing stats queries.
- `await_holding_lock` in `knowledge.rs:807` — `std::sync::Mutex` held
across `ollama.generate(...).await`. Genuine concurrency bug; fix
requires understanding the surrounding flow before just dropping
the guard.
- 2× `type_complexity` in `database/mod.rs` — cosmetic, would need a
`type` alias and corresponding callers updated.
- Dead `total_deleted` on `library_maintenance::GcStats` and
`file_scan::enumerate_indexable_files` — both are public surface
retained for future use; deletion is a separate decision.
All 707 tests still pass. Release build clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1076 lines
41 KiB
Rust
1076 lines
41 KiB
Rust
use actix_web::{HttpResponse, Responder, get, patch, web, web::Data};
|
|
use chrono::Utc;
|
|
use diesel::prelude::*;
|
|
use diesel::sqlite::SqliteConnection;
|
|
use log::{info, warn};
|
|
use serde::Deserialize;
|
|
use std::collections::HashMap;
|
|
use std::path::{Path, PathBuf};
|
|
use std::sync::{Arc, RwLock};
|
|
|
|
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,
|
|
/// Operator kill switch (mirrors `libraries.enabled`). When `false`
|
|
/// the watcher skips this library entirely — before the probe,
|
|
/// before ingest, before maintenance. Reads / serving still work
|
|
/// (a request whose path resolves to a disabled library's root
|
|
/// will succeed if the file is on disk; nothing prevents that
|
|
/// today and there's no obvious reason to). Toggle via SQL.
|
|
pub enabled: bool,
|
|
/// Per-library excluded paths/patterns, parsed from the
|
|
/// comma-separated DB column. The walker applies these
|
|
/// **in union** with the global `EXCLUDED_DIRS` env var; either
|
|
/// list matching a path is enough to exclude. Empty = no
|
|
/// library-specific excludes (only the global env var applies).
|
|
pub excluded_dirs: Vec<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<String> {
|
|
abs_path
|
|
.strip_prefix(&self.root_path)
|
|
.ok()
|
|
.map(|p| p.to_string_lossy().replace('\\', "/"))
|
|
}
|
|
|
|
/// Effective excluded directories for a walk of this library:
|
|
/// the union of the global env-var excludes (passed in by the
|
|
/// caller as `globals`) and this library's per-row excludes.
|
|
/// Order doesn't matter; `PathExcluder` accepts repeats.
|
|
pub fn effective_excluded_dirs(&self, globals: &[String]) -> Vec<String> {
|
|
if self.excluded_dirs.is_empty() {
|
|
return globals.to_vec();
|
|
}
|
|
let mut combined: Vec<String> =
|
|
Vec::with_capacity(globals.len() + self.excluded_dirs.len());
|
|
combined.extend_from_slice(globals);
|
|
combined.extend(self.excluded_dirs.iter().cloned());
|
|
combined
|
|
}
|
|
}
|
|
|
|
/// Parse an excluded_dirs string into a Vec, dropping empty entries.
|
|
/// NULL → empty Vec. Duplicates are preserved — `PathExcluder` accepts
|
|
/// repeats, and the storage-side normaliser is where dedup happens.
|
|
///
|
|
/// Accepts both `,` and newline (`\n` / `\r\n`) as separators so the
|
|
/// UI's textarea can submit one-entry-per-line input without forcing
|
|
/// the operator to remember commas. The DB stores the canonical
|
|
/// comma-joined form (see `normalize_excluded_dirs_input`); the
|
|
/// newline path matters mostly for the frontend submit, but mirroring
|
|
/// it here keeps the parse direction round-trip safe.
|
|
pub fn parse_excluded_dirs_column(raw: Option<&str>) -> Vec<String> {
|
|
match raw {
|
|
None => Vec::new(),
|
|
Some(s) => s
|
|
.split([',', '\n', '\r'])
|
|
.map(str::trim)
|
|
.filter(|s| !s.is_empty())
|
|
.map(String::from)
|
|
.collect(),
|
|
}
|
|
}
|
|
|
|
/// Validate a single excluded_dirs entry, normalising trivial cosmetic
|
|
/// differences and rejecting forms that `PathExcluder` would silently
|
|
/// drop. Returns the entry to store, or an error message describing
|
|
/// what's wrong with it.
|
|
///
|
|
/// Rules:
|
|
/// - Backslashes are rejected — PathExcluder strips only a leading `/`;
|
|
/// a Windows-typed `\photos` or `photos\2024` lands in the
|
|
/// component-pattern bucket and never matches anything. Suggest the
|
|
/// forward-slash form.
|
|
/// - A Windows drive letter prefix (`Z:` etc.) is rejected — excluded
|
|
/// entries are *relative to the library root*, not absolute system
|
|
/// paths.
|
|
/// - A no-leading-slash entry containing `/` is rejected — the
|
|
/// component-pattern path matches a single segment only; the user
|
|
/// almost certainly meant the leading-slash form.
|
|
/// - A `..` segment in a path entry is rejected — `base.join("../x")`
|
|
/// doesn't canonicalise, so the resulting prefix never matches and
|
|
/// the exclude silently fails.
|
|
/// - Trailing slashes on path entries are stripped silently
|
|
/// (`/photos/` → `/photos`) — purely cosmetic.
|
|
pub fn validate_excluded_dirs_entry(entry: &str) -> Result<String, String> {
|
|
let trimmed = entry.trim();
|
|
if trimmed.is_empty() {
|
|
return Err("empty entry".to_string());
|
|
}
|
|
if trimmed.contains('\\') {
|
|
return Err(format!(
|
|
"'{}': use forward slashes — backslash paths never match on the watcher's component-by-component compare",
|
|
trimmed
|
|
));
|
|
}
|
|
// Windows drive letter prefix like `Z:` or `Z:/something`. A
|
|
// length-2 ASCII-alpha + colon is the canonical form; we don't
|
|
// bother with longer multi-letter Windows drive-equivalents
|
|
// (`\\?\Volume{…}`) since the backslash check already catches them.
|
|
let bytes = trimmed.as_bytes();
|
|
if bytes.len() >= 2 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':' {
|
|
return Err(format!(
|
|
"'{}': excluded entries are relative to the library root, not absolute system paths — drop the drive letter",
|
|
trimmed
|
|
));
|
|
}
|
|
if let Some(rel) = trimmed.strip_prefix('/') {
|
|
// Path form. Reject `..` traversal — `base.join(\"../x\")` doesn't
|
|
// canonicalise, so `path.starts_with(...)` never matches.
|
|
if rel.split('/').any(|seg| seg == "..") {
|
|
return Err(format!(
|
|
"'{}': '..' segments don't normalise — the prefix-match never fires",
|
|
trimmed
|
|
));
|
|
}
|
|
// Strip a trailing slash if any (`/photos/` → `/photos`). Purely
|
|
// cosmetic; PathBuf::starts_with treats both forms identically.
|
|
let stripped = if rel.ends_with('/') {
|
|
format!("/{}", rel.trim_end_matches('/'))
|
|
} else {
|
|
trimmed.to_string()
|
|
};
|
|
// After stripping, an empty rel ("/" alone) excludes the root —
|
|
// certainly a typo.
|
|
if stripped == "/" {
|
|
return Err("'/': excluding the library root is almost certainly a typo".to_string());
|
|
}
|
|
Ok(stripped)
|
|
} else {
|
|
// Component-pattern form: must be a single segment. A `/`
|
|
// anywhere here is the common "I forgot the leading slash" typo
|
|
// — reject so the user fixes it instead of staring at an
|
|
// exclude that does nothing.
|
|
if trimmed.contains('/') {
|
|
return Err(format!(
|
|
"'{}': multi-segment names only match with a leading slash — try '/{}'",
|
|
trimmed, trimmed
|
|
));
|
|
}
|
|
Ok(trimmed.to_string())
|
|
}
|
|
}
|
|
|
|
/// Canonicalise an excluded_dirs string for storage: validate each
|
|
/// entry, then parse → trim → dedupe (preserving insertion order) →
|
|
/// comma-join with no inner whitespace. Empty / whitespace-only input
|
|
/// → `Ok(None)` (writes NULL). Any entry that fails validation aborts
|
|
/// the whole patch with a descriptive error so the operator can fix
|
|
/// the typo before retrying.
|
|
///
|
|
/// Used by `PATCH /libraries/{id}` so two users typing the same entries
|
|
/// in different orders / casings / whitespace land on the same stored
|
|
/// form, and a typo'd duplicate (`@eaDir, @eaDir`) collapses on save.
|
|
/// Round-trip stable: writing the output back through this function
|
|
/// yields the same string.
|
|
pub fn normalize_excluded_dirs_input(raw: &str) -> Result<Option<String>, String> {
|
|
let parsed = parse_excluded_dirs_column(Some(raw));
|
|
if parsed.is_empty() {
|
|
return Ok(None);
|
|
}
|
|
let mut seen = std::collections::HashSet::new();
|
|
let mut deduped: Vec<String> = Vec::with_capacity(parsed.len());
|
|
for entry in parsed {
|
|
let validated = validate_excluded_dirs_entry(&entry)?;
|
|
if seen.insert(validated.clone()) {
|
|
deduped.push(validated);
|
|
}
|
|
}
|
|
if deduped.is_empty() {
|
|
Ok(None)
|
|
} else {
|
|
Ok(Some(deduped.join(",")))
|
|
}
|
|
}
|
|
|
|
impl From<LibraryRow> for Library {
|
|
fn from(row: LibraryRow) -> Self {
|
|
Library {
|
|
id: row.id,
|
|
name: row.name,
|
|
root_path: row.root_path,
|
|
enabled: row.enabled,
|
|
excluded_dirs: parse_excluded_dirs_column(row.excluded_dirs.as_deref()),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// 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,
|
|
enabled: true,
|
|
excluded_dirs: None,
|
|
})
|
|
.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))
|
|
}
|
|
|
|
/// Health of a library at a point in time. Probed at the top of each
|
|
/// file-watcher tick. The `Stale` state is the "be conservative" signal:
|
|
/// destructive paths (ingest writes, future move-handoff and orphan GC in
|
|
/// branches B/C) skip a stale library, but reads/serving stay unaffected.
|
|
///
|
|
/// See `CLAUDE.md` → "Library availability and safety" for the policy.
|
|
#[derive(Clone, Debug, serde::Serialize, PartialEq, Eq)]
|
|
#[serde(tag = "state", rename_all = "snake_case")]
|
|
pub enum LibraryHealth {
|
|
Online,
|
|
Stale {
|
|
reason: String,
|
|
/// Unix timestamp (seconds) of the most recent transition into
|
|
/// Stale. Held for telemetry / `/libraries` surfacing only —
|
|
/// gating logic doesn't read it.
|
|
since: i64,
|
|
},
|
|
}
|
|
|
|
impl LibraryHealth {
|
|
pub fn is_online(&self) -> bool {
|
|
matches!(self, LibraryHealth::Online)
|
|
}
|
|
}
|
|
|
|
/// Shared snapshot of every configured library's health, keyed by
|
|
/// `library_id`. The watcher writes; HTTP handlers read. RwLock because
|
|
/// reads vastly outnumber writes (one tick vs. every status request).
|
|
pub type LibraryHealthMap = Arc<RwLock<HashMap<i32, LibraryHealth>>>;
|
|
|
|
/// Construct an initial health map. Libraries start `Online`; the first
|
|
/// probe will downgrade any that fail. Starting `Stale` would block ingest
|
|
/// for the watcher's first tick on a healthy mount, which is the wrong
|
|
/// default for a server that's just been restarted.
|
|
pub fn new_health_map(libs: &[Library]) -> LibraryHealthMap {
|
|
let mut m = HashMap::with_capacity(libs.len());
|
|
for lib in libs {
|
|
m.insert(lib.id, LibraryHealth::Online);
|
|
}
|
|
Arc::new(RwLock::new(m))
|
|
}
|
|
|
|
/// Probe a library's mount point. Cheap: stat + open dir + peek one entry.
|
|
///
|
|
/// `had_data` is the caller's prior knowledge that this library has been
|
|
/// non-empty before — typically `image_exif` row count > 0. When true, an
|
|
/// empty directory is suspicious (it's how an unmounted NFS share looks);
|
|
/// when false, it's accepted as a fresh mount that simply hasn't been
|
|
/// indexed yet.
|
|
///
|
|
/// Note: stat / read_dir on a hard-mounted, unreachable NFS share can
|
|
/// block. The watcher accepts that risk for now — the worst case is that
|
|
/// the tick stalls until the mount returns, which is no more destructive
|
|
/// than the pre-probe behavior. A future enhancement can wrap this in a
|
|
/// thread + timeout if it becomes an operational issue.
|
|
pub fn probe_online(lib: &Library, had_data: bool) -> LibraryHealth {
|
|
let now = Utc::now().timestamp();
|
|
let path = Path::new(&lib.root_path);
|
|
|
|
let metadata = match std::fs::metadata(path) {
|
|
Ok(m) => m,
|
|
Err(e) => {
|
|
return LibraryHealth::Stale {
|
|
reason: format!("root_path stat failed: {}", e),
|
|
since: now,
|
|
};
|
|
}
|
|
};
|
|
if !metadata.is_dir() {
|
|
return LibraryHealth::Stale {
|
|
reason: format!("root_path is not a directory: {}", lib.root_path),
|
|
since: now,
|
|
};
|
|
}
|
|
|
|
let mut entries = match std::fs::read_dir(path) {
|
|
Ok(it) => it,
|
|
Err(e) => {
|
|
return LibraryHealth::Stale {
|
|
reason: format!("read_dir failed: {}", e),
|
|
since: now,
|
|
};
|
|
}
|
|
};
|
|
|
|
// Empty directory only counts as Stale when we have prior evidence
|
|
// this library used to have content. A genuinely fresh mount is
|
|
// legitimately empty, and degrading it would block first-time ingest.
|
|
if had_data && entries.next().is_none() {
|
|
return LibraryHealth::Stale {
|
|
reason: "library is empty but image_exif has rows for it".to_string(),
|
|
since: now,
|
|
};
|
|
}
|
|
|
|
LibraryHealth::Online
|
|
}
|
|
|
|
/// Probe `lib`, update `map`, and return the new state. Logs only on a
|
|
/// state transition (Online↔Stale) so a long outage doesn't spam at every
|
|
/// tick — operators get one warn on the way down and one info on the way
|
|
/// up.
|
|
pub fn refresh_health(map: &LibraryHealthMap, lib: &Library, had_data: bool) -> LibraryHealth {
|
|
let new_state = probe_online(lib, had_data);
|
|
let mut guard = map.write().unwrap_or_else(|e| e.into_inner());
|
|
let prev = guard.get(&lib.id).cloned();
|
|
let transitioned = matches!(
|
|
(&prev, &new_state),
|
|
(None, LibraryHealth::Stale { .. })
|
|
| (Some(LibraryHealth::Online), LibraryHealth::Stale { .. })
|
|
| (Some(LibraryHealth::Stale { .. }), LibraryHealth::Online)
|
|
);
|
|
if transitioned {
|
|
match &new_state {
|
|
LibraryHealth::Online => info!(
|
|
"Library '{}' (id={}) recovered: {} is online",
|
|
lib.name, lib.id, lib.root_path
|
|
),
|
|
LibraryHealth::Stale { reason, .. } => warn!(
|
|
"Library '{}' (id={}) is STALE — pausing writes. Reason: {}. Path: {}",
|
|
lib.name, lib.id, reason, lib.root_path
|
|
),
|
|
}
|
|
}
|
|
guard.insert(lib.id, new_state.clone());
|
|
new_state
|
|
}
|
|
|
|
/// Snapshot of one library + its current health, for `/libraries`.
|
|
#[derive(serde::Serialize)]
|
|
pub struct LibraryStatus {
|
|
#[serde(flatten)]
|
|
pub library: Library,
|
|
pub health: LibraryHealth,
|
|
}
|
|
|
|
#[derive(serde::Serialize)]
|
|
pub struct LibrariesResponse {
|
|
pub libraries: Vec<LibraryStatus>,
|
|
/// Globally-excluded paths/patterns from the `EXCLUDED_DIRS` env var.
|
|
/// Applied **in union** with each library's own `excluded_dirs`. Surfaced
|
|
/// here so an admin UI can show the operator "you already skip these
|
|
/// everywhere" before they add per-library entries that would duplicate
|
|
/// the global list. Read-only — globals live in `.env` and aren't
|
|
/// mutable via the API today.
|
|
pub global_excluded_dirs: Vec<String>,
|
|
}
|
|
|
|
#[get("/libraries")]
|
|
pub async fn list_libraries(_claims: Claims, app_state: Data<AppState>) -> impl Responder {
|
|
// Read from the live view so a recent PATCH /libraries/{id} that
|
|
// flipped `enabled` or rewrote `excluded_dirs` surfaces immediately
|
|
// — the immutable `app_state.libraries` snapshot is stale once the
|
|
// first mutation lands.
|
|
let live_guard = app_state
|
|
.live_libraries
|
|
.read()
|
|
.unwrap_or_else(|e| e.into_inner());
|
|
let health_guard = app_state
|
|
.library_health
|
|
.read()
|
|
.unwrap_or_else(|e| e.into_inner());
|
|
let libraries = live_guard
|
|
.iter()
|
|
.map(|lib| LibraryStatus {
|
|
library: lib.clone(),
|
|
health: health_guard
|
|
.get(&lib.id)
|
|
.cloned()
|
|
.unwrap_or(LibraryHealth::Online),
|
|
})
|
|
.collect();
|
|
HttpResponse::Ok().json(LibrariesResponse {
|
|
libraries,
|
|
global_excluded_dirs: app_state.excluded_dirs.clone(),
|
|
})
|
|
}
|
|
|
|
/// Body for PATCH /libraries/{id}. Both fields are optional — omitting
|
|
/// one leaves it untouched. `excluded_dirs` is the same comma-separated
|
|
/// shape as the DB column; an empty string clears (writes NULL).
|
|
#[derive(Deserialize, Debug)]
|
|
pub struct PatchLibraryBody {
|
|
pub enabled: Option<bool>,
|
|
pub excluded_dirs: Option<String>,
|
|
}
|
|
|
|
/// Mutate one library row. The watcher reads `app_state.live_libraries`
|
|
/// at the top of each tick, so a successful PATCH is picked up within
|
|
/// one WATCH_QUICK_INTERVAL_SECONDS without restart — no separate
|
|
/// `apply_now` signal. Returns the updated `Library` so the caller can
|
|
/// render the new state without a follow-up GET.
|
|
///
|
|
/// Despite CLAUDE.md noting "Toggle via SQL; there is intentionally no
|
|
/// HTTP endpoint for library mutation", we now expose this for Apollo's
|
|
/// Settings panel. The single-user trust model hasn't changed; the
|
|
/// endpoint just removes the SSH-and-sqlite3 step.
|
|
#[patch("/libraries/{id}")]
|
|
pub async fn patch_library(
|
|
_claims: Claims,
|
|
path: web::Path<i32>,
|
|
body: web::Json<PatchLibraryBody>,
|
|
app_state: Data<AppState>,
|
|
) -> impl Responder {
|
|
let lib_id = path.into_inner();
|
|
let body = body.into_inner();
|
|
|
|
if body.enabled.is_none() && body.excluded_dirs.is_none() {
|
|
return HttpResponse::UnprocessableEntity().body("empty patch body");
|
|
}
|
|
|
|
let mut conn = crate::database::connect();
|
|
|
|
// Build the SET clause. Diesel's set() takes a tuple of assignments;
|
|
// we apply each field independently so an absent field doesn't get
|
|
// forced to NULL / its default.
|
|
let mut affected = 0usize;
|
|
if let Some(enabled) = body.enabled {
|
|
match diesel::update(libraries::table.filter(libraries::id.eq(lib_id)))
|
|
.set(libraries::enabled.eq(enabled))
|
|
.execute(&mut conn)
|
|
{
|
|
Ok(n) => affected = affected.max(n),
|
|
Err(e) => {
|
|
warn!(
|
|
"PATCH /libraries/{}: enabled update failed: {:?}",
|
|
lib_id, e
|
|
);
|
|
return HttpResponse::InternalServerError().body(format!("{}", e));
|
|
}
|
|
}
|
|
}
|
|
if let Some(raw) = body.excluded_dirs.as_deref() {
|
|
// Canonicalise on write — trim, dedupe, validate, drop empties —
|
|
// so the DB stores a round-trip-stable form regardless of how
|
|
// messy the user typed it. Empty / whitespace-only → NULL
|
|
// (matches a never-set library). Validation failures (Windows
|
|
// backslash paths, drive letters, `..` traversal, etc.) bounce
|
|
// back as 422 so the operator can fix the typo.
|
|
let normalised = match normalize_excluded_dirs_input(raw) {
|
|
Ok(v) => v,
|
|
Err(msg) => return HttpResponse::UnprocessableEntity().body(msg),
|
|
};
|
|
let stored: Option<&str> = normalised.as_deref();
|
|
match diesel::update(libraries::table.filter(libraries::id.eq(lib_id)))
|
|
.set(libraries::excluded_dirs.eq(stored))
|
|
.execute(&mut conn)
|
|
{
|
|
Ok(n) => affected = affected.max(n),
|
|
Err(e) => {
|
|
warn!(
|
|
"PATCH /libraries/{}: excluded_dirs update failed: {:?}",
|
|
lib_id, e
|
|
);
|
|
return HttpResponse::InternalServerError().body(format!("{}", e));
|
|
}
|
|
}
|
|
}
|
|
|
|
if affected == 0 {
|
|
return HttpResponse::NotFound().body(format!("library id {} not found", lib_id));
|
|
}
|
|
|
|
// Refresh the live view from the canonical DB state. Reloading the
|
|
// whole table (rather than mutating one entry in place) is cheap
|
|
// (handful of rows) and keeps the in-memory and DB views trivially
|
|
// consistent.
|
|
let fresh = load_all(&mut conn);
|
|
let updated = fresh.iter().find(|l| l.id == lib_id).cloned();
|
|
{
|
|
let mut live = app_state
|
|
.live_libraries
|
|
.write()
|
|
.unwrap_or_else(|e| e.into_inner());
|
|
*live = fresh;
|
|
}
|
|
|
|
match updated {
|
|
Some(lib) => {
|
|
info!(
|
|
"PATCH /libraries/{}: enabled={:?} excluded_dirs={:?} → applied",
|
|
lib_id, body.enabled, body.excluded_dirs
|
|
);
|
|
HttpResponse::Ok().json(lib)
|
|
}
|
|
None => {
|
|
HttpResponse::NotFound().body(format!("library id {} not found after update", lib_id))
|
|
}
|
|
}
|
|
}
|
|
|
|
#[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(),
|
|
enabled: true,
|
|
excluded_dirs: Vec::new(),
|
|
};
|
|
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(),
|
|
enabled: true,
|
|
excluded_dirs: Vec::new(),
|
|
};
|
|
let abs = lib.resolve("2024/photo.jpg");
|
|
assert_eq!(abs, PathBuf::from("/tmp/media/2024/photo.jpg"));
|
|
}
|
|
|
|
fn state_with_libraries(libs: Vec<Library>) -> AppState {
|
|
let mut state = AppState::test_state();
|
|
state.libraries = libs;
|
|
state
|
|
}
|
|
|
|
fn sample_libraries() -> Vec<Library> {
|
|
vec![
|
|
Library {
|
|
id: 1,
|
|
name: "main".into(),
|
|
root_path: "/tmp/main".into(),
|
|
enabled: true,
|
|
excluded_dirs: Vec::new(),
|
|
},
|
|
Library {
|
|
id: 7,
|
|
name: "archive".into(),
|
|
root_path: "/tmp/archive".into(),
|
|
enabled: true,
|
|
excluded_dirs: Vec::new(),
|
|
},
|
|
]
|
|
}
|
|
|
|
#[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"));
|
|
}
|
|
|
|
#[test]
|
|
fn parse_excluded_dirs_column_handles_null_and_whitespace() {
|
|
assert_eq!(parse_excluded_dirs_column(None), Vec::<String>::new());
|
|
assert_eq!(parse_excluded_dirs_column(Some("")), Vec::<String>::new());
|
|
assert_eq!(
|
|
parse_excluded_dirs_column(Some(" /a , /b/sub , @eaDir ,, ")),
|
|
vec!["/a".to_string(), "/b/sub".to_string(), "@eaDir".to_string()]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn parse_excluded_dirs_column_splits_on_newlines_too() {
|
|
// Newline-separated input from a textarea submit. One-per-line
|
|
// is the recommended UX because "I forgot the comma" was a
|
|
// recurring footgun (.thumbnails .thumbnails2 silently
|
|
// becomes a single never-matching pattern).
|
|
assert_eq!(
|
|
parse_excluded_dirs_column(Some("@eaDir\n.thumbnails\n/private")),
|
|
vec![
|
|
"@eaDir".to_string(),
|
|
".thumbnails".to_string(),
|
|
"/private".to_string()
|
|
]
|
|
);
|
|
// Windows line endings (CRLF) — the carriage return is its own
|
|
// separator so the trailing empty token between \r and \n gets
|
|
// trimmed + dropped.
|
|
assert_eq!(
|
|
parse_excluded_dirs_column(Some("a\r\nb\r\nc")),
|
|
vec!["a".to_string(), "b".to_string(), "c".to_string()]
|
|
);
|
|
// Mixed comma + newline — the user pastes from one source,
|
|
// adds a few entries inline. Both work, in any combination.
|
|
assert_eq!(
|
|
parse_excluded_dirs_column(Some("a, b\nc,d")),
|
|
vec![
|
|
"a".to_string(),
|
|
"b".to_string(),
|
|
"c".to_string(),
|
|
"d".to_string()
|
|
]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn effective_excluded_dirs_unions_global_and_per_library() {
|
|
let lib_no_extras = Library {
|
|
id: 1,
|
|
name: "main".into(),
|
|
root_path: "/x".into(),
|
|
enabled: true,
|
|
excluded_dirs: Vec::new(),
|
|
};
|
|
let globals = vec!["@eaDir".to_string(), ".thumbnails".to_string()];
|
|
// Empty per-library excludes → exactly the globals.
|
|
assert_eq!(lib_no_extras.effective_excluded_dirs(&globals), globals);
|
|
|
|
let lib_with_extras = Library {
|
|
id: 2,
|
|
name: "archive".into(),
|
|
root_path: "/y".into(),
|
|
enabled: true,
|
|
excluded_dirs: vec!["/photos".to_string()],
|
|
};
|
|
let combined = lib_with_extras.effective_excluded_dirs(&globals);
|
|
assert!(combined.contains(&"@eaDir".to_string()));
|
|
assert!(combined.contains(&".thumbnails".to_string()));
|
|
assert!(combined.contains(&"/photos".to_string()));
|
|
assert_eq!(combined.len(), 3);
|
|
}
|
|
|
|
#[test]
|
|
fn effective_excluded_dirs_keeps_overlap_between_global_and_per_library() {
|
|
// Two sources both excluding `@eaDir` is legal — `PathExcluder`
|
|
// accepts repeats, and there's no behavioral reason to dedupe
|
|
// here. Documents the design choice so a future refactor that
|
|
// tightens this is forced to update both code and tests.
|
|
let globals = vec!["@eaDir".to_string()];
|
|
let lib = Library {
|
|
id: 1,
|
|
name: "main".into(),
|
|
root_path: "/x".into(),
|
|
enabled: true,
|
|
excluded_dirs: vec!["@eaDir".to_string(), "/private".to_string()],
|
|
};
|
|
let combined = lib.effective_excluded_dirs(&globals);
|
|
// 2 occurrences of @eaDir + /private = 3 entries total.
|
|
assert_eq!(combined, vec!["@eaDir", "@eaDir", "/private"]);
|
|
}
|
|
|
|
#[test]
|
|
fn normalize_excluded_dirs_input_handles_empty_and_whitespace() {
|
|
assert_eq!(normalize_excluded_dirs_input(""), Ok(None));
|
|
assert_eq!(normalize_excluded_dirs_input(" "), Ok(None));
|
|
assert_eq!(normalize_excluded_dirs_input(",,,"), Ok(None));
|
|
assert_eq!(normalize_excluded_dirs_input(" , , "), Ok(None));
|
|
}
|
|
|
|
#[test]
|
|
fn normalize_excluded_dirs_input_trims_per_entry() {
|
|
// Inner whitespace stripped on each item, comma-joined without
|
|
// spaces. Mirrors how parse_excluded_dirs_column reads it back.
|
|
assert_eq!(
|
|
normalize_excluded_dirs_input(" @eaDir , /private , .thumbnails "),
|
|
Ok(Some("@eaDir,/private,.thumbnails".to_string()))
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn normalize_excluded_dirs_input_dedupes_preserving_first_occurrence() {
|
|
// Exact-string duplicates collapse; the first occurrence wins
|
|
// (preserves the operator's typed order so they recognise their
|
|
// intent on round-trip).
|
|
assert_eq!(
|
|
normalize_excluded_dirs_input("@eaDir, /private, @eaDir, /private"),
|
|
Ok(Some("@eaDir,/private".to_string()))
|
|
);
|
|
// Whitespace-distinct entries collapse to the same canonical
|
|
// form. Case is preserved — `Foo` and `foo` are different keys
|
|
// (filesystem case-sensitivity is platform-dependent; we don't
|
|
// make that call here).
|
|
assert_eq!(
|
|
normalize_excluded_dirs_input(" Foo,foo, Foo "),
|
|
Ok(Some("Foo,foo".to_string()))
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn normalize_excluded_dirs_input_is_round_trip_stable() {
|
|
// Writing the normaliser's output back through it yields the
|
|
// same string. PATCH-clearing edits round-trip cleanly through
|
|
// parse_excluded_dirs_column too.
|
|
let raw = " /a/b ,, /a/b , c ";
|
|
let once = normalize_excluded_dirs_input(raw)
|
|
.expect("validation passes")
|
|
.expect("not empty");
|
|
let twice = normalize_excluded_dirs_input(&once)
|
|
.expect("validation passes")
|
|
.expect("not empty");
|
|
assert_eq!(once, twice);
|
|
// Parsing the stored form back gives the deduped Vec.
|
|
assert_eq!(
|
|
parse_excluded_dirs_column(Some(&once)),
|
|
vec!["/a/b".to_string(), "c".to_string()]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn validate_rejects_backslash_paths() {
|
|
// Windows-typed entries land in the component-pattern bucket
|
|
// and never match — reject so the user gets feedback instead
|
|
// of a silent no-op.
|
|
assert!(validate_excluded_dirs_entry(r"\photos").is_err());
|
|
assert!(validate_excluded_dirs_entry(r"photos\2024").is_err());
|
|
assert!(validate_excluded_dirs_entry(r"\\server\share").is_err());
|
|
// The error message names the entry and points at the fix.
|
|
let err = validate_excluded_dirs_entry(r"\photos").unwrap_err();
|
|
assert!(err.contains("forward slashes"), "{}", err);
|
|
}
|
|
|
|
#[test]
|
|
fn validate_rejects_windows_drive_letters() {
|
|
assert!(validate_excluded_dirs_entry("Z:/photos").is_err());
|
|
assert!(validate_excluded_dirs_entry("z:photos").is_err());
|
|
// Single-letter alpha + colon is the canonical drive prefix;
|
|
// the message should steer toward the relative form.
|
|
let err = validate_excluded_dirs_entry("Z:/foo").unwrap_err();
|
|
assert!(err.contains("relative to the library root"), "{}", err);
|
|
}
|
|
|
|
#[test]
|
|
fn validate_rejects_multi_segment_name_without_leading_slash() {
|
|
// The common "I forgot the slash" typo. Today this would store
|
|
// a never-matching component pattern; we catch it.
|
|
let err = validate_excluded_dirs_entry("photos/2024").unwrap_err();
|
|
assert!(err.contains("multi-segment"), "{}", err);
|
|
// And the suggestion shows the corrected form.
|
|
assert!(err.contains("/photos/2024"), "{}", err);
|
|
}
|
|
|
|
#[test]
|
|
fn validate_rejects_parent_dir_traversal_in_path_entries() {
|
|
// base.join("../sensitive") doesn't canonicalise, so the
|
|
// resulting prefix never starts_with anything the walker sees.
|
|
assert!(validate_excluded_dirs_entry("/../secret").is_err());
|
|
assert!(validate_excluded_dirs_entry("/photos/../keys").is_err());
|
|
// Same string as a non-leading-slash component is fine — it
|
|
// just never matches (you'd literally need a directory named
|
|
// `..` which is impossible on every filesystem we care about),
|
|
// but the validator accepts it because the failure mode isn't
|
|
// a silent footgun in that direction.
|
|
assert!(validate_excluded_dirs_entry("..").is_ok());
|
|
}
|
|
|
|
#[test]
|
|
fn validate_strips_trailing_slash_on_path_entries() {
|
|
assert_eq!(validate_excluded_dirs_entry("/photos/").unwrap(), "/photos");
|
|
assert_eq!(
|
|
validate_excluded_dirs_entry("/photos//").unwrap(),
|
|
"/photos"
|
|
);
|
|
// Bare "/" is rejected — almost certainly a typo for the
|
|
// library root.
|
|
assert!(validate_excluded_dirs_entry("/").is_err());
|
|
assert!(validate_excluded_dirs_entry("///").is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn validate_passes_valid_entries() {
|
|
for entry in &[
|
|
"/photos",
|
|
"/photos/2024",
|
|
"/media/raw/private",
|
|
"@eaDir",
|
|
".thumbnails",
|
|
".DS_Store",
|
|
"node_modules",
|
|
] {
|
|
assert!(
|
|
validate_excluded_dirs_entry(entry).is_ok(),
|
|
"expected {} to pass",
|
|
entry
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn normalize_aborts_on_invalid_entry() {
|
|
// One bad entry kills the whole patch — better to surface the
|
|
// problem than to silently apply N-1 of N changes.
|
|
let err = normalize_excluded_dirs_input("/photos, photos/2024").unwrap_err();
|
|
assert!(err.contains("photos/2024"), "{}", err);
|
|
// A valid mix succeeds — the bad-entry test isn't accidentally
|
|
// matching the good prefix.
|
|
assert_eq!(
|
|
normalize_excluded_dirs_input("/photos, @eaDir, /private/"),
|
|
Ok(Some("/photos,@eaDir,/private".to_string()))
|
|
);
|
|
}
|
|
|
|
fn probe_lib(id: i32, root: String) -> Library {
|
|
Library {
|
|
id,
|
|
name: "main".into(),
|
|
root_path: root,
|
|
enabled: true,
|
|
excluded_dirs: Vec::new(),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn probe_online_for_existing_non_empty_dir() {
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
std::fs::write(tmp.path().join("photo.jpg"), b"hello").unwrap();
|
|
let lib = probe_lib(1, tmp.path().to_string_lossy().into());
|
|
// had_data doesn't matter when the dir has entries.
|
|
assert!(probe_online(&lib, true).is_online());
|
|
assert!(probe_online(&lib, false).is_online());
|
|
}
|
|
|
|
#[test]
|
|
fn probe_stale_when_root_missing() {
|
|
let lib = probe_lib(1, "/nonexistent/definitely/not/here".into());
|
|
assert!(matches!(
|
|
probe_online(&lib, false),
|
|
LibraryHealth::Stale { .. }
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn probe_stale_when_root_is_a_file() {
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let file = tmp.path().join("not-a-dir");
|
|
std::fs::write(&file, b"x").unwrap();
|
|
let lib = probe_lib(1, file.to_string_lossy().into());
|
|
assert!(matches!(
|
|
probe_online(&lib, false),
|
|
LibraryHealth::Stale { .. }
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn probe_empty_dir_is_online_when_no_prior_data() {
|
|
// Fresh mount: empty directory, no rows in image_exif. Accept it.
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let lib = probe_lib(1, tmp.path().to_string_lossy().into());
|
|
assert!(probe_online(&lib, false).is_online());
|
|
}
|
|
|
|
#[test]
|
|
fn probe_empty_dir_is_stale_when_prior_data_existed() {
|
|
// The "share went offline" signal: directory exists but is empty,
|
|
// and we know the library used to have content. Treat as Stale.
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let lib = probe_lib(1, tmp.path().to_string_lossy().into());
|
|
match probe_online(&lib, true) {
|
|
LibraryHealth::Stale { reason, .. } => {
|
|
assert!(reason.contains("empty"), "unexpected reason: {}", reason)
|
|
}
|
|
other => panic!("expected Stale, got {:?}", other),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn refresh_health_logs_only_on_transition() {
|
|
// Smoke test: refresh_health updates the map and reports correctly.
|
|
// (We can't easily assert on logs without a custom logger; the
|
|
// important thing is that the state churns properly.)
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let lib = Library {
|
|
id: 42,
|
|
name: "test".into(),
|
|
root_path: tmp.path().to_string_lossy().into(),
|
|
enabled: true,
|
|
excluded_dirs: Vec::new(),
|
|
};
|
|
let map = new_health_map(&[lib.clone()]);
|
|
|
|
// First probe: empty dir, no prior data — Online.
|
|
let s1 = refresh_health(&map, &lib, false);
|
|
assert!(s1.is_online());
|
|
|
|
// Probe again with had_data=true on the same empty dir — Stale.
|
|
let s2 = refresh_health(&map, &lib, true);
|
|
assert!(matches!(s2, LibraryHealth::Stale { .. }));
|
|
assert_eq!(
|
|
map.read().unwrap().get(&lib.id).cloned(),
|
|
Some(s2.clone()),
|
|
"map should reflect the latest probe"
|
|
);
|
|
|
|
// Recovery: drop a file and probe again.
|
|
std::fs::write(tmp.path().join("photo.jpg"), b"x").unwrap();
|
|
let s3 = refresh_health(&map, &lib, true);
|
|
assert!(s3.is_online());
|
|
}
|
|
}
|