From 6a4ba06fac7a1281e21c24bf3606589ae4fe3bd7 Mon Sep 17 00:00:00 2001 From: Froidefond Julien Date: Sat, 21 Mar 2026 14:34:11 +0100 Subject: [PATCH] fix: include series_metadata authors in authors listing and detail pages Authors were only sourced from books.authors/books.author fields which are often empty. Now also aggregates authors from series_metadata.authors (populated by metadata providers like bedetheque). Adds author filter to /series endpoint and updates the author detail page to use it. Co-Authored-By: Claude Opus 4.6 --- apps/api/src/authors.rs | 38 ++++++++++++++++----- apps/api/src/books.rs | 2 +- apps/api/src/series.rs | 15 +++++++- apps/backoffice/app/authors/[name]/page.tsx | 13 ++----- apps/backoffice/lib/api.ts | 2 ++ 5 files changed, 50 insertions(+), 20 deletions(-) diff --git a/apps/api/src/authors.rs b/apps/api/src/authors.rs index 44b6fed..8c0b35f 100644 --- a/apps/api/src/authors.rs +++ b/apps/api/src/authors.rs @@ -68,7 +68,7 @@ pub async fn list_authors( .filter(|s| !s.trim().is_empty()) .map(|s| format!("%{s}%")); - // Aggregate unique authors from books.authors + books.author + // Aggregate unique authors from books.authors + books.author + series_metadata.authors let sql = format!( r#" WITH all_authors AS ( @@ -79,18 +79,21 @@ pub async fn list_authors( ) ) AS name FROM books + UNION + SELECT DISTINCT UNNEST(authors) AS name + FROM series_metadata + WHERE authors != '{{}}' ), filtered AS ( SELECT name FROM all_authors WHERE ($1::text IS NULL OR name ILIKE $1) ), - counted AS ( + book_counts AS ( SELECT - f.name, - COUNT(DISTINCT b.id) AS book_count, - COUNT(DISTINCT NULLIF(b.series, '')) AS series_count + f.name AS author_name, + COUNT(DISTINCT b.id) AS book_count FROM filtered f - JOIN books b ON ( + LEFT JOIN books b ON ( f.name = ANY( COALESCE( NULLIF(b.authors, '{{}}'), @@ -99,9 +102,24 @@ pub async fn list_authors( ) ) GROUP BY f.name + ), + series_counts AS ( + SELECT + f.name AS author_name, + COUNT(DISTINCT (sm.library_id, sm.name)) AS series_count + FROM filtered f + LEFT JOIN series_metadata sm ON ( + f.name = ANY(sm.authors) AND sm.authors != '{{}}' + ) + GROUP BY f.name ) - SELECT name, book_count, series_count - FROM counted + SELECT + f.name, + COALESCE(bc.book_count, 0) AS book_count, + COALESCE(sc.series_count, 0) AS series_count + FROM filtered f + LEFT JOIN book_counts bc ON bc.author_name = f.name + LEFT JOIN series_counts sc ON sc.author_name = f.name ORDER BY {order_clause} LIMIT $2 OFFSET $3 "# @@ -116,6 +134,10 @@ pub async fn list_authors( ) ) AS name FROM books + UNION + SELECT DISTINCT UNNEST(authors) AS name + FROM series_metadata + WHERE authors != '{}' ) SELECT COUNT(*) AS total FROM all_authors diff --git a/apps/api/src/books.rs b/apps/api/src/books.rs index 15b2a51..22960ff 100644 --- a/apps/api/src/books.rs +++ b/apps/api/src/books.rs @@ -143,7 +143,7 @@ pub async fn list_books( p += 1; format!("AND COALESCE(brp.status, 'unread') = ANY(${p})") } else { String::new() }; let author_cond = if query.author.is_some() { - p += 1; format!("AND (${p} = ANY(COALESCE(NULLIF(b.authors, '{{}}'), CASE WHEN b.author IS NOT NULL AND b.author != '' THEN ARRAY[b.author] ELSE ARRAY[]::text[] END)))") + p += 1; format!("AND (${p} = ANY(COALESCE(NULLIF(b.authors, '{{}}'), CASE WHEN b.author IS NOT NULL AND b.author != '' THEN ARRAY[b.author] ELSE ARRAY[]::text[] END)) OR EXISTS (SELECT 1 FROM series_metadata sm WHERE sm.library_id = b.library_id AND sm.name = b.series AND ${p} = ANY(sm.authors)))") } else { String::new() }; let metadata_cond = match query.metadata_provider.as_deref() { Some("unlinked") => "AND eml.id IS NULL".to_string(), diff --git a/apps/api/src/series.rs b/apps/api/src/series.rs index e5d18e5..d7144fe 100644 --- a/apps/api/src/series.rs +++ b/apps/api/src/series.rs @@ -292,6 +292,9 @@ pub struct ListAllSeriesQuery { /// Filter by metadata provider: a provider name (e.g. "google_books"), "linked" (any provider), or "unlinked" (no provider) #[schema(value_type = Option, example = "google_books")] pub metadata_provider: Option, + /// Filter by author name (matches in series_metadata.authors or book-level authors) + #[schema(value_type = Option, example = "Toriyama")] + pub author: Option, #[schema(value_type = Option, example = 1)] pub page: Option, #[schema(value_type = Option, example = 50)] @@ -311,6 +314,7 @@ pub struct ListAllSeriesQuery { ("library_id" = Option, Query, description = "Filter by library ID"), ("reading_status" = Option, Query, description = "Filter by reading status, comma-separated (e.g. 'unread,reading')"), ("metadata_provider" = Option, Query, description = "Filter by metadata provider: a provider name (e.g. 'google_books'), 'linked' (any provider), or 'unlinked' (no provider)"), + ("author" = Option, Query, description = "Filter by author name (matches in series_metadata.authors or book-level authors)"), ("page" = Option, Query, description = "Page number (1-indexed, default 1)"), ("limit" = Option, Query, description = "Items per page (max 200, default 50)"), ("sort" = Option, Query, description = "Sort order: 'title' (default) or 'latest' (most recently added first)"), @@ -372,6 +376,10 @@ pub async fn list_all_series( None => String::new(), }; + let author_cond = if query.author.is_some() { + p += 1; format!("AND (${p} = ANY(sm.authors) OR EXISTS (SELECT 1 FROM books bk WHERE bk.series = sc.name AND bk.library_id = sc.library_id AND ${p} = ANY(COALESCE(NULLIF(bk.authors, '{{}}'), CASE WHEN bk.author IS NOT NULL AND bk.author != '' THEN ARRAY[bk.author] ELSE ARRAY[]::text[] END))))") + } else { String::new() }; + // Missing counts CTE — needs library_id filter when filtering by library let missing_cte = if query.library_id.is_some() { r#" @@ -427,7 +435,7 @@ pub async fn list_all_series( LEFT JOIN series_metadata sm ON sm.library_id = sc.library_id AND sm.name = sc.name LEFT JOIN missing_counts mc ON mc.series_name = sc.name AND mc.library_id = sc.library_id LEFT JOIN metadata_links ml ON ml.series_name = sc.name AND ml.library_id = sc.library_id - WHERE TRUE {q_cond} {rs_cond} {ss_cond} {missing_cond} {metadata_provider_cond} + WHERE TRUE {q_cond} {rs_cond} {ss_cond} {missing_cond} {metadata_provider_cond} {author_cond} "# ); @@ -492,6 +500,7 @@ pub async fn list_all_series( {ss_cond} {missing_cond} {metadata_provider_cond} + {author_cond} ORDER BY {series_order_clause} LIMIT ${limit_p} OFFSET ${offset_p} "# @@ -524,6 +533,10 @@ pub async fn list_all_series( data_builder = data_builder.bind(mp); } } + if let Some(ref author) = query.author { + count_builder = count_builder.bind(author.clone()); + data_builder = data_builder.bind(author.clone()); + } data_builder = data_builder.bind(limit).bind(offset); diff --git a/apps/backoffice/app/authors/[name]/page.tsx b/apps/backoffice/app/authors/[name]/page.tsx index 3885f5b..a41d4f5 100644 --- a/apps/backoffice/app/authors/[name]/page.tsx +++ b/apps/backoffice/app/authors/[name]/page.tsx @@ -21,26 +21,19 @@ export default async function AuthorDetailPage({ const page = typeof searchParamsAwaited.page === "string" ? parseInt(searchParamsAwaited.page) : 1; const limit = typeof searchParamsAwaited.limit === "string" ? parseInt(searchParamsAwaited.limit) : 20; - // Fetch books by this author (server-side filtering via API) and series + // Fetch books by this author (server-side filtering via API) and series by this author const [booksPage, seriesPage] = await Promise.all([ fetchBooks(undefined, undefined, page, limit, undefined, undefined, authorName).catch( () => ({ items: [], total: 0, page: 1, limit }) as BooksPageDto ), - fetchAllSeries(undefined, undefined, undefined, 1, 200).catch( + fetchAllSeries(undefined, undefined, undefined, 1, 200, undefined, undefined, undefined, undefined, authorName).catch( () => ({ items: [], total: 0, page: 1, limit: 200 }) as SeriesPageDto ), ]); const totalPages = Math.ceil(booksPage.total / limit); - // Extract unique series names from this author's books - const authorSeriesNames = new Set( - booksPage.items - .map((b) => b.series) - .filter((s): s is string => s != null && s !== "") - ); - - const authorSeries = seriesPage.items.filter((s) => authorSeriesNames.has(s.name)); + const authorSeries = seriesPage.items; return ( <> diff --git a/apps/backoffice/lib/api.ts b/apps/backoffice/lib/api.ts index 627dc30..a2dbc18 100644 --- a/apps/backoffice/lib/api.ts +++ b/apps/backoffice/lib/api.ts @@ -342,6 +342,7 @@ export async function fetchAllSeries( seriesStatus?: string, hasMissing?: boolean, metadataProvider?: string, + author?: string, ): Promise { const params = new URLSearchParams(); if (libraryId) params.set("library_id", libraryId); @@ -351,6 +352,7 @@ export async function fetchAllSeries( if (seriesStatus) params.set("series_status", seriesStatus); if (hasMissing) params.set("has_missing", "true"); if (metadataProvider) params.set("metadata_provider", metadataProvider); + if (author) params.set("author", author); params.set("page", page.toString()); params.set("limit", limit.toString());