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) => (
+
+
+
+
+
+
+
+ {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();