From 64347edabcdd7e1ef358aba7ab15e2a8b6fc1f36 Mon Sep 17 00:00:00 2001 From: Froidefond Julien Date: Wed, 11 Mar 2026 11:45:03 +0100 Subject: [PATCH] =?UTF-8?q?fix:=20thumbnails=20manquants=20dans=20les=20r?= =?UTF-8?q?=C3=A9sultats=20de=20recherche?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - meili.rs: corrige la désérialisation de la réponse paginée de Meilisearch (attendait Vec, l'API retourne {results:[...]}) — la suppression des documents obsolètes ne s'exécutait jamais, laissant d'anciens UUIDs qui généraient des 404 sur les thumbnails - books.rs: fallback sur render_book_page_1 si le fichier thumbnail n'est plus accessible sur le disque (au lieu de 500) - pages.rs: retourne 404 au lieu de 500 quand le fichier CBZ est absent - search.rs + api.ts + BookCard: ajout série hits, statut lecture, pagination OFFSET, filtre reading_status, et placeholder onError Co-Authored-By: Claude Sonnet 4.6 --- apps/api/src/books.rs | 11 +- apps/api/src/openapi.rs | 1 + apps/api/src/pages.rs | 8 +- apps/api/src/search.rs | 122 ++++++++++++++++---- apps/backoffice/app/books/page.tsx | 42 ++++++- apps/backoffice/app/components/BookCard.tsx | 16 ++- apps/backoffice/lib/api.ts | 9 ++ apps/indexer/src/meili.rs | 9 +- 8 files changed, 185 insertions(+), 33 deletions(-) diff --git a/apps/api/src/books.rs b/apps/api/src/books.rs index e4838ce..76d1572 100644 --- a/apps/api/src/books.rs +++ b/apps/api/src/books.rs @@ -605,10 +605,15 @@ pub async fn get_thumbnail( let thumbnail_path: Option = row.get("thumbnail_path"); let data = if let Some(ref path) = thumbnail_path { - std::fs::read(path) - .map_err(|e| ApiError::internal(format!("cannot read thumbnail: {}", e)))? + match std::fs::read(path) { + Ok(bytes) => bytes, + Err(_) => { + // File missing on disk (e.g. different mount in dev) — fall back to live render + crate::pages::render_book_page_1(&state, book_id, 300, 80).await? + } + } } else { - // Fallback: render page 1 on the fly (same as pages logic) + // No stored thumbnail yet — render page 1 on the fly crate::pages::render_book_page_1(&state, book_id, 300, 80).await? }; diff --git a/apps/api/src/openapi.rs b/apps/api/src/openapi.rs index 6ec7115..c10133c 100644 --- a/apps/api/src/openapi.rs +++ b/apps/api/src/openapi.rs @@ -51,6 +51,7 @@ use utoipa::OpenApi; crate::pages::PageQuery, crate::search::SearchQuery, crate::search::SearchResponse, + crate::search::SeriesHit, crate::index_jobs::RebuildRequest, crate::thumbnails::ThumbnailsRebuildRequest, crate::index_jobs::IndexJobResponse, diff --git a/apps/api/src/pages.rs b/apps/api/src/pages.rs index 3df4310..d048e66 100644 --- a/apps/api/src/pages.rs +++ b/apps/api/src/pages.rs @@ -365,8 +365,12 @@ fn render_page( fn extract_cbz_page(abs_path: &str, page_number: u32) -> Result, ApiError> { debug!("Opening CBZ archive: {}", abs_path); let file = std::fs::File::open(abs_path).map_err(|e| { - error!("Cannot open CBZ file {}: {}", abs_path, e); - ApiError::internal(format!("cannot open cbz: {e}")) + if e.kind() == std::io::ErrorKind::NotFound { + ApiError::not_found("book file not accessible") + } else { + error!("Cannot open CBZ file {}: {}", abs_path, e); + ApiError::internal(format!("cannot open cbz: {e}")) + } })?; let mut archive = zip::ZipArchive::new(file).map_err(|e| { diff --git a/apps/api/src/search.rs b/apps/api/src/search.rs index 59d7122..2fe4c30 100644 --- a/apps/api/src/search.rs +++ b/apps/api/src/search.rs @@ -1,6 +1,8 @@ use axum::{extract::{Query, State}, Json}; use serde::{Deserialize, Serialize}; +use sqlx::Row; use utoipa::ToSchema; +use uuid::Uuid; use crate::{error::ApiError, state::AppState}; @@ -18,9 +20,21 @@ pub struct SearchQuery { pub limit: Option, } +#[derive(Serialize, ToSchema)] +pub struct SeriesHit { + #[schema(value_type = String)] + pub library_id: Uuid, + pub name: String, + pub book_count: i64, + pub books_read_count: i64, + #[schema(value_type = String)] + pub first_book_id: Uuid, +} + #[derive(Serialize, ToSchema)] pub struct SearchResponse { pub hits: serde_json::Value, + pub series_hits: Vec, pub estimated_total_hits: Option, pub processing_time_ms: Option, } @@ -31,11 +45,11 @@ pub struct SearchResponse { path = "/search", tag = "books", params( - ("q" = String, Query, description = "Search query"), + ("q" = String, Query, description = "Search query (books via Meilisearch + series via ILIKE)"), ("library_id" = Option, Query, description = "Filter by library ID"), ("type" = Option, Query, description = "Filter by type (cbz, cbr, pdf)"), ("kind" = Option, Query, description = "Filter by kind (alias for type)"), - ("limit" = Option, Query, description = "Max results (max 100)"), + ("limit" = Option, Query, description = "Max results per type (max 100)"), ), responses( (status = 200, body = SearchResponse), @@ -66,36 +80,98 @@ pub async fn search_books( "filter": if filters.is_empty() { serde_json::Value::Null } else { serde_json::Value::String(filters.join(" AND ")) } }); + let limit_val = query.limit.unwrap_or(20).clamp(1, 100); + let q_pattern = format!("%{}%", query.q); + let library_id_uuid: Option = query.library_id.as_deref() + .and_then(|s| s.parse().ok()); + + // Recherche Meilisearch (books) + séries PG en parallèle let client = reqwest::Client::new(); let url = format!("{}/indexes/books/search", state.meili_url.trim_end_matches('/')); - let response = client - .post(url) + let meili_fut = client + .post(&url) .header("Authorization", format!("Bearer {}", state.meili_master_key)) .json(&body) - .send() - .await - .map_err(|e| ApiError::internal(format!("meili request failed: {e}")))?; + .send(); - if !response.status().is_success() { - let body = response.text().await.unwrap_or_else(|_| "unknown meili error".to_string()); + let series_sql = r#" + WITH sorted_books AS ( + SELECT + library_id, + COALESCE(NULLIF(series, ''), 'unclassified') as name, + id, + ROW_NUMBER() OVER ( + PARTITION BY library_id, COALESCE(NULLIF(series, ''), 'unclassified') + ORDER BY + REGEXP_REPLACE(LOWER(title), '[0-9]+', '', 'g'), + COALESCE((REGEXP_MATCH(LOWER(title), '\d+'))[1]::int, 0), + title ASC + ) as rn + FROM books + WHERE ($1::uuid IS NULL OR library_id = $1) + ), + series_counts AS ( + SELECT + sb.library_id, + sb.name, + COUNT(*) as book_count, + COUNT(brp.book_id) FILTER (WHERE brp.status = 'read') as books_read_count + FROM sorted_books sb + LEFT JOIN book_reading_progress brp ON brp.book_id = sb.id + GROUP BY sb.library_id, sb.name + ) + SELECT sc.library_id, sc.name, sc.book_count, sc.books_read_count, sb.id as first_book_id + FROM series_counts sc + JOIN sorted_books sb ON sb.library_id = sc.library_id AND sb.name = sc.name AND sb.rn = 1 + WHERE sc.name ILIKE $2 + ORDER BY sc.name ASC + LIMIT $3 + "#; + + let series_fut = sqlx::query(series_sql) + .bind(library_id_uuid) + .bind(&q_pattern) + .bind(limit_val as i64) + .fetch_all(&state.pool); + + let (meili_resp, series_rows) = tokio::join!(meili_fut, series_fut); + + // Traitement Meilisearch + let meili_resp = meili_resp.map_err(|e| ApiError::internal(format!("meili request failed: {e}")))?; + let (hits, estimated_total_hits, processing_time_ms) = if !meili_resp.status().is_success() { + let body = meili_resp.text().await.unwrap_or_default(); if body.contains("index_not_found") { - return Ok(Json(SearchResponse { - hits: serde_json::json!([]), - estimated_total_hits: Some(0), - processing_time_ms: Some(0), - })); + (serde_json::json!([]), Some(0u64), Some(0u64)) + } else { + return Err(ApiError::internal(format!("meili error: {body}"))); } - return Err(ApiError::internal(format!("meili error: {body}"))); - } + } else { + let payload: serde_json::Value = meili_resp.json().await + .map_err(|e| ApiError::internal(format!("invalid meili response: {e}")))?; + ( + payload.get("hits").cloned().unwrap_or_else(|| serde_json::json!([])), + payload.get("estimatedTotalHits").and_then(|v| v.as_u64()), + payload.get("processingTimeMs").and_then(|v| v.as_u64()), + ) + }; - let payload: serde_json::Value = response - .json() - .await - .map_err(|e| ApiError::internal(format!("invalid meili response: {e}")))?; + // Traitement séries + let series_hits: Vec = series_rows + .unwrap_or_default() + .iter() + .map(|row| SeriesHit { + library_id: row.get("library_id"), + name: row.get("name"), + book_count: row.get("book_count"), + books_read_count: row.get("books_read_count"), + first_book_id: row.get("first_book_id"), + }) + .collect(); Ok(Json(SearchResponse { - hits: payload.get("hits").cloned().unwrap_or_else(|| serde_json::json!([])), - estimated_total_hits: payload.get("estimatedTotalHits").and_then(|v| v.as_u64()), - processing_time_ms: payload.get("processingTimeMs").and_then(|v| v.as_u64()), + hits, + series_hits, + estimated_total_hits, + processing_time_ms, })) } diff --git a/apps/backoffice/app/books/page.tsx b/apps/backoffice/app/books/page.tsx index fa39219..c90bd4f 100644 --- a/apps/backoffice/app/books/page.tsx +++ b/apps/backoffice/app/books/page.tsx @@ -1,7 +1,8 @@ -import { fetchBooks, searchBooks, fetchLibraries, BookDto, LibraryDto, getBookCoverUrl } from "../../lib/api"; +import { fetchBooks, searchBooks, fetchLibraries, BookDto, LibraryDto, SeriesHitDto, getBookCoverUrl } from "../../lib/api"; import { BooksGrid, EmptyState } from "../components/BookCard"; import { Card, CardContent, Button, FormField, FormInput, FormSelect, FormRow, OffsetPagination } from "../components/ui"; import Link from "next/link"; +import Image from "next/image"; export const dynamic = "force-dynamic"; @@ -23,11 +24,13 @@ export default async function BooksPage({ let books: BookDto[] = []; let total = 0; let searchResults: BookDto[] | null = null; + let seriesHits: SeriesHitDto[] = []; let totalHits: number | null = null; if (searchQuery) { const searchResponse = await searchBooks(searchQuery, libraryId, limit).catch(() => null); if (searchResponse) { + seriesHits = searchResponse.series_hits ?? []; searchResults = searchResponse.hits.map(hit => ({ id: hit.id, library_id: hit.library_id, @@ -139,9 +142,46 @@ export default async function BooksPage({

)} + {/* Séries matchantes */} + {seriesHits.length > 0 && ( +
+

Series

+
+ {seriesHits.map((s) => ( + +
+
+ {`Cover +
+
+

+ {s.name === "unclassified" ? "Unclassified" : s.name} +

+

+ {s.book_count} book{s.book_count !== 1 ? 's' : ''} +

+
+
+ + ))} +
+
+ )} + {/* Grille de livres */} {displayBooks.length > 0 ? ( <> + {searchQuery &&

Books

} {!searchQuery && ( diff --git a/apps/backoffice/app/components/BookCard.tsx b/apps/backoffice/app/components/BookCard.tsx index 54b5462..cee2419 100644 --- a/apps/backoffice/app/components/BookCard.tsx +++ b/apps/backoffice/app/components/BookCard.tsx @@ -18,16 +18,27 @@ interface BookCardProps { function BookImage({ src, alt }: { src: string; alt: string }) { const [isLoaded, setIsLoaded] = useState(false); + const [hasError, setHasError] = useState(false); + + if (hasError) { + return ( +
+ + + +
+ ); + } return (
{/* Skeleton */} -
- + {/* Image */} setIsLoaded(true)} + onError={() => setHasError(true)} unoptimized />
diff --git a/apps/backoffice/lib/api.ts b/apps/backoffice/lib/api.ts index 8003872..eb66d27 100644 --- a/apps/backoffice/lib/api.ts +++ b/apps/backoffice/lib/api.ts @@ -91,8 +91,17 @@ export type SearchHitDto = { language: string | null; }; +export type SeriesHitDto = { + library_id: string; + name: string; + book_count: number; + books_read_count: number; + first_book_id: string; +}; + export type SearchResponseDto = { hits: SearchHitDto[]; + series_hits: SeriesHitDto[]; estimated_total_hits: number | null; processing_time_ms: number | null; }; diff --git a/apps/indexer/src/meili.rs b/apps/indexer/src/meili.rs index 8e293be..1b780dd 100644 --- a/apps/indexer/src/meili.rs +++ b/apps/indexer/src/meili.rs @@ -145,8 +145,13 @@ pub async fn sync_meili(pool: &PgPool, meili_url: &str, meili_master_key: &str) if let Ok(response) = meili_response { if response.status().is_success() { - if let Ok(meili_docs) = response.json::>().await { - let meili_ids: std::collections::HashSet = meili_docs + // Meilisearch returns { "results": [...], "offset": ..., "total": ... } + if let Ok(payload) = response.json::().await { + let docs = payload.get("results") + .and_then(|v| v.as_array()) + .cloned() + .unwrap_or_default(); + let meili_ids: std::collections::HashSet = docs .into_iter() .filter_map(|doc| doc.get("id").and_then(|id| id.as_str()).map(|s| s.to_string())) .collect();