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
.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<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(
State(state): State<AppState>,
Extension(user): Extension<AuthUser>,
Extension(_user): Extension<AuthUser>,
Path((library_id, name)): Path<(Uuid, String)>,
) -> Result<Json<crate::responses::DeletedResponse>, ApiError> {
use stripstream_core::paths::remap_libraries_path;