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

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())
}
}