use axum::{extract::{Path, Query, State}, Json}; use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use sqlx::Row; use uuid::Uuid; use utoipa::ToSchema; use crate::{error::ApiError, index_jobs::IndexJobResponse, state::AppState}; #[derive(Deserialize, ToSchema)] pub struct ListBooksQuery { #[schema(value_type = Option)] pub library_id: Option, #[schema(value_type = Option)] pub kind: Option, #[schema(value_type = Option, example = "cbz")] pub format: Option, #[schema(value_type = Option)] pub series: Option, #[schema(value_type = Option, example = "unread,reading")] pub reading_status: Option, /// Filter by exact author name (matches in authors array or scalar author field) #[schema(value_type = Option)] pub author: Option, #[schema(value_type = Option, example = 1)] pub page: Option, #[schema(value_type = Option, example = 50)] pub limit: Option, /// Sort order: "title" (default) or "latest" (most recently added first) #[schema(value_type = Option, example = "latest")] pub sort: Option, /// Filter by metadata provider: "linked" (any provider), "unlinked" (no provider), or a specific provider name #[schema(value_type = Option, example = "linked")] pub metadata_provider: Option, } #[derive(Serialize, ToSchema)] pub struct BookItem { #[schema(value_type = String)] pub id: Uuid, #[schema(value_type = String)] pub library_id: Uuid, pub kind: String, pub format: Option, pub title: String, pub author: Option, pub authors: Vec, pub series: Option, pub volume: Option, pub language: Option, pub page_count: Option, pub thumbnail_url: Option, #[schema(value_type = String)] pub updated_at: DateTime, /// Reading status: "unread", "reading", or "read" pub reading_status: String, pub reading_current_page: Option, #[schema(value_type = Option)] pub reading_last_read_at: Option>, } #[derive(Serialize, ToSchema)] pub struct BooksPage { pub items: Vec, pub total: i64, pub page: i64, pub limit: i64, } #[derive(Serialize, ToSchema)] pub struct BookDetails { #[schema(value_type = String)] pub id: Uuid, #[schema(value_type = String)] pub library_id: Uuid, pub kind: String, pub title: String, pub author: Option, pub authors: Vec, pub series: Option, pub volume: Option, pub language: Option, pub page_count: Option, pub thumbnail_url: Option, pub file_path: Option, pub file_format: Option, pub file_parse_status: Option, /// Reading status: "unread", "reading", or "read" pub reading_status: String, pub reading_current_page: Option, #[schema(value_type = Option)] pub reading_last_read_at: Option>, pub summary: Option, pub isbn: Option, pub publish_date: Option, /// Fields locked from external metadata sync #[serde(skip_serializing_if = "Option::is_none")] pub locked_fields: Option, } /// List books with optional filtering and pagination #[utoipa::path( get, path = "/books", tag = "books", params( ("library_id" = Option, Query, description = "Filter by library ID"), ("kind" = Option, Query, description = "Filter by book kind (cbz, cbr, pdf, epub)"), ("series" = Option, Query, description = "Filter by series name (use 'unclassified' for books without series)"), ("reading_status" = Option, Query, description = "Filter by reading status, comma-separated (e.g. 'unread,reading')"), ("page" = Option, Query, description = "Page number (1-indexed, default 1)"), ("limit" = Option, Query, description = "Items per page (max 200, default 50)"), ("sort" = Option, Query, description = "Sort order: 'title' (default) or 'latest' (most recently added first)"), ("metadata_provider" = Option, Query, description = "Filter by metadata provider: 'linked' (any provider), 'unlinked' (no provider), or a specific provider name"), ), responses( (status = 200, body = BooksPage), (status = 401, description = "Unauthorized"), ), security(("Bearer" = [])) )] pub async fn list_books( State(state): State, Query(query): Query, ) -> Result, ApiError> { let limit = query.limit.unwrap_or(50).clamp(1, 200); let page = query.page.unwrap_or(1).max(1); let offset = (page - 1) * limit; // Parse reading_status CSV → Vec let reading_statuses: Option> = query.reading_status.as_deref().map(|s| { s.split(',').map(|v| v.trim().to_string()).filter(|v| !v.is_empty()).collect() }); // Conditions partagées COUNT et DATA — $1=library_id $2=kind $3=format, puis optionnels let mut p: usize = 3; let series_cond = match query.series.as_deref() { Some("unclassified") => "AND (b.series IS NULL OR b.series = '')".to_string(), Some(_) => { p += 1; format!("AND b.series = ${p}") } None => String::new(), }; let rs_cond = if reading_statuses.is_some() { p += 1; format!("AND COALESCE(brp.status, 'unread') = ANY(${p})") } else { String::new() }; let author_cond = if query.author.is_some() { p += 1; format!("AND (${p} = ANY(COALESCE(NULLIF(b.authors, '{{}}'), CASE WHEN b.author IS NOT NULL AND b.author != '' THEN ARRAY[b.author] ELSE ARRAY[]::text[] END)))") } else { String::new() }; let metadata_cond = match query.metadata_provider.as_deref() { Some("unlinked") => "AND eml.id IS NULL".to_string(), Some("linked") => "AND eml.id IS NOT NULL".to_string(), Some(_) => { p += 1; format!("AND eml.provider = ${p}") }, None => String::new(), }; let metadata_links_cte = r#" metadata_links AS ( SELECT DISTINCT ON (eml.series_name, eml.library_id) eml.series_name, eml.library_id, eml.provider, eml.id FROM external_metadata_links eml WHERE eml.status = 'approved' ORDER BY eml.series_name, eml.library_id, eml.created_at DESC )"#; let count_sql = format!( r#"WITH {metadata_links_cte} SELECT COUNT(*) FROM books b LEFT JOIN book_reading_progress brp ON brp.book_id = b.id LEFT JOIN metadata_links eml ON eml.series_name = b.series AND eml.library_id = b.library_id WHERE ($1::uuid IS NULL OR b.library_id = $1) AND ($2::text IS NULL OR b.kind = $2) AND ($3::text IS NULL OR b.format = $3) {series_cond} {rs_cond} {author_cond} {metadata_cond}"# ); let order_clause = if query.sort.as_deref() == Some("latest") { "b.updated_at DESC".to_string() } else { "b.volume NULLS LAST, REGEXP_REPLACE(LOWER(b.title), '[0-9].*$', ''), COALESCE((REGEXP_MATCH(LOWER(b.title), '\\d+'))[1]::int, 0), b.title ASC".to_string() }; // DATA: mêmes params filtre, puis $N+1=limit $N+2=offset let limit_p = p + 1; let offset_p = p + 2; let data_sql = format!( r#" WITH {metadata_links_cte} SELECT b.id, b.library_id, b.kind, b.format, b.title, b.author, b.authors, b.series, b.volume, b.language, b.page_count, b.thumbnail_path, b.updated_at, COALESCE(brp.status, 'unread') AS reading_status, brp.current_page AS reading_current_page, brp.last_read_at AS reading_last_read_at FROM books b LEFT JOIN book_reading_progress brp ON brp.book_id = b.id LEFT JOIN metadata_links eml ON eml.series_name = b.series AND eml.library_id = b.library_id WHERE ($1::uuid IS NULL OR b.library_id = $1) AND ($2::text IS NULL OR b.kind = $2) AND ($3::text IS NULL OR b.format = $3) {series_cond} {rs_cond} {author_cond} {metadata_cond} ORDER BY {order_clause} LIMIT ${limit_p} OFFSET ${offset_p} "# ); let mut count_builder = sqlx::query(&count_sql) .bind(query.library_id) .bind(query.kind.as_deref()) .bind(query.format.as_deref()); let mut data_builder = sqlx::query(&data_sql) .bind(query.library_id) .bind(query.kind.as_deref()) .bind(query.format.as_deref()); if let Some(s) = query.series.as_deref() { if s != "unclassified" { count_builder = count_builder.bind(s); data_builder = data_builder.bind(s); } } if let Some(ref statuses) = reading_statuses { count_builder = count_builder.bind(statuses.clone()); data_builder = data_builder.bind(statuses.clone()); } if let Some(ref author) = query.author { count_builder = count_builder.bind(author.clone()); data_builder = data_builder.bind(author.clone()); } if let Some(ref mp) = query.metadata_provider { if mp != "linked" && mp != "unlinked" { count_builder = count_builder.bind(mp.clone()); data_builder = data_builder.bind(mp.clone()); } } data_builder = data_builder.bind(limit).bind(offset); let (count_row, rows) = tokio::try_join!( count_builder.fetch_one(&state.pool), data_builder.fetch_all(&state.pool), )?; let total: i64 = count_row.get(0); let mut items: Vec = rows .iter() .map(|row| { let thumbnail_path: Option = row.get("thumbnail_path"); BookItem { id: row.get("id"), library_id: row.get("library_id"), kind: row.get("kind"), format: row.get("format"), title: row.get("title"), author: row.get("author"), authors: row.get::, _>("authors"), series: row.get("series"), volume: row.get("volume"), language: row.get("language"), page_count: row.get("page_count"), thumbnail_url: thumbnail_path.map(|_p| format!("/books/{}/thumbnail", row.get::("id"))), updated_at: row.get("updated_at"), reading_status: row.get("reading_status"), reading_current_page: row.get("reading_current_page"), reading_last_read_at: row.get("reading_last_read_at"), } }) .collect(); Ok(Json(BooksPage { items: std::mem::take(&mut items), total, page, limit, })) } /// Get detailed information about a specific book #[utoipa::path( get, path = "/books/{id}", tag = "books", params( ("id" = String, Path, description = "Book UUID"), ), responses( (status = 200, body = BookDetails), (status = 404, description = "Book not found"), (status = 401, description = "Unauthorized"), ), security(("Bearer" = [])) )] pub async fn get_book( State(state): State, Path(id): Path, ) -> Result, ApiError> { let row = sqlx::query( r#" SELECT b.id, b.library_id, b.kind, b.title, b.author, b.authors, b.series, b.volume, b.language, b.page_count, b.thumbnail_path, b.locked_fields, b.summary, b.isbn, b.publish_date, bf.abs_path, bf.format, bf.parse_status, COALESCE(brp.status, 'unread') AS reading_status, brp.current_page AS reading_current_page, brp.last_read_at AS reading_last_read_at FROM books b LEFT JOIN LATERAL ( SELECT abs_path, format, parse_status FROM book_files WHERE book_id = b.id ORDER BY updated_at DESC LIMIT 1 ) bf ON TRUE LEFT JOIN book_reading_progress brp ON brp.book_id = b.id WHERE b.id = $1 "#, ) .bind(id) .fetch_optional(&state.pool) .await?; let row = row.ok_or_else(|| ApiError::not_found("book not found"))?; let thumbnail_path: Option = row.get("thumbnail_path"); Ok(Json(BookDetails { id: row.get("id"), library_id: row.get("library_id"), kind: row.get("kind"), title: row.get("title"), author: row.get("author"), authors: row.get::, _>("authors"), series: row.get("series"), volume: row.get("volume"), language: row.get("language"), page_count: row.get("page_count"), thumbnail_url: thumbnail_path.map(|_| format!("/books/{}/thumbnail", id)), file_path: row.get("abs_path"), file_format: row.get("format"), file_parse_status: row.get("parse_status"), reading_status: row.get("reading_status"), reading_current_page: row.get("reading_current_page"), reading_last_read_at: row.get("reading_last_read_at"), summary: row.get("summary"), isbn: row.get("isbn"), publish_date: row.get("publish_date"), locked_fields: Some(row.get::("locked_fields")), })) } // ─── Helpers ────────────────────────────────────────────────────────────────── pub(crate) fn remap_libraries_path(path: &str) -> String { if let Ok(root) = std::env::var("LIBRARIES_ROOT_PATH") { if path.starts_with("/libraries/") { return path.replacen("/libraries", &root, 1); } } path.to_string() } fn unmap_libraries_path(path: &str) -> String { if let Ok(root) = std::env::var("LIBRARIES_ROOT_PATH") { if path.starts_with(&root) { return path.replacen(&root, "/libraries", 1); } } path.to_string() } // ─── Convert CBR → CBZ ─────────────────────────────────────────────────────── /// Enqueue a CBR → CBZ conversion job for a single book #[utoipa::path( post, path = "/books/{id}/convert", tag = "books", params( ("id" = String, Path, description = "Book UUID"), ), responses( (status = 200, body = IndexJobResponse), (status = 404, description = "Book not found"), (status = 409, description = "Book is not CBR, or target CBZ already exists"), (status = 401, description = "Unauthorized"), (status = 403, description = "Forbidden - Admin scope required"), ), security(("Bearer" = [])) )] pub async fn convert_book( State(state): State, Path(book_id): Path, ) -> Result, ApiError> { // Fetch book file info let row = sqlx::query( r#" SELECT b.id, bf.abs_path, bf.format FROM books b LEFT JOIN LATERAL ( SELECT abs_path, format FROM book_files WHERE book_id = b.id ORDER BY updated_at DESC LIMIT 1 ) bf ON TRUE WHERE b.id = $1 "#, ) .bind(book_id) .fetch_optional(&state.pool) .await?; let row = row.ok_or_else(|| ApiError::not_found("book not found"))?; let abs_path: Option = row.get("abs_path"); let format: Option = row.get("format"); if format.as_deref() != Some("cbr") { return Err(ApiError { status: axum::http::StatusCode::CONFLICT, message: "book is not in CBR format".to_string(), }); } let abs_path = abs_path.ok_or_else(|| ApiError::not_found("book file path not found"))?; // Check for existing CBZ with same stem let physical_path = remap_libraries_path(&abs_path); let cbr_path = std::path::Path::new(&physical_path); if let (Some(parent), Some(stem)) = (cbr_path.parent(), cbr_path.file_stem()) { let cbz_path = parent.join(format!("{}.cbz", stem.to_string_lossy())); if cbz_path.exists() { return Err(ApiError { status: axum::http::StatusCode::CONFLICT, message: format!( "CBZ file already exists: {}", unmap_libraries_path(&cbz_path.to_string_lossy()) ), }); } } // Create the conversion job let job_id = Uuid::new_v4(); sqlx::query( "INSERT INTO index_jobs (id, book_id, type, status) VALUES ($1, $2, 'cbr_to_cbz', 'pending')", ) .bind(job_id) .bind(book_id) .execute(&state.pool) .await?; let job_row = sqlx::query( "SELECT id, library_id, book_id, type, status, started_at, finished_at, stats_json, error_opt, created_at, progress_percent, processed_files, total_files FROM index_jobs WHERE id = $1", ) .bind(job_id) .fetch_one(&state.pool) .await?; Ok(Json(crate::index_jobs::map_row(job_row))) } // ─── Metadata editing ───────────────────────────────────────────────────────── #[derive(Deserialize, ToSchema)] pub struct UpdateBookRequest { pub title: String, pub author: Option, #[serde(default)] pub authors: Vec, pub series: Option, pub volume: Option, pub language: Option, pub summary: Option, pub isbn: Option, pub publish_date: Option, /// Fields locked from external metadata sync #[serde(default)] pub locked_fields: Option, } /// Update metadata for a specific book #[utoipa::path( patch, path = "/books/{id}", tag = "books", params(("id" = String, Path, description = "Book UUID")), request_body = UpdateBookRequest, responses( (status = 200, body = BookDetails), (status = 400, description = "Invalid request"), (status = 404, description = "Book not found"), (status = 401, description = "Unauthorized"), (status = 403, description = "Forbidden - Admin scope required"), ), security(("Bearer" = [])) )] pub async fn update_book( State(state): State, Path(id): Path, Json(body): Json, ) -> Result, ApiError> { let title = body.title.trim().to_string(); if title.is_empty() { return Err(ApiError::bad_request("title cannot be empty")); } let author = body.author.as_deref().map(str::trim).filter(|s| !s.is_empty()).map(str::to_string); let authors: Vec = body.authors.iter() .map(|a| a.trim().to_string()) .filter(|a| !a.is_empty()) .collect(); let series = body.series.as_deref().map(str::trim).filter(|s| !s.is_empty()).map(str::to_string); let language = body.language.as_deref().map(str::trim).filter(|s| !s.is_empty()).map(str::to_string); let summary = body.summary.as_deref().map(str::trim).filter(|s| !s.is_empty()).map(str::to_string); let isbn = body.isbn.as_deref().map(str::trim).filter(|s| !s.is_empty()).map(str::to_string); let publish_date = body.publish_date.as_deref().map(str::trim).filter(|s| !s.is_empty()).map(str::to_string); let locked_fields = body.locked_fields.clone().unwrap_or(serde_json::json!({})); let row = sqlx::query( r#" UPDATE books SET title = $2, author = $3, authors = $4, series = $5, volume = $6, language = $7, summary = $8, isbn = $9, publish_date = $10, locked_fields = $11, updated_at = NOW() WHERE id = $1 RETURNING id, library_id, kind, title, author, authors, series, volume, language, page_count, thumbnail_path, summary, isbn, publish_date, COALESCE((SELECT status FROM book_reading_progress WHERE book_id = $1), 'unread') AS reading_status, (SELECT current_page FROM book_reading_progress WHERE book_id = $1) AS reading_current_page, (SELECT last_read_at FROM book_reading_progress WHERE book_id = $1) AS reading_last_read_at "#, ) .bind(id) .bind(&title) .bind(&author) .bind(&authors) .bind(&series) .bind(body.volume) .bind(&language) .bind(&summary) .bind(&isbn) .bind(&publish_date) .bind(&locked_fields) .fetch_optional(&state.pool) .await?; let row = row.ok_or_else(|| ApiError::not_found("book not found"))?; let thumbnail_path: Option = row.get("thumbnail_path"); Ok(Json(BookDetails { id: row.get("id"), library_id: row.get("library_id"), kind: row.get("kind"), title: row.get("title"), author: row.get("author"), authors: row.get::, _>("authors"), series: row.get("series"), volume: row.get("volume"), language: row.get("language"), page_count: row.get("page_count"), thumbnail_url: thumbnail_path.map(|_| format!("/books/{}/thumbnail", id)), file_path: None, file_format: None, file_parse_status: None, reading_status: row.get("reading_status"), reading_current_page: row.get("reading_current_page"), reading_last_read_at: row.get("reading_last_read_at"), summary: row.get("summary"), isbn: row.get("isbn"), publish_date: row.get("publish_date"), locked_fields: Some(locked_fields), })) } // ─── Thumbnail ──────────────────────────────────────────────────────────────── use axum::{ body::Body, http::{header, HeaderMap, HeaderValue, StatusCode}, response::IntoResponse, }; /// Detect content type from thumbnail file extension. fn detect_thumbnail_content_type(path: &str) -> &'static str { if path.ends_with(".jpg") || path.ends_with(".jpeg") { "image/jpeg" } else if path.ends_with(".png") { "image/png" } else { "image/webp" } } /// Get book thumbnail image #[utoipa::path( get, path = "/books/{id}/thumbnail", tag = "books", params( ("id" = String, Path, description = "Book UUID"), ), responses( (status = 200, description = "WebP thumbnail image", content_type = "image/webp"), (status = 404, description = "Book not found or thumbnail not available"), (status = 401, description = "Unauthorized"), ), security(("Bearer" = [])) )] pub async fn get_thumbnail( State(state): State, Path(book_id): Path, ) -> Result { let row = sqlx::query("SELECT thumbnail_path FROM books WHERE id = $1") .bind(book_id) .fetch_optional(&state.pool) .await .map_err(|e| ApiError::internal(e.to_string()))?; let row = row.ok_or_else(|| ApiError::not_found("book not found"))?; let thumbnail_path: Option = row.get("thumbnail_path"); let (data, content_type) = if let Some(ref path) = thumbnail_path { match std::fs::read(path) { Ok(bytes) => { let ct = detect_thumbnail_content_type(path); (bytes, ct) } Err(_) => { // File missing on disk (e.g. different mount in dev) — fall back to live render crate::pages::render_book_page_1(&state, book_id, 300, 80).await? } } } else { // No stored thumbnail yet — render page 1 on the fly crate::pages::render_book_page_1(&state, book_id, 300, 80).await? }; let mut headers = HeaderMap::new(); headers.insert(header::CONTENT_TYPE, HeaderValue::from_static(content_type)); headers.insert( header::CACHE_CONTROL, HeaderValue::from_static("public, max-age=31536000, immutable"), ); Ok((StatusCode::OK, headers, Body::from(data))) }