fix(dates): prefer earliest of fs created/modified as fallback

On copied or restored files (e.g. a backup library), the OS stamps
created at copy time while modified is preserved from the source, so
the earlier of the two is a better proxy for when the content
originated. Adds utils::earliest_fs_time and threads it through the
three spots that fall back to filesystem dates: photos-list sort,
memories grouping, and insight-generation timestamp.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Cameron
2026-04-23 17:20:12 -04:00
parent d54419e779
commit dc2a96162e
4 changed files with 31 additions and 9 deletions

View File

@@ -23,7 +23,7 @@ use crate::libraries::Library;
use crate::memories::extract_date_from_filename; use crate::memories::extract_date_from_filename;
use crate::otel::global_tracer; use crate::otel::global_tracer;
use crate::tags::TagDao; use crate::tags::TagDao;
use crate::utils::normalize_path; use crate::utils::{earliest_fs_time, normalize_path};
#[derive(Deserialize)] #[derive(Deserialize)]
struct NominatimResponse { struct NominatimResponse {
@@ -760,8 +760,6 @@ impl InsightGenerator {
let full_path = self.resolve_full_path(&file_path)?; let full_path = self.resolve_full_path(&file_path)?;
File::open(&full_path) File::open(&full_path)
.and_then(|f| f.metadata()) .and_then(|f| f.metadata())
.and_then(|m| m.created().or(m.modified()))
.map(|t| DateTime::<Utc>::from(t).timestamp())
.inspect_err(|e| { .inspect_err(|e| {
log::warn!( log::warn!(
"Failed to get file timestamp for insight {}: {}", "Failed to get file timestamp for insight {}: {}",
@@ -770,6 +768,8 @@ impl InsightGenerator {
) )
}) })
.ok() .ok()
.and_then(|m| earliest_fs_time(&m))
.map(|t| DateTime::<Utc>::from(t).timestamp())
}) })
.unwrap_or_else(|| Utc::now().timestamp()) .unwrap_or_else(|| Utc::now().timestamp())
}; };
@@ -1687,7 +1687,11 @@ Return ONLY the summary, nothing else."#,
let date = chrono::DateTime::from_timestamp(h.date, 0) let date = chrono::DateTime::from_timestamp(h.date, 0)
.map(|dt| dt.format("%Y-%m-%d").to_string()) .map(|dt| dt.format("%Y-%m-%d").to_string())
.unwrap_or_else(|| h.date.to_string()); .unwrap_or_else(|| h.date.to_string());
let direction: &str = if h.type_ == 2 { &user_name } else { &h.contact_name }; let direction: &str = if h.type_ == 2 {
&user_name
} else {
&h.contact_name
};
let score = h let score = h
.similarity_score .similarity_score
.map(|s| format!(" [score {:.2}]", s)) .map(|s| format!(" [score {:.2}]", s))
@@ -2858,8 +2862,6 @@ Return ONLY the summary, nothing else."#,
let full_path = self.resolve_full_path(&file_path)?; let full_path = self.resolve_full_path(&file_path)?;
File::open(&full_path) File::open(&full_path)
.and_then(|f| f.metadata()) .and_then(|f| f.metadata())
.and_then(|m| m.created().or(m.modified()))
.map(|t| DateTime::<Utc>::from(t).timestamp())
.inspect_err(|e| { .inspect_err(|e| {
log::warn!( log::warn!(
"Failed to get file timestamp for agentic insight {}: {}", "Failed to get file timestamp for agentic insight {}: {}",
@@ -2868,6 +2870,8 @@ Return ONLY the summary, nothing else."#,
) )
}) })
.ok() .ok()
.and_then(|m| earliest_fs_time(&m))
.map(|t| DateTime::<Utc>::from(t).timestamp())
}) })
.unwrap_or_else(|| Utc::now().timestamp()) .unwrap_or_else(|| Utc::now().timestamp())
}; };

View File

@@ -15,6 +15,7 @@ use crate::database::ExifDao;
use crate::file_types; use crate::file_types;
use crate::geo::{gps_bounding_box, haversine_distance}; use crate::geo::{gps_bounding_box, haversine_distance};
use crate::memories::extract_date_from_filename; use crate::memories::extract_date_from_filename;
use crate::utils::earliest_fs_time;
use crate::{AppState, create_thumbnails}; use crate::{AppState, create_thumbnails};
use actix_web::web::Data; use actix_web::web::Data;
use actix_web::{ use actix_web::{
@@ -138,8 +139,8 @@ fn in_memory_date_sort(
lib_roots.get(&lib_id).and_then(|root| { lib_roots.get(&lib_id).and_then(|root| {
let full_path = Path::new(root).join(&f.file_name); let full_path = Path::new(root).join(&f.file_name);
std::fs::metadata(full_path) std::fs::metadata(full_path)
.and_then(|md| md.created().or(md.modified()))
.ok() .ok()
.and_then(|md| earliest_fs_time(&md))
.map(|system_time| { .map(|system_time| {
<SystemTime as Into<DateTime<Utc>>>::into(system_time).timestamp() <SystemTime as Into<DateTime<Utc>>>::into(system_time).timestamp()
}) })

View File

@@ -19,6 +19,7 @@ use crate::files::is_image_or_video;
use crate::libraries::Library; use crate::libraries::Library;
use crate::otel::{extract_context_from_request, global_tracer}; use crate::otel::{extract_context_from_request, global_tracer};
use crate::state::AppState; use crate::state::AppState;
use crate::utils::earliest_fs_time;
// Helper that encapsulates path-exclusion semantics // Helper that encapsulates path-exclusion semantics
#[derive(Debug)] #[derive(Debug)]
@@ -336,8 +337,8 @@ fn get_memory_date_with_priority(
return Some((date, Some(exif_timestamp), modified)); return Some((date, Some(exif_timestamp), modified));
} }
// Priority 3: Fall back to metadata // Priority 3: Fall back to metadata (earlier of created/modified — see utils::earliest_fs_time)
let system_time = meta.created().ok().or_else(|| meta.modified().ok())?; let system_time = earliest_fs_time(&meta)?;
let dt_utc: DateTime<Utc> = system_time.into(); let dt_utc: DateTime<Utc> = system_time.into();
let date_in_timezone = if let Some(tz) = client_timezone { let date_in_timezone = if let Some(tz) = client_timezone {

View File

@@ -1,3 +1,5 @@
use std::time::SystemTime;
/// Normalize a file path to use forward slashes for cross-platform consistency /// Normalize a file path to use forward slashes for cross-platform consistency
/// This ensures paths stored in the database always use `/` regardless of OS /// This ensures paths stored in the database always use `/` regardless of OS
/// ///
@@ -12,6 +14,20 @@ pub fn normalize_path(path: &str) -> String {
path.replace('\\', "/") path.replace('\\', "/")
} }
/// Pick the earlier of a file's created and modified timestamps.
///
/// On copied/restored files (e.g., a backup library), `created` is stamped at
/// copy time while `modified` is preserved from the source — so the earlier
/// of the two is a better proxy for when the content originated. Falls back
/// to whichever timestamp is available if one platform lacks the other.
pub fn earliest_fs_time(md: &std::fs::Metadata) -> Option<SystemTime> {
match (md.created().ok(), md.modified().ok()) {
(Some(c), Some(m)) => Some(c.min(m)),
(Some(t), None) | (None, Some(t)) => Some(t),
(None, None) => None,
}
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;