From ccb61b72d72b0b0c63837bc64d8853906b9dd685 Mon Sep 17 00:00:00 2001 From: Gabriel Kaszewski Date: Sun, 31 May 2026 03:27:48 +0200 Subject: [PATCH] domain: add Media Catalog ports and MetadataResolver service --- .../domain/src/ports/asset_metadata_repo.rs | 10 +++++ crates/domain/src/ports/asset_repo.rs | 11 +++++ crates/domain/src/ports/asset_stack_repo.rs | 10 +++++ crates/domain/src/ports/derivative_repo.rs | 10 +++++ crates/domain/src/ports/duplicate_repo.rs | 10 +++++ crates/domain/src/ports/mod.rs | 10 +++++ .../domain/src/services/metadata_resolver.rs | 33 +++++++++++++- .../tests/services/metadata_resolver.rs | 45 +++++++++++++++++++ crates/domain/tests/services/mod.rs | 1 + 9 files changed, 139 insertions(+), 1 deletion(-) create mode 100644 crates/domain/src/ports/asset_metadata_repo.rs create mode 100644 crates/domain/src/ports/asset_repo.rs create mode 100644 crates/domain/src/ports/asset_stack_repo.rs create mode 100644 crates/domain/src/ports/derivative_repo.rs create mode 100644 crates/domain/src/ports/duplicate_repo.rs create mode 100644 crates/domain/tests/services/metadata_resolver.rs diff --git a/crates/domain/src/ports/asset_metadata_repo.rs b/crates/domain/src/ports/asset_metadata_repo.rs new file mode 100644 index 0000000..07975f8 --- /dev/null +++ b/crates/domain/src/ports/asset_metadata_repo.rs @@ -0,0 +1,10 @@ +use async_trait::async_trait; +use crate::{entities::{AssetMetadata, MetadataSource}, errors::DomainError, value_objects::SystemId}; + +#[async_trait] +pub trait AssetMetadataRepository: Send + Sync { + async fn find_by_asset(&self, asset_id: &SystemId) -> Result, DomainError>; + async fn find_by_asset_and_source(&self, asset_id: &SystemId, source: MetadataSource) -> Result, DomainError>; + async fn save(&self, metadata: &AssetMetadata) -> Result<(), DomainError>; + async fn delete_by_asset_and_source(&self, asset_id: &SystemId, source: MetadataSource) -> Result<(), DomainError>; +} diff --git a/crates/domain/src/ports/asset_repo.rs b/crates/domain/src/ports/asset_repo.rs new file mode 100644 index 0000000..c7a5537 --- /dev/null +++ b/crates/domain/src/ports/asset_repo.rs @@ -0,0 +1,11 @@ +use async_trait::async_trait; +use crate::{entities::Asset, errors::DomainError, value_objects::{Checksum, SystemId}}; + +#[async_trait] +pub trait AssetRepository: Send + Sync { + async fn find_by_id(&self, id: &SystemId) -> Result, DomainError>; + async fn find_by_checksum(&self, checksum: &Checksum) -> Result, DomainError>; + async fn find_by_owner(&self, owner_id: &SystemId, limit: u32, offset: u32) -> Result, DomainError>; + async fn save(&self, asset: &Asset) -> Result<(), DomainError>; + async fn delete(&self, id: &SystemId) -> Result<(), DomainError>; +} diff --git a/crates/domain/src/ports/asset_stack_repo.rs b/crates/domain/src/ports/asset_stack_repo.rs new file mode 100644 index 0000000..f8a8b66 --- /dev/null +++ b/crates/domain/src/ports/asset_stack_repo.rs @@ -0,0 +1,10 @@ +use async_trait::async_trait; +use crate::{entities::AssetStack, errors::DomainError, value_objects::SystemId}; + +#[async_trait] +pub trait AssetStackRepository: Send + Sync { + async fn find_by_id(&self, id: &SystemId) -> Result, DomainError>; + async fn find_by_asset(&self, asset_id: &SystemId) -> Result, DomainError>; + async fn save(&self, stack: &AssetStack) -> Result<(), DomainError>; + async fn delete(&self, id: &SystemId) -> Result<(), DomainError>; +} diff --git a/crates/domain/src/ports/derivative_repo.rs b/crates/domain/src/ports/derivative_repo.rs new file mode 100644 index 0000000..98d8262 --- /dev/null +++ b/crates/domain/src/ports/derivative_repo.rs @@ -0,0 +1,10 @@ +use async_trait::async_trait; +use crate::{entities::{DerivativeAsset, DerivativeProfile}, errors::DomainError, value_objects::SystemId}; + +#[async_trait] +pub trait DerivativeRepository: Send + Sync { + async fn find_by_asset(&self, asset_id: &SystemId) -> Result, DomainError>; + async fn find_by_asset_and_profile(&self, asset_id: &SystemId, profile: DerivativeProfile) -> Result, DomainError>; + async fn save(&self, derivative: &DerivativeAsset) -> Result<(), DomainError>; + async fn delete(&self, id: &SystemId) -> Result<(), DomainError>; +} diff --git a/crates/domain/src/ports/duplicate_repo.rs b/crates/domain/src/ports/duplicate_repo.rs new file mode 100644 index 0000000..12e2fb6 --- /dev/null +++ b/crates/domain/src/ports/duplicate_repo.rs @@ -0,0 +1,10 @@ +use async_trait::async_trait; +use crate::{entities::DuplicateGroup, errors::DomainError, value_objects::SystemId}; + +#[async_trait] +pub trait DuplicateRepository: Send + Sync { + async fn find_by_id(&self, id: &SystemId) -> Result, DomainError>; + async fn find_unresolved(&self) -> Result, DomainError>; + async fn find_by_asset(&self, asset_id: &SystemId) -> Result, DomainError>; + async fn save(&self, group: &DuplicateGroup) -> Result<(), DomainError>; +} diff --git a/crates/domain/src/ports/mod.rs b/crates/domain/src/ports/mod.rs index c0a44d4..3c5dba8 100644 --- a/crates/domain/src/ports/mod.rs +++ b/crates/domain/src/ports/mod.rs @@ -9,6 +9,11 @@ mod library_path_repo; mod ingest_session_repo; mod quota_repo; mod file_storage; +mod asset_repo; +mod asset_metadata_repo; +mod asset_stack_repo; +mod derivative_repo; +mod duplicate_repo; pub use auth::{PasswordHasher, TokenIssuer}; pub use event_publisher::EventPublisher; @@ -21,3 +26,8 @@ pub use library_path_repo::LibraryPathRepository; pub use ingest_session_repo::IngestSessionRepository; pub use quota_repo::{QuotaRepository, UsageLedgerRepository}; pub use file_storage::{FileEntry, FileStoragePort}; +pub use asset_repo::AssetRepository; +pub use asset_metadata_repo::AssetMetadataRepository; +pub use asset_stack_repo::AssetStackRepository; +pub use derivative_repo::DerivativeRepository; +pub use duplicate_repo::DuplicateRepository; diff --git a/crates/domain/src/services/metadata_resolver.rs b/crates/domain/src/services/metadata_resolver.rs index 4829705..8cbe1cc 100644 --- a/crates/domain/src/services/metadata_resolver.rs +++ b/crates/domain/src/services/metadata_resolver.rs @@ -1 +1,32 @@ -// Metadata resolver — will be implemented in Task 9 +use crate::entities::{AssetMetadata, MetadataSource}; +use crate::value_objects::{MetadataValue, StructuredData}; + +/// Merge metadata layers by priority: ExifExtracted < AiGenerated < UserEdited. +/// Later (higher-priority) layers overwrite earlier ones. +pub fn resolve_metadata(layers: &[AssetMetadata]) -> StructuredData { + let mut sorted = layers.to_vec(); + sorted.sort_by_key(|l| l.metadata_source); + + let mut result = StructuredData::new(); + for layer in &sorted { + result.merge_from(layer.data.clone()); + } + result +} + +/// Return the source and value for a field from the highest-priority layer that contains it. +pub fn get_field_source<'a>( + layers: &'a [AssetMetadata], + field: &str, +) -> Option<(MetadataSource, &'a MetadataValue)> { + // Build index list sorted by metadata_source descending (highest priority first) + let mut indices: Vec = (0..layers.len()).collect(); + indices.sort_by(|&a, &b| layers[b].metadata_source.cmp(&layers[a].metadata_source)); + + for i in indices { + if let Some(val) = layers[i].data.get(field) { + return Some((layers[i].metadata_source, val)); + } + } + None +} diff --git a/crates/domain/tests/services/metadata_resolver.rs b/crates/domain/tests/services/metadata_resolver.rs new file mode 100644 index 0000000..8dae9df --- /dev/null +++ b/crates/domain/tests/services/metadata_resolver.rs @@ -0,0 +1,45 @@ +use domain::entities::{AssetMetadata, MetadataSource}; +use domain::services::metadata_resolver::{get_field_source, resolve_metadata}; +use domain::value_objects::{MetadataValue, StructuredData, SystemId}; + +fn layer(source: MetadataSource, entries: &[(&str, &str)]) -> AssetMetadata { + let mut data = StructuredData::new(); + for (k, v) in entries { + data.insert(*k, MetadataValue::String(v.to_string())); + } + AssetMetadata::new(SystemId::new(), source, data) +} + +#[test] +fn user_edited_overrides_exif() { + let exif = layer(MetadataSource::ExifExtracted, &[("title", "DSC_0001")]); + let user = layer(MetadataSource::UserEdited, &[("title", "Sunset")]); + let result = resolve_metadata(&[exif, user]); + assert_eq!(result.get_string("title"), Some("Sunset")); +} + +#[test] +fn ai_overrides_exif_but_not_user() { + let exif = layer(MetadataSource::ExifExtracted, &[("desc", "raw"), ("camera", "Nikon")]); + let ai = layer(MetadataSource::AiGenerated, &[("desc", "ai-desc")]); + let user = layer(MetadataSource::UserEdited, &[("desc", "user-desc")]); + let result = resolve_metadata(&[exif, ai, user]); + assert_eq!(result.get_string("desc"), Some("user-desc")); + assert_eq!(result.get_string("camera"), Some("Nikon")); +} + +#[test] +fn get_field_source_returns_highest_priority() { + let exif = layer(MetadataSource::ExifExtracted, &[("iso", "100")]); + let ai = layer(MetadataSource::AiGenerated, &[("iso", "200")]); + let layers = [exif, ai]; + let (src, val) = get_field_source(&layers, "iso").unwrap(); + assert_eq!(src, MetadataSource::AiGenerated); + assert_eq!(val, &MetadataValue::String("200".to_string())); +} + +#[test] +fn empty_layers_returns_empty() { + let result = resolve_metadata(&[]); + assert!(result.is_empty()); +} diff --git a/crates/domain/tests/services/mod.rs b/crates/domain/tests/services/mod.rs index be01fea..a026b62 100644 --- a/crates/domain/tests/services/mod.rs +++ b/crates/domain/tests/services/mod.rs @@ -1,2 +1,3 @@ mod permission_service; mod quota_checker; +mod metadata_resolver;