diff --git a/src/db/book_store.rs b/src/db/book_store.rs index 662ef3e..3e4ff16 100644 --- a/src/db/book_store.rs +++ b/src/db/book_store.rs @@ -631,6 +631,23 @@ impl BookStore { Ok(row.map(|r| r.get::("id"))) } + /// Point an existing book's `source_uuid` at `uuid`. Used to reconcile a row + /// that was first stored under a different identifier (an old-plugin upload + /// keyed on the EPUB's OPF uuid, say) to the Calibre library uuid the plugin + /// pre-check queries, so the book skips on the next run instead of always + /// re-uploading. Identity-only: it deliberately does not bump `last_modified` + /// (no device re-sync). + pub async fn set_source_uuid(&self, book_id: i64, uuid: &str) -> Result<()> { + sqlx::query("UPDATE books SET source_uuid = ? WHERE id = ?") + .bind(uuid) + .bind(book_id) + .execute(&self.pool) + .await + .change_context(Error::Database) + .attach("Failed to update source_uuid")?; + Ok(()) + } + /// Every source UUID this owner has already uploaded. Lets the Calibre /// plugin pre-check a batch of books and skip re-sending the ones already /// present, rather than POSTing each full file only to receive a 409. The diff --git a/src/upload/handler.rs b/src/upload/handler.rs index 2a36eb0..8301d3e 100644 --- a/src/upload/handler.rs +++ b/src/upload/handler.rs @@ -124,7 +124,9 @@ pub async fn api_upload( )); } - // Dedupe by content hash for this owner. + // Dedupe by content hash for this owner (fast path for byte-identical + // re-sends). Reconcile the matched row's source_uuid to the plugin's + // declared uuid so the pre-check can skip it next time. let hash = sha256_hex(&bytes); if let Some(existing) = state .books @@ -132,6 +134,13 @@ pub async fn api_upload( .await .map_err(internal)? { + if let Some(decl) = declared_uuid.as_deref() { + state + .books + .set_source_uuid(existing, decl) + .await + .map_err(internal)?; + } return Err(( StatusCode::CONFLICT, format!("This book was already uploaded (id {existing})"), @@ -154,24 +163,38 @@ pub async fn api_upload( (StatusCode::BAD_REQUEST, format!("Invalid EPUB: {e}")) })?; - // Stable dedup identifier: the plugin's declared `book_uuid` if present, - // else the EPUB's own OPF identifier. Calibre re-exports the same book with - // different bytes each send (re-zip, refreshed metadata), so the content - // hash above misses re-uploads; a stable UUID does not. - let source_uuid = declared_uuid.or_else(|| parsed.meta.book_uuid.clone()); - if let Some(src_uuid) = source_uuid.as_deref() { + // Stable-UUID dedup. Calibre re-exports the same book with different bytes + // each send (re-zip, refreshed metadata), so the content hash above misses + // re-uploads; a stable UUID does not. Match on the plugin's declared uuid or + // the EPUB's own OPF uuid, so a book first stored under either identifier is + // still recognized, and reconcile the matched row to the declared uuid (the + // value the pre-check queries) so it skips next time. + let opf_uuid = parsed.meta.book_uuid.clone(); + for candidate in [declared_uuid.as_deref(), opf_uuid.as_deref()] + .into_iter() + .flatten() + { if let Some(existing) = state .books - .find_by_source_uuid(user.id, src_uuid) + .find_by_source_uuid(user.id, candidate) .await .map_err(internal)? { + if let Some(decl) = declared_uuid.as_deref() { + state + .books + .set_source_uuid(existing, decl) + .await + .map_err(internal)?; + } return Err(( StatusCode::CONFLICT, format!("This book was already uploaded (id {existing})"), )); } } + // Store the declared (canonical) uuid when present, else the OPF uuid. + let source_uuid = declared_uuid.or(opf_uuid); // Persist the file under upload_dir/{uuid}/ before responding. The heavy, // multi-second work (online enrichment, cover transcode, kepubify) is