diff --git a/apps/api/src/libraries.rs b/apps/api/src/libraries.rs index 8b5c147..33f3297 100644 --- a/apps/api/src/libraries.rs +++ b/apps/api/src/libraries.rs @@ -26,6 +26,10 @@ pub struct LibraryResponse { pub metadata_refresh_mode: String, #[schema(value_type = Option)] pub next_metadata_refresh_at: Option>, + pub series_count: i64, + /// First book IDs from up to 5 distinct series (for thumbnail fan display) + #[schema(value_type = Vec)] + pub thumbnail_book_ids: Vec, } #[derive(Deserialize, ToSchema)] @@ -51,7 +55,21 @@ pub struct CreateLibraryRequest { pub async fn list_libraries(State(state): State) -> Result>, 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) -> Result) -> Result = 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 = 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, })) } diff --git a/apps/backoffice/app/libraries/page.tsx b/apps/backoffice/app/libraries/page.tsx index 1ab9186..948b055 100644 --- a/apps/backoffice/app/libraries/page.tsx +++ b/apps/backoffice/app/libraries/page.tsx @@ -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 */}
{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 ( {/* 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" > - {seriesCount} + {lib.series_count} {t("libraries.series")}
diff --git a/apps/backoffice/lib/api.ts b/apps/backoffice/lib/api.ts index 6da3421..8c99f9d 100644 --- a/apps/backoffice/lib/api.ts +++ b/apps/backoffice/lib/api.ts @@ -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 = {