Pagination: count_by_owner + count_search on AssetRepository,
timeline/search return real total count (not page len).
Auto-derivatives: worker enqueues GenerateDerivative when
ExtractMetadata job completes, closing the upload→thumbnail gap.
List endpoints: GET /albums, GET /stacks with user scoping.
ListAlbumsHandler, ListStacksHandler, find_by_owner on AssetStackRepository.
Tag filtering: tag_name field on AssetFilters, JOIN asset_tags+tags
in postgres search/count queries.
Bulk operations: POST /assets/bulk-delete, POST /assets/bulk-tag.
Album update: PUT /albums/{id} with UpdateAlbumHandler (title, description).
OpenAPI: utoipa annotations on all 47 endpoints + all request/response
schemas registered. Scalar UI at /scalar covers full API.
280 lines
7.1 KiB
Rust
280 lines
7.1 KiB
Rust
use crate::common::errors::DomainError;
|
|
use crate::common::value_objects::{Checksum, DateTimeStamp, StructuredData, SystemId};
|
|
|
|
// --- Asset ---
|
|
|
|
#[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;
|
|
}
|
|
}
|
|
|
|
// --- AssetFilters ---
|
|
|
|
#[derive(Default)]
|
|
pub struct AssetFilters {
|
|
pub asset_type: Option<AssetType>,
|
|
pub mime_type: Option<String>,
|
|
pub date_from: Option<DateTimeStamp>,
|
|
pub date_to: Option<DateTimeStamp>,
|
|
pub is_processed: Option<bool>,
|
|
pub tag_name: Option<String>,
|
|
}
|
|
|
|
// --- AssetMetadata ---
|
|
|
|
#[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(),
|
|
}
|
|
}
|
|
}
|
|
|
|
// --- AssetStack ---
|
|
|
|
#[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,
|
|
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(())
|
|
}
|
|
}
|
|
|
|
// --- DerivativeAsset ---
|
|
|
|
#[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;
|
|
}
|
|
}
|
|
|
|
// --- Duplicate ---
|
|
|
|
#[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;
|
|
}
|
|
}
|