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:
101
Cargo.lock
generated
101
Cargo.lock
generated
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
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