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 <noreply@anthropic.com>
This commit is contained in:
2026-03-21 14:34:11 +01:00
parent e5c3542d3f
commit 6a4ba06fac
5 changed files with 50 additions and 20 deletions

View File

@@ -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

View File

@@ -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(),

View File

@@ -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<String>, example = "google_books")]
pub metadata_provider: Option<String>,
/// Filter by author name (matches in series_metadata.authors or book-level authors)
#[schema(value_type = Option<String>, example = "Toriyama")]
pub author: Option<String>,
#[schema(value_type = Option<i64>, example = 1)]
pub page: Option<i64>,
#[schema(value_type = Option<i64>, example = 50)]
@@ -311,6 +314,7 @@ pub struct ListAllSeriesQuery {
("library_id" = Option<String>, Query, description = "Filter by library ID"),
("reading_status" = Option<String>, Query, description = "Filter by reading status, comma-separated (e.g. 'unread,reading')"),
("metadata_provider" = Option<String>, Query, description = "Filter by metadata provider: a provider name (e.g. 'google_books'), 'linked' (any provider), or 'unlinked' (no provider)"),
("author" = Option<String>, Query, description = "Filter by author name (matches in series_metadata.authors or book-level authors)"),
("page" = Option<i64>, Query, description = "Page number (1-indexed, default 1)"),
("limit" = Option<i64>, Query, description = "Items per page (max 200, default 50)"),
("sort" = Option<String>, 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);

View File

@@ -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 (
<>

View File

@@ -342,6 +342,7 @@ export async function fetchAllSeries(
seriesStatus?: string,
hasMissing?: boolean,
metadataProvider?: string,
author?: string,
): Promise<SeriesPageDto> {
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());