feat(books): ajouter le champ format en base et l'exposer dans l'API

- Migration 0020 : colonne format sur books, backfill depuis book_files
- batch.rs / scanner.rs : l'indexer écrit le format dans books
- books.rs : format dans BookItem + filtre ?format= dans list_books
- perf_pages.sh : benchmarks par format CBZ/CBR/PDF

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-13 08:55:18 +01:00
parent 85e0945c9d
commit 5db2a7501b
5 changed files with 236 additions and 13 deletions

View File

@@ -13,6 +13,8 @@ pub struct ListBooksQuery {
pub library_id: Option<Uuid>,
#[schema(value_type = Option<String>)]
pub kind: Option<String>,
#[schema(value_type = Option<String>, example = "cbz")]
pub format: Option<String>,
#[schema(value_type = Option<String>)]
pub series: Option<String>,
#[schema(value_type = Option<String>, example = "unread,reading")]
@@ -30,6 +32,7 @@ pub struct BookItem {
#[schema(value_type = String)]
pub library_id: Uuid,
pub kind: String,
pub format: Option<String>,
pub title: String,
pub author: Option<String>,
pub series: Option<String>,
@@ -110,8 +113,8 @@ pub async fn list_books(
s.split(',').map(|v| v.trim().to_string()).filter(|v| !v.is_empty()).collect()
});
// Conditions partagées COUNT et DATA — $1=library_id $2=kind, puis optionnels
let mut p: usize = 2;
// 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}") }
@@ -126,6 +129,7 @@ pub async fn list_books(
LEFT JOIN book_reading_progress brp ON brp.book_id = b.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)
{series_cond}
{rs_cond}"#
);
@@ -135,7 +139,7 @@ pub async fn list_books(
let offset_p = p + 2;
let data_sql = format!(
r#"
SELECT b.id, b.library_id, b.kind, b.title, b.author, 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.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
@@ -143,6 +147,7 @@ pub async fn list_books(
LEFT JOIN book_reading_progress brp ON brp.book_id = b.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)
{series_cond}
{rs_cond}
ORDER BY
@@ -158,10 +163,12 @@ pub async fn list_books(
let mut count_builder = sqlx::query(&count_sql)
.bind(query.library_id)
.bind(query.kind.as_deref());
.bind(query.kind.as_deref())
.bind(query.format.as_deref());
let mut data_builder = sqlx::query(&data_sql)
.bind(query.library_id)
.bind(query.kind.as_deref());
.bind(query.kind.as_deref())
.bind(query.format.as_deref());
if let Some(s) = query.series.as_deref() {
if s != "unclassified" {
@@ -190,6 +197,7 @@ pub async fn list_books(
id: row.get("id"),
library_id: row.get("library_id"),
kind: row.get("kind"),
format: row.get("format"),
title: row.get("title"),
author: row.get("author"),
series: row.get("series"),

View File

@@ -8,6 +8,7 @@ pub struct BookUpdate {
pub book_id: Uuid,
pub title: String,
pub kind: String,
pub format: String,
pub series: Option<String>,
pub volume: Option<i32>,
pub page_count: Option<i32>,
@@ -25,6 +26,7 @@ pub struct BookInsert {
pub book_id: Uuid,
pub library_id: Uuid,
pub kind: String,
pub format: String,
pub title: String,
pub series: Option<String>,
pub volume: Option<i32>,
@@ -70,22 +72,24 @@ pub async fn flush_all_batches(
let book_ids: Vec<Uuid> = books_update.iter().map(|b| b.book_id).collect();
let titles: Vec<String> = books_update.iter().map(|b| b.title.clone()).collect();
let kinds: Vec<String> = books_update.iter().map(|b| b.kind.clone()).collect();
let formats: Vec<String> = books_update.iter().map(|b| b.format.clone()).collect();
let series: Vec<Option<String>> = books_update.iter().map(|b| b.series.clone()).collect();
let volumes: Vec<Option<i32>> = books_update.iter().map(|b| b.volume).collect();
let page_counts: Vec<Option<i32>> = books_update.iter().map(|b| b.page_count).collect();
sqlx::query(
r#"
UPDATE books SET
UPDATE books SET
title = data.title,
kind = data.kind,
format = data.format,
series = data.series,
volume = data.volume,
page_count = data.page_count,
updated_at = NOW()
FROM (
SELECT * FROM UNNEST($1::uuid[], $2::text[], $3::text[], $4::text[], $5::int[], $6::int[])
AS t(book_id, title, kind, series, volume, page_count)
SELECT * FROM UNNEST($1::uuid[], $2::text[], $3::text[], $4::text[], $5::text[], $6::int[], $7::int[])
AS t(book_id, title, kind, format, series, volume, page_count)
) AS data
WHERE books.id = data.book_id
"#
@@ -93,6 +97,7 @@ pub async fn flush_all_batches(
.bind(&book_ids)
.bind(&titles)
.bind(&kinds)
.bind(&formats)
.bind(&series)
.bind(&volumes)
.bind(&page_counts)
@@ -143,22 +148,24 @@ pub async fn flush_all_batches(
let book_ids: Vec<Uuid> = books_insert.iter().map(|b| b.book_id).collect();
let library_ids: Vec<Uuid> = books_insert.iter().map(|b| b.library_id).collect();
let kinds: Vec<String> = books_insert.iter().map(|b| b.kind.clone()).collect();
let formats: Vec<String> = books_insert.iter().map(|b| b.format.clone()).collect();
let titles: Vec<String> = books_insert.iter().map(|b| b.title.clone()).collect();
let series: Vec<Option<String>> = books_insert.iter().map(|b| b.series.clone()).collect();
let volumes: Vec<Option<i32>> = books_insert.iter().map(|b| b.volume).collect();
let page_counts: Vec<Option<i32>> = books_insert.iter().map(|b| b.page_count).collect();
let thumbnail_paths: Vec<Option<String>> = books_insert.iter().map(|b| b.thumbnail_path.clone()).collect();
sqlx::query(
r#"
INSERT INTO books (id, library_id, kind, title, series, volume, page_count, thumbnail_path)
SELECT * FROM UNNEST($1::uuid[], $2::uuid[], $3::text[], $4::text[], $5::text[], $6::int[], $7::int[], $8::text[])
AS t(id, library_id, kind, title, series, volume, page_count, thumbnail_path)
INSERT INTO books (id, library_id, kind, format, title, series, volume, page_count, thumbnail_path)
SELECT * FROM UNNEST($1::uuid[], $2::uuid[], $3::text[], $4::text[], $5::text[], $6::text[], $7::int[], $8::int[], $9::text[])
AS t(id, library_id, kind, format, title, series, volume, page_count, thumbnail_path)
"#
)
.bind(&book_ids)
.bind(&library_ids)
.bind(&kinds)
.bind(&formats)
.bind(&titles)
.bind(&series)
.bind(&volumes)

View File

@@ -281,6 +281,7 @@ pub async fn scan_library_discovery(
book_id,
title: parsed.title,
kind: utils::kind_from_format(format).to_string(),
format: format.as_str().to_string(),
series: parsed.series,
volume: parsed.volume,
// Reset page_count so analyzer re-processes this book
@@ -335,6 +336,7 @@ pub async fn scan_library_discovery(
book_id,
library_id,
kind: utils::kind_from_format(format).to_string(),
format: format.as_str().to_string(),
title: parsed.title,
series: parsed.series,
volume: parsed.volume,