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/all", delete(routes::media::delete_all_media))
|
||||||
.route("/media/{id}", patch(routes::media::update_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))
|
.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(
|
.route(
|
||||||
"/media/{id}/custom-fields",
|
"/media/{id}/custom-fields",
|
||||||
post(routes::media::set_custom_field),
|
post(routes::media::set_custom_field),
|
||||||
|
|
|
||||||
|
|
@ -63,6 +63,39 @@ pub struct UpdateMediaRequest {
|
||||||
pub description: Option<String>,
|
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
|
// Tags
|
||||||
#[derive(Debug, Serialize)]
|
#[derive(Debug, Serialize)]
|
||||||
pub struct TagResponse {
|
pub struct TagResponse {
|
||||||
|
|
|
||||||
|
|
@ -793,3 +793,320 @@ pub async fn get_media_count(
|
||||||
let count = state.storage.count_media().await?;
|
let count = state.storage.count_media().await?;
|
||||||
Ok(Json(MediaCountResponse { count }))
|
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