pinakes-server: add more media management endpoints

Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: Id3ce15a21618efbf079b277a82bf530f6a6a6964
This commit is contained in:
raf 2026-02-05 11:07:46 +03:00
commit 59041e9620
Signed by: NotAShelf
GPG key ID: 29D95B64378DB4BF
3 changed files with 364 additions and 1 deletions

View file

@ -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),

View file

@ -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 {

View file

@ -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
}
}