From 147206d8a5d5fa298aa96fa16bffc679b529cf4f Mon Sep 17 00:00:00 2001 From: Gabriel Kaszewski Date: Sun, 31 May 2026 03:27:41 +0200 Subject: [PATCH] domain: add Media Catalog entities (Asset, Metadata, Stack, Derivative, Duplicate) --- crates/domain/src/entities/asset.rs | 52 ++++++++++++++ crates/domain/src/entities/asset_metadata.rs | 27 ++++++++ crates/domain/src/entities/asset_stack.rs | 67 +++++++++++++++++++ .../domain/src/entities/derivative_asset.rs | 54 +++++++++++++++ crates/domain/src/entities/duplicate.rs | 45 +++++++++++++ crates/domain/src/entities/mod.rs | 10 +++ crates/domain/tests/entities/asset.rs | 24 +++++++ .../domain/tests/entities/asset_metadata.rs | 17 +++++ crates/domain/tests/entities/asset_stack.rs | 31 +++++++++ .../domain/tests/entities/derivative_asset.rs | 20 ++++++ crates/domain/tests/entities/duplicate.rs | 19 ++++++ crates/domain/tests/entities/mod.rs | 5 ++ 12 files changed, 371 insertions(+) create mode 100644 crates/domain/src/entities/asset.rs create mode 100644 crates/domain/src/entities/asset_metadata.rs create mode 100644 crates/domain/src/entities/asset_stack.rs create mode 100644 crates/domain/src/entities/derivative_asset.rs create mode 100644 crates/domain/src/entities/duplicate.rs create mode 100644 crates/domain/tests/entities/asset.rs create mode 100644 crates/domain/tests/entities/asset_metadata.rs create mode 100644 crates/domain/tests/entities/asset_stack.rs create mode 100644 crates/domain/tests/entities/derivative_asset.rs create mode 100644 crates/domain/tests/entities/duplicate.rs diff --git a/crates/domain/src/entities/asset.rs b/crates/domain/src/entities/asset.rs new file mode 100644 index 0000000..81ea669 --- /dev/null +++ b/crates/domain/src/entities/asset.rs @@ -0,0 +1,52 @@ +use crate::value_objects::{Checksum, DateTimeStamp, SystemId}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub enum AssetType { + Image, + Video, + LivePhoto, +} + +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub struct SourceReference { + pub volume_id: SystemId, + pub relative_path: String, + pub checksum: Checksum, +} + +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct Asset { + pub asset_id: SystemId, + pub source_reference: SourceReference, + pub asset_type: AssetType, + pub mime_type: String, + pub file_size: u64, + pub is_processed: bool, + pub owner_user_id: SystemId, + pub created_at: DateTimeStamp, +} + +impl Asset { + pub fn new( + source_reference: SourceReference, + asset_type: AssetType, + mime_type: impl Into, + file_size: u64, + owner: SystemId, + ) -> Self { + Self { + asset_id: SystemId::new(), + source_reference, + asset_type, + mime_type: mime_type.into(), + file_size, + is_processed: false, + owner_user_id: owner, + created_at: DateTimeStamp::now(), + } + } + + pub fn mark_processed(&mut self) { + self.is_processed = true; + } +} diff --git a/crates/domain/src/entities/asset_metadata.rs b/crates/domain/src/entities/asset_metadata.rs new file mode 100644 index 0000000..8acb28c --- /dev/null +++ b/crates/domain/src/entities/asset_metadata.rs @@ -0,0 +1,27 @@ +use crate::value_objects::{DateTimeStamp, StructuredData, SystemId}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, serde::Serialize, serde::Deserialize)] +pub enum MetadataSource { + ExifExtracted, + AiGenerated, + UserEdited, +} + +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct AssetMetadata { + pub asset_id: SystemId, + pub metadata_source: MetadataSource, + pub data: StructuredData, + pub updated_at: DateTimeStamp, +} + +impl AssetMetadata { + pub fn new(asset_id: SystemId, source: MetadataSource, data: StructuredData) -> Self { + Self { + asset_id, + metadata_source: source, + data, + updated_at: DateTimeStamp::now(), + } + } +} diff --git a/crates/domain/src/entities/asset_stack.rs b/crates/domain/src/entities/asset_stack.rs new file mode 100644 index 0000000..b21880b --- /dev/null +++ b/crates/domain/src/entities/asset_stack.rs @@ -0,0 +1,67 @@ +use crate::errors::DomainError; +use crate::value_objects::SystemId; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub enum StackType { + LivePhoto, + FormatPair, + BurstSequence, + ExposureBracket, + ManualGroup, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub enum StackMemberRole { + PrimaryDisplay, + HighResSource, + MotionClip, + AlternateFrame, +} + +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct AssetStackMember { + pub asset_id: SystemId, + pub role: StackMemberRole, + pub sort_order: u32, +} + +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct AssetStack { + pub stack_id: SystemId, + pub stack_type: StackType, + pub primary_asset_id: SystemId, + pub owner_user_id: SystemId, + pub members: Vec, +} + +impl AssetStack { + pub fn new(stack_type: StackType, primary_asset_id: SystemId, owner: SystemId) -> Self { + let primary_member = AssetStackMember { + asset_id: primary_asset_id.clone(), + role: StackMemberRole::PrimaryDisplay, + sort_order: 0, + }; + Self { + stack_id: SystemId::new(), + stack_type, + primary_asset_id, + owner_user_id: owner, + members: vec![primary_member], + } + } + + pub fn add_member(&mut self, asset_id: SystemId, role: StackMemberRole) -> Result<(), DomainError> { + if self.members.iter().any(|m| m.asset_id == asset_id) { + return Err(DomainError::Conflict( + "Asset already exists in stack".to_string(), + )); + } + let next_order = self.members.iter().map(|m| m.sort_order).max().unwrap_or(0) + 1; + self.members.push(AssetStackMember { + asset_id, + role, + sort_order: next_order, + }); + Ok(()) + } +} diff --git a/crates/domain/src/entities/derivative_asset.rs b/crates/domain/src/entities/derivative_asset.rs new file mode 100644 index 0000000..27e49f2 --- /dev/null +++ b/crates/domain/src/entities/derivative_asset.rs @@ -0,0 +1,54 @@ +use crate::value_objects::SystemId; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub enum DerivativeProfile { + ThumbnailSquare, + ThumbnailLarge, + WebOptimized, + VideoSd, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub enum GenerationStatus { + Pending, + Ready, + Failed, +} + +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct DerivativeAsset { + pub derivative_id: SystemId, + pub parent_asset_id: SystemId, + pub profile_type: DerivativeProfile, + pub storage_path: String, + pub mime_type: String, + pub file_size: u64, + pub dimensions: (u32, u32), + pub generation_status: GenerationStatus, +} + +impl DerivativeAsset { + pub fn new_pending(parent: SystemId, profile: DerivativeProfile, path: impl Into) -> Self { + Self { + derivative_id: SystemId::new(), + parent_asset_id: parent, + profile_type: profile, + storage_path: path.into(), + mime_type: String::new(), + file_size: 0, + dimensions: (0, 0), + generation_status: GenerationStatus::Pending, + } + } + + pub fn mark_ready(&mut self, mime_type: impl Into, size: u64, dims: (u32, u32)) { + self.mime_type = mime_type.into(); + self.file_size = size; + self.dimensions = dims; + self.generation_status = GenerationStatus::Ready; + } + + pub fn mark_failed(&mut self) { + self.generation_status = GenerationStatus::Failed; + } +} diff --git a/crates/domain/src/entities/duplicate.rs b/crates/domain/src/entities/duplicate.rs new file mode 100644 index 0000000..3050b17 --- /dev/null +++ b/crates/domain/src/entities/duplicate.rs @@ -0,0 +1,45 @@ +use crate::value_objects::SystemId; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub enum DetectionMethod { + ExactHash, + PerceptualHash, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub enum DuplicateStatus { + Unresolved, + Resolved, +} + +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct DuplicateCandidate { + pub asset_id: SystemId, + pub similarity_score: f64, +} + +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct DuplicateGroup { + pub group_id: SystemId, + pub detection_method: DetectionMethod, + pub status: DuplicateStatus, + pub candidates: Vec, +} + +impl DuplicateGroup { + pub fn new_exact(asset_a: SystemId, asset_b: SystemId) -> Self { + Self { + group_id: SystemId::new(), + detection_method: DetectionMethod::ExactHash, + status: DuplicateStatus::Unresolved, + candidates: vec![ + DuplicateCandidate { asset_id: asset_a, similarity_score: 1.0 }, + DuplicateCandidate { asset_id: asset_b, similarity_score: 1.0 }, + ], + } + } + + pub fn resolve(&mut self) { + self.status = DuplicateStatus::Resolved; + } +} diff --git a/crates/domain/src/entities/mod.rs b/crates/domain/src/entities/mod.rs index 4ba8f18..e4a4ffb 100644 --- a/crates/domain/src/entities/mod.rs +++ b/crates/domain/src/entities/mod.rs @@ -6,6 +6,11 @@ mod storage_volume; mod library_path; mod ingest_session; mod quota; +mod asset; +mod asset_metadata; +mod asset_stack; +mod derivative_asset; +mod duplicate; pub use permission::{Permission, PermissionAction, ResourceType}; pub use role::Role; @@ -15,3 +20,8 @@ pub use storage_volume::StorageVolume; pub use library_path::{LibraryPath, OwnershipPolicy}; pub use ingest_session::{IngestSession, IngestStatus}; pub use quota::{QuotaDefinition, QuotaRule, TimePeriod, UsageLedgerEntry, UsageType}; +pub use asset::{Asset, AssetType, SourceReference}; +pub use asset_metadata::{AssetMetadata, MetadataSource}; +pub use asset_stack::{AssetStack, AssetStackMember, StackMemberRole, StackType}; +pub use derivative_asset::{DerivativeAsset, DerivativeProfile, GenerationStatus}; +pub use duplicate::{DetectionMethod, DuplicateCandidate, DuplicateGroup, DuplicateStatus}; diff --git a/crates/domain/tests/entities/asset.rs b/crates/domain/tests/entities/asset.rs new file mode 100644 index 0000000..a266732 --- /dev/null +++ b/crates/domain/tests/entities/asset.rs @@ -0,0 +1,24 @@ +use domain::entities::{Asset, AssetType, SourceReference}; +use domain::value_objects::{Checksum, SystemId}; + +fn make_asset() -> Asset { + let src = SourceReference { + volume_id: SystemId::new(), + relative_path: "photos/img.jpg".to_string(), + checksum: Checksum::new("a".repeat(64)).unwrap(), + }; + Asset::new(src, AssetType::Image, "image/jpeg", 1024, SystemId::new()) +} + +#[test] +fn new_asset_is_unprocessed() { + let a = make_asset(); + assert!(!a.is_processed); +} + +#[test] +fn mark_processed() { + let mut a = make_asset(); + a.mark_processed(); + assert!(a.is_processed); +} diff --git a/crates/domain/tests/entities/asset_metadata.rs b/crates/domain/tests/entities/asset_metadata.rs new file mode 100644 index 0000000..13126fa --- /dev/null +++ b/crates/domain/tests/entities/asset_metadata.rs @@ -0,0 +1,17 @@ +use domain::entities::{AssetMetadata, MetadataSource}; +use domain::value_objects::{MetadataValue, StructuredData, SystemId}; + +#[test] +fn metadata_source_ordering() { + assert!(MetadataSource::ExifExtracted < MetadataSource::AiGenerated); + assert!(MetadataSource::AiGenerated < MetadataSource::UserEdited); +} + +#[test] +fn create_metadata_layer() { + let mut data = StructuredData::new(); + data.insert("camera", MetadataValue::String("Canon".to_string())); + let meta = AssetMetadata::new(SystemId::new(), MetadataSource::ExifExtracted, data); + assert_eq!(meta.metadata_source, MetadataSource::ExifExtracted); + assert_eq!(meta.data.get_string("camera"), Some("Canon")); +} diff --git a/crates/domain/tests/entities/asset_stack.rs b/crates/domain/tests/entities/asset_stack.rs new file mode 100644 index 0000000..b9a26b4 --- /dev/null +++ b/crates/domain/tests/entities/asset_stack.rs @@ -0,0 +1,31 @@ +use domain::entities::{AssetStack, StackMemberRole, StackType}; +use domain::errors::DomainError; +use domain::value_objects::SystemId; + +#[test] +fn new_stack_contains_primary() { + let primary = SystemId::new(); + let stack = AssetStack::new(StackType::LivePhoto, primary.clone(), SystemId::new()); + assert_eq!(stack.members.len(), 1); + assert_eq!(stack.members[0].asset_id, primary); + assert_eq!(stack.members[0].role, StackMemberRole::PrimaryDisplay); + assert_eq!(stack.members[0].sort_order, 0); +} + +#[test] +fn add_motion_clip() { + let mut stack = AssetStack::new(StackType::LivePhoto, SystemId::new(), SystemId::new()); + let clip_id = SystemId::new(); + stack.add_member(clip_id.clone(), StackMemberRole::MotionClip).unwrap(); + assert_eq!(stack.members.len(), 2); + assert_eq!(stack.members[1].asset_id, clip_id); + assert_eq!(stack.members[1].sort_order, 1); +} + +#[test] +fn cannot_add_duplicate() { + let primary = SystemId::new(); + let mut stack = AssetStack::new(StackType::LivePhoto, primary.clone(), SystemId::new()); + let result = stack.add_member(primary, StackMemberRole::HighResSource); + assert!(matches!(result, Err(DomainError::Conflict(_)))); +} diff --git a/crates/domain/tests/entities/derivative_asset.rs b/crates/domain/tests/entities/derivative_asset.rs new file mode 100644 index 0000000..f8a2280 --- /dev/null +++ b/crates/domain/tests/entities/derivative_asset.rs @@ -0,0 +1,20 @@ +use domain::entities::{DerivativeAsset, DerivativeProfile, GenerationStatus}; +use domain::value_objects::SystemId; + +#[test] +fn lifecycle() { + let mut d = DerivativeAsset::new_pending( + SystemId::new(), + DerivativeProfile::ThumbnailSquare, + "/thumbs/abc.webp", + ); + assert_eq!(d.generation_status, GenerationStatus::Pending); + assert_eq!(d.file_size, 0); + assert_eq!(d.dimensions, (0, 0)); + + d.mark_ready("image/webp", 4096, (256, 256)); + assert_eq!(d.generation_status, GenerationStatus::Ready); + assert_eq!(d.mime_type, "image/webp"); + assert_eq!(d.file_size, 4096); + assert_eq!(d.dimensions, (256, 256)); +} diff --git a/crates/domain/tests/entities/duplicate.rs b/crates/domain/tests/entities/duplicate.rs new file mode 100644 index 0000000..f403d4d --- /dev/null +++ b/crates/domain/tests/entities/duplicate.rs @@ -0,0 +1,19 @@ +use domain::entities::{DetectionMethod, DuplicateGroup, DuplicateStatus}; +use domain::value_objects::SystemId; + +#[test] +fn exact_duplicate_group() { + let g = DuplicateGroup::new_exact(SystemId::new(), SystemId::new()); + assert_eq!(g.detection_method, DetectionMethod::ExactHash); + assert_eq!(g.status, DuplicateStatus::Unresolved); + assert_eq!(g.candidates.len(), 2); + assert_eq!(g.candidates[0].similarity_score, 1.0); + assert_eq!(g.candidates[1].similarity_score, 1.0); +} + +#[test] +fn resolve_group() { + let mut g = DuplicateGroup::new_exact(SystemId::new(), SystemId::new()); + g.resolve(); + assert_eq!(g.status, DuplicateStatus::Resolved); +} diff --git a/crates/domain/tests/entities/mod.rs b/crates/domain/tests/entities/mod.rs index 633f67e..402eaf5 100644 --- a/crates/domain/tests/entities/mod.rs +++ b/crates/domain/tests/entities/mod.rs @@ -6,3 +6,8 @@ mod storage_volume; mod library_path; mod ingest_session; mod quota; +mod asset; +mod asset_metadata; +mod asset_stack; +mod derivative_asset; +mod duplicate;