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)] pub next_scan_at: Option>, pub watcher_enabled: bool, pub metadata_provider: Option, pub fallback_metadata_provider: Option, pub metadata_refresh_mode: String, #[schema(value_type = Option)] pub next_metadata_refresh_at: Option>, pub series_count: i64, /// First book IDs from up to 5 distinct series (for thumbnail fan display) #[schema(value_type = Vec)] pub thumbnail_book_ids: Vec, } #[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), (status = 401, description = "Unauthorized"), ), security(("Bearer" = [])) )] pub async fn list_libraries(State(state): State) -> Result>, 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, l.metadata_provider, l.fallback_metadata_provider, l.metadata_refresh_mode, l.next_metadata_refresh_at, (SELECT COUNT(*) FROM books b WHERE b.library_id = l.id) as book_count, (SELECT COUNT(DISTINCT COALESCE(NULLIF(b.series, ''), 'unclassified')) FROM books b WHERE b.library_id = l.id) as series_count, COALESCE(( SELECT ARRAY_AGG(first_id ORDER BY series_name) FROM ( SELECT DISTINCT ON (COALESCE(NULLIF(b.series, ''), 'unclassified')) COALESCE(NULLIF(b.series, ''), 'unclassified') as series_name, b.id as first_id FROM books b WHERE b.library_id = l.id ORDER BY COALESCE(NULLIF(b.series, ''), 'unclassified'), b.volume NULLS LAST, b.title ASC LIMIT 5 ) sub ), ARRAY[]::uuid[]) as thumbnail_book_ids 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"), series_count: row.get("series_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"), metadata_provider: row.get("metadata_provider"), fallback_metadata_provider: row.get("fallback_metadata_provider"), metadata_refresh_mode: row.get("metadata_refresh_mode"), next_metadata_refresh_at: row.get("next_metadata_refresh_at"), thumbnail_book_ids: row.get("thumbnail_book_ids"), }) .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, Json(input): Json, ) -> Result, 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, series_count: 0, monitor_enabled: false, scan_mode: "manual".to_string(), next_scan_at: None, watcher_enabled: false, metadata_provider: None, fallback_metadata_provider: None, metadata_refresh_mode: "manual".to_string(), next_metadata_refresh_at: None, thumbnail_book_ids: vec![], })) } /// 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, AxumPath(id): AxumPath, ) -> Result, 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 { 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, responses( (status = 200, body = IndexJobResponse), (status = 404, description = "Library not found"), (status = 401, description = "Unauthorized"), ), security(("Bearer" = [])) )] pub async fn scan_library( State(state): State, AxumPath(library_id): AxumPath, payload: Option>, ) -> Result, 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 is_rescan = payload.as_ref().and_then(|p| p.rescan).unwrap_or(false); let job_type = if is_full { "full_rebuild" } else if is_rescan { "rescan" } 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, #[schema(value_type = Option, example = "daily")] pub metadata_refresh_mode: Option, // 'manual', 'hourly', 'daily', 'weekly' } /// 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, AxumPath(library_id): AxumPath, Json(input): Json, ) -> Result, 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")); } // Validate metadata_refresh_mode let metadata_refresh_mode = input.metadata_refresh_mode.as_deref().unwrap_or("manual"); if !valid_modes.contains(&metadata_refresh_mode) { return Err(ApiError::bad_request("metadata_refresh_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 }; // Calculate next_metadata_refresh_at let next_metadata_refresh_at = if metadata_refresh_mode != "manual" { let interval_minutes = match metadata_refresh_mode { "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, metadata_refresh_mode = $6, next_metadata_refresh_at = $7 WHERE id = $1 RETURNING id, name, root_path, enabled, monitor_enabled, scan_mode, next_scan_at, watcher_enabled, metadata_provider, fallback_metadata_provider, metadata_refresh_mode, next_metadata_refresh_at" ) .bind(library_id) .bind(input.monitor_enabled) .bind(input.scan_mode) .bind(next_scan_at) .bind(watcher_enabled) .bind(metadata_refresh_mode) .bind(next_metadata_refresh_at) .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?; let series_count: i64 = sqlx::query_scalar("SELECT COUNT(DISTINCT COALESCE(NULLIF(series, ''), 'unclassified')) FROM books WHERE library_id = $1") .bind(library_id) .fetch_one(&state.pool) .await?; let thumbnail_book_ids: Vec = sqlx::query_scalar( "SELECT b.id FROM books b WHERE b.library_id = $1 ORDER BY COALESCE(NULLIF(b.series, ''), 'unclassified'), b.volume NULLS LAST, b.title ASC LIMIT 5" ) .bind(library_id) .fetch_all(&state.pool) .await .unwrap_or_default(); Ok(Json(LibraryResponse { id: row.get("id"), name: row.get("name"), root_path: row.get("root_path"), enabled: row.get("enabled"), book_count, series_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"), metadata_provider: row.get("metadata_provider"), fallback_metadata_provider: row.get("fallback_metadata_provider"), metadata_refresh_mode: row.get("metadata_refresh_mode"), next_metadata_refresh_at: row.get("next_metadata_refresh_at"), thumbnail_book_ids, })) } #[derive(Deserialize, ToSchema)] pub struct UpdateMetadataProviderRequest { pub metadata_provider: Option, pub fallback_metadata_provider: Option, } /// Update the metadata provider for a library #[utoipa::path( patch, path = "/libraries/{id}/metadata-provider", tag = "libraries", params( ("id" = String, Path, description = "Library UUID"), ), request_body = UpdateMetadataProviderRequest, 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_metadata_provider( State(state): State, AxumPath(library_id): AxumPath, Json(input): Json, ) -> Result, ApiError> { let provider = input.metadata_provider.as_deref().filter(|s| !s.is_empty()); let fallback = input.fallback_metadata_provider.as_deref().filter(|s| !s.is_empty()); let result = sqlx::query( "UPDATE libraries SET metadata_provider = $2, fallback_metadata_provider = $3 WHERE id = $1 RETURNING id, name, root_path, enabled, monitor_enabled, scan_mode, next_scan_at, watcher_enabled, metadata_provider, fallback_metadata_provider, metadata_refresh_mode, next_metadata_refresh_at" ) .bind(library_id) .bind(provider) .bind(fallback) .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?; let series_count: i64 = sqlx::query_scalar("SELECT COUNT(DISTINCT COALESCE(NULLIF(series, ''), 'unclassified')) FROM books WHERE library_id = $1") .bind(library_id) .fetch_one(&state.pool) .await?; let thumbnail_book_ids: Vec = sqlx::query_scalar( "SELECT b.id FROM books b WHERE b.library_id = $1 ORDER BY COALESCE(NULLIF(b.series, ''), 'unclassified'), b.volume NULLS LAST, b.title ASC LIMIT 5" ) .bind(library_id) .fetch_all(&state.pool) .await .unwrap_or_default(); Ok(Json(LibraryResponse { id: row.get("id"), name: row.get("name"), root_path: row.get("root_path"), enabled: row.get("enabled"), book_count, series_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"), metadata_provider: row.get("metadata_provider"), fallback_metadata_provider: row.get("fallback_metadata_provider"), metadata_refresh_mode: row.get("metadata_refresh_mode"), next_metadata_refresh_at: row.get("next_metadata_refresh_at"), thumbnail_book_ids, })) }