Files
stripstream-librarian/apps/api/src/libraries.rs
Froidefond Julien 6947af10fe perf(api,indexer): optimiser pages, thumbnails, watcher et robustesse fd
- Pages: mode Original (zero-transcoding), ETag/304, cache index CBZ,
  préfetch next 2 pages, filtre Triangle par défaut
- Thumbnails: DCT scaling JPEG via jpeg-decoder (decode 7x plus rapide),
  img.thumbnail() pour resize, support format Original, fix JPEG RGBA8
- API fallback thumbnail: OutputFormat::Original + DCT scaling au lieu
  de WebP full-decode, retour (bytes, content_type) dynamique
- Watcher: remplacement notify par poll léger sans inotify/fd,
  skip poll quand job actif, snapshots en mémoire
- Jobs: mutex exclusif corrigé (tous statuts actifs, tous types exclusifs)
- Robustesse: suppression fs::canonicalize (problèmes fd Docker),
  list_folders avec erreurs explicites, has_children default true
- Backoffice: FormRow items-start pour alignement inputs avec helper text,
  labels settings clarifiés

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-14 23:07:42 +01:00

315 lines
9.7 KiB
Rust

use std::path::{Path, PathBuf};
use axum::{extract::{Path as AxumPath, State}, Json};
use serde::{Deserialize, Serialize};
use sqlx::Row;
use uuid::Uuid;
use utoipa::ToSchema;
use crate::{error::ApiError, state::AppState};
#[derive(Serialize, ToSchema)]
pub struct LibraryResponse {
#[schema(value_type = String)]
pub id: Uuid,
pub name: String,
pub root_path: String,
pub enabled: bool,
pub book_count: i64,
pub monitor_enabled: bool,
pub scan_mode: String,
#[schema(value_type = Option<String>)]
pub next_scan_at: Option<chrono::DateTime<chrono::Utc>>,
pub watcher_enabled: bool,
}
#[derive(Deserialize, ToSchema)]
pub struct CreateLibraryRequest {
#[schema(value_type = String, example = "Comics")]
pub name: String,
#[schema(value_type = String, example = "/data/comics")]
pub root_path: String,
}
/// List all libraries with their book counts
#[utoipa::path(
get,
path = "/libraries",
tag = "libraries",
responses(
(status = 200, body = Vec<LibraryResponse>),
(status = 401, description = "Unauthorized"),
(status = 403, description = "Forbidden - Admin scope required"),
),
security(("Bearer" = []))
)]
pub async fn list_libraries(State(state): State<AppState>) -> Result<Json<Vec<LibraryResponse>>, ApiError> {
let rows = sqlx::query(
"SELECT l.id, l.name, l.root_path, l.enabled, l.monitor_enabled, l.scan_mode, l.next_scan_at, l.watcher_enabled,
(SELECT COUNT(*) FROM books b WHERE b.library_id = l.id) as book_count
FROM libraries l ORDER BY l.created_at DESC"
)
.fetch_all(&state.pool)
.await?;
let items = rows
.into_iter()
.map(|row| LibraryResponse {
id: row.get("id"),
name: row.get("name"),
root_path: row.get("root_path"),
enabled: row.get("enabled"),
book_count: row.get("book_count"),
monitor_enabled: row.get("monitor_enabled"),
scan_mode: row.get("scan_mode"),
next_scan_at: row.get("next_scan_at"),
watcher_enabled: row.get("watcher_enabled"),
})
.collect();
Ok(Json(items))
}
/// Create a new library from an absolute path
#[utoipa::path(
post,
path = "/libraries",
tag = "libraries",
request_body = CreateLibraryRequest,
responses(
(status = 200, body = LibraryResponse),
(status = 400, description = "Invalid input"),
(status = 401, description = "Unauthorized"),
(status = 403, description = "Forbidden - Admin scope required"),
),
security(("Bearer" = []))
)]
pub async fn create_library(
State(state): State<AppState>,
Json(input): Json<CreateLibraryRequest>,
) -> Result<Json<LibraryResponse>, ApiError> {
if input.name.trim().is_empty() {
return Err(ApiError::bad_request("name is required"));
}
let canonical = canonicalize_library_root(&input.root_path)?;
let id = Uuid::new_v4();
let root_path = canonical.to_string_lossy().to_string();
sqlx::query(
"INSERT INTO libraries (id, name, root_path, enabled) VALUES ($1, $2, $3, TRUE)",
)
.bind(id)
.bind(input.name.trim())
.bind(&root_path)
.execute(&state.pool)
.await?;
Ok(Json(LibraryResponse {
id,
name: input.name.trim().to_string(),
root_path,
enabled: true,
book_count: 0,
monitor_enabled: false,
scan_mode: "manual".to_string(),
next_scan_at: None,
watcher_enabled: false,
}))
}
/// Delete a library by ID
#[utoipa::path(
delete,
path = "/libraries/{id}",
tag = "libraries",
params(
("id" = String, Path, description = "Library UUID"),
),
responses(
(status = 200, description = "Library deleted"),
(status = 404, description = "Library not found"),
(status = 401, description = "Unauthorized"),
(status = 403, description = "Forbidden - Admin scope required"),
),
security(("Bearer" = []))
)]
pub async fn delete_library(
State(state): State<AppState>,
AxumPath(id): AxumPath<Uuid>,
) -> Result<Json<serde_json::Value>, ApiError> {
let result = sqlx::query("DELETE FROM libraries WHERE id = $1")
.bind(id)
.execute(&state.pool)
.await?;
if result.rows_affected() == 0 {
return Err(ApiError::not_found("library not found"));
}
Ok(Json(serde_json::json!({"deleted": true, "id": id})))
}
fn canonicalize_library_root(root_path: &str) -> Result<PathBuf, ApiError> {
let path = Path::new(root_path);
if !path.is_absolute() {
return Err(ApiError::bad_request("root_path must be absolute"));
}
// Avoid fs::canonicalize — it opens extra file descriptors to resolve symlinks
// and can fail on Docker volume mounts (ro, cached) when fd limits are low.
if !path.exists() {
return Err(ApiError::bad_request(format!(
"root_path does not exist: {}",
root_path
)));
}
if !path.is_dir() {
return Err(ApiError::bad_request("root_path must point to a directory"));
}
Ok(path.to_path_buf())
}
use crate::index_jobs::{IndexJobResponse, RebuildRequest};
/// Trigger a scan/indexing job for a specific library
#[utoipa::path(
post,
path = "/libraries/{id}/scan",
tag = "libraries",
params(
("id" = String, Path, description = "Library UUID"),
),
request_body = Option<RebuildRequest>,
responses(
(status = 200, body = IndexJobResponse),
(status = 404, description = "Library not found"),
(status = 401, description = "Unauthorized"),
(status = 403, description = "Forbidden - Admin scope required"),
),
security(("Bearer" = []))
)]
pub async fn scan_library(
State(state): State<AppState>,
AxumPath(library_id): AxumPath<Uuid>,
payload: Option<Json<RebuildRequest>>,
) -> Result<Json<IndexJobResponse>, ApiError> {
// Verify library exists
let library_exists = sqlx::query("SELECT 1 FROM libraries WHERE id = $1")
.bind(library_id)
.fetch_optional(&state.pool)
.await?;
if library_exists.is_none() {
return Err(ApiError::not_found("library not found"));
}
let is_full = payload.as_ref().and_then(|p| p.full).unwrap_or(false);
let job_type = if is_full { "full_rebuild" } else { "rebuild" };
// Create indexing job for this library
let job_id = Uuid::new_v4();
sqlx::query(
"INSERT INTO index_jobs (id, library_id, type, status) VALUES ($1, $2, $3, 'pending')",
)
.bind(job_id)
.bind(library_id)
.bind(job_type)
.execute(&state.pool)
.await?;
let row = sqlx::query(
"SELECT id, library_id, type, status, started_at, finished_at, stats_json, error_opt, created_at FROM index_jobs WHERE id = $1",
)
.bind(job_id)
.fetch_one(&state.pool)
.await?;
Ok(Json(crate::index_jobs::map_row(row)))
}
#[derive(Deserialize, ToSchema)]
pub struct UpdateMonitoringRequest {
pub monitor_enabled: bool,
#[schema(value_type = String, example = "hourly")]
pub scan_mode: String, // 'manual', 'hourly', 'daily', 'weekly'
pub watcher_enabled: Option<bool>,
}
/// Update monitoring settings for a library
#[utoipa::path(
patch,
path = "/libraries/{id}/monitoring",
tag = "libraries",
params(
("id" = String, Path, description = "Library UUID"),
),
request_body = UpdateMonitoringRequest,
responses(
(status = 200, body = LibraryResponse),
(status = 404, description = "Library not found"),
(status = 401, description = "Unauthorized"),
(status = 403, description = "Forbidden - Admin scope required"),
),
security(("Bearer" = []))
)]
pub async fn update_monitoring(
State(state): State<AppState>,
AxumPath(library_id): AxumPath<Uuid>,
Json(input): Json<UpdateMonitoringRequest>,
) -> Result<Json<LibraryResponse>, ApiError> {
// Validate scan_mode
let valid_modes = ["manual", "hourly", "daily", "weekly"];
if !valid_modes.contains(&input.scan_mode.as_str()) {
return Err(ApiError::bad_request("scan_mode must be one of: manual, hourly, daily, weekly"));
}
// Calculate next_scan_at if monitoring is enabled
let next_scan_at = if input.monitor_enabled {
let interval_minutes = match input.scan_mode.as_str() {
"hourly" => 60,
"daily" => 1440,
"weekly" => 10080,
_ => 1440,
};
Some(chrono::Utc::now() + chrono::Duration::minutes(interval_minutes))
} else {
None
};
let watcher_enabled = input.watcher_enabled.unwrap_or(false);
let result = sqlx::query(
"UPDATE libraries SET monitor_enabled = $2, scan_mode = $3, next_scan_at = $4, watcher_enabled = $5 WHERE id = $1 RETURNING id, name, root_path, enabled, monitor_enabled, scan_mode, next_scan_at, watcher_enabled"
)
.bind(library_id)
.bind(input.monitor_enabled)
.bind(input.scan_mode)
.bind(next_scan_at)
.bind(watcher_enabled)
.fetch_optional(&state.pool)
.await?;
let Some(row) = result else {
return Err(ApiError::not_found("library not found"));
};
let book_count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM books WHERE library_id = $1")
.bind(library_id)
.fetch_one(&state.pool)
.await?;
Ok(Json(LibraryResponse {
id: row.get("id"),
name: row.get("name"),
root_path: row.get("root_path"),
enabled: row.get("enabled"),
book_count,
monitor_enabled: row.get("monitor_enabled"),
scan_mode: row.get("scan_mode"),
next_scan_at: row.get("next_scan_at"),
watcher_enabled: row.get("watcher_enabled"),
}))
}