use std::path::Path; use std::sync::atomic::{AtomicI32, Ordering}; use std::sync::Arc; use anyhow::Context; use axum::{ extract::{Path as AxumPath, State}, http::StatusCode, Json, }; use futures::stream::{self, StreamExt}; use image::GenericImageView; use serde::Deserialize; use sqlx::Row; use tracing::{info, warn}; use uuid::Uuid; use utoipa::ToSchema; use crate::{error::ApiError, index_jobs, pages, AppState}; #[derive(Clone)] struct ThumbnailConfig { enabled: bool, width: u32, height: u32, quality: u8, directory: String, } async fn load_thumbnail_concurrency(pool: &sqlx::PgPool) -> usize { let default_concurrency = 4; let row = sqlx::query(r#"SELECT value FROM app_settings WHERE key = 'limits'"#) .fetch_optional(pool) .await; match row { Ok(Some(row)) => { let value: serde_json::Value = row.get("value"); value .get("concurrent_renders") .and_then(|v| v.as_u64()) .map(|v| v as usize) .unwrap_or(default_concurrency) } _ => default_concurrency, } } async fn load_thumbnail_config(pool: &sqlx::PgPool) -> ThumbnailConfig { let fallback = ThumbnailConfig { enabled: true, width: 300, height: 400, quality: 80, directory: "/data/thumbnails".to_string(), }; let row = sqlx::query(r#"SELECT value FROM app_settings WHERE key = 'thumbnail'"#) .fetch_optional(pool) .await; match row { Ok(Some(row)) => { let value: serde_json::Value = row.get("value"); ThumbnailConfig { enabled: value .get("enabled") .and_then(|v| v.as_bool()) .unwrap_or(fallback.enabled), width: value .get("width") .and_then(|v| v.as_u64()) .map(|v| v as u32) .unwrap_or(fallback.width), height: value .get("height") .and_then(|v| v.as_u64()) .map(|v| v as u32) .unwrap_or(fallback.height), quality: value .get("quality") .and_then(|v| v.as_u64()) .map(|v| v as u8) .unwrap_or(fallback.quality), directory: value .get("directory") .and_then(|v| v.as_str()) .map(|s| s.to_string()) .unwrap_or_else(|| fallback.directory.clone()), } } _ => fallback, } } fn generate_thumbnail(image_bytes: &[u8], config: &ThumbnailConfig) -> anyhow::Result> { let img = image::load_from_memory(image_bytes).context("failed to load image")?; let (orig_w, orig_h) = img.dimensions(); let ratio_w = config.width as f32 / orig_w as f32; let ratio_h = config.height as f32 / orig_h as f32; let ratio = ratio_w.min(ratio_h); let new_w = (orig_w as f32 * ratio) as u32; let new_h = (orig_h as f32 * ratio) as u32; let resized = img.resize(new_w, new_h, image::imageops::FilterType::Lanczos3); let rgba = resized.to_rgba8(); let (w, h) = rgba.dimensions(); let rgb_data: Vec = rgba.pixels().flat_map(|p| [p[0], p[1], p[2]]).collect(); let quality = f32::max(config.quality as f32, 85.0); let webp_data = webp::Encoder::new(&rgb_data, webp::PixelLayout::Rgb, w, h).encode(quality); Ok(webp_data.to_vec()) } fn save_thumbnail(book_id: Uuid, thumbnail_bytes: &[u8], config: &ThumbnailConfig) -> anyhow::Result { let dir = Path::new(&config.directory); std::fs::create_dir_all(dir)?; let filename = format!("{}.webp", book_id); let path = dir.join(&filename); std::fs::write(&path, thumbnail_bytes)?; Ok(path.to_string_lossy().to_string()) } async fn run_checkup(state: AppState, job_id: Uuid) { let pool = &state.pool; let row = sqlx::query("SELECT library_id, type FROM index_jobs WHERE id = $1") .bind(job_id) .fetch_optional(pool) .await; let (library_id, job_type) = match row { Ok(Some(r)) => ( r.get::, _>("library_id"), r.get::("type"), ), _ => { warn!("thumbnails checkup: job {} not found", job_id); return; } }; // Regenerate: clear existing thumbnails in scope so they get regenerated if job_type == "thumbnail_regenerate" { let cleared = sqlx::query( r#"UPDATE books SET thumbnail_path = NULL WHERE (library_id = $1 OR $1 IS NULL)"#, ) .bind(library_id) .execute(pool) .await; if let Ok(res) = cleared { info!("thumbnails regenerate: cleared {} books", res.rows_affected()); } } let book_ids: Vec = sqlx::query_scalar( r#"SELECT id FROM books WHERE (library_id = $1 OR $1 IS NULL) AND thumbnail_path IS NULL"#, ) .bind(library_id) .fetch_all(pool) .await .unwrap_or_default(); let config = load_thumbnail_config(pool).await; if !config.enabled || book_ids.is_empty() { let _ = sqlx::query( "UPDATE index_jobs SET status = 'success', finished_at = NOW(), progress_percent = 100, current_file = NULL WHERE id = $1", ) .bind(job_id) .execute(pool) .await; return; } let total = book_ids.len() as i32; let _ = sqlx::query( "UPDATE index_jobs SET status = 'generating_thumbnails', total_files = $2, processed_files = 0, current_file = NULL WHERE id = $1", ) .bind(job_id) .bind(total) .execute(pool) .await; let concurrency = load_thumbnail_concurrency(pool).await; let processed_count = Arc::new(AtomicI32::new(0)); let pool_clone = pool.clone(); let job_id_clone = job_id; let config_clone = config.clone(); let state_clone = state.clone(); stream::iter(book_ids) .for_each_concurrent(concurrency, |book_id| { let processed_count = processed_count.clone(); let pool = pool_clone.clone(); let job_id = job_id_clone; let config = config_clone.clone(); let state = state_clone.clone(); let total = total; async move { match pages::render_book_page_1(&state, book_id, config.width, config.quality).await { Ok(page_bytes) => { match generate_thumbnail(&page_bytes, &config) { Ok(thumb_bytes) => { if let Ok(path) = save_thumbnail(book_id, &thumb_bytes, &config) { if sqlx::query("UPDATE books SET thumbnail_path = $1 WHERE id = $2") .bind(&path) .bind(book_id) .execute(&pool) .await .is_ok() { let processed = processed_count.fetch_add(1, Ordering::Relaxed) + 1; let percent = (processed as f64 / total as f64 * 100.0) as i32; let _ = sqlx::query( "UPDATE index_jobs SET processed_files = $2, progress_percent = $3 WHERE id = $1", ) .bind(job_id) .bind(processed) .bind(percent) .execute(&pool) .await; } } } Err(e) => warn!("thumbnail generate failed for book {}: {:?}", book_id, e), } } Err(e) => warn!("render page 1 failed for book {}: {:?}", book_id, e), } } }) .await; let _ = sqlx::query( "UPDATE index_jobs SET status = 'success', finished_at = NOW(), progress_percent = 100, current_file = NULL WHERE id = $1", ) .bind(job_id) .execute(pool) .await; info!("thumbnails checkup finished for job {} ({} books)", job_id, total); } #[derive(Deserialize, ToSchema)] pub struct ThumbnailsRebuildRequest { #[schema(value_type = Option)] pub library_id: Option, } /// POST /index/thumbnails/rebuild — create a job and generate thumbnails for books that don't have one (optional library scope). #[utoipa::path( post, path = "/index/thumbnails/rebuild", tag = "indexing", request_body = Option, responses( (status = 200, body = index_jobs::IndexJobResponse), (status = 401, description = "Unauthorized"), (status = 403, description = "Forbidden - Admin scope required"), ), security(("Bearer" = [])) )] pub async fn start_thumbnails_rebuild( State(state): State, payload: Option>, ) -> Result, ApiError> { let library_id = payload.as_ref().and_then(|p| p.0.library_id); let job_id = Uuid::new_v4(); let row = sqlx::query( r#"INSERT INTO index_jobs (id, library_id, type, status) VALUES ($1, $2, 'thumbnail_rebuild', 'pending') RETURNING id, library_id, type, status, started_at, finished_at, stats_json, error_opt, created_at"#, ) .bind(job_id) .bind(library_id) .fetch_one(&state.pool) .await .map_err(|e| ApiError::internal(e.to_string()))?; Ok(Json(index_jobs::map_row(row))) } /// POST /index/thumbnails/regenerate — create a job and regenerate all thumbnails in scope (clears then regenerates). #[utoipa::path( post, path = "/index/thumbnails/regenerate", tag = "indexing", request_body = Option, responses( (status = 200, body = index_jobs::IndexJobResponse), (status = 401, description = "Unauthorized"), (status = 403, description = "Forbidden - Admin scope required"), ), security(("Bearer" = [])) )] pub async fn start_thumbnails_regenerate( State(state): State, payload: Option>, ) -> Result, ApiError> { let library_id = payload.as_ref().and_then(|p| p.0.library_id); let job_id = Uuid::new_v4(); let row = sqlx::query( r#"INSERT INTO index_jobs (id, library_id, type, status) VALUES ($1, $2, 'thumbnail_regenerate', 'pending') RETURNING id, library_id, type, status, started_at, finished_at, stats_json, error_opt, created_at"#, ) .bind(job_id) .bind(library_id) .fetch_one(&state.pool) .await .map_err(|e| ApiError::internal(e.to_string()))?; Ok(Json(index_jobs::map_row(row))) } /// POST /index/jobs/:id/thumbnails/checkup — start thumbnail generation for books missing thumbnails (called by indexer at end of build). pub async fn start_checkup( State(state): State, AxumPath(job_id): AxumPath, ) -> Result { let state = state.clone(); tokio::spawn(async move { run_checkup(state, job_id).await }); Ok(StatusCode::ACCEPTED) }