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:
11
crates/adapters/sidecar/Cargo.toml
Normal file
11
crates/adapters/sidecar/Cargo.toml
Normal 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 }
|
||||
139
crates/adapters/sidecar/src/lib.rs
Normal file
139
crates/adapters/sidecar/src/lib.rs
Normal 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;
|
||||
45
crates/adapters/sidecar/src/tests.rs
Normal file
45
crates/adapters/sidecar/src/tests.rs
Normal 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");
|
||||
}
|
||||
@@ -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 }
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
pub mod config;
|
||||
pub mod factory;
|
||||
pub mod log_sidecar_writer;
|
||||
pub mod services;
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,6 @@ use tracing::info;
|
||||
|
||||
mod config;
|
||||
mod factory;
|
||||
mod log_sidecar_writer;
|
||||
mod services;
|
||||
|
||||
#[tokio::main]
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user