domain: add Media Catalog entities (Asset, Metadata, Stack, Derivative, Duplicate)
This commit is contained in:
52
crates/domain/src/entities/asset.rs
Normal file
52
crates/domain/src/entities/asset.rs
Normal file
@@ -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<String>,
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
27
crates/domain/src/entities/asset_metadata.rs
Normal file
27
crates/domain/src/entities/asset_metadata.rs
Normal file
@@ -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(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
67
crates/domain/src/entities/asset_stack.rs
Normal file
67
crates/domain/src/entities/asset_stack.rs
Normal file
@@ -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<AssetStackMember>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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(())
|
||||||
|
}
|
||||||
|
}
|
||||||
54
crates/domain/src/entities/derivative_asset.rs
Normal file
54
crates/domain/src/entities/derivative_asset.rs
Normal file
@@ -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<String>) -> 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<String>, 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
45
crates/domain/src/entities/duplicate.rs
Normal file
45
crates/domain/src/entities/duplicate.rs
Normal file
@@ -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<DuplicateCandidate>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,11 @@ mod storage_volume;
|
|||||||
mod library_path;
|
mod library_path;
|
||||||
mod ingest_session;
|
mod ingest_session;
|
||||||
mod quota;
|
mod quota;
|
||||||
|
mod asset;
|
||||||
|
mod asset_metadata;
|
||||||
|
mod asset_stack;
|
||||||
|
mod derivative_asset;
|
||||||
|
mod duplicate;
|
||||||
|
|
||||||
pub use permission::{Permission, PermissionAction, ResourceType};
|
pub use permission::{Permission, PermissionAction, ResourceType};
|
||||||
pub use role::Role;
|
pub use role::Role;
|
||||||
@@ -15,3 +20,8 @@ pub use storage_volume::StorageVolume;
|
|||||||
pub use library_path::{LibraryPath, OwnershipPolicy};
|
pub use library_path::{LibraryPath, OwnershipPolicy};
|
||||||
pub use ingest_session::{IngestSession, IngestStatus};
|
pub use ingest_session::{IngestSession, IngestStatus};
|
||||||
pub use quota::{QuotaDefinition, QuotaRule, TimePeriod, UsageLedgerEntry, UsageType};
|
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};
|
||||||
|
|||||||
24
crates/domain/tests/entities/asset.rs
Normal file
24
crates/domain/tests/entities/asset.rs
Normal file
@@ -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);
|
||||||
|
}
|
||||||
17
crates/domain/tests/entities/asset_metadata.rs
Normal file
17
crates/domain/tests/entities/asset_metadata.rs
Normal file
@@ -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"));
|
||||||
|
}
|
||||||
31
crates/domain/tests/entities/asset_stack.rs
Normal file
31
crates/domain/tests/entities/asset_stack.rs
Normal file
@@ -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(_))));
|
||||||
|
}
|
||||||
20
crates/domain/tests/entities/derivative_asset.rs
Normal file
20
crates/domain/tests/entities/derivative_asset.rs
Normal file
@@ -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));
|
||||||
|
}
|
||||||
19
crates/domain/tests/entities/duplicate.rs
Normal file
19
crates/domain/tests/entities/duplicate.rs
Normal file
@@ -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);
|
||||||
|
}
|
||||||
@@ -6,3 +6,8 @@ mod storage_volume;
|
|||||||
mod library_path;
|
mod library_path;
|
||||||
mod ingest_session;
|
mod ingest_session;
|
||||||
mod quota;
|
mod quota;
|
||||||
|
mod asset;
|
||||||
|
mod asset_metadata;
|
||||||
|
mod asset_stack;
|
||||||
|
mod derivative_asset;
|
||||||
|
mod duplicate;
|
||||||
|
|||||||
Reference in New Issue
Block a user