feat: safe deletion, album/asset delete, trash, README update

- volume-aware deletion: read-only volumes remove DB only, writable
  volumes soft-delete to trash with configurable grace period
- trash page with restore, worker purge sweep (TRASH_RETENTION_DAYS)
- album delete endpoint + sidebar trash icon
- asset delete from timeline selection toolbar
- all listing queries exclude trashed assets (deleted_at IS NULL)
- timeline ordered by EXIF capture date, date-summary endpoint
- README rewritten with features, setup, full env var table
This commit is contained in:
2026-06-01 01:57:53 +02:00
parent 957737ac9b
commit 0077caa743
36 changed files with 752 additions and 125 deletions

View File

@@ -5,8 +5,8 @@ use api_types::{
responses::AlbumResponse,
};
use application::organization::{
AlbumAction, CreateAlbumCommand, GetAlbumQuery, ListAlbumsQuery, ManageAlbumEntriesCommand,
UpdateAlbumCommand,
AlbumAction, CreateAlbumCommand, DeleteAlbumCommand, GetAlbumQuery, ListAlbumsQuery,
ManageAlbumEntriesCommand, UpdateAlbumCommand,
};
use axum::{
Json,
@@ -108,6 +108,19 @@ pub async fn update_album(
Ok(Json(AlbumResponse::from_domain(&album)))
}
pub async fn delete_album(
State(state): State<AppState>,
claims: JwtClaims,
Path((album_id,)): Path<(uuid::Uuid,)>,
) -> Result<StatusCode, AppError> {
let cmd = DeleteAlbumCommand {
album_id: SystemId::from_uuid(album_id),
user_id: claims.user_id,
};
state.organization.delete_album.execute(cmd).await?;
Ok(StatusCode::NO_CONTENT)
}
#[utoipa::path(
post, path = "/api/v1/albums/{id}/entries",
request_body = AlbumEntryRequest,

View File

@@ -14,8 +14,9 @@ use api_types::{
};
use application::{
catalog::{
DeleteAssetCommand, GetAssetQuery, GetDateSummaryQuery, GetTimelineQuery, ReadAssetFileQuery,
ReadDerivativeQuery, RegisterAssetCommand, SearchAssetsQuery, UpdateMetadataCommand,
DeleteAssetCommand, GetAssetQuery, GetDateSummaryQuery, GetTimelineQuery, ListTrashQuery,
ReadAssetFileQuery, ReadDerivativeQuery, RegisterAssetCommand, RestoreAssetCommand,
SearchAssetsQuery, UpdateMetadataCommand,
},
organization::TagAssetCommand,
storage::IngestAssetCommand,
@@ -473,3 +474,46 @@ pub async fn bulk_tag(
}
Ok(Json(serde_json::json!({ "tagged": tagged })))
}
pub async fn restore_asset(
State(state): State<AppState>,
claims: JwtClaims,
Path((asset_id,)): Path<(uuid::Uuid,)>,
) -> Result<StatusCode, AppError> {
let cmd = RestoreAssetCommand {
asset_id: SystemId::from_uuid(asset_id),
user_id: claims.user_id,
};
state.catalog.restore_asset.execute(cmd).await?;
Ok(StatusCode::NO_CONTENT)
}
#[derive(Debug, serde::Deserialize)]
pub struct TrashParams {
pub limit: Option<u32>,
pub offset: Option<u32>,
}
pub async fn list_trash(
State(state): State<AppState>,
claims: JwtClaims,
Query(params): Query<TrashParams>,
) -> Result<Json<TimelineResponse>, AppError> {
let limit = params.limit.unwrap_or(DEFAULT_PAGE_SIZE).min(MAX_PAGE_SIZE);
let offset = params.offset.unwrap_or(0);
let query = ListTrashQuery {
owner_id: claims.user_id,
limit,
offset,
};
let result = state.catalog.list_trash.execute(query).await?;
let items = result
.assets
.iter()
.map(|a| AssetResponse::from_domain(a, &StructuredData::new()))
.collect();
Ok(Json(TimelineResponse {
assets: items,
total: result.total,
}))
}

View File

@@ -25,6 +25,8 @@ pub fn routes() -> Router<AppState> {
get(assets::serve_derivative),
)
.route("/assets/{id}/tags", post(assets::tag_asset))
.route("/assets/trash", get(assets::list_trash))
.route("/assets/{id}/restore", post(assets::restore_asset))
.route("/assets/bulk-delete", post(assets::bulk_delete))
.route("/assets/bulk-tag", post(assets::bulk_tag))
.route(

View File

@@ -12,7 +12,9 @@ pub fn routes() -> Router<AppState> {
)
.route(
"/albums/{id}",
get(albums::get_album).put(albums::update_album),
get(albums::get_album)
.put(albums::update_album)
.delete(albums::delete_album),
)
.route("/albums/{id}/entries", post(albums::add_entry))
.route(

View File

@@ -4,17 +4,17 @@ use application::{
catalog::{
CreateStackHandler, DeleteAssetHandler, DeleteStackHandler, DetectLivePhotosHandler,
GetAssetHandler, GetDateSummaryHandler, GetStackHandler, GetTimelineHandler,
ListDuplicatesHandler, ListStacksHandler, ReadAssetFileHandler, ReadDerivativeHandler,
RegisterAssetHandler, ResolveDuplicateHandler, SearchAssetsHandler,
UpdateMetadataHandler,
ListDuplicatesHandler, ListStacksHandler, ListTrashHandler, ReadAssetFileHandler,
ReadDerivativeHandler, RegisterAssetHandler, ResolveDuplicateHandler,
RestoreAssetHandler, SearchAssetsHandler, UpdateMetadataHandler,
},
identity::{
GetProfileHandler, LoginUserHandler, LogoutHandler, RefreshTokenHandler,
RegisterUserHandler,
},
organization::{
CreateAlbumHandler, GetAlbumHandler, ListAlbumsHandler, ManageAlbumEntriesHandler,
TagAssetHandler, UpdateAlbumHandler,
CreateAlbumHandler, DeleteAlbumHandler, GetAlbumHandler, ListAlbumsHandler,
ManageAlbumEntriesHandler, TagAssetHandler, UpdateAlbumHandler,
},
processing::{
CompleteJobHandler, ConfigurePipelineHandler, EnqueueJobHandler, FailJobHandler,
@@ -58,6 +58,8 @@ pub struct CatalogHandlers {
pub read_derivative: Arc<ReadDerivativeHandler>,
pub register_asset: Arc<RegisterAssetHandler>,
pub delete_asset: Arc<DeleteAssetHandler>,
pub restore_asset: Arc<RestoreAssetHandler>,
pub list_trash: Arc<ListTrashHandler>,
pub search_assets: Arc<SearchAssetsHandler>,
pub list_duplicates: Arc<ListDuplicatesHandler>,
pub resolve_duplicate: Arc<ResolveDuplicateHandler>,
@@ -71,6 +73,7 @@ pub struct CatalogHandlers {
#[derive(Clone)]
pub struct OrganizationHandlers {
pub create_album: Arc<CreateAlbumHandler>,
pub delete_album: Arc<DeleteAlbumHandler>,
pub get_album: Arc<GetAlbumHandler>,
pub list_albums: Arc<ListAlbumsHandler>,
pub manage_album_entries: Arc<ManageAlbumEntriesHandler>,