From 59041e9620a7b74addb6360470cccecd0e014eb6 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Thu, 5 Feb 2026 11:07:46 +0300 Subject: [PATCH] pinakes-server: add more media management endpoints Signed-off-by: NotAShelf Change-Id: Id3ce15a21618efbf079b277a82bf530f6a6a6964 --- crates/pinakes-server/src/app.rs | 15 +- crates/pinakes-server/src/dto.rs | 33 +++ crates/pinakes-server/src/routes/media.rs | 317 ++++++++++++++++++++++ 3 files changed, 364 insertions(+), 1 deletion(-) diff --git a/crates/pinakes-server/src/app.rs b/crates/pinakes-server/src/app.rs index 92ef13a..9a95468 100644 --- a/crates/pinakes-server/src/app.rs +++ b/crates/pinakes-server/src/app.rs @@ -264,8 +264,21 @@ pub fn create_router_with_tls( ) .route("/media/all", delete(routes::media::delete_all_media)) .route("/media/{id}", patch(routes::media::update_media)) - .route("/media/{id}", delete(routes::media::delete_media)) + .route("/media/{id}", delete(routes::media::permanent_delete_media)) .route("/media/{id}/open", post(routes::media::open_media)) + // File management + .route("/media/{id}/rename", patch(routes::media::rename_media)) + .route( + "/media/{id}/move", + patch(routes::media::move_media_endpoint), + ) + .route("/media/{id}/trash", post(routes::media::soft_delete_media)) + .route("/media/{id}/restore", post(routes::media::restore_media)) + .route("/media/batch/move", post(routes::media::batch_move_media)) + // Trash management + .route("/trash", get(routes::media::list_trash)) + .route("/trash/info", get(routes::media::trash_info)) + .route("/trash", delete(routes::media::empty_trash)) .route( "/media/{id}/custom-fields", post(routes::media::set_custom_field), diff --git a/crates/pinakes-server/src/dto.rs b/crates/pinakes-server/src/dto.rs index 54847ec..f7204fa 100644 --- a/crates/pinakes-server/src/dto.rs +++ b/crates/pinakes-server/src/dto.rs @@ -63,6 +63,39 @@ pub struct UpdateMediaRequest { pub description: Option, } +// File Management +#[derive(Debug, Deserialize)] +pub struct RenameMediaRequest { + pub new_name: String, +} + +#[derive(Debug, Deserialize)] +pub struct MoveMediaRequest { + pub destination: PathBuf, +} + +#[derive(Debug, Deserialize)] +pub struct BatchMoveRequest { + pub media_ids: Vec, + pub destination: PathBuf, +} + +#[derive(Debug, Serialize)] +pub struct TrashResponse { + pub items: Vec, + pub total_count: u64, +} + +#[derive(Debug, Serialize)] +pub struct TrashInfoResponse { + pub count: u64, +} + +#[derive(Debug, Serialize)] +pub struct EmptyTrashResponse { + pub deleted_count: u64, +} + // Tags #[derive(Debug, Serialize)] pub struct TagResponse { diff --git a/crates/pinakes-server/src/routes/media.rs b/crates/pinakes-server/src/routes/media.rs index abba968..e3a033c 100644 --- a/crates/pinakes-server/src/routes/media.rs +++ b/crates/pinakes-server/src/routes/media.rs @@ -793,3 +793,320 @@ pub async fn get_media_count( let count = state.storage.count_media().await?; Ok(Json(MediaCountResponse { count })) } + +// ===== File Management Endpoints ===== + +pub async fn rename_media( + State(state): State, + Path(id): Path, + Json(req): Json, +) -> Result, ApiError> { + let media_id = MediaId(id); + + // Perform the rename + let old_path = state.storage.rename_media(media_id, &req.new_name).await?; + + // Record in sync log + let item = state.storage.get_media(media_id).await?; + let change = pinakes_core::sync::SyncLogEntry { + id: uuid::Uuid::now_v7(), + sequence: 0, + change_type: pinakes_core::sync::SyncChangeType::Moved, + media_id: Some(media_id), + path: item.path.to_string_lossy().to_string(), + content_hash: Some(item.content_hash.clone()), + file_size: Some(item.file_size), + metadata_json: Some(serde_json::json!({ "old_path": old_path }).to_string()), + changed_by_device: None, + timestamp: chrono::Utc::now(), + }; + let _ = state.storage.record_sync_change(&change).await; + + // Record audit + pinakes_core::audit::record_action( + &state.storage, + Some(media_id), + pinakes_core::model::AuditAction::Updated, + Some(format!("renamed from {} to {}", old_path, req.new_name)), + ) + .await?; + + Ok(Json(MediaResponse::from(item))) +} + +pub async fn move_media_endpoint( + State(state): State, + Path(id): Path, + Json(req): Json, +) -> Result, ApiError> { + let media_id = MediaId(id); + + // Perform the move + let old_path = state.storage.move_media(media_id, &req.destination).await?; + + // Record in sync log + let item = state.storage.get_media(media_id).await?; + let change = pinakes_core::sync::SyncLogEntry { + id: uuid::Uuid::now_v7(), + sequence: 0, + change_type: pinakes_core::sync::SyncChangeType::Moved, + media_id: Some(media_id), + path: item.path.to_string_lossy().to_string(), + content_hash: Some(item.content_hash.clone()), + file_size: Some(item.file_size), + metadata_json: Some(serde_json::json!({ "old_path": old_path }).to_string()), + changed_by_device: None, + timestamp: chrono::Utc::now(), + }; + let _ = state.storage.record_sync_change(&change).await; + + // Record audit + pinakes_core::audit::record_action( + &state.storage, + Some(media_id), + pinakes_core::model::AuditAction::Updated, + Some(format!( + "moved from {} to {}", + old_path, + req.destination.display() + )), + ) + .await?; + + Ok(Json(MediaResponse::from(item))) +} + +pub async fn batch_move_media( + State(state): State, + Json(req): Json, +) -> Result, ApiError> { + if req.media_ids.len() > 10_000 { + return Err(ApiError( + pinakes_core::error::PinakesError::InvalidOperation( + "batch size exceeds limit of 10000".into(), + ), + )); + } + + let media_ids: Vec = req.media_ids.iter().map(|id| MediaId(*id)).collect(); + + match state + .storage + .batch_move_media(&media_ids, &req.destination) + .await + { + Ok(results) => { + // Record sync changes for each moved item + for (media_id, old_path) in &results { + if let Ok(item) = state.storage.get_media(*media_id).await { + let change = pinakes_core::sync::SyncLogEntry { + id: uuid::Uuid::now_v7(), + sequence: 0, + change_type: pinakes_core::sync::SyncChangeType::Moved, + media_id: Some(*media_id), + path: item.path.to_string_lossy().to_string(), + content_hash: Some(item.content_hash.clone()), + file_size: Some(item.file_size), + metadata_json: Some( + serde_json::json!({ "old_path": old_path }).to_string(), + ), + changed_by_device: None, + timestamp: chrono::Utc::now(), + }; + let _ = state.storage.record_sync_change(&change).await; + } + } + + Ok(Json(BatchOperationResponse { + processed: results.len(), + errors: Vec::new(), + })) + } + Err(e) => Ok(Json(BatchOperationResponse { + processed: 0, + errors: vec![e.to_string()], + })), + } +} + +// ===== Trash Endpoints ===== + +pub async fn soft_delete_media( + State(state): State, + Path(id): Path, +) -> Result, ApiError> { + let media_id = MediaId(id); + + // Get item info before soft delete + let item = state.storage.get_media(media_id).await?; + + // Perform soft delete + state.storage.soft_delete_media(media_id).await?; + + // Record in sync log + let change = pinakes_core::sync::SyncLogEntry { + id: uuid::Uuid::now_v7(), + sequence: 0, + change_type: pinakes_core::sync::SyncChangeType::Deleted, + media_id: Some(media_id), + path: item.path.to_string_lossy().to_string(), + content_hash: Some(item.content_hash.clone()), + file_size: Some(item.file_size), + metadata_json: None, + changed_by_device: None, + timestamp: chrono::Utc::now(), + }; + let _ = state.storage.record_sync_change(&change).await; + + // Record audit + pinakes_core::audit::record_action( + &state.storage, + Some(media_id), + pinakes_core::model::AuditAction::Deleted, + Some("moved to trash".to_string()), + ) + .await?; + + Ok(Json(serde_json::json!({"deleted": true, "trashed": true}))) +} + +pub async fn restore_media( + State(state): State, + Path(id): Path, +) -> Result, ApiError> { + let media_id = MediaId(id); + + // Perform restore + state.storage.restore_media(media_id).await?; + + // Get updated item + let item = state.storage.get_media(media_id).await?; + + // Record in sync log + let change = pinakes_core::sync::SyncLogEntry { + id: uuid::Uuid::now_v7(), + sequence: 0, + change_type: pinakes_core::sync::SyncChangeType::Created, + media_id: Some(media_id), + path: item.path.to_string_lossy().to_string(), + content_hash: Some(item.content_hash.clone()), + file_size: Some(item.file_size), + metadata_json: None, + changed_by_device: None, + timestamp: chrono::Utc::now(), + }; + let _ = state.storage.record_sync_change(&change).await; + + // Record audit + pinakes_core::audit::record_action( + &state.storage, + Some(media_id), + pinakes_core::model::AuditAction::Updated, + Some("restored from trash".to_string()), + ) + .await?; + + Ok(Json(MediaResponse::from(item))) +} + +pub async fn list_trash( + State(state): State, + Query(params): Query, +) -> Result, ApiError> { + let pagination = Pagination::new( + params.offset.unwrap_or(0), + params.limit.unwrap_or(50).min(1000), + params.sort, + ); + + let items = state.storage.list_trash(&pagination).await?; + let count = state.storage.count_trash().await?; + + Ok(Json(TrashResponse { + items: items.into_iter().map(MediaResponse::from).collect(), + total_count: count, + })) +} + +pub async fn trash_info( + State(state): State, +) -> Result, ApiError> { + let count = state.storage.count_trash().await?; + Ok(Json(TrashInfoResponse { count })) +} + +pub async fn empty_trash( + State(state): State, +) -> Result, ApiError> { + // Record audit before emptying + pinakes_core::audit::record_action( + &state.storage, + None, + pinakes_core::model::AuditAction::Deleted, + Some("emptied trash".to_string()), + ) + .await?; + + let deleted_count = state.storage.empty_trash().await?; + + Ok(Json(EmptyTrashResponse { deleted_count })) +} + +pub async fn permanent_delete_media( + State(state): State, + Path(id): Path, + Query(params): Query>, +) -> Result, ApiError> { + let media_id = MediaId(id); + let permanent = params + .get("permanent") + .map(|v| v == "true") + .unwrap_or(false); + + if permanent { + // Get item info before delete + let item = state.storage.get_media(media_id).await?; + + // Record audit BEFORE delete + pinakes_core::audit::record_action( + &state.storage, + Some(media_id), + pinakes_core::model::AuditAction::Deleted, + Some("permanently deleted".to_string()), + ) + .await?; + + // Perform hard delete + state.storage.delete_media(media_id).await?; + + // Record in sync log + let change = pinakes_core::sync::SyncLogEntry { + id: uuid::Uuid::now_v7(), + sequence: 0, + change_type: pinakes_core::sync::SyncChangeType::Deleted, + media_id: Some(media_id), + path: item.path.to_string_lossy().to_string(), + content_hash: Some(item.content_hash.clone()), + file_size: Some(item.file_size), + metadata_json: Some(serde_json::json!({"permanent": true}).to_string()), + changed_by_device: None, + timestamp: chrono::Utc::now(), + }; + let _ = state.storage.record_sync_change(&change).await; + + // Clean up thumbnail + if let Some(ref thumb_path) = item.thumbnail_path + && let Err(e) = tokio::fs::remove_file(thumb_path).await + && e.kind() != std::io::ErrorKind::NotFound + { + tracing::warn!(path = %thumb_path.display(), error = %e, "failed to remove thumbnail"); + } + + Ok(Json( + serde_json::json!({"deleted": true, "permanent": true}), + )) + } else { + // Soft delete (move to trash) + soft_delete_media(State(state), Path(id)).await + } +}