diff --git a/apps/api/src/metadata_refresh.rs b/apps/api/src/metadata_refresh.rs index 8377db4..d995344 100644 --- a/apps/api/src/metadata_refresh.rs +++ b/apps/api/src/metadata_refresh.rs @@ -643,6 +643,10 @@ pub(crate) async fn refresh_link( .await .map_err(|e| e.to_string())?; + // Re-match any external books that couldn't be matched during insert + // (e.g., metadata was fetched before books were imported) + let _ = rematch_unlinked_books(pool, library_id).await; + let has_changes = !series_changes.is_empty() || !book_changes.is_empty(); Ok(SeriesRefreshResult { @@ -947,3 +951,40 @@ async fn sync_book_with_diff( Ok(diffs) } + +/// Re-match external_book_metadata rows that have book_id IS NULL +/// by joining on volume number with local books in the same series. +/// Called after scans/imports to fix metadata that was fetched before books existed. +pub async fn rematch_unlinked_books(pool: &PgPool, library_id: Uuid) -> Result { + let result = sqlx::query( + r#" + UPDATE external_book_metadata ebm + SET book_id = matched.book_id + FROM ( + SELECT DISTINCT ON (ebm2.id) + ebm2.id AS ebm_id, + b.id AS book_id + FROM external_book_metadata ebm2 + JOIN external_metadata_links eml ON eml.id = ebm2.link_id + JOIN books b ON b.library_id = eml.library_id + AND LOWER(COALESCE(NULLIF(b.series, ''), 'unclassified')) = LOWER(eml.series_name) + AND b.volume = ebm2.volume_number + WHERE eml.library_id = $1 + AND ebm2.book_id IS NULL + AND ebm2.volume_number IS NOT NULL + AND eml.status = 'approved' + ) matched + WHERE ebm.id = matched.ebm_id + "#, + ) + .bind(library_id) + .execute(pool) + .await + .map_err(|e| e.to_string())?; + + let count = result.rows_affected() as i64; + if count > 0 { + info!("[METADATA] Re-matched {count} unlinked external books for library {library_id}"); + } + Ok(count) +} diff --git a/apps/api/src/series.rs b/apps/api/src/series.rs index 0151aa1..83bcd68 100644 --- a/apps/api/src/series.rs +++ b/apps/api/src/series.rs @@ -1096,7 +1096,7 @@ pub async fn update_series( )] pub async fn delete_series( State(state): State, - Extension(user): Extension, + Extension(_user): Extension, Path((library_id, name)): Path<(Uuid, String)>, ) -> Result, ApiError> { use stripstream_core::paths::remap_libraries_path; diff --git a/apps/indexer/src/job.rs b/apps/indexer/src/job.rs index 20ed1a0..1dcce9e 100644 --- a/apps/indexer/src/job.rs +++ b/apps/indexer/src/job.rs @@ -5,6 +5,44 @@ use uuid::Uuid; use crate::{analyzer, converter, scanner, AppState}; +/// Re-match external_book_metadata with book_id IS NULL by volume number. +async fn rematch_unlinked_books(pool: &PgPool, library_id: Uuid) { + let result = sqlx::query( + r#" + UPDATE external_book_metadata ebm + SET book_id = matched.book_id + FROM ( + SELECT DISTINCT ON (ebm2.id) + ebm2.id AS ebm_id, + b.id AS book_id + FROM external_book_metadata ebm2 + JOIN external_metadata_links eml ON eml.id = ebm2.link_id + JOIN books b ON b.library_id = eml.library_id + AND LOWER(COALESCE(NULLIF(b.series, ''), 'unclassified')) = LOWER(eml.series_name) + AND b.volume = ebm2.volume_number + WHERE eml.library_id = $1 + AND ebm2.book_id IS NULL + AND ebm2.volume_number IS NOT NULL + AND eml.status = 'approved' + ) matched + WHERE ebm.id = matched.ebm_id + "#, + ) + .bind(library_id) + .execute(pool) + .await; + + match result { + Ok(r) if r.rows_affected() > 0 => { + info!("[METADATA] Re-matched {} unlinked external books for library {}", r.rows_affected(), library_id); + } + Err(e) => { + error!("[METADATA] Failed to rematch unlinked books: {e}"); + } + _ => {} + } +} + pub async fn cleanup_stale_jobs(pool: &PgPool) -> Result<()> { let result = sqlx::query( r#" @@ -379,6 +417,12 @@ pub async fn process_job( analyzer::analyze_library_books(state, job_id, target_library_id, false).await?; + // Re-match external book metadata that couldn't be linked during initial metadata fetch + // (e.g., metadata was fetched before books were scanned) + if let Some(lib_id) = target_library_id { + rematch_unlinked_books(&state.pool, lib_id).await; + } + sqlx::query( "UPDATE index_jobs SET status = 'success', finished_at = NOW(), progress_percent = 100, current_file = NULL WHERE id = $1", )