Merge pull request 'feature/improve-memories-exclude' (#42) from feature/improve-memories-exclude into master

Reviewed-on: #42
This commit was merged in pull request #42.
This commit is contained in:
2025-12-01 18:07:07 +00:00
9 changed files with 238 additions and 46 deletions

4
Cargo.lock generated
View File

@@ -1,6 +1,6 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 3
version = 4
[[package]]
name = "actix"
@@ -1534,7 +1534,7 @@ dependencies = [
[[package]]
name = "image-api"
version = "0.3.0"
version = "0.3.1"
dependencies = [
"actix",
"actix-files",

View File

@@ -1,6 +1,6 @@
[package]
name = "image-api"
version = "0.3.0"
version = "0.3.1"
authors = ["Cameron Cordes <cameronc.dev@gmail.com>"]
edition = "2024"

View File

@@ -88,12 +88,10 @@ impl UserDao for SqliteUserDao {
fn user_exists(&mut self, user: &str) -> bool {
use schema::users::dsl::*;
users
!users
.filter(username.eq(user))
.load::<User>(&mut self.connection)
.unwrap_or_default()
.first()
.is_some()
.unwrap_or_default().is_empty()
}
}

View File

@@ -64,8 +64,8 @@ pub async fn list_photos<TagD: TagDao, FS: FileSystemAccess>(
let span_context = opentelemetry::Context::current_with_span(span);
let search_recursively = req.recursive.unwrap_or(false);
if let Some(tag_ids) = &req.tag_ids {
if search_recursively {
if let Some(tag_ids) = &req.tag_ids
&& search_recursively {
let filter_mode = &req.tag_filter_mode.unwrap_or(FilterMode::Any);
info!(
"Searching for tags: {}. With path: '{}' and filter mode: {:?}",
@@ -142,7 +142,6 @@ pub async fn list_photos<TagD: TagDao, FS: FileSystemAccess>(
.into_http_internal_err()
.unwrap_or_else(|e| e.error_response());
}
}
match file_system.get_files_for_path(search_path) {
Ok(files) => {
@@ -221,7 +220,7 @@ pub async fn list_photos<TagD: TagDao, FS: FileSystemAccess>(
let dirs = files
.iter()
.filter(|&f| f.metadata().map_or(false, |md| md.is_dir()))
.filter(|&f| f.metadata().is_ok_and(|md| md.is_dir()))
.map(|path: &PathBuf| {
let relative = path.strip_prefix(&app_state.base_path).unwrap();
relative.to_path_buf()

View File

@@ -200,7 +200,7 @@ async fn upload_image(
while let Some(Ok(data)) = part.next().await {
file_content.put(data);
}
} else if content_type.get_name().map_or(false, |name| name == "path") {
} else if content_type.get_name() == Some("path") {
while let Some(Ok(data)) = part.next().await {
if let Ok(path) = std::str::from_utf8(&data) {
file_path = Some(path.to_string())

View File

@@ -16,6 +16,80 @@ use crate::files::is_image_or_video;
use crate::otel::{extract_context_from_request, global_tracer};
use crate::state::AppState;
// Helper that encapsulates path-exclusion semantics
#[derive(Debug)]
struct PathExcluder {
excluded_dirs: Vec<PathBuf>,
excluded_patterns: Vec<String>,
}
impl PathExcluder {
/// Build from a `base` path and the raw exclusion entries.
///
/// Rules:
/// - Entries starting with '/' are interpreted as "absolute under base"
/// (e.g. "/photos/private" -> base/photos/private).
/// - Entries without '/' are treated as substring patterns that match
/// anywhere in the full path string (still scoped under base).
fn new(base: &Path, raw_excluded: &[String]) -> Self {
let mut excluded_dirs = Vec::new();
let mut excluded_patterns = Vec::new();
for dir in raw_excluded {
if let Some(rel) = dir.strip_prefix('/') {
// Absolute under base
if !rel.is_empty() {
excluded_dirs.push(base.join(rel));
}
} else {
// Pattern anywhere under base
excluded_patterns.push(dir.clone());
}
}
debug!(
"PathExcluder created. dirs={:?}, patterns={:?}",
excluded_dirs, excluded_patterns
);
Self {
excluded_dirs,
excluded_patterns,
}
}
/// Returns true if `path` should be excluded.
fn is_excluded(&self, path: &Path) -> bool {
// Directory-based exclusions
for excluded in &self.excluded_dirs {
if path.starts_with(excluded) {
debug!(
"PathExcluder: excluded by dir: {:?} (rule: {:?})",
path, excluded
);
return true;
}
}
// Pattern-based exclusions: match whole path components (dir or file name),
// not substrings.
if !self.excluded_patterns.is_empty() {
for component in path.components() {
if let Some(comp_str) = component.as_os_str().to_str()
&& self.excluded_patterns.iter().any(|pat| pat == comp_str) {
debug!(
"PathExcluder: excluded by component pattern: {:?} (component: {:?}, patterns: {:?})",
path, comp_str, self.excluded_patterns
);
return true;
}
}
}
false
}
}
#[derive(Copy, Clone, Deserialize, PartialEq, Debug)]
#[serde(rename_all = "lowercase")]
pub enum MemoriesSpan {
@@ -178,20 +252,19 @@ fn extract_date_from_filename(filename: &str) -> Option<DateTime<FixedOffset>> {
let timestamp_str = captures.get(1)?.as_str();
// Millisecond timestamp (13 digits)
if timestamp_str.len() >= 13 {
if let Some(date_time) = timestamp_str[0..13]
if timestamp_str.len() >= 13
&& let Some(date_time) = timestamp_str[0..13]
.parse::<i64>()
.ok()
.and_then(|timestamp_millis| DateTime::from_timestamp_millis(timestamp_millis))
.and_then(DateTime::from_timestamp_millis)
.map(|naive_dt| naive_dt.fixed_offset())
{
return Some(date_time);
}
}
// Second timestamp (10 digits)
if timestamp_str.len() >= 10 {
if let Some(date_time) = timestamp_str[0..10]
if timestamp_str.len() >= 10
&& let Some(date_time) = timestamp_str[0..10]
.parse::<i64>()
.ok()
.and_then(|timestamp_secs| DateTime::from_timestamp(timestamp_secs, 0))
@@ -200,7 +273,6 @@ fn extract_date_from_filename(filename: &str) -> Option<DateTime<FixedOffset>> {
return Some(date_time);
}
}
}
None
}
@@ -242,29 +314,19 @@ pub async fn list_memories(
let base = Path::new(&app_state.base_path);
// Build a list of excluded directories with full paths
let excluded_paths: Vec<PathBuf> = app_state
.excluded_dirs
.iter()
.map(|dir| base.join(dir))
.collect();
debug!("Excluded directories: {:?}", excluded_paths);
// Build the path excluder from base and env-configured exclusions
let path_excluder = PathExcluder::new(base, &app_state.excluded_dirs);
let entries: Vec<_> = WalkDir::new(base)
.into_iter()
.filter_map(|e| e.ok())
.filter(|e| {
// Skip excluded directories
if !excluded_paths.is_empty() {
let path = e.path();
for excluded in &excluded_paths {
if path.starts_with(excluded) {
debug!("Skipping excluded path: {:?}", path);
// Skip paths that should be excluded
if path_excluder.is_excluded(path) {
return false;
}
}
}
true
})
@@ -393,11 +455,9 @@ fn same_month_any_year(a: NaiveDate, b: NaiveDate) -> bool {
mod tests {
use super::*;
use chrono::Timelike;
use std::fs::File;
use std::fs::{self, File};
use tempfile::tempdir;
// Add new tests for our date extraction functionality
#[test]
fn test_extract_date_from_filename_screenshot_format() {
let filename = "Screenshot_2014-06-01-20-44-50.png";
@@ -541,4 +601,141 @@ mod tests {
let dt_modified = DateTime::<Utc>::from_timestamp(modified.unwrap(), 0).unwrap();
assert_eq!(dt_modified.year(), today.year());
}
#[test]
fn test_path_excluder_absolute_under_base() {
let tmp = tempdir().unwrap();
let base = tmp.path();
// Simulate structure:
// base/photos/private/secret.jpg
// base/photos/public/ok.jpg
// base/screenshots/img.png
let photos_private = base.join("photos/private");
let photos_public = base.join("photos/public");
let screenshots = base.join("screenshots");
fs::create_dir_all(&photos_private).unwrap();
fs::create_dir_all(&photos_public).unwrap();
fs::create_dir_all(&screenshots).unwrap();
let secret = photos_private.join("secret.jpg");
let ok = photos_public.join("ok.jpg");
let shot = screenshots.join("img.png");
File::create(&secret).unwrap();
File::create(&ok).unwrap();
File::create(&shot).unwrap();
// Exclude "/photos/private" and "/screenshots" under base
let excluded = vec![
String::from("/photos/private"),
String::from("/screenshots"),
];
let excluder = PathExcluder::new(base, &excluded);
assert!(excluder.is_excluded(&secret), "secret should be excluded");
assert!(
excluder.is_excluded(&shot),
"screenshots should be excluded"
);
assert!(
!excluder.is_excluded(&ok),
"public photo should NOT be excluded"
);
}
#[test]
fn test_path_excluder_pattern_anywhere_under_base() {
let tmp = tempdir().unwrap();
let base = tmp.path();
// Simulate:
// base/a/tmp_file.jpg
// base/b/normal.jpg
// base/c/sometmpdir/file.jpg
let a = base.join("a");
let b = base.join("b");
let c = base.join("c/tmp");
fs::create_dir_all(&a).unwrap();
fs::create_dir_all(&b).unwrap();
fs::create_dir_all(&c).unwrap();
let tmp_file = a.join("tmp_file.jpg");
let normal = b.join("normal.jpg");
let tmp_dir_file = c.join("file.jpg");
File::create(&tmp_file).unwrap();
File::create(&normal).unwrap();
File::create(&tmp_dir_file).unwrap();
// Exclude any path containing "tmp"
let excluded = vec![String::from("tmp")];
let excluder = PathExcluder::new(base, &excluded);
assert!(
!excluder.is_excluded(&tmp_file),
"file with 'tmp' in name should NOT be excluded"
);
assert!(
excluder.is_excluded(&tmp_dir_file),
"file in directory with 'tmp' in path should be excluded"
);
assert!(
!excluder.is_excluded(&normal),
"file without 'tmp' in its path should NOT be excluded"
);
}
#[test]
fn test_path_excluder_mixed_absolute_and_pattern() {
let tmp = tempdir().unwrap();
let base = tmp.path();
// Simulate:
// base/photos/private/secret_tmp.jpg -> excluded by absolute dir rule
// base/photos/private/secret.jpg -> excluded by absolute dir rule
// base/photos/tmp/public.jpg -> excluded by pattern "tmp" (dir name)
// base/photos/public/tmp_public.jpg -> NOT excluded (file name contains "tmp" but not equal)
// base/other/keep.jpg -> NOT excluded
let photos_private = base.join("photos/private");
let photos_tmp = base.join("photos/tmp");
let photos_public = base.join("photos/public");
let other = base.join("other");
fs::create_dir_all(&photos_private).unwrap();
fs::create_dir_all(&photos_tmp).unwrap();
fs::create_dir_all(&photos_public).unwrap();
fs::create_dir_all(&other).unwrap();
let secret_tmp = photos_private.join("secret_tmp.jpg");
let secret = photos_private.join("secret.jpg");
let tmp_dir_file = photos_tmp.join("public.jpg");
let tmp_in_name = photos_public.join("tmp_public.jpg");
let keep = other.join("keep.jpg");
File::create(&secret_tmp).unwrap();
File::create(&secret).unwrap();
File::create(&tmp_dir_file).unwrap();
File::create(&tmp_in_name).unwrap();
File::create(&keep).unwrap();
// Mixed: exclude "/photos/private" (dir) and any component equal to "tmp"
let excluded = vec![String::from("/photos/private"), String::from("tmp")];
let excluder = PathExcluder::new(base, &excluded);
// Entire private tree is excluded by dir rule
assert!(excluder.is_excluded(&secret_tmp));
assert!(excluder.is_excluded(&secret));
// Dir 'tmp' under photos excluded by pattern
assert!(excluder.is_excluded(&tmp_dir_file));
// File name containing 'tmp' but not equal should NOT be excluded
assert!(!excluder.is_excluded(&tmp_in_name));
// keep.jpg doesn't match any rule
assert!(!excluder.is_excluded(&keep));
}
}

View File

@@ -504,12 +504,10 @@ impl TagDao for SqliteTagDao {
trace_db_call(context, "query", "get_files_with_any_tags", |_| {
use diesel::dsl::*;
// Create the placeholders for the IN clauses
let tag_placeholders = std::iter::repeat("?")
.take(tag_ids.len())
let tag_placeholders = std::iter::repeat_n("?", tag_ids.len())
.collect::<Vec<_>>()
.join(",");
let exclude_placeholders = std::iter::repeat("?")
.take(exclude_tag_ids.len())
let exclude_placeholders = std::iter::repeat_n("?", exclude_tag_ids.len())
.collect::<Vec<_>>()
.join(",");

View File

@@ -293,7 +293,7 @@ impl Handler<GeneratePlaylistMessage> for PlaylistGenerator {
.stderr(Stdio::piped())
.output()
.inspect_err(|e| error!("Failed to run ffmpeg on child process: {}", e))
.map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e.to_string()))
.map_err(|e| std::io::Error::other(e.to_string()))
.await;
// Hang on to the permit until we're done decoding and then explicitly drop

View File

@@ -34,7 +34,7 @@ impl Ffmpeg {
.stderr(Stdio::piped())
.output()
.inspect_err(|e| error!("Failed to run ffmpeg on child process: {}", e))
.map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e.to_string()))
.map_err(|e| std::io::Error::other(e.to_string()))
.await;
if let Ok(ref res) = ffmpeg_result {
@@ -58,7 +58,7 @@ impl Ffmpeg {
duration
.parse::<f32>()
.map(|duration| duration as u32)
.map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e.to_string()))
.map_err(|e| std::io::Error::other(e.to_string()))
})
.inspect(|duration| debug!("Found video duration: {:?}", duration))
}