- Make mapped_status nullable so unmapping (X button) sets NULL instead of deleting the row — provider statuses never disappear from the UI - normalize_series_status now returns the raw provider status (lowercased) when no mapping exists, so all statuses are stored in series_metadata - Fix series_statuses query crash caused by NULL mapped_status values - Fix metadata batch/refresh server actions crashing page on 400 errors - StatusMappingDto.mapped_status is now string | null in the backoffice Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
458 lines
14 KiB
Rust
458 lines
14 KiB
Rust
use axum::{
|
|
extract::{Path as AxumPath, State},
|
|
routing::{delete, get, post},
|
|
Json, Router,
|
|
};
|
|
use serde::{Deserialize, Serialize};
|
|
use serde_json::Value;
|
|
use sqlx::Row;
|
|
use uuid::Uuid;
|
|
use utoipa::ToSchema;
|
|
|
|
use crate::{error::ApiError, state::{AppState, load_dynamic_settings}};
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
|
pub struct UpdateSettingRequest {
|
|
pub value: Value,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
|
pub struct ClearCacheResponse {
|
|
pub success: bool,
|
|
pub message: String,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
|
pub struct CacheStats {
|
|
pub total_size_mb: f64,
|
|
pub file_count: u64,
|
|
pub directory: String,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
|
pub struct ThumbnailStats {
|
|
pub total_size_mb: f64,
|
|
pub file_count: u64,
|
|
pub directory: String,
|
|
}
|
|
|
|
pub fn settings_routes() -> Router<AppState> {
|
|
Router::new()
|
|
.route("/settings", get(get_settings))
|
|
.route("/settings/:key", get(get_setting).post(update_setting))
|
|
.route("/settings/cache/clear", post(clear_cache))
|
|
.route("/settings/cache/stats", get(get_cache_stats))
|
|
.route("/settings/thumbnail/stats", get(get_thumbnail_stats))
|
|
.route(
|
|
"/settings/status-mappings",
|
|
get(list_status_mappings).post(upsert_status_mapping),
|
|
)
|
|
.route(
|
|
"/settings/status-mappings/:id",
|
|
delete(delete_status_mapping),
|
|
)
|
|
}
|
|
|
|
/// List all settings
|
|
#[utoipa::path(
|
|
get,
|
|
path = "/settings",
|
|
tag = "settings",
|
|
responses(
|
|
(status = 200, description = "All settings as key/value object"),
|
|
(status = 401, description = "Unauthorized"),
|
|
),
|
|
security(("Bearer" = []))
|
|
)]
|
|
pub async fn get_settings(State(state): State<AppState>) -> Result<Json<Value>, ApiError> {
|
|
let rows = sqlx::query(r#"SELECT key, value FROM app_settings"#)
|
|
.fetch_all(&state.pool)
|
|
.await?;
|
|
|
|
let mut settings = serde_json::Map::new();
|
|
for row in rows {
|
|
let key: String = row.get("key");
|
|
let value: Value = row.get("value");
|
|
settings.insert(key, value);
|
|
}
|
|
|
|
Ok(Json(Value::Object(settings)))
|
|
}
|
|
|
|
/// Get a single setting by key
|
|
#[utoipa::path(
|
|
get,
|
|
path = "/settings/{key}",
|
|
tag = "settings",
|
|
params(("key" = String, Path, description = "Setting key")),
|
|
responses(
|
|
(status = 200, description = "Setting value"),
|
|
(status = 404, description = "Setting not found"),
|
|
(status = 401, description = "Unauthorized"),
|
|
),
|
|
security(("Bearer" = []))
|
|
)]
|
|
pub async fn get_setting(
|
|
State(state): State<AppState>,
|
|
axum::extract::Path(key): axum::extract::Path<String>,
|
|
) -> Result<Json<Value>, ApiError> {
|
|
let row = sqlx::query(r#"SELECT value FROM app_settings WHERE key = $1"#)
|
|
.bind(&key)
|
|
.fetch_optional(&state.pool)
|
|
.await?;
|
|
|
|
match row {
|
|
Some(row) => {
|
|
let value: Value = row.get("value");
|
|
Ok(Json(value))
|
|
}
|
|
None => Err(ApiError::not_found(format!("setting '{}' not found", key))),
|
|
}
|
|
}
|
|
|
|
/// Create or update a setting
|
|
#[utoipa::path(
|
|
post,
|
|
path = "/settings/{key}",
|
|
tag = "settings",
|
|
params(("key" = String, Path, description = "Setting key")),
|
|
request_body = UpdateSettingRequest,
|
|
responses(
|
|
(status = 200, description = "Updated setting value"),
|
|
(status = 401, description = "Unauthorized"),
|
|
),
|
|
security(("Bearer" = []))
|
|
)]
|
|
pub async fn update_setting(
|
|
State(state): State<AppState>,
|
|
axum::extract::Path(key): axum::extract::Path<String>,
|
|
Json(body): Json<UpdateSettingRequest>,
|
|
) -> Result<Json<Value>, ApiError> {
|
|
let row = sqlx::query(
|
|
r#"
|
|
INSERT INTO app_settings (key, value, updated_at)
|
|
VALUES ($1, $2, CURRENT_TIMESTAMP)
|
|
ON CONFLICT (key)
|
|
DO UPDATE SET value = $2, updated_at = CURRENT_TIMESTAMP
|
|
RETURNING value
|
|
"#,
|
|
)
|
|
.bind(&key)
|
|
.bind(&body.value)
|
|
.fetch_one(&state.pool)
|
|
.await?;
|
|
|
|
let value: Value = row.get("value");
|
|
|
|
// Rechargement des settings dynamiques si la clé affecte le comportement runtime
|
|
if key == "limits" || key == "image_processing" || key == "cache" {
|
|
let new_settings = load_dynamic_settings(&state.pool).await;
|
|
*state.settings.write().await = new_settings;
|
|
}
|
|
|
|
Ok(Json(value))
|
|
}
|
|
|
|
/// Clear the image page cache
|
|
#[utoipa::path(
|
|
post,
|
|
path = "/settings/cache/clear",
|
|
tag = "settings",
|
|
responses(
|
|
(status = 200, body = ClearCacheResponse),
|
|
(status = 401, description = "Unauthorized"),
|
|
),
|
|
security(("Bearer" = []))
|
|
)]
|
|
pub async fn clear_cache(State(state): State<AppState>) -> Result<Json<ClearCacheResponse>, ApiError> {
|
|
let cache_dir = state.settings.read().await.cache_directory.clone();
|
|
|
|
let result = tokio::task::spawn_blocking(move || {
|
|
if std::path::Path::new(&cache_dir).exists() {
|
|
match std::fs::remove_dir_all(&cache_dir) {
|
|
Ok(_) => ClearCacheResponse {
|
|
success: true,
|
|
message: format!("Cache directory '{}' cleared successfully", cache_dir),
|
|
},
|
|
Err(e) => ClearCacheResponse {
|
|
success: false,
|
|
message: format!("Failed to clear cache: {}", e),
|
|
},
|
|
}
|
|
} else {
|
|
ClearCacheResponse {
|
|
success: true,
|
|
message: format!("Cache directory '{}' does not exist, nothing to clear", cache_dir),
|
|
}
|
|
}
|
|
})
|
|
.await
|
|
.map_err(|e| ApiError::internal(format!("cache clear failed: {}", e)))?;
|
|
|
|
Ok(Json(result))
|
|
}
|
|
|
|
/// Get image page cache statistics
|
|
#[utoipa::path(
|
|
get,
|
|
path = "/settings/cache/stats",
|
|
tag = "settings",
|
|
responses(
|
|
(status = 200, body = CacheStats),
|
|
(status = 401, description = "Unauthorized"),
|
|
),
|
|
security(("Bearer" = []))
|
|
)]
|
|
pub async fn get_cache_stats(State(state): State<AppState>) -> Result<Json<CacheStats>, ApiError> {
|
|
let cache_dir = state.settings.read().await.cache_directory.clone();
|
|
|
|
let cache_dir_clone = cache_dir.clone();
|
|
let stats = tokio::task::spawn_blocking(move || {
|
|
let path = std::path::Path::new(&cache_dir_clone);
|
|
if !path.exists() {
|
|
return CacheStats {
|
|
total_size_mb: 0.0,
|
|
file_count: 0,
|
|
directory: cache_dir_clone,
|
|
};
|
|
}
|
|
|
|
let mut total_size: u64 = 0;
|
|
let mut file_count: u64 = 0;
|
|
|
|
fn visit_dirs(
|
|
dir: &std::path::Path,
|
|
total_size: &mut u64,
|
|
file_count: &mut u64,
|
|
) -> std::io::Result<()> {
|
|
if dir.is_dir() {
|
|
for entry in std::fs::read_dir(dir)? {
|
|
let entry = entry?;
|
|
let path = entry.path();
|
|
if path.is_dir() {
|
|
visit_dirs(&path, total_size, file_count)?;
|
|
} else {
|
|
*total_size += entry.metadata()?.len();
|
|
*file_count += 1;
|
|
}
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
let _ = visit_dirs(path, &mut total_size, &mut file_count);
|
|
|
|
CacheStats {
|
|
total_size_mb: total_size as f64 / 1024.0 / 1024.0,
|
|
file_count,
|
|
directory: cache_dir_clone,
|
|
}
|
|
})
|
|
.await
|
|
.map_err(|e| ApiError::internal(format!("cache stats failed: {}", e)))?;
|
|
|
|
Ok(Json(stats))
|
|
}
|
|
|
|
fn compute_dir_stats(path: &std::path::Path) -> (u64, u64) {
|
|
let mut total_size: u64 = 0;
|
|
let mut file_count: u64 = 0;
|
|
|
|
fn visit_dirs(
|
|
dir: &std::path::Path,
|
|
total_size: &mut u64,
|
|
file_count: &mut u64,
|
|
) -> std::io::Result<()> {
|
|
if dir.is_dir() {
|
|
for entry in std::fs::read_dir(dir)? {
|
|
let entry = entry?;
|
|
let path = entry.path();
|
|
if path.is_dir() {
|
|
visit_dirs(&path, total_size, file_count)?;
|
|
} else {
|
|
*total_size += entry.metadata()?.len();
|
|
*file_count += 1;
|
|
}
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
let _ = visit_dirs(path, &mut total_size, &mut file_count);
|
|
(total_size, file_count)
|
|
}
|
|
|
|
/// Get thumbnail storage statistics
|
|
#[utoipa::path(
|
|
get,
|
|
path = "/settings/thumbnail/stats",
|
|
tag = "settings",
|
|
responses(
|
|
(status = 200, body = ThumbnailStats),
|
|
(status = 401, description = "Unauthorized"),
|
|
),
|
|
security(("Bearer" = []))
|
|
)]
|
|
pub async fn get_thumbnail_stats(State(_state): State<AppState>) -> Result<Json<ThumbnailStats>, ApiError> {
|
|
let settings = sqlx::query(r#"SELECT value FROM app_settings WHERE key = 'thumbnail'"#)
|
|
.fetch_optional(&_state.pool)
|
|
.await?;
|
|
|
|
let directory = match settings {
|
|
Some(row) => {
|
|
let value: serde_json::Value = row.get("value");
|
|
value.get("directory")
|
|
.and_then(|v| v.as_str())
|
|
.unwrap_or("/data/thumbnails")
|
|
.to_string()
|
|
}
|
|
None => "/data/thumbnails".to_string(),
|
|
};
|
|
|
|
let directory_clone = directory.clone();
|
|
let stats = tokio::task::spawn_blocking(move || {
|
|
let path = std::path::Path::new(&directory_clone);
|
|
if !path.exists() {
|
|
return ThumbnailStats {
|
|
total_size_mb: 0.0,
|
|
file_count: 0,
|
|
directory: directory_clone,
|
|
};
|
|
}
|
|
|
|
let (total_size, file_count) = compute_dir_stats(path);
|
|
|
|
ThumbnailStats {
|
|
total_size_mb: total_size as f64 / 1024.0 / 1024.0,
|
|
file_count,
|
|
directory: directory_clone,
|
|
}
|
|
})
|
|
.await
|
|
.map_err(|e| ApiError::internal(format!("thumbnail stats failed: {}", e)))?;
|
|
|
|
Ok(Json(stats))
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Status Mappings
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
|
pub struct StatusMappingDto {
|
|
pub id: String,
|
|
pub provider_status: String,
|
|
pub mapped_status: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Deserialize, ToSchema)]
|
|
pub struct UpsertStatusMappingRequest {
|
|
pub provider_status: String,
|
|
pub mapped_status: String,
|
|
}
|
|
|
|
/// List all status mappings
|
|
#[utoipa::path(
|
|
get,
|
|
path = "/settings/status-mappings",
|
|
tag = "settings",
|
|
responses(
|
|
(status = 200, body = Vec<StatusMappingDto>),
|
|
(status = 401, description = "Unauthorized"),
|
|
),
|
|
security(("Bearer" = []))
|
|
)]
|
|
pub async fn list_status_mappings(
|
|
State(state): State<AppState>,
|
|
) -> Result<Json<Vec<StatusMappingDto>>, ApiError> {
|
|
let rows = sqlx::query(
|
|
"SELECT id, provider_status, mapped_status FROM status_mappings ORDER BY mapped_status NULLS LAST, provider_status",
|
|
)
|
|
.fetch_all(&state.pool)
|
|
.await?;
|
|
|
|
let mappings = rows
|
|
.iter()
|
|
.map(|row| StatusMappingDto {
|
|
id: row.get::<Uuid, _>("id").to_string(),
|
|
provider_status: row.get("provider_status"),
|
|
mapped_status: row.get::<Option<String>, _>("mapped_status"),
|
|
})
|
|
.collect();
|
|
|
|
Ok(Json(mappings))
|
|
}
|
|
|
|
/// Create or update a status mapping
|
|
#[utoipa::path(
|
|
post,
|
|
path = "/settings/status-mappings",
|
|
tag = "settings",
|
|
request_body = UpsertStatusMappingRequest,
|
|
responses(
|
|
(status = 200, body = StatusMappingDto),
|
|
(status = 401, description = "Unauthorized"),
|
|
),
|
|
security(("Bearer" = []))
|
|
)]
|
|
pub async fn upsert_status_mapping(
|
|
State(state): State<AppState>,
|
|
Json(body): Json<UpsertStatusMappingRequest>,
|
|
) -> Result<Json<StatusMappingDto>, ApiError> {
|
|
let provider_status = body.provider_status.to_lowercase();
|
|
|
|
let row = sqlx::query(
|
|
r#"
|
|
INSERT INTO status_mappings (provider_status, mapped_status)
|
|
VALUES ($1, $2)
|
|
ON CONFLICT (provider_status)
|
|
DO UPDATE SET mapped_status = $2, updated_at = NOW()
|
|
RETURNING id, provider_status, mapped_status
|
|
"#,
|
|
)
|
|
.bind(&provider_status)
|
|
.bind(&body.mapped_status)
|
|
.fetch_one(&state.pool)
|
|
.await?;
|
|
|
|
Ok(Json(StatusMappingDto {
|
|
id: row.get::<Uuid, _>("id").to_string(),
|
|
provider_status: row.get("provider_status"),
|
|
mapped_status: row.get::<Option<String>, _>("mapped_status"),
|
|
}))
|
|
}
|
|
|
|
/// Unmap a status mapping (sets mapped_status to NULL, keeps the provider status known)
|
|
#[utoipa::path(
|
|
delete,
|
|
path = "/settings/status-mappings/{id}",
|
|
tag = "settings",
|
|
params(("id" = String, Path, description = "Mapping UUID")),
|
|
responses(
|
|
(status = 200, body = StatusMappingDto),
|
|
(status = 401, description = "Unauthorized"),
|
|
(status = 404, description = "Not found"),
|
|
),
|
|
security(("Bearer" = []))
|
|
)]
|
|
pub async fn delete_status_mapping(
|
|
State(state): State<AppState>,
|
|
AxumPath(id): AxumPath<Uuid>,
|
|
) -> Result<Json<StatusMappingDto>, ApiError> {
|
|
let row = sqlx::query(
|
|
"UPDATE status_mappings SET mapped_status = NULL, updated_at = NOW() WHERE id = $1 RETURNING id, provider_status, mapped_status",
|
|
)
|
|
.bind(id)
|
|
.fetch_optional(&state.pool)
|
|
.await?;
|
|
|
|
match row {
|
|
Some(row) => Ok(Json(StatusMappingDto {
|
|
id: row.get::<Uuid, _>("id").to_string(),
|
|
provider_status: row.get("provider_status"),
|
|
mapped_status: row.get::<Option<String>, _>("mapped_status"),
|
|
})),
|
|
None => Err(ApiError::not_found("status mapping not found")),
|
|
}
|
|
}
|