diff --git a/src/db/book_store.rs b/src/db/book_store.rs index 9fe70b7..5e39d3b 100644 --- a/src/db/book_store.rs +++ b/src/db/book_store.rs @@ -78,6 +78,28 @@ pub struct NewBook { pub last_modified: DateTime, } +/// The fields of an uploaded book that are settled after background +/// post-processing (online enrichment, cover transcode, kepubify conversion) +/// finishes. The row is inserted immediately with tentative values so the +/// upload request can return fast; [`BookStore::finalize_upload`] then rewrites +/// these columns once the slow work completes. +#[derive(Debug, Clone)] +pub struct UploadFinalize { + pub id: i64, + pub title: String, + pub authors: Vec, + pub publisher: Option, + pub language: Option, + pub description: Option, + pub series_name: Option, + pub series_index: Option, + pub pubdate: Option>, + pub file_format: String, + pub file_size: i64, + pub has_cover: bool, + pub last_modified: DateTime, +} + /// A book as shown in the web catalog UI. #[derive(Debug, Clone)] pub struct CatalogBook { @@ -405,6 +427,40 @@ impl BookStore { Ok(book.id) } + /// Settle an uploaded book's tentative columns after background + /// post-processing completes (enrichment, cover, kepubify). Only `upload` + /// rows are touched. + pub async fn finalize_upload(&self, f: &UploadFinalize) -> Result<()> { + let authors = serde_json::to_string(&f.authors).unwrap_or_else(|_| "[]".to_string()); + sqlx::query( + r#" + UPDATE books SET + title = ?, authors = ?, publisher = ?, language = ?, + description = ?, series_name = ?, series_index = ?, pubdate = ?, + file_format = ?, file_size = ?, has_cover = ?, last_modified = ? + WHERE id = ? AND source = 'upload' + "#, + ) + .bind(&f.title) + .bind(authors) + .bind(&f.publisher) + .bind(&f.language) + .bind(&f.description) + .bind(&f.series_name) + .bind(f.series_index) + .bind(f.pubdate.map(format_dt)) + .bind(&f.file_format) + .bind(f.file_size) + .bind(f.has_cover as i64) + .bind(format_dt(f.last_modified)) + .bind(f.id) + .execute(&self.pool) + .await + .change_context(Error::Database) + .attach_with(|| format!("Failed to finalize upload {}", f.id))?; + Ok(()) + } + /// Delete an uploaded book owned by `owner_user_id`. Returns the book's /// locator (its directory name under `upload_dir`) when a row was removed, /// so the caller can clean up the files. `None` if no matching owned upload. diff --git a/src/upload/handler.rs b/src/upload/handler.rs index f4736fd..8fefd64 100644 --- a/src/upload/handler.rs +++ b/src/upload/handler.rs @@ -1,3 +1,5 @@ +use std::path::PathBuf; + use axum::extract::{Multipart, Path, State}; use axum::http::StatusCode; use axum::Json; @@ -8,10 +10,10 @@ use tracing::error; use uuid::Uuid; use crate::auth::ApiUser; -use crate::db::book_store::{BookSource, NewBook}; +use crate::db::book_store::{BookSource, NewBook, UploadFinalize}; use crate::kobo::auth::AppState; use crate::upload::enrich::Enriched; -use crate::upload::epub::parse_epub; +use crate::upload::epub::{parse_epub, EpubMeta}; use crate::upload::{convert, cover, enrich}; /// EPUB files are zip archives; every one starts with this local-file signature. @@ -96,12 +98,13 @@ pub async fn api_upload( (StatusCode::BAD_REQUEST, format!("Invalid EPUB: {e}")) })?; - // Enrich metadata online (best-effort, config-gated). Fills only gaps and - // may supply a cover URL when the EPUB carried none. + // Persist the file under upload_dir/{uuid}/ before responding. The heavy, + // multi-second work (online enrichment, cover transcode, kepubify) is + // deferred to a background task so the upload request returns immediately — + // the Calibre plugin blocks its GUI thread on this response, so a slow reply + // freezes the app ("Application Not Responding"). let embedded_cover = parsed.cover; - let Enriched { meta, cover_url } = enrich::enrich(state.config.enrichment, parsed.meta).await; - - // Persist the file + cover under upload_dir/{uuid}/. + let meta = parsed.meta; let uuid = Uuid::new_v4().to_string(); let dir = state.config.upload_dir.join(&uuid); tokio::fs::create_dir_all(&dir).await.map_err(|e| { @@ -121,6 +124,87 @@ pub async fn api_upload( ) })?; + // Insert the catalog row with a freshly allocated upload id. The row starts + // as an EPUB with no cover; the background task settles the final format, + // size, cover, and any enriched metadata via `finalize_upload`. + let id = state.books.next_upload_id().await.map_err(internal)?; + // New uploads sync by default unless the server setting says otherwise. + let default_sync = state + .app_db + .get_bool_setting("sync_new_uploads", true) + .await + .map_err(internal)?; + let now = Utc::now(); + let title = meta + .title + .clone() + .filter(|t| !t.trim().is_empty()) + .unwrap_or_else(|| strip_extension(&filename)); + + let book = NewBook { + id, + uuid: uuid.clone(), + title: title.clone(), + authors: meta.authors.clone(), + publisher: meta.publisher.clone(), + language: meta.language.clone(), + description: meta.description.clone(), + series_name: meta.series_name.clone(), + series_index: meta.series_index, + pubdate: meta.pubdate, + file_format: "EPUB".to_string(), + file_size: bytes.len() as i64, + source: BookSource::Upload, + locator: uuid.clone(), + file_basename: "book".to_string(), + content_hash: Some(hash), + has_cover: false, + owner_user_id: Some(user.id), + default_sync, + timestamp: now, + last_modified: now, + }; + + if let Err(e) = state.books.insert_book(&book).await { + error!("Failed to insert uploaded book: {e:?}"); + // Best-effort cleanup of the orphaned files. + let _ = tokio::fs::remove_dir_all(&dir).await; + return Err(( + StatusCode::INTERNAL_SERVER_ERROR, + "Failed to save book".to_string(), + )); + } + + // Fire-and-forget the slow enhancement so the response returns now. + tokio::spawn(finalize_upload( + state, + id, + dir, + meta, + title.clone(), + embedded_cover, + )); + + Ok(Json(UploadedJson { id, uuid, title })) +} + +/// Background post-processing for a freshly stored upload: online enrichment, +/// cover resolution + transcode, and optional kepubify conversion. Runs after +/// the upload response has been sent; every step is best-effort, and the final +/// state is written back with [`BookStore::finalize_upload`]. On any failure the +/// book simply keeps its tentative EPUB/no-cover state (still fully usable). +async fn finalize_upload( + state: AppState, + id: i64, + dir: PathBuf, + meta: EpubMeta, + initial_title: String, + embedded_cover: Option>, +) { + // Enrich metadata online (best-effort, config-gated). Fills only gaps and + // may supply a cover URL when the EPUB carried none. + let Enriched { meta, cover_url } = enrich::enrich(state.config.enrichment, meta).await; + // Resolve cover bytes: embedded first, else fetched from enrichment. let raw_cover = match embedded_cover { Some(c) => Some(c), @@ -130,9 +214,12 @@ pub async fn api_upload( }, }; // Always transcode to JPEG so the stored cover.jpg is a real JPEG. + let cover_dir = dir.clone(); let has_cover = match raw_cover { Some(raw) => match tokio::task::spawn_blocking(move || cover::to_jpeg(&raw)).await { - Ok(Some(jpeg)) => tokio::fs::write(dir.join("cover.jpg"), &jpeg).await.is_ok(), + Ok(Some(jpeg)) => tokio::fs::write(cover_dir.join("cover.jpg"), &jpeg) + .await + .is_ok(), _ => false, }, None => false, @@ -140,43 +227,38 @@ pub async fn api_upload( // Convert to KEPUB for native per-page progress when kepubify is configured. // Soft dependency: on any failure we keep serving the EPUB. + let epub_path = dir.join("book.epub"); + let epub_size = tokio::fs::metadata(&epub_path) + .await + .map(|m| m.len() as i64) + .unwrap_or(0); let (file_format, file_size) = match &state.config.kepubify_path { - Some(bin) => match convert::kepubify(bin, &dir.join("book.epub"), &dir).await { + Some(bin) => match convert::kepubify(bin, &epub_path, &dir).await { Ok(Some(kepub)) => { let size = tokio::fs::metadata(&kepub) .await .map(|m| m.len() as i64) - .unwrap_or(bytes.len() as i64); + .unwrap_or(epub_size); ("KEPUB".to_string(), size) } - Ok(None) => ("EPUB".to_string(), bytes.len() as i64), + Ok(None) => ("EPUB".to_string(), epub_size), Err(e) => { error!("kepubify errored: {e:?}"); - ("EPUB".to_string(), bytes.len() as i64) + ("EPUB".to_string(), epub_size) } }, - None => ("EPUB".to_string(), bytes.len() as i64), + None => ("EPUB".to_string(), epub_size), }; - // Insert the catalog row with a freshly allocated upload id. - let id = state.books.next_upload_id().await.map_err(internal)?; - // New uploads sync by default unless the server setting says otherwise. - let default_sync = state - .app_db - .get_bool_setting("sync_new_uploads", true) - .await - .map_err(internal)?; - let now = Utc::now(); let title = meta .title .clone() .filter(|t| !t.trim().is_empty()) - .unwrap_or_else(|| strip_extension(&filename)); + .unwrap_or(initial_title); - let book = NewBook { + let finalized = UploadFinalize { id, - uuid: uuid.clone(), - title: title.clone(), + title, authors: meta.authors, publisher: meta.publisher, language: meta.language, @@ -186,28 +268,12 @@ pub async fn api_upload( pubdate: meta.pubdate, file_format, file_size, - source: BookSource::Upload, - locator: uuid.clone(), - file_basename: "book".to_string(), - content_hash: Some(hash), has_cover, - owner_user_id: Some(user.id), - default_sync, - timestamp: now, - last_modified: now, + last_modified: Utc::now(), }; - - if let Err(e) = state.books.insert_book(&book).await { - error!("Failed to insert uploaded book: {e:?}"); - // Best-effort cleanup of the orphaned files. - let _ = tokio::fs::remove_dir_all(&dir).await; - return Err(( - StatusCode::INTERNAL_SERVER_ERROR, - "Failed to save book".to_string(), - )); + if let Err(e) = state.books.finalize_upload(&finalized).await { + error!("Failed to finalize upload {id}: {e:?}"); } - - Ok(Json(UploadedJson { id, uuid, title })) } /// DELETE /api/upload/{id} — Remove an uploaded book owned by the current user.