multi-library: per-library excluded_dirs
Adds a nullable comma-separated TEXT column to the libraries table.
Effective excludes for a walk = (env-var globals) ∪
(library.excluded_dirs). Empty / NULL = no library-specific
extras; the global env var still applies.
Migration (2026-05-01-110000_libraries_excluded_dirs)
ALTER TABLE libraries ADD COLUMN excluded_dirs TEXT. NULL on every
existing row — no behavior change on upgrade.
Library struct + helpers (libraries.rs)
- Library gains excluded_dirs: Vec<String>, parsed from the column
by parse_excluded_dirs_column (drops empties / whitespace,
matches the env-var parser).
- Library::effective_excluded_dirs(globals) returns the union.
- From<LibraryRow> hydrates the field on AppState construction so
/libraries surfaces it.
Watcher / walkers / memories
Every per-library walker now consults the effective set:
- process_new_files (file-watch ingest, RAW/EXIF/face)
- process_face_backlog (filter_excluded inherits)
- create_thumbnails (startup + new-file branch)
- update_media_counts (Prometheus gauge)
- cleanup_orphaned_playlists (per-library source-existence check)
- memories endpoint (PathExcluder)
Effective set is computed once per per-library iteration in the
watcher tick and threaded through; called functions retain their
flat &[String] signature (no per-library awareness needed inside
the walker primitives).
Use case: mount a parent directory while a sibling library covers
a child subtree, and exclude the child subtree from the parent so
the libraries don't double-walk / double-write image_exif. With
hash-keyed derived data (Branches B/C), the duplication-avoidance
is the only cost prevented — face / tag / insight sharing was
already correct via content_hash.
Tests: 228 pass (226 from previous + 2 new in libraries::tests:
parse_excluded_dirs_column edge cases,
effective_excluded_dirs_unions_global_and_per_library).
CLAUDE.md gains a "Per-library excludes" subsection of the
multi-library data model.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -35,6 +35,12 @@ pub struct Library {
|
||||
/// 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 {
|
||||
@@ -56,6 +62,35 @@ impl Library {
|
||||
.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 a comma-separated excluded_dirs column into a Vec, dropping
|
||||
/// empty entries (mirrors `AppState::parse_excluded_dirs` for the env
|
||||
/// var). NULL → empty Vec.
|
||||
pub fn parse_excluded_dirs_column(raw: Option<&str>) -> Vec<String> {
|
||||
match raw {
|
||||
None => Vec::new(),
|
||||
Some(s) => s
|
||||
.split(',')
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(String::from)
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
|
||||
impl From<LibraryRow> for Library {
|
||||
@@ -65,6 +100,7 @@ impl From<LibraryRow> for Library {
|
||||
name: row.name,
|
||||
root_path: row.root_path,
|
||||
enabled: row.enabled,
|
||||
excluded_dirs: parse_excluded_dirs_column(row.excluded_dirs.as_deref()),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -120,6 +156,7 @@ pub fn seed_or_patch_from_env(conn: &mut SqliteConnection, base_path: &str) {
|
||||
root_path: base_path,
|
||||
created_at: now,
|
||||
enabled: true,
|
||||
excluded_dirs: None,
|
||||
})
|
||||
.execute(conn);
|
||||
match result {
|
||||
@@ -353,6 +390,7 @@ mod tests {
|
||||
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"));
|
||||
@@ -367,6 +405,7 @@ mod tests {
|
||||
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"));
|
||||
@@ -385,12 +424,14 @@ mod tests {
|
||||
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(),
|
||||
},
|
||||
]
|
||||
}
|
||||
@@ -444,12 +485,50 @@ mod tests {
|
||||
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 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);
|
||||
}
|
||||
|
||||
fn probe_lib(id: i32, root: String) -> Library {
|
||||
Library {
|
||||
id,
|
||||
name: "main".into(),
|
||||
root_path: root,
|
||||
enabled: true,
|
||||
excluded_dirs: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -517,6 +596,7 @@ mod tests {
|
||||
name: "test".into(),
|
||||
root_path: tmp.path().to_string_lossy().into(),
|
||||
enabled: true,
|
||||
excluded_dirs: Vec::new(),
|
||||
};
|
||||
let map = new_health_map(&[lib.clone()]);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user