Files
k-photos/crates/presentation/src/handlers/assets.rs
Gabriel Kaszewski bcaf49cc81 perf: scale fixes for 1M+ photo libraries
Indexes: share_targets.target_id, duplicate_groups.status,
GIN on stacks members + duplicate candidates JSONB,
composite (owner_user_id, created_at DESC) on assets.

N+1 elimination: batch metadata loading via find_by_assets(ids)
using WHERE asset_id = ANY($1), used in timeline + sidecar export.

Visibility: cache find_targets_for_user per request via OnceCell,
extract filter_visible helper to reduce duplication.

Streaming: FileStoragePort.open_file() returns (DataStream, u64),
LocalFileStorage uses ReaderStream instead of loading full file.
serve_file/serve_derivative use Body::from_stream().

Unbounded queries: sidecar full_export/import batched in 500-row
chunks instead of u32::MAX. find_unresolved paginated with
limit/offset. list_duplicates API accepts pagination params.
2026-05-31 22:40:25 +02:00

286 lines
9.0 KiB
Rust

use crate::{
constants::{DEFAULT_PAGE_SIZE, MAX_PAGE_SIZE},
errors::AppError,
extractors::{JwtClaims, UploadedAsset},
parsers,
state::AppState,
};
use api_types::{
requests::{RegisterAssetRequest, TagAssetRequest},
responses::{AssetResponse, IngestResponse, TagResponse, TimelineResponse},
};
use application::{
catalog::{
DeleteAssetCommand, GetAssetQuery, GetTimelineQuery, ReadAssetFileQuery,
ReadDerivativeQuery, RegisterAssetCommand, SearchAssetsQuery, UpdateMetadataCommand,
},
organization::TagAssetCommand,
storage::IngestAssetCommand,
};
use axum::{
Json,
body::Body,
extract::{Multipart, Path, Query, State},
http::{StatusCode, header},
response::Response,
};
use domain::{
catalog::entities::AssetFilters,
errors::DomainError,
value_objects::{DateTimeStamp, MetadataValue, StructuredData, SystemId},
};
#[derive(Debug, serde::Deserialize)]
pub struct TimelineParams {
pub limit: Option<u32>,
pub offset: Option<u32>,
}
#[derive(Debug, serde::Deserialize)]
pub struct SearchParams {
#[serde(rename = "type")]
pub asset_type: Option<String>,
pub mime_type: Option<String>,
pub date_from: Option<String>,
pub date_to: Option<String>,
pub is_processed: Option<bool>,
pub limit: Option<u32>,
pub offset: Option<u32>,
}
pub async fn search_assets(
State(state): State<AppState>,
claims: JwtClaims,
Query(params): Query<SearchParams>,
) -> Result<Json<TimelineResponse>, AppError> {
let asset_type = params
.asset_type
.as_deref()
.map(parsers::asset_type)
.transpose()?;
let date_from = params
.date_from
.as_deref()
.map(|s| {
let d = chrono::NaiveDate::parse_from_str(s, "%Y-%m-%d")
.map_err(|_| AppError::from(DomainError::Validation("Invalid date_from".into())))?;
d.and_hms_opt(0, 0, 0)
.map(|dt| DateTimeStamp::from_datetime(dt.and_utc()))
.ok_or_else(|| AppError::from(DomainError::Validation("Invalid date_from".into())))
})
.transpose()?;
let date_to = params
.date_to
.as_deref()
.map(|s| {
let d = chrono::NaiveDate::parse_from_str(s, "%Y-%m-%d")
.map_err(|_| AppError::from(DomainError::Validation("Invalid date_to".into())))?;
d.and_hms_opt(23, 59, 59)
.map(|dt| DateTimeStamp::from_datetime(dt.and_utc()))
.ok_or_else(|| AppError::from(DomainError::Validation("Invalid date_to".into())))
})
.transpose()?;
let filters = AssetFilters {
asset_type,
mime_type: params.mime_type,
date_from,
date_to,
is_processed: params.is_processed,
};
let limit = params.limit.unwrap_or(DEFAULT_PAGE_SIZE).min(MAX_PAGE_SIZE);
let offset = params.offset.unwrap_or(0);
let query = SearchAssetsQuery {
owner_id: claims.user_id,
filters,
limit,
offset,
};
let results = state.catalog.search_assets.execute(query).await?;
let total = results.len();
let assets = results
.iter()
.map(|a| AssetResponse::from_domain(a, &StructuredData::new()))
.collect();
Ok(Json(TimelineResponse { assets, total }))
}
pub async fn ingest(
State(state): State<AppState>,
claims: JwtClaims,
multipart: Multipart,
) -> Result<(StatusCode, Json<IngestResponse>), AppError> {
let upload = UploadedAsset::from_multipart(multipart).await?;
let cmd = IngestAssetCommand {
uploader_id: claims.user_id,
client_device_id: upload.client_device_id,
filename: upload.filename,
target_path_id: upload.target_path_id,
file_size: upload.data.len() as u64,
data: upload.data,
};
let (asset, session) = state.catalog.ingest_asset.execute(cmd).await?;
Ok((
StatusCode::CREATED,
Json(IngestResponse {
asset: AssetResponse::from_domain(&asset, &StructuredData::new()),
session_id: *session.session_id.as_uuid(),
}),
))
}
pub async fn get_asset(
State(state): State<AppState>,
claims: JwtClaims,
Path((asset_id,)): Path<(uuid::Uuid,)>,
) -> Result<Json<AssetResponse>, AppError> {
let query = GetAssetQuery {
asset_id: SystemId::from_uuid(asset_id),
user_id: claims.user_id,
};
let (asset, metadata) = state.catalog.get_asset.execute(query).await?;
Ok(Json(AssetResponse::from_domain(&asset, &metadata)))
}
pub async fn timeline(
State(state): State<AppState>,
claims: JwtClaims,
Query(params): Query<TimelineParams>,
) -> Result<Json<TimelineResponse>, AppError> {
let query = GetTimelineQuery {
owner_id: claims.user_id,
caller_id: None,
limit: params.limit.unwrap_or(DEFAULT_PAGE_SIZE).min(MAX_PAGE_SIZE),
offset: params.offset.unwrap_or(0),
};
let results = state.catalog.get_timeline.execute(query).await?;
let total = results.len();
let assets = results
.iter()
.map(|(asset, meta)| AssetResponse::from_domain(asset, meta))
.collect();
Ok(Json(TimelineResponse { assets, total }))
}
pub async fn update_metadata(
State(state): State<AppState>,
claims: JwtClaims,
Path((asset_id,)): Path<(uuid::Uuid,)>,
Json(req): Json<api_types::requests::UpdateMetadataRequest>,
) -> Result<Json<serde_json::Value>, AppError> {
let mut data = StructuredData::new();
for (k, v) in req.data {
data.insert(k, MetadataValue::from(v));
}
let cmd = UpdateMetadataCommand {
asset_id: SystemId::from_uuid(asset_id),
user_id: claims.user_id,
data,
};
state.catalog.update_metadata.execute(cmd).await?;
Ok(Json(serde_json::json!({ "status": "updated" })))
}
pub async fn serve_file(
State(state): State<AppState>,
claims: JwtClaims,
Path((asset_id,)): Path<(uuid::Uuid,)>,
) -> Result<Response, AppError> {
let query = ReadAssetFileQuery {
asset_id: SystemId::from_uuid(asset_id),
caller_id: claims.user_id,
};
let result = state.catalog.read_asset_file.execute(query).await?;
Response::builder()
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, &result.mime_type)
.header(header::CONTENT_LENGTH, result.size)
.header(
header::CONTENT_DISPOSITION,
format!("inline; filename=\"{}\"", result.filename),
)
.body(Body::from_stream(result.stream))
.map_err(|e| AppError::from(domain::errors::DomainError::Internal(e.to_string())))
}
pub async fn tag_asset(
State(state): State<AppState>,
claims: JwtClaims,
Path((asset_id,)): Path<(uuid::Uuid,)>,
Json(req): Json<TagAssetRequest>,
) -> Result<(StatusCode, Json<TagResponse>), AppError> {
let cmd = TagAssetCommand {
asset_id: SystemId::from_uuid(asset_id),
tag_name: req.tag_name,
user_id: claims.user_id,
};
let (tag, _asset_tag) = state.organization.tag_asset.execute(cmd).await?;
Ok((StatusCode::CREATED, Json(TagResponse::from_domain(&tag))))
}
pub async fn delete_asset(
State(state): State<AppState>,
claims: JwtClaims,
Path((asset_id,)): Path<(uuid::Uuid,)>,
) -> Result<StatusCode, AppError> {
let cmd = DeleteAssetCommand {
asset_id: SystemId::from_uuid(asset_id),
deleted_by: claims.user_id,
};
state.catalog.delete_asset.execute(cmd).await?;
Ok(StatusCode::NO_CONTENT)
}
pub async fn serve_derivative(
State(state): State<AppState>,
claims: JwtClaims,
Path((asset_id, profile)): Path<(uuid::Uuid, String)>,
) -> Result<Response, AppError> {
let profile = parsers::derivative_profile(&profile)?;
let query = ReadDerivativeQuery {
asset_id: SystemId::from_uuid(asset_id),
profile,
caller_id: claims.user_id,
};
let result = state.catalog.read_derivative.execute(query).await?;
Response::builder()
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, &result.mime_type)
.header(header::CONTENT_LENGTH, result.size)
.header(header::CACHE_CONTROL, "public, max-age=31536000, immutable")
.body(Body::from_stream(result.stream))
.map_err(|e| AppError::from(DomainError::Internal(e.to_string())))
}
pub async fn register_asset(
State(state): State<AppState>,
claims: JwtClaims,
Json(req): Json<RegisterAssetRequest>,
) -> Result<(StatusCode, Json<AssetResponse>), AppError> {
let asset_type = parsers::asset_type(&req.asset_type)?;
let cmd = RegisterAssetCommand {
volume_id: SystemId::from_uuid(req.volume_id),
relative_path: req.relative_path,
checksum: req.checksum,
asset_type,
mime_type: req.mime_type,
file_size: req.file_size,
owner_id: claims.user_id,
};
let (asset, _dup_group) = state.catalog.register_asset.execute(cmd).await?;
Ok((
StatusCode::CREATED,
Json(AssetResponse::from_domain(&asset, &StructuredData::new())),
))
}