feat: unifier la recherche livres via le endpoint /books avec paramètre q

La recherche utilise désormais le endpoint paginé /books avec un filtre
ILIKE sur title/series/author, ce qui permet la pagination des résultats.
Les series_hits sont toujours récupérés en parallèle via searchBooks.
Corrige aussi le remount du LiveSearchForm lors de la navigation.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-28 13:23:51 +01:00
parent aa1a501adf
commit 03e4fce5f9
4 changed files with 44 additions and 51 deletions

View File

@@ -9,6 +9,9 @@ use crate::{auth::AuthUser, error::ApiError, index_jobs::IndexJobResponse, state
#[derive(Deserialize, ToSchema)] #[derive(Deserialize, ToSchema)]
pub struct ListBooksQuery { pub struct ListBooksQuery {
/// Text search on title, series and author (case-insensitive, partial match)
#[schema(value_type = Option<String>, example = "dragon")]
pub q: Option<String>,
#[schema(value_type = Option<String>)] #[schema(value_type = Option<String>)]
pub library_id: Option<Uuid>, pub library_id: Option<Uuid>,
#[schema(value_type = Option<String>)] #[schema(value_type = Option<String>)]
@@ -104,6 +107,7 @@ pub struct BookDetails {
path = "/books", path = "/books",
tag = "books", tag = "books",
params( params(
("q" = Option<String>, Query, description = "Text search on title, series and author (case-insensitive, partial match)"),
("library_id" = Option<String>, Query, description = "Filter by library ID"), ("library_id" = Option<String>, Query, description = "Filter by library ID"),
("kind" = Option<String>, Query, description = "Filter by book kind (cbz, cbr, pdf, epub)"), ("kind" = Option<String>, Query, description = "Filter by book kind (cbz, cbr, pdf, epub)"),
("series" = Option<String>, Query, description = "Filter by series name (use 'unclassified' for books without series)"), ("series" = Option<String>, Query, description = "Filter by series name (use 'unclassified' for books without series)"),
@@ -153,6 +157,9 @@ pub async fn list_books(
Some(_) => { p += 1; format!("AND eml.provider = ${p}") }, Some(_) => { p += 1; format!("AND eml.provider = ${p}") },
None => String::new(), 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})")
} else { String::new() };
p += 1; p += 1;
let uid_p = p; let uid_p = p;
@@ -176,7 +183,8 @@ pub async fn list_books(
{series_cond} {series_cond}
{rs_cond} {rs_cond}
{author_cond} {author_cond}
{metadata_cond}"# {metadata_cond}
{q_cond}"#
); );
let order_clause = if query.sort.as_deref() == Some("latest") { let order_clause = if query.sort.as_deref() == Some("latest") {
@@ -205,6 +213,7 @@ pub async fn list_books(
{rs_cond} {rs_cond}
{author_cond} {author_cond}
{metadata_cond} {metadata_cond}
{q_cond}
ORDER BY {order_clause} ORDER BY {order_clause}
LIMIT ${limit_p} OFFSET ${offset_p} LIMIT ${limit_p} OFFSET ${offset_p}
"# "#
@@ -239,6 +248,11 @@ pub async fn list_books(
data_builder = data_builder.bind(mp.clone()); data_builder = data_builder.bind(mp.clone());
} }
} }
if let Some(ref q) = query.q {
let pattern = format!("%{q}%");
count_builder = count_builder.bind(pattern.clone());
data_builder = data_builder.bind(pattern);
}
count_builder = count_builder.bind(user_id); count_builder = count_builder.bind(user_id);
data_builder = data_builder.bind(user_id).bind(limit).bind(offset); data_builder = data_builder.bind(user_id).bind(limit).bind(offset);

View File

@@ -28,53 +28,27 @@ export default async function BooksPage({
fetchLibraries().catch(() => [] as LibraryDto[]) fetchLibraries().catch(() => [] as LibraryDto[])
]); ]);
let books: BookDto[] = [];
let total = 0;
let searchResults: BookDto[] | null = null;
let seriesHits: SeriesHitDto[] = []; let seriesHits: SeriesHitDto[] = [];
let totalHits: number | null = null;
if (searchQuery) { const [booksPage, searchResponse] = await Promise.all([
const searchResponse = await searchBooks(searchQuery, libraryId, limit).catch(() => null); fetchBooks(libraryId, undefined, page, limit, readingStatus, sort, undefined, format, metadataProvider, searchQuery || undefined).catch(() => ({
if (searchResponse) {
seriesHits = searchResponse.series_hits ?? [];
searchResults = searchResponse.hits.map(hit => ({
id: hit.id,
library_id: hit.library_id,
kind: hit.kind,
title: hit.title,
author: hit.authors?.[0] ?? null,
authors: hit.authors ?? [],
series: hit.series,
volume: hit.volume,
language: hit.language,
page_count: null,
format: null,
file_path: null,
file_format: null,
file_parse_status: null,
updated_at: "",
reading_status: "unread" as const,
reading_current_page: null,
reading_last_read_at: null,
summary: null,
isbn: null,
publish_date: null,
}));
totalHits = searchResponse.estimated_total_hits;
}
} else {
const booksPage = await fetchBooks(libraryId, undefined, page, limit, readingStatus, sort, undefined, format, metadataProvider).catch(() => ({
items: [] as BookDto[], items: [] as BookDto[],
total: 0, total: 0,
page: 1, page: 1,
limit, limit,
})); })),
books = booksPage.items; searchQuery
total = booksPage.total; ? searchBooks(searchQuery, libraryId, limit).catch(() => null)
: Promise.resolve(null),
]);
const books = booksPage.items;
const total = booksPage.total;
if (searchResponse) {
seriesHits = searchResponse.series_hits ?? [];
} }
const displayBooks = (searchResults || books).map(book => ({ const displayBooks = books.map(book => ({
...book, ...book,
coverUrl: getBookCoverUrl(book.id) coverUrl: getBookCoverUrl(book.id)
})); }));
@@ -142,11 +116,11 @@ export default async function BooksPage({
</Card> </Card>
{/* Résultats */} {/* Résultats */}
{searchQuery && totalHits !== null ? ( {searchQuery ? (
<p className="text-sm text-muted-foreground mb-4"> <p className="text-sm text-muted-foreground mb-4">
{t("books.resultCountFor", { count: String(totalHits), plural: totalHits !== 1 ? "s" : "", query: searchQuery })} {t("books.resultCountFor", { count: String(total), plural: total !== 1 ? "s" : "", query: searchQuery })}
</p> </p>
) : !searchQuery && ( ) : (
<p className="text-sm text-muted-foreground mb-4"> <p className="text-sm text-muted-foreground mb-4">
{t("books.resultCount", { count: String(total), plural: total !== 1 ? "s" : "" })} {t("books.resultCount", { count: String(total), plural: total !== 1 ? "s" : "" })}
</p> </p>
@@ -194,14 +168,12 @@ export default async function BooksPage({
{searchQuery && <h2 className="text-lg font-semibold text-foreground mb-3">{t("books.title")}</h2>} {searchQuery && <h2 className="text-lg font-semibold text-foreground mb-3">{t("books.title")}</h2>}
<BooksGrid books={displayBooks} /> <BooksGrid books={displayBooks} />
{!searchQuery && ( <OffsetPagination
<OffsetPagination currentPage={page}
currentPage={page} totalPages={totalPages}
totalPages={totalPages} pageSize={limit}
pageSize={limit} totalItems={total}
totalItems={total} />
/>
)}
</> </>
) : ( ) : (
<EmptyState message={searchQuery ? t("books.noResults", { query: searchQuery }) : t("books.noBooks")} /> <EmptyState message={searchQuery ? t("books.noResults", { query: searchQuery }) : t("books.noBooks")} />

View File

@@ -118,8 +118,13 @@ export function LiveSearchForm({ fields, basePath, debounceMs = 300 }: LiveSearc
const textFields = fields.filter((f) => f.type === "text"); const textFields = fields.filter((f) => f.type === "text");
const selectFields = fields.filter((f) => f.type === "select"); const selectFields = fields.filter((f) => f.type === "select");
// Force remount when URL params change externally (back/forward, cookie redirect)
// so that defaultValue stays in sync with the URL.
const formKey = searchParams.toString();
return ( return (
<form <form
key={formKey}
ref={formRef} ref={formRef}
onSubmit={(e) => { onSubmit={(e) => {
e.preventDefault(); e.preventDefault();

View File

@@ -430,8 +430,10 @@ export async function fetchBooks(
author?: string, author?: string,
format?: string, format?: string,
metadataProvider?: string, metadataProvider?: string,
q?: string,
): Promise<BooksPageDto> { ): Promise<BooksPageDto> {
const params = new URLSearchParams(); const params = new URLSearchParams();
if (q) params.set("q", q);
if (libraryId) params.set("library_id", libraryId); if (libraryId) params.set("library_id", libraryId);
if (series) params.set("series", series); if (series) params.set("series", series);
if (readingStatus) params.set("reading_status", readingStatus); if (readingStatus) params.set("reading_status", readingStatus);