feat: real XMP sidecar adapter, replaces LogSidecarWriter stubs

- 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
This commit is contained in:
2026-05-31 21:05:46 +02:00
parent d379f3d3c8
commit f85c0cb246
12 changed files with 304 additions and 53 deletions

101
Cargo.lock generated
View File

@@ -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"

View File

@@ -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"

View File

@@ -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 }

View File

@@ -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<StructuredData, DomainError> {
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;

View File

@@ -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");
}

View File

@@ -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 }

View File

@@ -1,4 +1,3 @@
pub mod config;
pub mod factory;
pub mod log_sidecar_writer;
pub mod services;

View File

@@ -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<StructuredData, DomainError> {
tracing::info!(path, "sidecar read (no-op)");
Ok(StructuredData::new())
}
}

View File

@@ -3,7 +3,6 @@ use tracing::info;
mod config;
mod factory;
mod log_sidecar_writer;
mod services;
#[tokio::main]

View File

@@ -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<LogSidecarWriter> = Arc::new(LogSidecarWriter);
let sidecar_writer: Arc<adapters_sidecar::XmpSidecarWriter> =
Arc::new(adapters_sidecar::XmpSidecarWriter);
let export = Arc::new(ExportSidecarHandler::new(
metadata_repo.clone(),

View File

@@ -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 }

View File

@@ -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<dyn domain::ports::SidecarWriterPort> = Arc::new(LogSidecarWriter);
let sidecar_writer: Arc<dyn domain::ports::SidecarWriterPort> =
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<domain::value_objects::StructuredData, domain::errors::DomainError> {
info!(path, "sidecar read (no-op)");
Ok(domain::value_objects::StructuredData::new())
}
}