refactor: migrer tout le code Rust vers series_id (table series)

API (15 fichiers):
- series.rs: helpers resolve_series_id/get_or_create_series, toutes les
  queries migrent de books.series TEXT vers series_id FK + JOIN series
- Routes /series/:name → /series/:series_id (UUID)
- books.rs: filtres série par series_id, SELECT s.name AS series via JOIN
- metadata.rs: sync écrit dans series au lieu de series_metadata
- metadata_refresh.rs: refresh_link et rematch via series_id
- metadata_batch.rs: sync via series table
- anilist.rs: liens par series_id au lieu de series_name
- download_detection.rs: available_downloads via series_id
- reading_progress.rs: mark_series_read par series_id
- torrent_import.rs: import via series JOIN
- search.rs, stats.rs, libraries.rs: JOINs series pour les noms
- reading_status_match.rs, reading_status_push.rs: séries via JOIN

Indexer (3 fichiers):
- scanner.rs: get_or_create_series_id() avec cache HashMap
- batch.rs: BookInsert/BookUpdate.series_id UUID au lieu de series String
- job.rs: rematch_unlinked_books via series JOIN

4 nouveaux tests (SeriesItem, SeriesMetadata, UpdateSeriesResponse,
BatchStructs avec series_id)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-29 21:13:11 +02:00
parent f2a7db939f
commit 292e9bc77f
18 changed files with 675 additions and 443 deletions

View File

@@ -141,15 +141,15 @@ pub async fn list_books(
// Conditions partagées COUNT et DATA — $1=library_id $2=kind $3=format, puis optionnels
let mut p: usize = 3;
let series_cond = match query.series.as_deref() {
Some("unclassified") => "AND (b.series IS NULL OR b.series = '')".to_string(),
Some(_) => { p += 1; format!("AND b.series = ${p}") }
Some("unclassified") => "AND b.series_id IS NULL".to_string(),
Some(_) => { p += 1; format!("AND b.series_id = ${p}") }
None => String::new(),
};
let rs_cond = if reading_statuses.is_some() {
p += 1; format!("AND COALESCE(brp.status, 'unread') = ANY(${p})")
} else { String::new() };
let author_cond = if query.author.is_some() {
p += 1; format!("AND (${p} = ANY(COALESCE(NULLIF(b.authors, '{{}}'), CASE WHEN b.author IS NOT NULL AND b.author != '' THEN ARRAY[b.author] ELSE ARRAY[]::text[] END)) OR EXISTS (SELECT 1 FROM series_metadata sm WHERE sm.library_id = b.library_id AND sm.name = b.series AND ${p} = ANY(sm.authors)))")
p += 1; format!("AND (${p} = ANY(COALESCE(NULLIF(b.authors, '{{}}'), CASE WHEN b.author IS NOT NULL AND b.author != '' THEN ARRAY[b.author] ELSE ARRAY[]::text[] END)) OR (s.id IS NOT NULL AND ${p} = ANY(COALESCE(s.authors, ARRAY[]::text[]))))")
} else { String::new() };
let metadata_cond = match query.metadata_provider.as_deref() {
Some("unlinked") => "AND eml.id IS NULL".to_string(),
@@ -158,25 +158,26 @@ pub async fn list_books(
None => String::new(),
};
let q_cond = if query.q.is_some() {
p += 1; format!("AND (b.title ILIKE ${p} OR b.series ILIKE ${p} OR b.author ILIKE ${p})")
p += 1; format!("AND (b.title ILIKE ${p} OR s.name ILIKE ${p} OR b.author ILIKE ${p})")
} else { String::new() };
p += 1;
let uid_p = p;
let metadata_links_cte = r#"
metadata_links AS (
SELECT DISTINCT ON (eml.series_name, eml.library_id)
eml.series_name, eml.library_id, eml.provider, eml.id
SELECT DISTINCT ON (eml.series_id, eml.library_id)
eml.series_id, eml.library_id, eml.provider, eml.id
FROM external_metadata_links eml
WHERE eml.status = 'approved'
ORDER BY eml.series_name, eml.library_id, eml.created_at DESC
ORDER BY eml.series_id, eml.library_id, eml.created_at DESC
)"#;
let count_sql = format!(
r#"WITH {metadata_links_cte}
SELECT COUNT(*) FROM books b
LEFT JOIN series s ON s.id = b.series_id
LEFT JOIN book_reading_progress brp ON brp.book_id = b.id AND ${uid_p}::uuid IS NOT NULL AND brp.user_id = ${uid_p}
LEFT JOIN metadata_links eml ON eml.series_name = b.series AND eml.library_id = b.library_id
LEFT JOIN metadata_links eml ON eml.series_id = b.series_id AND eml.library_id = b.library_id
WHERE ($1::uuid IS NULL OR b.library_id = $1)
AND ($2::text IS NULL OR b.kind = $2)
AND ($3::text IS NULL OR b.format = $3)
@@ -199,13 +200,14 @@ pub async fn list_books(
let data_sql = format!(
r#"
WITH {metadata_links_cte}
SELECT b.id, b.library_id, b.kind, b.format, b.title, b.author, b.authors, b.series, b.volume, b.language, b.page_count, b.thumbnail_path, b.updated_at,
SELECT b.id, b.library_id, b.kind, b.format, b.title, b.author, b.authors, s.name AS series, b.volume, b.language, b.page_count, b.thumbnail_path, b.updated_at,
COALESCE(brp.status, 'unread') AS reading_status,
brp.current_page AS reading_current_page,
brp.last_read_at AS reading_last_read_at
FROM books b
LEFT JOIN series s ON s.id = b.series_id
LEFT JOIN book_reading_progress brp ON brp.book_id = b.id AND ${uid_p}::uuid IS NOT NULL AND brp.user_id = ${uid_p}
LEFT JOIN metadata_links eml ON eml.series_name = b.series AND eml.library_id = b.library_id
LEFT JOIN metadata_links eml ON eml.series_id = b.series_id AND eml.library_id = b.library_id
WHERE ($1::uuid IS NULL OR b.library_id = $1)
AND ($2::text IS NULL OR b.kind = $2)
AND ($3::text IS NULL OR b.format = $3)
@@ -230,8 +232,9 @@ pub async fn list_books(
if let Some(s) = query.series.as_deref() {
if s != "unclassified" {
count_builder = count_builder.bind(s);
data_builder = data_builder.bind(s);
let series_uuid: Uuid = s.parse().map_err(|_| ApiError::bad_request("invalid series id"))?;
count_builder = count_builder.bind(series_uuid);
data_builder = data_builder.bind(series_uuid);
}
}
if let Some(ref statuses) = reading_statuses {
@@ -318,12 +321,13 @@ pub async fn get_book(
let user_id: Option<uuid::Uuid> = user.map(|u| u.0.user_id);
let row = sqlx::query(
r#"
SELECT b.id, b.library_id, b.kind, b.title, b.author, b.authors, b.series, b.volume, b.language, b.page_count, b.thumbnail_path, b.locked_fields, b.summary, b.isbn, b.publish_date,
SELECT b.id, b.library_id, b.kind, b.title, b.author, b.authors, s.name AS series, b.volume, b.language, b.page_count, b.thumbnail_path, b.locked_fields, b.summary, b.isbn, b.publish_date,
bf.abs_path, bf.format, bf.parse_status,
COALESCE(brp.status, 'unread') AS reading_status,
brp.current_page AS reading_current_page,
brp.last_read_at AS reading_last_read_at
FROM books b
LEFT JOIN series s ON s.id = b.series_id
LEFT JOIN LATERAL (
SELECT abs_path, format, parse_status
FROM book_files
@@ -519,13 +523,39 @@ pub async fn update_book(
let isbn = body.isbn.as_deref().map(str::trim).filter(|s| !s.is_empty()).map(str::to_string);
let publish_date = body.publish_date.as_deref().map(str::trim).filter(|s| !s.is_empty()).map(str::to_string);
let locked_fields = body.locked_fields.clone().unwrap_or(serde_json::json!({}));
// Resolve series name to series_id
let series_id: Option<Uuid> = if let Some(ref s) = series {
// Look up existing series or create one
let book_row = sqlx::query("SELECT library_id FROM books WHERE id = $1")
.bind(id)
.fetch_optional(&state.pool)
.await?
.ok_or_else(|| ApiError::not_found("book not found"))?;
let lib_id: Uuid = book_row.get("library_id");
let sid: Uuid = sqlx::query_scalar(
r#"
INSERT INTO series (id, library_id, name, created_at, updated_at)
VALUES (gen_random_uuid(), $1, $2, NOW(), NOW())
ON CONFLICT (library_id, name) DO UPDATE SET updated_at = NOW()
RETURNING id
"#,
)
.bind(lib_id)
.bind(s)
.fetch_one(&state.pool)
.await?;
Some(sid)
} else {
None
};
let row = sqlx::query(
r#"
UPDATE books
SET title = $2, author = $3, authors = $4, series = $5, volume = $6, language = $7,
SET title = $2, author = $3, authors = $4, series_id = $5, volume = $6, language = $7,
summary = $8, isbn = $9, publish_date = $10, locked_fields = $11, updated_at = NOW()
WHERE id = $1
RETURNING id, library_id, kind, title, author, authors, series, volume, language, page_count, thumbnail_path,
RETURNING id, library_id, kind, title, author, authors, volume, language, page_count, thumbnail_path,
summary, isbn, publish_date,
'unread' AS reading_status,
NULL::integer AS reading_current_page,
@@ -536,7 +566,7 @@ pub async fn update_book(
.bind(&title)
.bind(&author)
.bind(&authors)
.bind(&series)
.bind(series_id)
.bind(body.volume)
.bind(&language)
.bind(&summary)
@@ -556,7 +586,7 @@ pub async fn update_book(
title: row.get("title"),
author: row.get("author"),
authors: row.get::<Vec<String>, _>("authors"),
series: row.get("series"),
series: series.clone(),
volume: row.get("volume"),
language: row.get("language"),
page_count: row.get("page_count"),