fix: use sort-order position as fallback volume for book matching

When books have no volume number, use their 1-based position in the
backoffice sort order (volume ASC NULLS LAST, natural title sort) as
effective volume for matching against external provider books.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-18 15:21:32 +01:00
parent 1a91c051b5
commit 00f4445924

View File

@@ -829,12 +829,17 @@ async fn sync_books_metadata(
let mut matched_count: i64 = 0; let mut matched_count: i64 = 0;
let mut book_reports: Vec<BookSyncReport> = Vec::new(); let mut book_reports: Vec<BookSyncReport> = Vec::new();
// Pre-fetch all local books for this series to enable flexible matching // Pre-fetch all local books for this series, sorted like the backoffice
// (volume ASC NULLS LAST, then natural title sort)
let local_books: Vec<(Uuid, Option<i32>, String)> = sqlx::query_as( let local_books: Vec<(Uuid, Option<i32>, String)> = sqlx::query_as(
r#" r#"
SELECT id, volume, title FROM books SELECT id, volume, title FROM books
WHERE library_id = $1 WHERE library_id = $1
AND COALESCE(NULLIF(series, ''), 'unclassified') = $2 AND COALESCE(NULLIF(series, ''), 'unclassified') = $2
ORDER BY volume NULLS LAST,
REGEXP_REPLACE(LOWER(title), '[0-9].*$', ''),
COALESCE((REGEXP_MATCH(LOWER(title), '\d+'))[1]::int, 0),
title ASC
"#, "#,
) )
.bind(library_id) .bind(library_id)
@@ -842,24 +847,30 @@ async fn sync_books_metadata(
.fetch_all(&state.pool) .fetch_all(&state.pool)
.await?; .await?;
// Build effective position for each local book: use volume if set, otherwise 1-based sort order
let local_books_with_pos: Vec<(Uuid, i32, String)> = local_books
.iter()
.enumerate()
.map(|(idx, (id, vol, title))| (*id, vol.unwrap_or((idx + 1) as i32), title.clone()))
.collect();
// Track which local books have already been matched to avoid double-matching // Track which local books have already been matched to avoid double-matching
let mut matched_local_ids = std::collections::HashSet::new(); let mut matched_local_ids = std::collections::HashSet::new();
for book in &books { for (ext_idx, book) in books.iter().enumerate() {
// Strategy 1: Match by volume number // Effective volume for the external book: provider volume_number, or 1-based position
let mut local_book_id: Option<Uuid> = if let Some(vol) = book.volume_number { let ext_vol = book.volume_number.unwrap_or((ext_idx + 1) as i32);
local_books
.iter() // Strategy 1: Match by effective volume number
.find(|(id, v, _)| *v == Some(vol) && !matched_local_ids.contains(id)) let mut local_book_id: Option<Uuid> = local_books_with_pos
.map(|(id, _, _)| *id) .iter()
} else { .find(|(id, v, _)| *v == ext_vol && !matched_local_ids.contains(id))
None .map(|(id, _, _)| *id);
};
// Strategy 2: External title contained in local title or vice-versa (case-insensitive) // Strategy 2: External title contained in local title or vice-versa (case-insensitive)
if local_book_id.is_none() { if local_book_id.is_none() {
let ext_title_lower = book.title.to_lowercase(); let ext_title_lower = book.title.to_lowercase();
local_book_id = local_books.iter().find(|(id, _, local_title)| { local_book_id = local_books_with_pos.iter().find(|(id, _, local_title)| {
if matched_local_ids.contains(id) { if matched_local_ids.contains(id) {
return false; return false;
} }