From f85c0cb246fb2764deabf93cf24b186e6013544d Mon Sep 17 00:00:00 2001 From: Gabriel Kaszewski Date: Sun, 31 May 2026 21:05:46 +0200 Subject: [PATCH] feat: real XMP sidecar adapter, replaces LogSidecarWriter stubs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - adapters-sidecar: XmpSidecarWriter using xmp_toolkit - Writes StructuredData → XMP with EXIF/DC/XMP namespace routing - Reads XMP back to StructuredData - Wired into bootstrap + worker, deleted both LogSidecarWriter stubs --- Cargo.lock | 101 +++++++++++++++ Cargo.toml | 2 + crates/adapters/sidecar/Cargo.toml | 11 ++ crates/adapters/sidecar/src/lib.rs | 139 +++++++++++++++++++++ crates/adapters/sidecar/src/tests.rs | 45 +++++++ crates/bootstrap/Cargo.toml | 1 + crates/bootstrap/src/lib.rs | 1 - crates/bootstrap/src/log_sidecar_writer.rs | 21 ---- crates/bootstrap/src/main.rs | 1 - crates/bootstrap/src/services/sidecar.rs | 5 +- crates/worker/Cargo.toml | 1 + crates/worker/src/main.rs | 29 +---- 12 files changed, 304 insertions(+), 53 deletions(-) create mode 100644 crates/adapters/sidecar/Cargo.toml create mode 100644 crates/adapters/sidecar/src/lib.rs create mode 100644 crates/adapters/sidecar/src/tests.rs delete mode 100644 crates/bootstrap/src/log_sidecar_writer.rs diff --git a/Cargo.lock b/Cargo.lock index 6985fb2..dc17d12 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -78,6 +78,17 @@ dependencies = [ "uuid", ] +[[package]] +name = "adapters-sidecar" +version = "0.1.0" +dependencies = [ + "async-trait", + "domain", + "tokio", + "tracing", + "xmp_toolkit", +] + [[package]] name = "adapters-storage" version = "0.1.0" @@ -478,6 +489,7 @@ dependencies = [ "adapters-event-transport", "adapters-nats", "adapters-postgres", + "adapters-sidecar", "adapters-storage", "anyhow", "application", @@ -988,6 +1000,12 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + [[package]] name = "futures" version = "0.3.32" @@ -2020,6 +2038,28 @@ dependencies = [ "libm", ] +[[package]] +name = "num_enum" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26" +dependencies = [ + "num_enum_derive", + "rustversion", +] + +[[package]] +name = "num_enum_derive" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "object_store" version = "0.11.2" @@ -2261,6 +2301,15 @@ dependencies = [ "syn", ] +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit", +] + [[package]] name = "proc-macro2" version = "1.0.104" @@ -3474,6 +3523,36 @@ dependencies = [ "webpki-roots 0.26.11", ] +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.25.12+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2153edc6955a6c354fad8f5efd38b6a8769bdccf9fe50f8e1329f81b0baa5d7" +dependencies = [ + "indexmap", + "toml_datetime", + "toml_parser", + "winnow", +] + +[[package]] +name = "toml_parser" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" +dependencies = [ + "winnow", +] + [[package]] name = "tower" version = "0.5.3" @@ -4164,6 +4243,15 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" +[[package]] +name = "winnow" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1" +dependencies = [ + "memchr", +] + [[package]] name = "wit-bindgen" version = "0.46.0" @@ -4266,6 +4354,7 @@ dependencies = [ "adapters-exif", "adapters-nats", "adapters-postgres", + "adapters-sidecar", "adapters-storage", "adapters-thumbnail", "anyhow", @@ -4286,6 +4375,18 @@ version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" +[[package]] +name = "xmp_toolkit" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04517ad6f440d16e52ade0ad6d781029313bdacaac4590de9a9114f8d737af61" +dependencies = [ + "cc", + "fs_extra", + "num_enum", + "thiserror", +] + [[package]] name = "y4m" version = "0.8.0" diff --git a/Cargo.toml b/Cargo.toml index 21f8a33..6d21e7f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,6 +12,7 @@ members = [ "crates/adapters/nats", "crates/adapters/exif", "crates/adapters/thumbnail", + "crates/adapters/sidecar", "crates/presentation", "crates/bootstrap", "crates/worker", @@ -50,6 +51,7 @@ adapters-event-transport = { path = "crates/adapters/event-transport" } adapters-nats = { path = "crates/adapters/nats" } adapters-exif = { path = "crates/adapters/exif" } adapters-thumbnail = { path = "crates/adapters/thumbnail" } +adapters-sidecar = { path = "crates/adapters/sidecar" } adapters-postgres = { path = "crates/adapters/postgres" } async-nats = "0.48" async-stream = "0.3" diff --git a/crates/adapters/sidecar/Cargo.toml b/crates/adapters/sidecar/Cargo.toml new file mode 100644 index 0000000..9b3286a --- /dev/null +++ b/crates/adapters/sidecar/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "adapters-sidecar" +version = "0.1.0" +edition = "2024" + +[dependencies] +domain = { workspace = true } +async-trait = { workspace = true } +tokio = { workspace = true, features = ["fs"] } +xmp_toolkit = "1.11" +tracing = { workspace = true } diff --git a/crates/adapters/sidecar/src/lib.rs b/crates/adapters/sidecar/src/lib.rs new file mode 100644 index 0000000..026cfa2 --- /dev/null +++ b/crates/adapters/sidecar/src/lib.rs @@ -0,0 +1,139 @@ +use async_trait::async_trait; +use domain::{ + errors::DomainError, + ports::SidecarWriterPort, + value_objects::{MetadataValue, StructuredData}, +}; +use xmp_toolkit::{ + XmpMeta, XmpValue, + xmp_ns::{DC, EXIF, XMP}, +}; + +pub struct XmpSidecarWriter; + +const EXIF_TAGS: &[&str] = &[ + "DateTimeOriginal", + "ExposureTime", + "FNumber", + "ISOSpeedRatings", + "FocalLength", + "Make", + "Model", + "LensModel", + "GPSLatitude", + "GPSLongitude", + "GPSAltitude", + "Orientation", + "ImageWidth", + "ImageHeight", + "Flash", + "MeteringMode", + "WhiteBalance", + "ExposureProgram", + "ExposureBiasValue", + "ModifyDate", + "Software", +]; + +#[async_trait] +impl SidecarWriterPort for XmpSidecarWriter { + fn format_name(&self) -> &str { + "xmp" + } + + async fn write_sidecar(&self, data: &StructuredData, path: &str) -> Result<(), DomainError> { + let mut xmp = + XmpMeta::new().map_err(|e| DomainError::Internal(format!("xmp init failed: {e}")))?; + + register_namespaces()?; + + for (key, value) in data.inner() { + let value_str = match value { + MetadataValue::String(s) => s.clone(), + MetadataValue::Integer(i) => i.to_string(), + MetadataValue::Float(f) => f.to_string(), + MetadataValue::Boolean(b) => b.to_string(), + MetadataValue::Null => continue, + }; + + let ns = if EXIF_TAGS.contains(&key.as_str()) || key.starts_with("track:") { + EXIF + } else if key == "title" || key == "description" || key == "subject" { + DC + } else { + XMP + }; + + set_prop(&mut xmp, ns, key, &value_str)?; + } + + let xmp_str = xmp.to_string(); + let path = path.to_string(); + tokio::task::spawn_blocking(move || { + if let Some(parent) = std::path::Path::new(&path).parent() { + std::fs::create_dir_all(parent) + .map_err(|e| DomainError::Internal(format!("mkdir failed: {e}")))?; + } + std::fs::write(&path, xmp_str) + .map_err(|e| DomainError::Internal(format!("write failed: {e}"))) + }) + .await + .map_err(|e| DomainError::Internal(format!("spawn_blocking failed: {e}")))? + } + + async fn read_sidecar(&self, path: &str) -> Result { + let path = path.to_string(); + let content = tokio::fs::read_to_string(&path).await.map_err(|e| { + if e.kind() == std::io::ErrorKind::NotFound { + DomainError::NotFound(format!("sidecar not found: {path}")) + } else { + DomainError::Internal(format!("read failed: {e}")) + } + })?; + + let xmp: XmpMeta = content + .parse() + .map_err(|e| DomainError::Internal(format!("xmp parse failed: {e}")))?; + + let mut data = StructuredData::new(); + + for ns in [DC, EXIF, XMP] { + let iter = xmp.iter(xmp_toolkit::IterOptions::default().schema_ns(ns)); + for prop in iter { + if prop.name.is_empty() || prop.value.value.is_empty() { + continue; + } + let key = prop + .name + .split(':') + .next_back() + .unwrap_or(&prop.name) + .to_string(); + if !key.is_empty() { + data.insert(key, MetadataValue::String(prop.value.value)); + } + } + } + + Ok(data) + } +} + +fn register_namespaces() -> Result<(), DomainError> { + XmpMeta::register_namespace(DC, "dc") + .map_err(|e| DomainError::Internal(format!("ns register failed: {e}")))?; + XmpMeta::register_namespace(EXIF, "exif") + .map_err(|e| DomainError::Internal(format!("ns register failed: {e}")))?; + XmpMeta::register_namespace(XMP, "xmp") + .map_err(|e| DomainError::Internal(format!("ns register failed: {e}")))?; + Ok(()) +} + +fn set_prop(xmp: &mut XmpMeta, ns: &str, key: &str, value: &str) -> Result<(), DomainError> { + xmp.set_property(ns, key, &XmpValue::from(value)) + .map_err(|e| DomainError::Internal(format!("set {key} failed: {e}")))?; + Ok(()) +} + +#[cfg(test)] +mod tests; diff --git a/crates/adapters/sidecar/src/tests.rs b/crates/adapters/sidecar/src/tests.rs new file mode 100644 index 0000000..bf2f45d --- /dev/null +++ b/crates/adapters/sidecar/src/tests.rs @@ -0,0 +1,45 @@ +use crate::XmpSidecarWriter; +use domain::{ + ports::SidecarWriterPort, + value_objects::{MetadataValue, StructuredData}, +}; + +fn sample_metadata() -> StructuredData { + let mut data = StructuredData::new(); + data.insert("Make", MetadataValue::String("Canon".into())); + data.insert("Model", MetadataValue::String("EOS R5".into())); + data.insert( + "DateTimeOriginal", + MetadataValue::String("2024:06:15 14:30:00".into()), + ); + data.insert("ISOSpeedRatings", MetadataValue::Integer(800)); + data +} + +#[tokio::test] +async fn write_and_read_roundtrip() { + let writer = XmpSidecarWriter; + let data = sample_metadata(); + let path = "/tmp/k-photos-test-sidecar-roundtrip.xmp"; + + writer.write_sidecar(&data, path).await.unwrap(); + let read_back = writer.read_sidecar(path).await.unwrap(); + + assert_eq!(read_back.get_string("Make"), Some("Canon")); + assert_eq!(read_back.get_string("Model"), Some("EOS R5")); + + tokio::fs::remove_file(path).await.ok(); +} + +#[tokio::test] +async fn read_missing_returns_not_found() { + let writer = XmpSidecarWriter; + let result = writer.read_sidecar("/tmp/nonexistent-xmp-file.xmp").await; + assert!(result.is_err()); +} + +#[tokio::test] +async fn format_name_is_xmp() { + let writer = XmpSidecarWriter; + assert_eq!(writer.format_name(), "xmp"); +} diff --git a/crates/bootstrap/Cargo.toml b/crates/bootstrap/Cargo.toml index 41c7c5e..9c32f93 100644 --- a/crates/bootstrap/Cargo.toml +++ b/crates/bootstrap/Cargo.toml @@ -13,6 +13,7 @@ application = { workspace = true } adapters-auth = { workspace = true } adapters-storage = { workspace = true, features = ["s3"] } +adapters-sidecar = { workspace = true } adapters-nats = { workspace = true } adapters-event-transport = { workspace = true } async-nats = { workspace = true } diff --git a/crates/bootstrap/src/lib.rs b/crates/bootstrap/src/lib.rs index 70319fb..02e2c37 100644 --- a/crates/bootstrap/src/lib.rs +++ b/crates/bootstrap/src/lib.rs @@ -1,4 +1,3 @@ pub mod config; pub mod factory; -pub mod log_sidecar_writer; pub mod services; diff --git a/crates/bootstrap/src/log_sidecar_writer.rs b/crates/bootstrap/src/log_sidecar_writer.rs deleted file mode 100644 index 2e6f0ba..0000000 --- a/crates/bootstrap/src/log_sidecar_writer.rs +++ /dev/null @@ -1,21 +0,0 @@ -use async_trait::async_trait; -use domain::{errors::DomainError, ports::SidecarWriterPort, value_objects::StructuredData}; - -pub struct LogSidecarWriter; - -#[async_trait] -impl SidecarWriterPort for LogSidecarWriter { - fn format_name(&self) -> &str { - "log_noop" - } - - async fn write_sidecar(&self, _data: &StructuredData, path: &str) -> Result<(), DomainError> { - tracing::info!(path, "sidecar write (no-op)"); - Ok(()) - } - - async fn read_sidecar(&self, path: &str) -> Result { - tracing::info!(path, "sidecar read (no-op)"); - Ok(StructuredData::new()) - } -} diff --git a/crates/bootstrap/src/main.rs b/crates/bootstrap/src/main.rs index ef5dbcb..6f5d0bc 100644 --- a/crates/bootstrap/src/main.rs +++ b/crates/bootstrap/src/main.rs @@ -3,7 +3,6 @@ use tracing::info; mod config; mod factory; -mod log_sidecar_writer; mod services; #[tokio::main] diff --git a/crates/bootstrap/src/services/sidecar.rs b/crates/bootstrap/src/services/sidecar.rs index 7d40520..e0df2aa 100644 --- a/crates/bootstrap/src/services/sidecar.rs +++ b/crates/bootstrap/src/services/sidecar.rs @@ -9,13 +9,12 @@ use application::sidecar::{ }; use presentation::state::SidecarHandlers; -use crate::log_sidecar_writer::LogSidecarWriter; - pub fn build(pool: &PgPool) -> SidecarHandlers { let metadata_repo = Arc::new(PostgresAssetMetadataRepository::new(pool.clone())); let asset_repo = Arc::new(PostgresAssetRepository::new(pool.clone())); let sidecar_repo = Arc::new(PostgresSidecarRepository::new(pool.clone())); - let sidecar_writer: Arc = Arc::new(LogSidecarWriter); + let sidecar_writer: Arc = + Arc::new(adapters_sidecar::XmpSidecarWriter); let export = Arc::new(ExportSidecarHandler::new( metadata_repo.clone(), diff --git a/crates/worker/Cargo.toml b/crates/worker/Cargo.toml index 891709f..186f950 100644 --- a/crates/worker/Cargo.toml +++ b/crates/worker/Cargo.toml @@ -17,6 +17,7 @@ adapters-nats = { workspace = true } adapters-event-transport = { workspace = true } adapters-exif = { workspace = true } adapters-thumbnail = { workspace = true } +adapters-sidecar = { workspace = true } async-nats = { workspace = true } futures = { workspace = true } diff --git a/crates/worker/src/main.rs b/crates/worker/src/main.rs index 0df14de..88cb45a 100644 --- a/crates/worker/src/main.rs +++ b/crates/worker/src/main.rs @@ -41,7 +41,8 @@ async fn main() -> anyhow::Result<()> { let file_storage = Arc::new(adapters_storage::LocalFileStorage::new( &config.storage_path, )); - let sidecar_writer: Arc = Arc::new(LogSidecarWriter); + let sidecar_writer: Arc = + Arc::new(adapters_sidecar::XmpSidecarWriter); // Publisher transport consumes a client clone; the consumer gets another. let pub_transport = adapters_nats::NatsTransport::new(nats_client.clone()); @@ -168,29 +169,3 @@ async fn main() -> anyhow::Result<()> { error!("event loop: NATS stream ended unexpectedly"); Ok(()) } - -struct LogSidecarWriter; - -#[async_trait::async_trait] -impl domain::ports::SidecarWriterPort for LogSidecarWriter { - fn format_name(&self) -> &str { - "log_noop" - } - - async fn write_sidecar( - &self, - _data: &domain::value_objects::StructuredData, - path: &str, - ) -> Result<(), domain::errors::DomainError> { - info!(path, "sidecar write (no-op)"); - Ok(()) - } - - async fn read_sidecar( - &self, - path: &str, - ) -> Result { - info!(path, "sidecar read (no-op)"); - Ok(domain::value_objects::StructuredData::new()) - } -}