fix: thumbnails manquants dans les résultats de recherche
- meili.rs: corrige la désérialisation de la réponse paginée de
Meilisearch (attendait Vec<Value>, 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 <noreply@anthropic.com>
This commit is contained in:
@@ -605,10 +605,15 @@ pub async fn get_thumbnail(
|
|||||||
let thumbnail_path: Option<String> = row.get("thumbnail_path");
|
let thumbnail_path: Option<String> = row.get("thumbnail_path");
|
||||||
|
|
||||||
let data = if let Some(ref path) = thumbnail_path {
|
let data = if let Some(ref path) = thumbnail_path {
|
||||||
std::fs::read(path)
|
match std::fs::read(path) {
|
||||||
.map_err(|e| ApiError::internal(format!("cannot read thumbnail: {}", e)))?
|
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 {
|
} 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?
|
crate::pages::render_book_page_1(&state, book_id, 300, 80).await?
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ use utoipa::OpenApi;
|
|||||||
crate::pages::PageQuery,
|
crate::pages::PageQuery,
|
||||||
crate::search::SearchQuery,
|
crate::search::SearchQuery,
|
||||||
crate::search::SearchResponse,
|
crate::search::SearchResponse,
|
||||||
|
crate::search::SeriesHit,
|
||||||
crate::index_jobs::RebuildRequest,
|
crate::index_jobs::RebuildRequest,
|
||||||
crate::thumbnails::ThumbnailsRebuildRequest,
|
crate::thumbnails::ThumbnailsRebuildRequest,
|
||||||
crate::index_jobs::IndexJobResponse,
|
crate::index_jobs::IndexJobResponse,
|
||||||
|
|||||||
@@ -365,8 +365,12 @@ fn render_page(
|
|||||||
fn extract_cbz_page(abs_path: &str, page_number: u32) -> Result<Vec<u8>, ApiError> {
|
fn extract_cbz_page(abs_path: &str, page_number: u32) -> Result<Vec<u8>, ApiError> {
|
||||||
debug!("Opening CBZ archive: {}", abs_path);
|
debug!("Opening CBZ archive: {}", abs_path);
|
||||||
let file = std::fs::File::open(abs_path).map_err(|e| {
|
let file = std::fs::File::open(abs_path).map_err(|e| {
|
||||||
error!("Cannot open CBZ file {}: {}", abs_path, e);
|
if e.kind() == std::io::ErrorKind::NotFound {
|
||||||
ApiError::internal(format!("cannot open cbz: {e}"))
|
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| {
|
let mut archive = zip::ZipArchive::new(file).map_err(|e| {
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
use axum::{extract::{Query, State}, Json};
|
use axum::{extract::{Query, State}, Json};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
use sqlx::Row;
|
||||||
use utoipa::ToSchema;
|
use utoipa::ToSchema;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::{error::ApiError, state::AppState};
|
use crate::{error::ApiError, state::AppState};
|
||||||
|
|
||||||
@@ -18,9 +20,21 @@ pub struct SearchQuery {
|
|||||||
pub limit: Option<usize>,
|
pub limit: Option<usize>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[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)]
|
#[derive(Serialize, ToSchema)]
|
||||||
pub struct SearchResponse {
|
pub struct SearchResponse {
|
||||||
pub hits: serde_json::Value,
|
pub hits: serde_json::Value,
|
||||||
|
pub series_hits: Vec<SeriesHit>,
|
||||||
pub estimated_total_hits: Option<u64>,
|
pub estimated_total_hits: Option<u64>,
|
||||||
pub processing_time_ms: Option<u64>,
|
pub processing_time_ms: Option<u64>,
|
||||||
}
|
}
|
||||||
@@ -31,11 +45,11 @@ pub struct SearchResponse {
|
|||||||
path = "/search",
|
path = "/search",
|
||||||
tag = "books",
|
tag = "books",
|
||||||
params(
|
params(
|
||||||
("q" = String, Query, description = "Search query"),
|
("q" = String, Query, description = "Search query (books via Meilisearch + series via ILIKE)"),
|
||||||
("library_id" = Option<String>, Query, description = "Filter by library ID"),
|
("library_id" = Option<String>, Query, description = "Filter by library ID"),
|
||||||
("type" = Option<String>, Query, description = "Filter by type (cbz, cbr, pdf)"),
|
("type" = Option<String>, Query, description = "Filter by type (cbz, cbr, pdf)"),
|
||||||
("kind" = Option<String>, Query, description = "Filter by kind (alias for type)"),
|
("kind" = Option<String>, Query, description = "Filter by kind (alias for type)"),
|
||||||
("limit" = Option<usize>, Query, description = "Max results (max 100)"),
|
("limit" = Option<usize>, Query, description = "Max results per type (max 100)"),
|
||||||
),
|
),
|
||||||
responses(
|
responses(
|
||||||
(status = 200, body = SearchResponse),
|
(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 ")) }
|
"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<uuid::Uuid> = 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 client = reqwest::Client::new();
|
||||||
let url = format!("{}/indexes/books/search", state.meili_url.trim_end_matches('/'));
|
let url = format!("{}/indexes/books/search", state.meili_url.trim_end_matches('/'));
|
||||||
let response = client
|
let meili_fut = client
|
||||||
.post(url)
|
.post(&url)
|
||||||
.header("Authorization", format!("Bearer {}", state.meili_master_key))
|
.header("Authorization", format!("Bearer {}", state.meili_master_key))
|
||||||
.json(&body)
|
.json(&body)
|
||||||
.send()
|
.send();
|
||||||
.await
|
|
||||||
.map_err(|e| ApiError::internal(format!("meili request failed: {e}")))?;
|
|
||||||
|
|
||||||
if !response.status().is_success() {
|
let series_sql = r#"
|
||||||
let body = response.text().await.unwrap_or_else(|_| "unknown meili error".to_string());
|
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") {
|
if body.contains("index_not_found") {
|
||||||
return Ok(Json(SearchResponse {
|
(serde_json::json!([]), Some(0u64), Some(0u64))
|
||||||
hits: serde_json::json!([]),
|
} else {
|
||||||
estimated_total_hits: Some(0),
|
return Err(ApiError::internal(format!("meili error: {body}")));
|
||||||
processing_time_ms: Some(0),
|
|
||||||
}));
|
|
||||||
}
|
}
|
||||||
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
|
// Traitement séries
|
||||||
.json()
|
let series_hits: Vec<SeriesHit> = series_rows
|
||||||
.await
|
.unwrap_or_default()
|
||||||
.map_err(|e| ApiError::internal(format!("invalid meili response: {e}")))?;
|
.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 {
|
Ok(Json(SearchResponse {
|
||||||
hits: payload.get("hits").cloned().unwrap_or_else(|| serde_json::json!([])),
|
hits,
|
||||||
estimated_total_hits: payload.get("estimatedTotalHits").and_then(|v| v.as_u64()),
|
series_hits,
|
||||||
processing_time_ms: payload.get("processingTimeMs").and_then(|v| v.as_u64()),
|
estimated_total_hits,
|
||||||
|
processing_time_ms,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 { BooksGrid, EmptyState } from "../components/BookCard";
|
||||||
import { Card, CardContent, Button, FormField, FormInput, FormSelect, FormRow, OffsetPagination } from "../components/ui";
|
import { Card, CardContent, Button, FormField, FormInput, FormSelect, FormRow, OffsetPagination } from "../components/ui";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
import Image from "next/image";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
@@ -23,11 +24,13 @@ export default async function BooksPage({
|
|||||||
let books: BookDto[] = [];
|
let books: BookDto[] = [];
|
||||||
let total = 0;
|
let total = 0;
|
||||||
let searchResults: BookDto[] | null = null;
|
let searchResults: BookDto[] | null = null;
|
||||||
|
let seriesHits: SeriesHitDto[] = [];
|
||||||
let totalHits: number | null = null;
|
let totalHits: number | null = null;
|
||||||
|
|
||||||
if (searchQuery) {
|
if (searchQuery) {
|
||||||
const searchResponse = await searchBooks(searchQuery, libraryId, limit).catch(() => null);
|
const searchResponse = await searchBooks(searchQuery, libraryId, limit).catch(() => null);
|
||||||
if (searchResponse) {
|
if (searchResponse) {
|
||||||
|
seriesHits = searchResponse.series_hits ?? [];
|
||||||
searchResults = searchResponse.hits.map(hit => ({
|
searchResults = searchResponse.hits.map(hit => ({
|
||||||
id: hit.id,
|
id: hit.id,
|
||||||
library_id: hit.library_id,
|
library_id: hit.library_id,
|
||||||
@@ -139,9 +142,46 @@ export default async function BooksPage({
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Séries matchantes */}
|
||||||
|
{seriesHits.length > 0 && (
|
||||||
|
<div className="mb-8">
|
||||||
|
<h2 className="text-lg font-semibold text-foreground mb-3">Series</h2>
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 gap-4">
|
||||||
|
{seriesHits.map((s) => (
|
||||||
|
<Link
|
||||||
|
key={`${s.library_id}-${s.name}`}
|
||||||
|
href={`/libraries/${s.library_id}/books?series=${encodeURIComponent(s.name)}`}
|
||||||
|
className="group"
|
||||||
|
>
|
||||||
|
<div className="bg-card rounded-xl shadow-sm border border-border/60 overflow-hidden hover:shadow-md transition-shadow duration-200">
|
||||||
|
<div className="aspect-[2/3] relative bg-muted/50">
|
||||||
|
<Image
|
||||||
|
src={getBookCoverUrl(s.first_book_id)}
|
||||||
|
alt={`Cover of ${s.name}`}
|
||||||
|
fill
|
||||||
|
className="object-cover"
|
||||||
|
unoptimized
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="p-2">
|
||||||
|
<h3 className="font-medium text-foreground truncate text-sm" title={s.name}>
|
||||||
|
{s.name === "unclassified" ? "Unclassified" : s.name}
|
||||||
|
</h3>
|
||||||
|
<p className="text-xs text-muted-foreground mt-0.5">
|
||||||
|
{s.book_count} book{s.book_count !== 1 ? 's' : ''}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Grille de livres */}
|
{/* Grille de livres */}
|
||||||
{displayBooks.length > 0 ? (
|
{displayBooks.length > 0 ? (
|
||||||
<>
|
<>
|
||||||
|
{searchQuery && <h2 className="text-lg font-semibold text-foreground mb-3">Books</h2>}
|
||||||
<BooksGrid books={displayBooks} />
|
<BooksGrid books={displayBooks} />
|
||||||
|
|
||||||
{!searchQuery && (
|
{!searchQuery && (
|
||||||
|
|||||||
@@ -18,16 +18,27 @@ interface BookCardProps {
|
|||||||
|
|
||||||
function BookImage({ src, alt }: { src: string; alt: string }) {
|
function BookImage({ src, alt }: { src: string; alt: string }) {
|
||||||
const [isLoaded, setIsLoaded] = useState(false);
|
const [isLoaded, setIsLoaded] = useState(false);
|
||||||
|
const [hasError, setHasError] = useState(false);
|
||||||
|
|
||||||
|
if (hasError) {
|
||||||
|
return (
|
||||||
|
<div className="relative aspect-[2/3] overflow-hidden bg-muted flex items-center justify-center">
|
||||||
|
<svg className="w-10 h-10 text-muted-foreground/30" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative aspect-[2/3] overflow-hidden bg-muted">
|
<div className="relative aspect-[2/3] overflow-hidden bg-muted">
|
||||||
{/* Skeleton */}
|
{/* Skeleton */}
|
||||||
<div
|
<div
|
||||||
className={`absolute inset-0 bg-muted/50 animate-pulse transition-opacity duration-300 ${
|
className={`absolute inset-0 bg-muted/50 animate-pulse transition-opacity duration-300 ${
|
||||||
isLoaded ? 'opacity-0 pointer-events-none' : 'opacity-100'
|
isLoaded ? 'opacity-0 pointer-events-none' : 'opacity-100'
|
||||||
}`}
|
}`}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Image */}
|
{/* Image */}
|
||||||
<Image
|
<Image
|
||||||
src={src}
|
src={src}
|
||||||
@@ -38,6 +49,7 @@ function BookImage({ src, alt }: { src: string; alt: string }) {
|
|||||||
}`}
|
}`}
|
||||||
sizes="(max-width: 640px) 50vw, (max-width: 768px) 33vw, (max-width: 1024px) 25vw, 16vw"
|
sizes="(max-width: 640px) 50vw, (max-width: 768px) 33vw, (max-width: 1024px) 25vw, 16vw"
|
||||||
onLoad={() => setIsLoaded(true)}
|
onLoad={() => setIsLoaded(true)}
|
||||||
|
onError={() => setHasError(true)}
|
||||||
unoptimized
|
unoptimized
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -91,8 +91,17 @@ export type SearchHitDto = {
|
|||||||
language: string | null;
|
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 = {
|
export type SearchResponseDto = {
|
||||||
hits: SearchHitDto[];
|
hits: SearchHitDto[];
|
||||||
|
series_hits: SeriesHitDto[];
|
||||||
estimated_total_hits: number | null;
|
estimated_total_hits: number | null;
|
||||||
processing_time_ms: number | null;
|
processing_time_ms: number | null;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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 let Ok(response) = meili_response {
|
||||||
if response.status().is_success() {
|
if response.status().is_success() {
|
||||||
if let Ok(meili_docs) = response.json::<Vec<serde_json::Value>>().await {
|
// Meilisearch returns { "results": [...], "offset": ..., "total": ... }
|
||||||
let meili_ids: std::collections::HashSet<String> = meili_docs
|
if let Ok(payload) = response.json::<serde_json::Value>().await {
|
||||||
|
let docs = payload.get("results")
|
||||||
|
.and_then(|v| v.as_array())
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_default();
|
||||||
|
let meili_ids: std::collections::HashSet<String> = docs
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.filter_map(|doc| doc.get("id").and_then(|id| id.as_str()).map(|s| s.to_string()))
|
.filter_map(|doc| doc.get("id").and_then(|id| id.as_str()).map(|s| s.to_string()))
|
||||||
.collect();
|
.collect();
|
||||||
|
|||||||
Reference in New Issue
Block a user