fix: re-matching automatique des métadonnées externes après scan/import

Quand les métadonnées externes sont récupérées avant que les livres
n'existent localement, le book_id reste NULL et les livres apparaissent
comme "manquants" alors qu'ils sont présents.

- Ajoute rematch_unlinked_books() qui associe les external_book_metadata
  non liées aux livres locaux par correspondance de volume
- Appelé automatiquement à la fin de refresh_link() (API)
- Appelé après chaque scan terminé dans l'indexer (job.rs)
- Testé sur Dragon Ball : 47/85 external books rematched, 43 restants
  correspondent aux tomes Dragon Ball Z non présents localement

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-29 17:48:58 +02:00
parent 0cce4e50a7
commit 0c7685215b
3 changed files with 86 additions and 1 deletions

View File

@@ -643,6 +643,10 @@ pub(crate) async fn refresh_link(
.await .await
.map_err(|e| e.to_string())?; .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(); let has_changes = !series_changes.is_empty() || !book_changes.is_empty();
Ok(SeriesRefreshResult { Ok(SeriesRefreshResult {
@@ -947,3 +951,40 @@ async fn sync_book_with_diff(
Ok(diffs) 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<i64, String> {
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)
}

View File

@@ -1096,7 +1096,7 @@ pub async fn update_series(
)] )]
pub async fn delete_series( pub async fn delete_series(
State(state): State<AppState>, State(state): State<AppState>,
Extension(user): Extension<AuthUser>, Extension(_user): Extension<AuthUser>,
Path((library_id, name)): Path<(Uuid, String)>, Path((library_id, name)): Path<(Uuid, String)>,
) -> Result<Json<crate::responses::DeletedResponse>, ApiError> { ) -> Result<Json<crate::responses::DeletedResponse>, ApiError> {
use stripstream_core::paths::remap_libraries_path; use stripstream_core::paths::remap_libraries_path;

View File

@@ -5,6 +5,44 @@ use uuid::Uuid;
use crate::{analyzer, converter, scanner, AppState}; 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<()> { pub async fn cleanup_stale_jobs(pool: &PgPool) -> Result<()> {
let result = sqlx::query( let result = sqlx::query(
r#" r#"
@@ -379,6 +417,12 @@ pub async fn process_job(
analyzer::analyze_library_books(state, job_id, target_library_id, false).await?; 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( sqlx::query(
"UPDATE index_jobs SET status = 'success', finished_at = NOW(), progress_percent = 100, current_file = NULL WHERE id = $1", "UPDATE index_jobs SET status = 'success', finished_at = NOW(), progress_percent = 100, current_file = NULL WHERE id = $1",
) )