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:
@@ -26,6 +26,10 @@ pub struct LibraryResponse {
|
|||||||
pub metadata_refresh_mode: String,
|
pub metadata_refresh_mode: String,
|
||||||
#[schema(value_type = Option<String>)]
|
#[schema(value_type = Option<String>)]
|
||||||
pub next_metadata_refresh_at: Option<chrono::DateTime<chrono::Utc>>,
|
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)]
|
#[derive(Deserialize, ToSchema)]
|
||||||
@@ -51,7 +55,21 @@ pub struct CreateLibraryRequest {
|
|||||||
pub async fn list_libraries(State(state): State<AppState>) -> Result<Json<Vec<LibraryResponse>>, ApiError> {
|
pub async fn list_libraries(State(state): State<AppState>) -> Result<Json<Vec<LibraryResponse>>, ApiError> {
|
||||||
let rows = sqlx::query(
|
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 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"
|
FROM libraries l ORDER BY l.created_at DESC"
|
||||||
)
|
)
|
||||||
.fetch_all(&state.pool)
|
.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"),
|
root_path: row.get("root_path"),
|
||||||
enabled: row.get("enabled"),
|
enabled: row.get("enabled"),
|
||||||
book_count: row.get("book_count"),
|
book_count: row.get("book_count"),
|
||||||
|
series_count: row.get("series_count"),
|
||||||
monitor_enabled: row.get("monitor_enabled"),
|
monitor_enabled: row.get("monitor_enabled"),
|
||||||
scan_mode: row.get("scan_mode"),
|
scan_mode: row.get("scan_mode"),
|
||||||
next_scan_at: row.get("next_scan_at"),
|
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"),
|
fallback_metadata_provider: row.get("fallback_metadata_provider"),
|
||||||
metadata_refresh_mode: row.get("metadata_refresh_mode"),
|
metadata_refresh_mode: row.get("metadata_refresh_mode"),
|
||||||
next_metadata_refresh_at: row.get("next_metadata_refresh_at"),
|
next_metadata_refresh_at: row.get("next_metadata_refresh_at"),
|
||||||
|
thumbnail_book_ids: row.get("thumbnail_book_ids"),
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
@@ -120,6 +140,7 @@ pub async fn create_library(
|
|||||||
root_path,
|
root_path,
|
||||||
enabled: true,
|
enabled: true,
|
||||||
book_count: 0,
|
book_count: 0,
|
||||||
|
series_count: 0,
|
||||||
monitor_enabled: false,
|
monitor_enabled: false,
|
||||||
scan_mode: "manual".to_string(),
|
scan_mode: "manual".to_string(),
|
||||||
next_scan_at: None,
|
next_scan_at: None,
|
||||||
@@ -128,6 +149,7 @@ pub async fn create_library(
|
|||||||
fallback_metadata_provider: None,
|
fallback_metadata_provider: None,
|
||||||
metadata_refresh_mode: "manual".to_string(),
|
metadata_refresh_mode: "manual".to_string(),
|
||||||
next_metadata_refresh_at: None,
|
next_metadata_refresh_at: None,
|
||||||
|
thumbnail_book_ids: vec![],
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -337,12 +359,29 @@ pub async fn update_monitoring(
|
|||||||
.fetch_one(&state.pool)
|
.fetch_one(&state.pool)
|
||||||
.await?;
|
.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 {
|
Ok(Json(LibraryResponse {
|
||||||
id: row.get("id"),
|
id: row.get("id"),
|
||||||
name: row.get("name"),
|
name: row.get("name"),
|
||||||
root_path: row.get("root_path"),
|
root_path: row.get("root_path"),
|
||||||
enabled: row.get("enabled"),
|
enabled: row.get("enabled"),
|
||||||
book_count,
|
book_count,
|
||||||
|
series_count,
|
||||||
monitor_enabled: row.get("monitor_enabled"),
|
monitor_enabled: row.get("monitor_enabled"),
|
||||||
scan_mode: row.get("scan_mode"),
|
scan_mode: row.get("scan_mode"),
|
||||||
next_scan_at: row.get("next_scan_at"),
|
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"),
|
fallback_metadata_provider: row.get("fallback_metadata_provider"),
|
||||||
metadata_refresh_mode: row.get("metadata_refresh_mode"),
|
metadata_refresh_mode: row.get("metadata_refresh_mode"),
|
||||||
next_metadata_refresh_at: row.get("next_metadata_refresh_at"),
|
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)
|
.fetch_one(&state.pool)
|
||||||
.await?;
|
.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 {
|
Ok(Json(LibraryResponse {
|
||||||
id: row.get("id"),
|
id: row.get("id"),
|
||||||
name: row.get("name"),
|
name: row.get("name"),
|
||||||
root_path: row.get("root_path"),
|
root_path: row.get("root_path"),
|
||||||
enabled: row.get("enabled"),
|
enabled: row.get("enabled"),
|
||||||
book_count,
|
book_count,
|
||||||
|
series_count,
|
||||||
monitor_enabled: row.get("monitor_enabled"),
|
monitor_enabled: row.get("monitor_enabled"),
|
||||||
scan_mode: row.get("scan_mode"),
|
scan_mode: row.get("scan_mode"),
|
||||||
next_scan_at: row.get("next_scan_at"),
|
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"),
|
fallback_metadata_provider: row.get("fallback_metadata_provider"),
|
||||||
metadata_refresh_mode: row.get("metadata_refresh_mode"),
|
metadata_refresh_mode: row.get("metadata_refresh_mode"),
|
||||||
next_metadata_refresh_at: row.get("next_metadata_refresh_at"),
|
next_metadata_refresh_at: row.get("next_metadata_refresh_at"),
|
||||||
|
thumbnail_book_ids,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import Link from "next/link";
|
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 type { TranslationKey } from "../../lib/i18n/fr";
|
||||||
import { getServerTranslations } from "../../lib/i18n/server";
|
import { getServerTranslations } from "../../lib/i18n/server";
|
||||||
import { LibraryActions } from "../components/LibraryActions";
|
import { LibraryActions } from "../components/LibraryActions";
|
||||||
@@ -33,27 +33,13 @@ export default async function LibrariesPage() {
|
|||||||
listFolders().catch(() => [] as FolderItem[])
|
listFolders().catch(() => [] as FolderItem[])
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const seriesData = await Promise.all(
|
const thumbnailMap = new Map(
|
||||||
libraries.map(async (lib) => {
|
libraries.map(lib => [
|
||||||
try {
|
lib.id,
|
||||||
const seriesPage = await fetchSeries(lib.id, 1, 6);
|
(lib.thumbnail_book_ids || []).map(bookId => getBookCoverUrl(bookId)),
|
||||||
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 seriesMap = new Map(seriesData.map(s => [s.id, s]));
|
|
||||||
|
|
||||||
async function addLibrary(formData: FormData) {
|
async function addLibrary(formData: FormData) {
|
||||||
"use server";
|
"use server";
|
||||||
const name = formData.get("name") as string;
|
const name = formData.get("name") as string;
|
||||||
@@ -96,9 +82,7 @@ export default async function LibrariesPage() {
|
|||||||
{/* Libraries Grid */}
|
{/* Libraries Grid */}
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
{libraries.map((lib) => {
|
{libraries.map((lib) => {
|
||||||
const series = seriesMap.get(lib.id);
|
const thumbnails = thumbnailMap.get(lib.id) || [];
|
||||||
const seriesCount = series?.count || 0;
|
|
||||||
const thumbnails = series?.thumbnails || [];
|
|
||||||
return (
|
return (
|
||||||
<Card key={lib.id} className="flex flex-col overflow-hidden">
|
<Card key={lib.id} className="flex flex-col overflow-hidden">
|
||||||
{/* Thumbnail fan */}
|
{/* Thumbnail fan */}
|
||||||
@@ -183,7 +167,7 @@ export default async function LibrariesPage() {
|
|||||||
href={`/libraries/${lib.id}/series`}
|
href={`/libraries/${lib.id}/series`}
|
||||||
className="text-center p-2.5 bg-muted/50 rounded-lg hover:bg-accent transition-colors duration-200"
|
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>
|
<span className="text-xs text-muted-foreground">{t("libraries.series")}</span>
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ export type LibraryDto = {
|
|||||||
fallback_metadata_provider: string | null;
|
fallback_metadata_provider: string | null;
|
||||||
metadata_refresh_mode: string;
|
metadata_refresh_mode: string;
|
||||||
next_metadata_refresh_at: string | null;
|
next_metadata_refresh_at: string | null;
|
||||||
|
series_count: number;
|
||||||
|
thumbnail_book_ids: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export type IndexJobDto = {
|
export type IndexJobDto = {
|
||||||
|
|||||||
Reference in New Issue
Block a user