feat: include series_count and thumbnail_book_ids in libraries API response

Eliminates N+1 sequential fetchSeries calls on the libraries page by
returning series count and up to 5 thumbnail book IDs (one per series)
directly from GET /libraries.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-21 12:47:10 +01:00
parent fc8856c83f
commit 3f0bd783cd
3 changed files with 69 additions and 25 deletions

View File

@@ -26,6 +26,10 @@ pub struct LibraryResponse {
pub metadata_refresh_mode: String,
#[schema(value_type = Option<String>)]
pub next_metadata_refresh_at: Option<chrono::DateTime<chrono::Utc>>,
pub series_count: i64,
/// First book IDs from up to 5 distinct series (for thumbnail fan display)
#[schema(value_type = Vec<String>)]
pub thumbnail_book_ids: Vec<Uuid>,
}
#[derive(Deserialize, ToSchema)]
@@ -51,7 +55,21 @@ pub struct CreateLibraryRequest {
pub async fn list_libraries(State(state): State<AppState>) -> Result<Json<Vec<LibraryResponse>>, ApiError> {
let rows = sqlx::query(
"SELECT l.id, l.name, l.root_path, l.enabled, l.monitor_enabled, l.scan_mode, l.next_scan_at, l.watcher_enabled, l.metadata_provider, l.fallback_metadata_provider, l.metadata_refresh_mode, l.next_metadata_refresh_at,
(SELECT COUNT(*) FROM books b WHERE b.library_id = l.id) as book_count
(SELECT COUNT(*) FROM books b WHERE b.library_id = l.id) as book_count,
(SELECT COUNT(DISTINCT COALESCE(NULLIF(b.series, ''), 'unclassified')) FROM books b WHERE b.library_id = l.id) as series_count,
COALESCE((
SELECT ARRAY_AGG(first_id ORDER BY series_name)
FROM (
SELECT DISTINCT ON (COALESCE(NULLIF(b.series, ''), 'unclassified'))
COALESCE(NULLIF(b.series, ''), 'unclassified') as series_name,
b.id as first_id
FROM books b
WHERE b.library_id = l.id
ORDER BY COALESCE(NULLIF(b.series, ''), 'unclassified'),
b.volume NULLS LAST, b.title ASC
LIMIT 5
) sub
), ARRAY[]::uuid[]) as thumbnail_book_ids
FROM libraries l ORDER BY l.created_at DESC"
)
.fetch_all(&state.pool)
@@ -65,6 +83,7 @@ pub async fn list_libraries(State(state): State<AppState>) -> Result<Json<Vec<Li
root_path: row.get("root_path"),
enabled: row.get("enabled"),
book_count: row.get("book_count"),
series_count: row.get("series_count"),
monitor_enabled: row.get("monitor_enabled"),
scan_mode: row.get("scan_mode"),
next_scan_at: row.get("next_scan_at"),
@@ -73,6 +92,7 @@ pub async fn list_libraries(State(state): State<AppState>) -> Result<Json<Vec<Li
fallback_metadata_provider: row.get("fallback_metadata_provider"),
metadata_refresh_mode: row.get("metadata_refresh_mode"),
next_metadata_refresh_at: row.get("next_metadata_refresh_at"),
thumbnail_book_ids: row.get("thumbnail_book_ids"),
})
.collect();
@@ -120,6 +140,7 @@ pub async fn create_library(
root_path,
enabled: true,
book_count: 0,
series_count: 0,
monitor_enabled: false,
scan_mode: "manual".to_string(),
next_scan_at: None,
@@ -128,6 +149,7 @@ pub async fn create_library(
fallback_metadata_provider: None,
metadata_refresh_mode: "manual".to_string(),
next_metadata_refresh_at: None,
thumbnail_book_ids: vec![],
}))
}
@@ -337,12 +359,29 @@ pub async fn update_monitoring(
.fetch_one(&state.pool)
.await?;
let series_count: i64 = sqlx::query_scalar("SELECT COUNT(DISTINCT COALESCE(NULLIF(series, ''), 'unclassified')) FROM books WHERE library_id = $1")
.bind(library_id)
.fetch_one(&state.pool)
.await?;
let thumbnail_book_ids: Vec<Uuid> = sqlx::query_scalar(
"SELECT b.id FROM books b
WHERE b.library_id = $1
ORDER BY COALESCE(NULLIF(b.series, ''), 'unclassified'), b.volume NULLS LAST, b.title ASC
LIMIT 5"
)
.bind(library_id)
.fetch_all(&state.pool)
.await
.unwrap_or_default();
Ok(Json(LibraryResponse {
id: row.get("id"),
name: row.get("name"),
root_path: row.get("root_path"),
enabled: row.get("enabled"),
book_count,
series_count,
monitor_enabled: row.get("monitor_enabled"),
scan_mode: row.get("scan_mode"),
next_scan_at: row.get("next_scan_at"),
@@ -351,6 +390,7 @@ pub async fn update_monitoring(
fallback_metadata_provider: row.get("fallback_metadata_provider"),
metadata_refresh_mode: row.get("metadata_refresh_mode"),
next_metadata_refresh_at: row.get("next_metadata_refresh_at"),
thumbnail_book_ids,
}))
}
@@ -403,12 +443,29 @@ pub async fn update_metadata_provider(
.fetch_one(&state.pool)
.await?;
let series_count: i64 = sqlx::query_scalar("SELECT COUNT(DISTINCT COALESCE(NULLIF(series, ''), 'unclassified')) FROM books WHERE library_id = $1")
.bind(library_id)
.fetch_one(&state.pool)
.await?;
let thumbnail_book_ids: Vec<Uuid> = sqlx::query_scalar(
"SELECT b.id FROM books b
WHERE b.library_id = $1
ORDER BY COALESCE(NULLIF(b.series, ''), 'unclassified'), b.volume NULLS LAST, b.title ASC
LIMIT 5"
)
.bind(library_id)
.fetch_all(&state.pool)
.await
.unwrap_or_default();
Ok(Json(LibraryResponse {
id: row.get("id"),
name: row.get("name"),
root_path: row.get("root_path"),
enabled: row.get("enabled"),
book_count,
series_count,
monitor_enabled: row.get("monitor_enabled"),
scan_mode: row.get("scan_mode"),
next_scan_at: row.get("next_scan_at"),
@@ -417,5 +474,6 @@ pub async fn update_metadata_provider(
fallback_metadata_provider: row.get("fallback_metadata_provider"),
metadata_refresh_mode: row.get("metadata_refresh_mode"),
next_metadata_refresh_at: row.get("next_metadata_refresh_at"),
thumbnail_book_ids,
}))
}

View File

@@ -1,6 +1,6 @@
import { revalidatePath } from "next/cache";
import Link from "next/link";
import { listFolders, createLibrary, deleteLibrary, fetchLibraries, fetchSeries, getBookCoverUrl, LibraryDto, FolderItem } from "../../lib/api";
import { listFolders, createLibrary, deleteLibrary, fetchLibraries, getBookCoverUrl, LibraryDto, FolderItem } from "../../lib/api";
import type { TranslationKey } from "../../lib/i18n/fr";
import { getServerTranslations } from "../../lib/i18n/server";
import { LibraryActions } from "../components/LibraryActions";
@@ -33,27 +33,13 @@ export default async function LibrariesPage() {
listFolders().catch(() => [] as FolderItem[])
]);
const seriesData = await Promise.all(
libraries.map(async (lib) => {
try {
const seriesPage = await fetchSeries(lib.id, 1, 6);
return {
id: lib.id,
count: seriesPage.total,
thumbnails: seriesPage.items
.map(s => s.first_book_id)
.filter(Boolean)
.slice(0, 4)
.map(bookId => getBookCoverUrl(bookId)),
};
} catch {
return { id: lib.id, count: 0, thumbnails: [] as string[] };
}
})
const thumbnailMap = new Map(
libraries.map(lib => [
lib.id,
(lib.thumbnail_book_ids || []).map(bookId => getBookCoverUrl(bookId)),
])
);
const seriesMap = new Map(seriesData.map(s => [s.id, s]));
async function addLibrary(formData: FormData) {
"use server";
const name = formData.get("name") as string;
@@ -96,9 +82,7 @@ export default async function LibrariesPage() {
{/* Libraries Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{libraries.map((lib) => {
const series = seriesMap.get(lib.id);
const seriesCount = series?.count || 0;
const thumbnails = series?.thumbnails || [];
const thumbnails = thumbnailMap.get(lib.id) || [];
return (
<Card key={lib.id} className="flex flex-col overflow-hidden">
{/* Thumbnail fan */}
@@ -183,7 +167,7 @@ export default async function LibrariesPage() {
href={`/libraries/${lib.id}/series`}
className="text-center p-2.5 bg-muted/50 rounded-lg hover:bg-accent transition-colors duration-200"
>
<span className="block text-2xl font-bold text-foreground">{seriesCount}</span>
<span className="block text-2xl font-bold text-foreground">{lib.series_count}</span>
<span className="text-xs text-muted-foreground">{t("libraries.series")}</span>
</Link>
</div>

View File

@@ -12,6 +12,8 @@ export type LibraryDto = {
fallback_metadata_provider: string | null;
metadata_refresh_mode: string;
next_metadata_refresh_at: string | null;
series_count: number;
thumbnail_book_ids: string[];
};
export type IndexJobDto = {