domain: add Media Catalog ports and MetadataResolver service
This commit is contained in:
10
crates/domain/src/ports/asset_metadata_repo.rs
Normal file
10
crates/domain/src/ports/asset_metadata_repo.rs
Normal file
@@ -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<Vec<AssetMetadata>, DomainError>;
|
||||||
|
async fn find_by_asset_and_source(&self, asset_id: &SystemId, source: MetadataSource) -> Result<Option<AssetMetadata>, DomainError>;
|
||||||
|
async fn save(&self, metadata: &AssetMetadata) -> Result<(), DomainError>;
|
||||||
|
async fn delete_by_asset_and_source(&self, asset_id: &SystemId, source: MetadataSource) -> Result<(), DomainError>;
|
||||||
|
}
|
||||||
11
crates/domain/src/ports/asset_repo.rs
Normal file
11
crates/domain/src/ports/asset_repo.rs
Normal file
@@ -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<Option<Asset>, DomainError>;
|
||||||
|
async fn find_by_checksum(&self, checksum: &Checksum) -> Result<Vec<Asset>, DomainError>;
|
||||||
|
async fn find_by_owner(&self, owner_id: &SystemId, limit: u32, offset: u32) -> Result<Vec<Asset>, DomainError>;
|
||||||
|
async fn save(&self, asset: &Asset) -> Result<(), DomainError>;
|
||||||
|
async fn delete(&self, id: &SystemId) -> Result<(), DomainError>;
|
||||||
|
}
|
||||||
10
crates/domain/src/ports/asset_stack_repo.rs
Normal file
10
crates/domain/src/ports/asset_stack_repo.rs
Normal file
@@ -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<Option<AssetStack>, DomainError>;
|
||||||
|
async fn find_by_asset(&self, asset_id: &SystemId) -> Result<Vec<AssetStack>, DomainError>;
|
||||||
|
async fn save(&self, stack: &AssetStack) -> Result<(), DomainError>;
|
||||||
|
async fn delete(&self, id: &SystemId) -> Result<(), DomainError>;
|
||||||
|
}
|
||||||
10
crates/domain/src/ports/derivative_repo.rs
Normal file
10
crates/domain/src/ports/derivative_repo.rs
Normal file
@@ -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<Vec<DerivativeAsset>, DomainError>;
|
||||||
|
async fn find_by_asset_and_profile(&self, asset_id: &SystemId, profile: DerivativeProfile) -> Result<Option<DerivativeAsset>, DomainError>;
|
||||||
|
async fn save(&self, derivative: &DerivativeAsset) -> Result<(), DomainError>;
|
||||||
|
async fn delete(&self, id: &SystemId) -> Result<(), DomainError>;
|
||||||
|
}
|
||||||
10
crates/domain/src/ports/duplicate_repo.rs
Normal file
10
crates/domain/src/ports/duplicate_repo.rs
Normal file
@@ -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<Option<DuplicateGroup>, DomainError>;
|
||||||
|
async fn find_unresolved(&self) -> Result<Vec<DuplicateGroup>, DomainError>;
|
||||||
|
async fn find_by_asset(&self, asset_id: &SystemId) -> Result<Vec<DuplicateGroup>, DomainError>;
|
||||||
|
async fn save(&self, group: &DuplicateGroup) -> Result<(), DomainError>;
|
||||||
|
}
|
||||||
@@ -9,6 +9,11 @@ mod library_path_repo;
|
|||||||
mod ingest_session_repo;
|
mod ingest_session_repo;
|
||||||
mod quota_repo;
|
mod quota_repo;
|
||||||
mod file_storage;
|
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 auth::{PasswordHasher, TokenIssuer};
|
||||||
pub use event_publisher::EventPublisher;
|
pub use event_publisher::EventPublisher;
|
||||||
@@ -21,3 +26,8 @@ pub use library_path_repo::LibraryPathRepository;
|
|||||||
pub use ingest_session_repo::IngestSessionRepository;
|
pub use ingest_session_repo::IngestSessionRepository;
|
||||||
pub use quota_repo::{QuotaRepository, UsageLedgerRepository};
|
pub use quota_repo::{QuotaRepository, UsageLedgerRepository};
|
||||||
pub use file_storage::{FileEntry, FileStoragePort};
|
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;
|
||||||
|
|||||||
@@ -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<usize> = (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
|
||||||
|
}
|
||||||
|
|||||||
45
crates/domain/tests/services/metadata_resolver.rs
Normal file
45
crates/domain/tests/services/metadata_resolver.rs
Normal file
@@ -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());
|
||||||
|
}
|
||||||
@@ -1,2 +1,3 @@
|
|||||||
mod permission_service;
|
mod permission_service;
|
||||||
mod quota_checker;
|
mod quota_checker;
|
||||||
|
mod metadata_resolver;
|
||||||
|
|||||||
Reference in New Issue
Block a user