pinakes-server: add more media management endpoints
Signed-off-by: NotAShelf <raf@notashelf.dev> Change-Id: Id3ce15a21618efbf079b277a82bf530f6a6a6964
This commit is contained in:
parent
f5371a30bb
commit
59041e9620
3 changed files with 364 additions and 1 deletions
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -63,6 +63,39 @@ pub struct UpdateMediaRequest {
|
|||
pub description: Option<String>,
|
||||
}
|
||||
|
||||
// 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<Uuid>,
|
||||
pub destination: PathBuf,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct TrashResponse {
|
||||
pub items: Vec<MediaResponse>,
|
||||
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 {
|
||||
|
|
|
|||
|
|
@ -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<AppState>,
|
||||
Path(id): Path<Uuid>,
|
||||
Json(req): Json<RenameMediaRequest>,
|
||||
) -> Result<Json<MediaResponse>, 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<AppState>,
|
||||
Path(id): Path<Uuid>,
|
||||
Json(req): Json<MoveMediaRequest>,
|
||||
) -> Result<Json<MediaResponse>, 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<AppState>,
|
||||
Json(req): Json<BatchMoveRequest>,
|
||||
) -> Result<Json<BatchOperationResponse>, 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<MediaId> = 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<AppState>,
|
||||
Path(id): Path<Uuid>,
|
||||
) -> Result<Json<serde_json::Value>, 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<AppState>,
|
||||
Path(id): Path<Uuid>,
|
||||
) -> Result<Json<MediaResponse>, 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<AppState>,
|
||||
Query(params): Query<PaginationParams>,
|
||||
) -> Result<Json<TrashResponse>, 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<AppState>,
|
||||
) -> Result<Json<TrashInfoResponse>, ApiError> {
|
||||
let count = state.storage.count_trash().await?;
|
||||
Ok(Json(TrashInfoResponse { count }))
|
||||
}
|
||||
|
||||
pub async fn empty_trash(
|
||||
State(state): State<AppState>,
|
||||
) -> Result<Json<EmptyTrashResponse>, 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<AppState>,
|
||||
Path(id): Path<Uuid>,
|
||||
Query(params): Query<std::collections::HashMap<String, String>>,
|
||||
) -> Result<Json<serde_json::Value>, 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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue