diff --git a/Cargo.lock b/Cargo.lock index fa96ad5..76cfbfa 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -97,7 +97,7 @@ dependencies = [ "mime", "percent-encoding", "pin-project-lite", - "rand", + "rand 0.8.5", "sha1", "smallvec", "tokio", @@ -133,7 +133,7 @@ dependencies = [ "log", "memchr", "mime", - "rand", + "rand 0.8.5", "serde", "serde_json", "serde_plain", @@ -449,28 +449,6 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" -[[package]] -name = "async-stream" -version = "0.3.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b5a71a6f37880a80d1d7f19efd781e4b5de42c88f0722cc13bcb6cc2cfe8476" -dependencies = [ - "async-stream-impl", - "futures-core", - "pin-project-lite", -] - -[[package]] -name = "async-stream-impl" -version = "0.3.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - [[package]] name = "async-trait" version = "0.1.87" @@ -517,53 +495,6 @@ dependencies = [ "arrayvec", ] -[[package]] -name = "axum" -version = "0.7.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "edca88bc138befd0323b20752846e6587272d3b03b0343c8ea28a6f819e6e71f" -dependencies = [ - "async-trait", - "axum-core", - "bytes", - "futures-util", - "http 1.3.1", - "http-body", - "http-body-util", - "itoa", - "matchit", - "memchr", - "mime", - "percent-encoding", - "pin-project-lite", - "rustversion", - "serde", - "sync_wrapper", - "tower 0.5.2", - "tower-layer", - "tower-service", -] - -[[package]] -name = "axum-core" -version = "0.4.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09f2bd6146b97ae3359fa0cc6d6b376d9539582c7b4220f041a33ec24c226199" -dependencies = [ - "async-trait", - "bytes", - "futures-util", - "http 1.3.1", - "http-body", - "http-body-util", - "mime", - "pin-project-lite", - "rustversion", - "sync_wrapper", - "tower-layer", - "tower-service", -] - [[package]] name = "backtrace" version = "0.3.74" @@ -1236,12 +1167,6 @@ version = "0.31.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "07e28edb80900c19c28f1072f2e8aeca7fa06b23cd4169cefe1af5aa3260783f" -[[package]] -name = "glob" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a8d1add55171497b4705a648c6b583acafb01d58050a51727785f0b2c8e0a2b2" - [[package]] name = "h2" version = "0.3.26" @@ -1254,7 +1179,7 @@ dependencies = [ "futures-sink", "futures-util", "http 0.2.12", - "indexmap 2.6.0", + "indexmap", "slab", "tokio", "tokio-util", @@ -1273,19 +1198,13 @@ dependencies = [ "futures-core", "futures-sink", "http 1.3.1", - "indexmap 2.6.0", + "indexmap", "slab", "tokio", "tokio-util", "tracing", ] -[[package]] -name = "hashbrown" -version = "0.12.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" - [[package]] name = "hashbrown" version = "0.15.1" @@ -1386,7 +1305,6 @@ dependencies = [ "http 1.3.1", "http-body", "httparse", - "httpdate", "itoa", "pin-project-lite", "smallvec", @@ -1612,7 +1530,7 @@ dependencies = [ [[package]] name = "image-api" -version = "0.2.0" +version = "0.3.0" dependencies = [ "actix", "actix-files", @@ -1640,8 +1558,9 @@ dependencies = [ "opentelemetry_sdk", "path-absolutize", "prometheus", - "rand", + "rand 0.8.5", "rayon", + "regex", "serde", "serde_json", "tempfile", @@ -1661,16 +1580,6 @@ version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aae21c3177a27788957044151cc2800043d127acaa460a47ebb9b84dfa2c6aa0" -[[package]] -name = "indexmap" -version = "1.9.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" -dependencies = [ - "autocfg", - "hashbrown 0.12.3", -] - [[package]] name = "indexmap" version = "2.6.0" @@ -1678,7 +1587,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "707907fe3c25f5424cce2cb7e1cbcafee6bdbe735ca90ef77c29e84591e5b9da" dependencies = [ "equivalent", - "hashbrown 0.15.1", + "hashbrown", ] [[package]] @@ -1904,12 +1813,6 @@ dependencies = [ "imgref", ] -[[package]] -name = "matchit" -version = "0.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94" - [[package]] name = "maybe-rayon" version = "0.1.1" @@ -2118,9 +2021,9 @@ checksum = "1261fe7e33c73b354eab43b1273a57c8f967d0391e80353e51f764ac02cf6775" [[package]] name = "opentelemetry" -version = "0.28.0" +version = "0.30.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "236e667b670a5cdf90c258f5a55794ec5ac5027e960c224bff8367a59e1e6426" +checksum = "aaf416e4cb72756655126f7dd7bb0af49c674f4c1b9903e80c009e0c37e552e6" dependencies = [ "futures-core", "futures-sink", @@ -2132,9 +2035,9 @@ dependencies = [ [[package]] name = "opentelemetry-appender-log" -version = "0.28.0" +version = "0.30.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3bbd76b0dafbb54e8631ca147e7e090d09616ae7da45781d5403a83ac9af4290" +checksum = "e688026e48f4603494f619583e0aa0b0edd9c0b9430e1c46804df2ff32bc8798" dependencies = [ "log", "opentelemetry", @@ -2142,26 +2045,23 @@ dependencies = [ [[package]] name = "opentelemetry-http" -version = "0.28.0" +version = "0.30.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a8863faf2910030d139fb48715ad5ff2f35029fc5f244f6d5f689ddcf4d26253" +checksum = "50f6639e842a97dbea8886e3439710ae463120091e2e064518ba8e716e6ac36d" dependencies = [ "async-trait", "bytes", "http 1.3.1", "opentelemetry", "reqwest", - "tracing", ] [[package]] name = "opentelemetry-otlp" -version = "0.28.0" +version = "0.30.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5bef114c6d41bea83d6dc60eb41720eedd0261a67af57b66dd2b84ac46c01d91" +checksum = "dbee664a43e07615731afc539ca60c6d9f1a9425e25ca09c57bc36c87c55852b" dependencies = [ - "async-trait", - "futures-core", "http 1.3.1", "opentelemetry", "opentelemetry-http", @@ -2177,9 +2077,9 @@ dependencies = [ [[package]] name = "opentelemetry-proto" -version = "0.28.0" +version = "0.30.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56f8870d3024727e99212eb3bb1762ec16e255e3e6f58eeb3dc8db1aa226746d" +checksum = "2e046fd7660710fe5a05e8748e70d9058dc15c94ba914e7c4faa7c728f0e8ddc" dependencies = [ "opentelemetry", "opentelemetry_sdk", @@ -2189,38 +2089,31 @@ dependencies = [ [[package]] name = "opentelemetry-stdout" -version = "0.28.0" +version = "0.30.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5eb0e5a5132e4b80bf037a78e3e12c8402535199f5de490d0c38f7eac71bc831" +checksum = "447191061af41c3943e082ea359ab8b64ff27d6d34d30d327df309ddef1eef6f" dependencies = [ - "async-trait", "chrono", - "futures-util", "opentelemetry", "opentelemetry_sdk", - "serde", - "thiserror 2.0.12", ] [[package]] name = "opentelemetry_sdk" -version = "0.28.0" +version = "0.30.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "84dfad6042089c7fc1f6118b7040dc2eb4ab520abbf410b79dc481032af39570" +checksum = "11f644aa9e5e31d11896e024305d7e3c98a88884d9f8919dbf37a9991bc47a4b" dependencies = [ - "async-trait", "futures-channel", "futures-executor", "futures-util", - "glob", "opentelemetry", "percent-encoding", - "rand", + "rand 0.9.2", "serde_json", "thiserror 2.0.12", "tokio", "tokio-stream", - "tracing", ] [[package]] @@ -2458,8 +2351,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" dependencies = [ "libc", - "rand_chacha", - "rand_core", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.3", ] [[package]] @@ -2469,7 +2372,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" dependencies = [ "ppv-lite86", - "rand_core", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.3", ] [[package]] @@ -2481,6 +2394,15 @@ dependencies = [ "getrandom 0.2.15", ] +[[package]] +name = "rand_core" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "99d9a13982dcf210057a8a78572b2217b667c3beacbf3a0d8b454f6f82837d38" +dependencies = [ + "getrandom 0.3.3", +] + [[package]] name = "rav1e" version = "0.7.1" @@ -2507,8 +2429,8 @@ dependencies = [ "once_cell", "paste", "profiling", - "rand", - "rand_chacha", + "rand 0.8.5", + "rand_chacha 0.3.1", "simd_helpers", "system-deps", "thiserror 1.0.69", @@ -2623,7 +2545,7 @@ dependencies = [ "serde_urlencoded", "sync_wrapper", "tokio", - "tower 0.5.2", + "tower", "tower-service", "url", "wasm-bindgen", @@ -2681,12 +2603,6 @@ dependencies = [ "windows-sys 0.59.0", ] -[[package]] -name = "rustversion" -version = "1.0.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eded382c5f5f786b989652c49544c4877d9f015cc22e145a5ea8ea66c2921cd2" - [[package]] name = "ryu" version = "1.0.18" @@ -3042,21 +2958,9 @@ dependencies = [ "pin-project-lite", "signal-hook-registry", "socket2", - "tokio-macros", "windows-sys 0.52.0", ] -[[package]] -name = "tokio-macros" -version = "2.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "693d596312e88961bc67d7f1f97af8a70227d9f90c31bba5806eec004978d752" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - [[package]] name = "tokio-stream" version = "0.1.17" @@ -3108,7 +3012,7 @@ version = "0.22.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4ae48d6208a266e853d946088ed816055e556cc6028c5e8e2b84d9fa5dd7c7f5" dependencies = [ - "indexmap 2.6.0", + "indexmap", "serde", "serde_spanned", "toml_datetime", @@ -3117,16 +3021,13 @@ dependencies = [ [[package]] name = "tonic" -version = "0.12.3" +version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "877c5b330756d856ffcc4553ab34a5684481ade925ecc54bcd1bf02b1d0d4d52" +checksum = "7e581ba15a835f4d9ea06c55ab1bd4dce26fc53752c69a04aac00703bfb49ba9" dependencies = [ - "async-stream", "async-trait", - "axum", "base64 0.22.1", "bytes", - "h2 0.4.8", "http 1.3.1", "http-body", "http-body-util", @@ -3136,30 +3037,9 @@ dependencies = [ "percent-encoding", "pin-project", "prost", - "socket2", "tokio", "tokio-stream", - "tower 0.4.13", - "tower-layer", - "tower-service", - "tracing", -] - -[[package]] -name = "tower" -version = "0.4.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8fa9be0de6cf49e536ce1851f987bd21a43b771b09473c3549a6c853db37c1c" -dependencies = [ - "futures-core", - "futures-util", - "indexmap 1.9.3", - "pin-project", - "pin-project-lite", - "rand", - "slab", - "tokio", - "tokio-util", + "tower", "tower-layer", "tower-service", "tracing", @@ -3173,11 +3053,15 @@ checksum = "d039ad9159c98b70ecfd540b2573b97f7f52c3e8d9f8ad57a24b916a536975f9" dependencies = [ "futures-core", "futures-util", + "indexmap", "pin-project-lite", + "slab", "sync_wrapper", "tokio", + "tokio-util", "tower-layer", "tower-service", + "tracing", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index a3c12e6..79fb4a8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "image-api" -version = "0.2.0" +version = "0.3.0" authors = ["Cameron Cordes "] edition = "2021" @@ -37,9 +37,10 @@ prometheus = "0.13" lazy_static = "1.5" anyhow = "1.0" rand = "0.8.5" -opentelemetry = { version = "0.28.0", features = ["default", "metrics", "tracing"] } -opentelemetry_sdk = { version = "0.28.0", features = ["default", "rt-tokio-current-thread", "tracing", "metrics"] } -opentelemetry-otlp = { version = "0.28.0", features = ["default", "metrics", "tracing", "grpc-tonic"] } -opentelemetry-stdout = "0.28.0" -opentelemetry-appender-log = "0.28.0" -tempfile = "3.20.0" \ No newline at end of file +opentelemetry = { version = "0.30.0", features = ["default", "metrics", "tracing"] } +opentelemetry_sdk = { version = "0.30.0", features = ["default", "rt-tokio-current-thread", "metrics"] } +opentelemetry-otlp = { version = "0.30.0", features = ["default", "metrics", "tracing", "grpc-tonic"] } +opentelemetry-stdout = "0.30.0" +opentelemetry-appender-log = "0.30.0" +tempfile = "3.20.0" +regex = "1.11.1" \ No newline at end of file diff --git a/src/data/mod.rs b/src/data/mod.rs index 4be379d..30da4e3 100644 --- a/src/data/mod.rs +++ b/src/data/mod.rs @@ -137,9 +137,9 @@ pub enum PhotoSize { #[derive(Debug, Deserialize)] pub struct ThumbnailRequest { pub(crate) path: String, - pub(crate)size: Option, + pub(crate) size: Option, #[serde(default)] - pub(crate)format: Option, + pub(crate) format: Option, } #[derive(Debug, Deserialize, PartialEq)] diff --git a/src/files.rs b/src/files.rs index b71b0c3..44285df 100644 --- a/src/files.rs +++ b/src/files.rs @@ -505,7 +505,6 @@ mod tests { mod api { use super::*; - use actix::Actor; use actix_web::{web::Query, HttpResponse}; use crate::{ @@ -516,9 +515,9 @@ mod tests { use crate::database::test::in_memory_db_connection; use crate::tags::SqliteTagDao; - use actix_web::web::Data; - use std::{fs, sync::Arc}; use actix_web::test::TestRequest; + use actix_web::web::Data; + use std::fs; fn setup() { let _ = env_logger::builder().is_test(true).try_init(); @@ -609,12 +608,22 @@ mod tests { let mut tag_dao = SqliteTagDao::new(in_memory_db_connection()); - let tag1 = tag_dao.create_tag(&opentelemetry::Context::current(), "tag1").unwrap(); - let _tag2 = tag_dao.create_tag(&opentelemetry::Context::current(), "tag2").unwrap(); - let tag3 = tag_dao.create_tag(&opentelemetry::Context::current(), "tag3").unwrap(); + let tag1 = tag_dao + .create_tag(&opentelemetry::Context::current(), "tag1") + .unwrap(); + let _tag2 = tag_dao + .create_tag(&opentelemetry::Context::current(), "tag2") + .unwrap(); + let tag3 = tag_dao + .create_tag(&opentelemetry::Context::current(), "tag3") + .unwrap(); - let _ = &tag_dao.tag_file(&opentelemetry::Context::current(), "test.jpg", tag1.id).unwrap(); - let _ = &tag_dao.tag_file(&opentelemetry::Context::current(), "test.jpg", tag3.id).unwrap(); + let _ = &tag_dao + .tag_file(&opentelemetry::Context::current(), "test.jpg", tag1.id) + .unwrap(); + let _ = &tag_dao + .tag_file(&opentelemetry::Context::current(), "test.jpg", tag3.id) + .unwrap(); let mut files = HashMap::new(); files.insert( @@ -654,15 +663,31 @@ mod tests { let mut tag_dao = SqliteTagDao::new(in_memory_db_connection()); - let tag1 = tag_dao.create_tag(&opentelemetry::Context::current(), "tag1").unwrap(); - let _tag2 = tag_dao.create_tag(&opentelemetry::Context::current(), "tag2").unwrap(); - let tag3 = tag_dao.create_tag(&opentelemetry::Context::current(), "tag3").unwrap(); + let tag1 = tag_dao + .create_tag(&opentelemetry::Context::current(), "tag1") + .unwrap(); + let _tag2 = tag_dao + .create_tag(&opentelemetry::Context::current(), "tag2") + .unwrap(); + let tag3 = tag_dao + .create_tag(&opentelemetry::Context::current(), "tag3") + .unwrap(); - let _ = &tag_dao.tag_file(&opentelemetry::Context::current(), "test.jpg", tag1.id).unwrap(); - let _ = &tag_dao.tag_file(&opentelemetry::Context::current(), "test.jpg", tag3.id).unwrap(); + let _ = &tag_dao + .tag_file(&opentelemetry::Context::current(), "test.jpg", tag1.id) + .unwrap(); + let _ = &tag_dao + .tag_file(&opentelemetry::Context::current(), "test.jpg", tag3.id) + .unwrap(); // Should get filtered since it doesn't have tag3 - tag_dao.tag_file(&opentelemetry::Context::current(), "some-other.jpg", tag1.id).unwrap(); + tag_dao + .tag_file( + &opentelemetry::Context::current(), + "some-other.jpg", + tag1.id, + ) + .unwrap(); let mut files = HashMap::new(); files.insert( @@ -755,8 +780,8 @@ mod tests { assert!(is_valid_full_path(&base, &test_file, false).is_some()); assert_eq!( - Some(PathBuf::from(test_file.clone())), - is_valid_full_path(&base, &PathBuf::from(test_file), false) + Some(test_file.clone()), + is_valid_full_path(&base, &test_file, false) ); } diff --git a/src/main.rs b/src/main.rs index aea239a..9604b67 100644 --- a/src/main.rs +++ b/src/main.rs @@ -59,6 +59,7 @@ mod state; mod tags; mod video; +mod memories; mod otel; mod service; #[cfg(test)] @@ -654,6 +655,7 @@ fn main() -> std::io::Result<()> { .service(put_add_favorite) .service(delete_favorite) .service(get_file_metadata) + .service(memories::list_memories) .add_feature(add_tag_services::<_, SqliteTagDao>) .app_data(app_data.clone()) .app_data::>(Data::new(RealFileSystem::new( diff --git a/src/memories.rs b/src/memories.rs new file mode 100644 index 0000000..6066840 --- /dev/null +++ b/src/memories.rs @@ -0,0 +1,476 @@ +use actix_web::web::Data; +use actix_web::{get, web, HttpRequest, HttpResponse, Responder}; +use chrono::LocalResult::{Ambiguous, Single}; +use chrono::{DateTime, Datelike, FixedOffset, Local, LocalResult, NaiveDate, TimeZone, Utc}; +use log::{debug, trace, warn}; +use opentelemetry::trace::{Span, Status, Tracer}; +use opentelemetry::KeyValue; +use serde::{Deserialize, Serialize}; +use std::path::Path; +use walkdir::WalkDir; + +use crate::data::Claims; +use crate::files::is_image_or_video; +use crate::otel::{extract_context_from_request, global_tracer}; +use crate::state::AppState; + +#[derive(Copy, Clone, Deserialize, PartialEq, Debug)] +#[serde(rename_all = "lowercase")] +pub enum MemoriesSpan { + Day, + Week, + Month, +} + +#[derive(Deserialize)] +pub struct MemoriesRequest { + pub span: Option, + /// Client timezone offset in minutes from UTC (e.g., -480 for PST, 60 for CET) + pub timezone_offset_minutes: Option, +} + +#[derive(Debug, Serialize, Clone)] +pub struct MemoryItem { + pub path: String, + pub created: Option, + pub modified: Option, +} + +#[derive(Debug, Serialize)] +pub struct MemoriesResponse { + pub items: Vec, +} + +fn get_file_date_info( + path: &Path, + client_timezone: &Option, +) -> Option<(NaiveDate, Option, Option)> { + // Read file metadata once + let meta = std::fs::metadata(path).ok()?; + + // Extract metadata timestamps + let metadata_created = meta.created().ok().map(|t| { + let utc: DateTime = t.into(); + if let Some(tz) = client_timezone { + utc.with_timezone(tz).timestamp() + } else { + utc.timestamp() + } + }); + + let metadata_modified = meta.modified().ok().map(|t| { + let utc: DateTime = t.into(); + if let Some(tz) = client_timezone { + utc.with_timezone(tz).timestamp() + } else { + utc.timestamp() + } + }); + + // Try to get date from filename + if let Some(date_time) = path + .file_name() + .and_then(|filename| filename.to_str()) + .and_then(extract_date_from_filename) + { + // Convert to client timezone if specified + let date_in_timezone = if let Some(tz) = client_timezone { + date_time.with_timezone(tz) + } else { + date_time.with_timezone(&Local).fixed_offset() + }; + + // Use the timestamp from the filename date + let created_ts = date_in_timezone.timestamp(); + + debug!( + "File date from file {:?} > {:?} = {:?}", + path.file_name(), + date_time, + date_in_timezone + ); + return Some(( + date_in_timezone.date_naive(), + Some(created_ts), + metadata_modified, + )); + } + + // Fall back to metadata if no date in filename + let system_time = meta.created().ok().or_else(|| meta.modified().ok())?; + let dt_utc: DateTime = system_time.into(); + + let date_in_timezone = if let Some(tz) = client_timezone { + dt_utc.with_timezone(tz).date_naive() + } else { + dt_utc.with_timezone(&Local).date_naive() + }; + + trace!("Fallback metadata create date = {:?}", date_in_timezone); + Some((date_in_timezone, metadata_created, metadata_modified)) +} + +fn extract_date_from_filename(filename: &str) -> Option> { + let build_date_from_ymd_capture = + |captures: ®ex::Captures| -> Option> { + let year = captures.get(1)?.as_str().parse::().ok()?; + let month = captures.get(2)?.as_str().parse::().ok()?; + let day = captures.get(3)?.as_str().parse::().ok()?; + let hour = captures.get(4)?.as_str().parse::().ok()?; + let min = captures.get(5)?.as_str().parse::().ok()?; + let sec = captures.get(6)?.as_str().parse::().ok()?; + + match Local.from_local_datetime( + &NaiveDate::from_ymd_opt(year, month, day)?.and_hms_opt(hour, min, sec)?, + ) { + Single(dt) => Some(dt.fixed_offset()), + Ambiguous(early_dt, _) => Some(early_dt.fixed_offset()), + LocalResult::None => { + warn!("Weird local date: {:?}", filename); + None + } + } + }; + + // 1. Screenshot format: Screenshot_2014-06-01-20-44-50.png + if let Some(captures) = + regex::Regex::new(r"Screenshot_(\d{4})-(\d{2})-(\d{2})-(\d{2})-(\d{2})-(\d{2})") + .ok()? + .captures(filename) + .and_then(|c| build_date_from_ymd_capture(&c)) + { + return Some(captures); + } + + // 2. Dash format: 2015-01-09_02-15-15.jpg + if let Some(captures) = regex::Regex::new(r"(\d{4})-(\d{2})-(\d{2})_(\d{2})-(\d{2})-(\d{2})") + .ok()? + .captures(filename) + .and_then(|c| build_date_from_ymd_capture(&c)) + { + return Some(captures); + } + + // 3. Compact format: 20140927101712.jpg + if let Some(captures) = regex::Regex::new(r"(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})") + .ok()? + .captures(filename) + .and_then(|c| build_date_from_ymd_capture(&c)) + { + return Some(captures); + } + + // 4. Timestamp format: 1401638400.jpeg + if let Some(captures) = regex::Regex::new(r"(\d{10}|\d{13})\.") + .ok()? + .captures(filename) + { + let timestamp_str = captures.get(1)?.as_str(); + + // Millisecond timestamp (13 digits) + if timestamp_str.len() >= 13 { + if let Ok(ts_millis) = timestamp_str[0..13].parse::() { + if let Some(naive_dt) = DateTime::from_timestamp_millis(ts_millis) { + return Some(naive_dt.fixed_offset()); + } + } + } + + // Second timestamp (10 digits) + if timestamp_str.len() >= 10 { + if let Ok(ts_secs) = timestamp_str[0..10].parse::() { + if let Some(naive_dt) = DateTime::from_timestamp(ts_secs, 0) { + return Some(naive_dt.fixed_offset()); + } + } + } + } + + None +} + +#[get("/memories")] +pub async fn list_memories( + _claims: Claims, + request: HttpRequest, + q: web::Query, + app_state: Data, +) -> impl Responder { + let tracer = global_tracer(); + let context = extract_context_from_request(&request); + let mut span = tracer.start_with_context("list_memories", &context); + + let span_mode = q.span.unwrap_or(MemoriesSpan::Day); + let years_back: u32 = 15; + + // Create timezone from client offset, default to local timezone if not provided + let client_timezone = match q.timezone_offset_minutes { + Some(offset_mins) => { + let offset_secs = offset_mins * 60; + Some( + FixedOffset::east_opt(offset_secs) + .unwrap_or_else(|| FixedOffset::east_opt(0).unwrap()), + ) + } + None => None, + }; + + let now = if let Some(tz) = client_timezone { + debug!("Client timezone: {:?}", tz); + Utc::now().with_timezone(&tz).date_naive() + } else { + Local::now().date_naive() + }; + + debug!("Now: {:?}", now); + + let base = Path::new(&app_state.base_path); + + let mut memories_with_dates: Vec<(MemoryItem, NaiveDate)> = Vec::new(); + + for entry in WalkDir::new(base) + .into_iter() + .filter_map(|e| e.ok()) + .filter(|e| e.file_type().is_file()) + { + let path = entry.path(); + + if !is_image_or_video(path) { + continue; + } + + // Get file date and timestamps in one operation + let (file_date, created, modified) = match get_file_date_info(path, &client_timezone) { + Some(info) => info, + None => { + warn!("No date info found for file: {:?}", path); + continue; + } + }; + + if is_memories_match(file_date, now, span_mode, years_back) { + if let Ok(rel) = path.strip_prefix(base) { + memories_with_dates.push(( + MemoryItem { + path: rel.to_string_lossy().to_string(), + created, + modified, + }, + file_date, + )); + } else { + warn!("Failed to strip prefix from path: {:?}", path); + } + } + } + + match span_mode { + // Sort by absolute time for a more 'overview' + MemoriesSpan::Month => memories_with_dates.sort_by(|a, b| a.1.cmp(&b.1)), + _ => { + memories_with_dates.sort_by(|a, b| { + let day_comparison = a.1.day().cmp(&b.1.day()); + + if day_comparison == std::cmp::Ordering::Equal { + match (a.0.created, b.0.created) { + (Some(a_time), Some(b_time)) => a_time.cmp(&b_time), + (Some(_), None) => std::cmp::Ordering::Less, + (None, Some(_)) => std::cmp::Ordering::Greater, + (None, None) => std::cmp::Ordering::Equal, + } + } else { + day_comparison + } + }); + } + } + // Sort by day of the month and time (using the created timestamp) + + let items: Vec = memories_with_dates.into_iter().map(|(m, _)| m).collect(); + + span.add_event( + "memories_scanned", + vec![ + KeyValue::new("span", format!("{:?}", span_mode)), + KeyValue::new("years_back", years_back.to_string()), + KeyValue::new("result_count", items.len().to_string()), + KeyValue::new( + "client_timezone", + format!( + "{:?}", + client_timezone.unwrap_or_else(|| FixedOffset::east_opt(0).unwrap()) + ), + ), + ], + ); + span.set_status(Status::Ok); + + HttpResponse::Ok().json(MemoriesResponse { items }) +} + +fn is_memories_match( + file_date: NaiveDate, + today: NaiveDate, + span: MemoriesSpan, + years_back: u32, +) -> bool { + if file_date > today { + return false; + } + let years_diff = (today.year() - file_date.year()).unsigned_abs(); + if years_diff > years_back { + warn!( + "File date is too far in the past: {:?} vs {:?}", + file_date, today + ); + return false; + } + + match span { + MemoriesSpan::Day => same_month_day_any_year(file_date, today), + MemoriesSpan::Week => same_week_any_year(file_date, today), + MemoriesSpan::Month => same_month_any_year(file_date, today), + } +} + +fn same_month_day_any_year(a: NaiveDate, b: NaiveDate) -> bool { + a.month() == b.month() && a.day() == b.day() +} + +// Match same ISO week number and same weekday (ignoring year) +fn same_week_any_year(a: NaiveDate, b: NaiveDate) -> bool { + a.iso_week().week().eq(&b.iso_week().week()) +} + +// Match same month (ignoring day and year) +fn same_month_any_year(a: NaiveDate, b: NaiveDate) -> bool { + a.month() == b.month() +} + +#[cfg(test)] +mod tests { + use super::*; + use chrono::Timelike; + use std::fs::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"; + let date_time = extract_date_from_filename(filename).unwrap(); + + assert_eq!(date_time.year(), 2014); + assert_eq!(date_time.month(), 6); + assert_eq!(date_time.day(), 1); + assert_eq!(date_time.hour(), 20); + assert_eq!(date_time.minute(), 44); + assert_eq!(date_time.second(), 50); + } + + #[test] + fn test_extract_date_from_filename_dash_format() { + let filename = "2015-01-09_02-15-15.jpg"; + let date_time = extract_date_from_filename(filename).unwrap(); + + assert_eq!(date_time.year(), 2015); + assert_eq!(date_time.month(), 1); + assert_eq!(date_time.day(), 9); + assert_eq!(date_time.hour(), 2); + assert_eq!(date_time.minute(), 15); + assert_eq!(date_time.second(), 15); + } + + #[test] + fn test_extract_date_from_filename_compact_format() { + let filename = "20140927101712.jpg"; + let date_time = extract_date_from_filename(filename).unwrap(); + + assert_eq!(date_time.year(), 2014); + assert_eq!(date_time.month(), 9); + assert_eq!(date_time.day(), 27); + assert_eq!(date_time.hour(), 10); + assert_eq!(date_time.minute(), 17); + assert_eq!(date_time.second(), 12); + } + + #[test] + fn test_extract_date_from_filename_timestamp_format() { + let filename = "xyz_1401638400.jpeg"; // Unix timestamp for 2014-06-01 16:00:00 UTC + // Timestamps are already in UTC, so timezone doesn't matter for this test + let date_time = extract_date_from_filename(filename).unwrap(); + + assert_eq!(date_time.year(), 2014); + assert_eq!(date_time.month(), 6); + assert_eq!(date_time.day(), 1); + assert_eq!(date_time.hour(), 16); + assert_eq!(date_time.minute(), 0); + assert_eq!(date_time.second(), 0); + } + + #[test] + fn test_extract_date_from_filename_timestamp_millis_format() { + let filename = "xyz_1401638400000.jpeg"; // Unix timestamp in milliseconds + let date_time = extract_date_from_filename(filename).unwrap(); + + assert_eq!(date_time.year(), 2014); + assert_eq!(date_time.month(), 6); + assert_eq!(date_time.day(), 1); + assert_eq!(date_time.hour(), 16); + assert_eq!(date_time.minute(), 0); + assert_eq!(date_time.second(), 0); + } + + #[test] + fn test_get_file_date_info_from_filename() { + let temp_dir = tempdir().unwrap(); + let temp_file = temp_dir.path().join("Screenshot_2014-06-01-20-44-50.png"); + File::create(&temp_file).unwrap(); + + let (date, created, _) = + get_file_date_info(&temp_file, &Some(*Local::now().fixed_offset().offset())).unwrap(); + + // Check that date is from filename + assert_eq!(date.year(), 2014); + assert_eq!(date.month(), 6); + assert_eq!(date.day(), 1); + + // Check that created timestamp matches the date from filename + assert!(created.is_some()); + let ts = created.unwrap(); + // The timestamp should be for 2014-06-01 20:44:50 in the LOCAL timezone + let dt_from_ts = Local.timestamp_opt(ts, 0).unwrap(); + assert_eq!(dt_from_ts.year(), 2014); + assert_eq!(dt_from_ts.month(), 6); + assert_eq!(dt_from_ts.day(), 1); + assert_eq!(dt_from_ts.hour(), 20); + assert_eq!(dt_from_ts.minute(), 44); + assert_eq!(dt_from_ts.second(), 50); + } + + #[test] + fn test_get_file_date_info_from_metadata() { + let temp_dir = tempdir().unwrap(); + let temp_file = temp_dir.path().join("regular_image.jpg"); + File::create(&temp_file).unwrap(); + + let (date, created, modified) = get_file_date_info(&temp_file, &None).unwrap(); + + // Both date and timestamps should be from metadata (recent) + let today = Local::now().date_naive(); + assert_eq!(date.year(), today.year()); + assert_eq!(date.month(), today.month()); + + // Both timestamps should be valid + assert!(created.is_some()); + assert!(modified.is_some()); + + // Check that timestamps are recent + let dt_created = DateTime::::from_timestamp(created.unwrap(), 0).unwrap(); + assert_eq!(dt_created.year(), today.year()); + + let dt_modified = DateTime::::from_timestamp(modified.unwrap(), 0).unwrap(); + assert_eq!(dt_modified.year(), today.year()); + } +} diff --git a/src/state.rs b/src/state.rs index 0853787..8594ec8 100644 --- a/src/state.rs +++ b/src/state.rs @@ -32,7 +32,6 @@ impl AppState { gif_path, } } - } impl Default for AppState { @@ -64,10 +63,10 @@ impl AppState { // Create the AppState with the temporary paths AppState::new( std::sync::Arc::new(crate::video::actors::StreamActor {}.start()), - base_path.to_string_lossy().to_string(), - thumbnail_path.to_string_lossy().to_string(), - video_path.to_string_lossy().to_string(), - gif_path.to_string_lossy().to_string(), + base_path.to_string_lossy().to_string(), + thumbnail_path.to_string_lossy().to_string(), + video_path.to_string_lossy().to_string(), + gif_path.to_string_lossy().to_string(), ) } } @@ -77,6 +76,6 @@ impl AppState { fn create_test_subdir(base_path: &std::path::Path, name: &str) -> std::path::PathBuf { let dir_path = base_path.join(name); std::fs::create_dir_all(&dir_path) - .expect(&format!("Failed to create {} directory", name)); + .unwrap_or_else(|_| panic!("Failed to create {} directory", name)); dir_path } diff --git a/src/tags.rs b/src/tags.rs index 276ffdb..0f83037 100644 --- a/src/tags.rs +++ b/src/tags.rs @@ -552,8 +552,8 @@ impl TagDao for SqliteTagDao { #[cfg(test)] mod tests { - use actix_web::web::{Data, Json}; use actix_web::test::TestRequest; + use actix_web::web::Data; use std::{cell::RefCell, collections::HashMap}; use diesel::result::Error::NotFound; @@ -724,7 +724,9 @@ mod tests { add_tag(claims, request, web::Json(body), tag_data.clone()).await; let mut tag_dao = tag_data.lock().unwrap(); - let tags = tag_dao.get_all_tags(&opentelemetry::Context::current(), None).unwrap(); + let tags = tag_dao + .get_all_tags(&opentelemetry::Context::current(), None) + .unwrap(); assert_eq!(tags.len(), 1); assert_eq!(tags.first().unwrap().1.name, "test-tag"); let tagged_photos = tag_dao.tagged_photos.borrow(); @@ -747,11 +749,19 @@ mod tests { let tag_data = Data::new(Mutex::new(tag_dao)); let request = TestRequest::default().to_http_request(); - add_tag(claims.clone(), request.clone(), web::Json(add_request), tag_data.clone()).await; + add_tag( + claims.clone(), + request.clone(), + web::Json(add_request), + tag_data.clone(), + ) + .await; remove_tagged_photo(claims, request, web::Json(remove_request), tag_data.clone()).await; let mut tag_dao = tag_data.lock().unwrap(); - let tags = tag_dao.get_all_tags(&opentelemetry::Context::current(), None).unwrap(); + let tags = tag_dao + .get_all_tags(&opentelemetry::Context::current(), None) + .unwrap(); assert!(tags.is_empty()); let tagged_photos = tag_dao.tagged_photos.borrow(); let previously_added_tagged_photo = tagged_photos.get("test.png").unwrap(); @@ -761,12 +771,22 @@ mod tests { #[actix_rt::test] async fn replace_tags_keeps_existing_tags_removes_extras_adds_missing_test() { let mut tag_dao = TestTagDao::new(); - let new_tag = tag_dao.create_tag(&opentelemetry::Context::current(), "Test").unwrap(); - let new_tag2 = tag_dao.create_tag(&opentelemetry::Context::current(), "Test2").unwrap(); - let _ = tag_dao.create_tag(&opentelemetry::Context::current(), "Test3").unwrap(); + let new_tag = tag_dao + .create_tag(&opentelemetry::Context::current(), "Test") + .unwrap(); + let new_tag2 = tag_dao + .create_tag(&opentelemetry::Context::current(), "Test2") + .unwrap(); + let _ = tag_dao + .create_tag(&opentelemetry::Context::current(), "Test3") + .unwrap(); - tag_dao.tag_file(&opentelemetry::Context::current(), "test.jpg", new_tag.id).unwrap(); - tag_dao.tag_file(&opentelemetry::Context::current(), "test.jpg", new_tag2.id).unwrap(); + tag_dao + .tag_file(&opentelemetry::Context::current(), "test.jpg", new_tag.id) + .unwrap(); + tag_dao + .tag_file(&opentelemetry::Context::current(), "test.jpg", new_tag2.id) + .unwrap(); let claims = Claims::valid_user(String::from("1")); let tag_data = Data::new(Mutex::new(tag_dao)); @@ -777,7 +797,13 @@ mod tests { }; let request = TestRequest::default().to_http_request(); - update_tags(claims, tag_data.clone(), request, web::Json(add_tags_request)).await; + update_tags( + claims, + tag_data.clone(), + request, + web::Json(add_tags_request), + ) + .await; let tag_dao = tag_data.lock().unwrap(); let tags_for_test_photo = &tag_dao.tagged_photos.borrow()["test.jpg"]; diff --git a/src/video/ffmpeg.rs b/src/video/ffmpeg.rs index d678153..4722115 100644 --- a/src/video/ffmpeg.rs +++ b/src/video/ffmpeg.rs @@ -1,6 +1,6 @@ use futures::TryFutureExt; use log::{debug, error, info, warn}; -use std::io::{Result}; +use std::io::Result; use std::process::{Output, Stdio}; use std::time::Instant; use tokio::process::Command; @@ -182,4 +182,4 @@ impl Ffmpeg { Ok(output.status.code().unwrap_or(-1)) } -} \ No newline at end of file +} diff --git a/src/video/mod.rs b/src/video/mod.rs index 0d64712..9889243 100644 --- a/src/video/mod.rs +++ b/src/video/mod.rs @@ -1,10 +1,10 @@ use crate::otel::global_tracer; use crate::video::ffmpeg::{Ffmpeg, GifType}; use crate::{is_video, update_media_counts}; -use log::{info}; -use opentelemetry::trace::{Tracer}; +use log::info; +use opentelemetry::trace::Tracer; +use std::fs; use std::path::{Path, PathBuf}; -use std::{fs}; use walkdir::WalkDir; pub mod actors; @@ -43,8 +43,9 @@ pub async fn generate_video_gifs() { let gif_path = Path::new(gif_directory).join(relative_path); let gif_path = gif_path.with_extension("gif"); if let Some(parent_dir) = gif_path.parent() { - fs::create_dir_all(parent_dir).unwrap_or_else(|_| panic!("There was an issue creating gif directory {:?}", - gif_path)); + fs::create_dir_all(parent_dir).unwrap_or_else(|_| { + panic!("There was an issue creating gif directory {:?}", gif_path) + }); } info!("Generating gif for {:?}", path);