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

@@ -1,7 +1,8 @@
use std::sync::Arc;
use application::catalog::DeleteAssetHandler;
use application::processing::{EnqueueJobHandler, ProcessNextJobHandler};
use domain::ports::JobRepository;
use domain::ports::{AssetRepository, JobRepository};
use crate::config::WorkerConfig;
use crate::factories::{
@@ -12,6 +13,9 @@ pub struct WorkerServices {
pub process_next: Arc<ProcessNextJobHandler>,
pub enqueue: Arc<EnqueueJobHandler>,
pub job_repo: Arc<dyn JobRepository>,
pub asset_repo: Arc<dyn AssetRepository>,
pub delete_handler: Arc<DeleteAssetHandler>,
pub trash_retention_days: u64,
pub event_consumer:
adapters_event_transport::EventConsumerAdapter<adapters_nats::NatsMessageSource>,
}
@@ -27,9 +31,8 @@ pub async fn build(config: &WorkerConfig) -> anyhow::Result<WorkerServices> {
let event_store: Arc<dyn domain::ports::EventStore> =
Arc::new(adapters_postgres::PostgresEventStore::new(pool.clone()));
let repos = Repos::new(pool);
let file_storage = Arc::new(adapters_storage::LocalFileStorage::new(
&config.storage_path,
));
let file_storage: Arc<dyn domain::ports::FileStoragePort> =
Arc::new(adapters_storage::LocalFileStorage::new(&config.storage_path));
let sidecar_writer: Arc<dyn domain::ports::SidecarWriterPort> =
Arc::new(adapters_sidecar::XmpSidecarWriter);
@@ -47,7 +50,7 @@ pub async fn build(config: &WorkerConfig) -> anyhow::Result<WorkerServices> {
Arc::new(adapters_thumbnail::ImageThumbnailGenerator);
let registry = Arc::new(build_plugin_registry(
&repos,
file_storage,
file_storage.clone(),
sidecar_writer,
extractor,
thumbnail_gen,
@@ -60,7 +63,18 @@ pub async fn build(config: &WorkerConfig) -> anyhow::Result<WorkerServices> {
event_pub.clone(),
));
let job_repo: Arc<dyn JobRepository> = repos.job.clone();
let enqueue = Arc::new(build_enqueue_handler(&repos, event_pub));
let asset_repo: Arc<dyn AssetRepository> = repos.asset.clone();
let enqueue = Arc::new(build_enqueue_handler(&repos, event_pub.clone()));
let sidecar_repo: Arc<dyn domain::ports::SidecarRepository> = repos.sidecar.clone();
let delete_handler = Arc::new(DeleteAssetHandler::new(
repos.asset.clone(),
repos.volume.clone(),
repos.derivative.clone(),
sidecar_repo,
file_storage,
event_pub,
));
let consumer_source = adapters_nats::NatsMessageSource::new(nats_client);
let event_consumer = adapters_event_transport::EventConsumerAdapter::new(consumer_source);
@@ -69,6 +83,9 @@ pub async fn build(config: &WorkerConfig) -> anyhow::Result<WorkerServices> {
process_next,
enqueue,
job_repo,
asset_repo,
delete_handler,
trash_retention_days: config.trash_retention_days,
event_consumer,
})
}

View File

@@ -4,6 +4,7 @@ pub struct WorkerConfig {
pub nats_url: String,
pub fallback_sweep_secs: u64,
pub storage_path: String,
pub trash_retention_days: u64,
}
impl WorkerConfig {
@@ -17,6 +18,10 @@ impl WorkerConfig {
.and_then(|v| v.parse().ok())
.unwrap_or(60),
storage_path: std::env::var("STORAGE_PATH").unwrap_or_else(|_| "./storage".into()),
trash_retention_days: std::env::var("TRASH_RETENTION_DAYS")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(30),
}
}
}

View File

@@ -54,6 +54,13 @@ async fn main() -> anyhow::Result<()> {
shutdown_rx.clone(),
));
tokio::spawn(sweep::purge_trash(
services.asset_repo.clone(),
services.delete_handler.clone(),
services.trash_retention_days,
shutdown_rx.clone(),
));
event_loop::run(services, shutdown_rx).await;
info!("worker shutdown complete");

View File

@@ -4,7 +4,9 @@ use std::time::Duration;
use tokio::sync::watch;
use tracing::{error, info};
use application::catalog::DeleteAssetHandler;
use application::processing::{ProcessNextJobCommand, ProcessNextJobHandler};
use domain::ports::AssetRepository;
pub async fn run(
handler: Arc<ProcessNextJobHandler>,
@@ -35,3 +37,37 @@ pub async fn run(
}
}
}
pub async fn purge_trash(
asset_repo: Arc<dyn AssetRepository>,
delete_handler: Arc<DeleteAssetHandler>,
retention_days: u64,
mut shutdown: watch::Receiver<bool>,
) {
let interval = Duration::from_secs(3600);
info!(retention_days, "trash purge task started");
loop {
tokio::select! {
_ = shutdown.changed() => {
info!("trash purge: shutting down");
break;
}
_ = tokio::time::sleep(interval) => {}
}
let cutoff = chrono::Utc::now() - chrono::Duration::days(retention_days as i64);
match asset_repo.find_trashed_before(cutoff).await {
Ok(assets) if assets.is_empty() => {}
Ok(assets) => {
info!(count = assets.len(), "trash purge: purging expired assets");
for asset in &assets {
if let Err(e) = delete_handler.purge(&asset.asset_id).await {
error!(asset_id = %asset.asset_id, error = %e, "trash purge: failed");
}
}
}
Err(e) => {
error!(error = %e, "trash purge: failed to query trashed assets");
}
}
}
}