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:
@@ -68,7 +68,7 @@ pub async fn list_authors(
|
|||||||
.filter(|s| !s.trim().is_empty())
|
.filter(|s| !s.trim().is_empty())
|
||||||
.map(|s| format!("%{s}%"));
|
.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!(
|
let sql = format!(
|
||||||
r#"
|
r#"
|
||||||
WITH all_authors AS (
|
WITH all_authors AS (
|
||||||
@@ -79,18 +79,21 @@ pub async fn list_authors(
|
|||||||
)
|
)
|
||||||
) AS name
|
) AS name
|
||||||
FROM books
|
FROM books
|
||||||
|
UNION
|
||||||
|
SELECT DISTINCT UNNEST(authors) AS name
|
||||||
|
FROM series_metadata
|
||||||
|
WHERE authors != '{{}}'
|
||||||
),
|
),
|
||||||
filtered AS (
|
filtered AS (
|
||||||
SELECT name FROM all_authors
|
SELECT name FROM all_authors
|
||||||
WHERE ($1::text IS NULL OR name ILIKE $1)
|
WHERE ($1::text IS NULL OR name ILIKE $1)
|
||||||
),
|
),
|
||||||
counted AS (
|
book_counts AS (
|
||||||
SELECT
|
SELECT
|
||||||
f.name,
|
f.name AS author_name,
|
||||||
COUNT(DISTINCT b.id) AS book_count,
|
COUNT(DISTINCT b.id) AS book_count
|
||||||
COUNT(DISTINCT NULLIF(b.series, '')) AS series_count
|
|
||||||
FROM filtered f
|
FROM filtered f
|
||||||
JOIN books b ON (
|
LEFT JOIN books b ON (
|
||||||
f.name = ANY(
|
f.name = ANY(
|
||||||
COALESCE(
|
COALESCE(
|
||||||
NULLIF(b.authors, '{{}}'),
|
NULLIF(b.authors, '{{}}'),
|
||||||
@@ -99,9 +102,24 @@ pub async fn list_authors(
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
GROUP BY f.name
|
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
|
SELECT
|
||||||
FROM counted
|
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}
|
ORDER BY {order_clause}
|
||||||
LIMIT $2 OFFSET $3
|
LIMIT $2 OFFSET $3
|
||||||
"#
|
"#
|
||||||
@@ -116,6 +134,10 @@ pub async fn list_authors(
|
|||||||
)
|
)
|
||||||
) AS name
|
) AS name
|
||||||
FROM books
|
FROM books
|
||||||
|
UNION
|
||||||
|
SELECT DISTINCT UNNEST(authors) AS name
|
||||||
|
FROM series_metadata
|
||||||
|
WHERE authors != '{}'
|
||||||
)
|
)
|
||||||
SELECT COUNT(*) AS total
|
SELECT COUNT(*) AS total
|
||||||
FROM all_authors
|
FROM all_authors
|
||||||
|
|||||||
@@ -143,7 +143,7 @@ pub async fn list_books(
|
|||||||
p += 1; format!("AND COALESCE(brp.status, 'unread') = ANY(${p})")
|
p += 1; format!("AND COALESCE(brp.status, 'unread') = ANY(${p})")
|
||||||
} else { String::new() };
|
} else { String::new() };
|
||||||
let author_cond = if query.author.is_some() {
|
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() };
|
} else { String::new() };
|
||||||
let metadata_cond = match query.metadata_provider.as_deref() {
|
let metadata_cond = match query.metadata_provider.as_deref() {
|
||||||
Some("unlinked") => "AND eml.id IS NULL".to_string(),
|
Some("unlinked") => "AND eml.id IS NULL".to_string(),
|
||||||
|
|||||||
@@ -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)
|
/// 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")]
|
#[schema(value_type = Option<String>, example = "google_books")]
|
||||||
pub metadata_provider: Option<String>,
|
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)]
|
#[schema(value_type = Option<i64>, example = 1)]
|
||||||
pub page: Option<i64>,
|
pub page: Option<i64>,
|
||||||
#[schema(value_type = Option<i64>, example = 50)]
|
#[schema(value_type = Option<i64>, example = 50)]
|
||||||
@@ -311,6 +314,7 @@ pub struct ListAllSeriesQuery {
|
|||||||
("library_id" = Option<String>, Query, description = "Filter by library ID"),
|
("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')"),
|
("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)"),
|
("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)"),
|
("page" = Option<i64>, Query, description = "Page number (1-indexed, default 1)"),
|
||||||
("limit" = Option<i64>, Query, description = "Items per page (max 200, default 50)"),
|
("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)"),
|
("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(),
|
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
|
// Missing counts CTE — needs library_id filter when filtering by library
|
||||||
let missing_cte = if query.library_id.is_some() {
|
let missing_cte = if query.library_id.is_some() {
|
||||||
r#"
|
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 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 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
|
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}
|
{ss_cond}
|
||||||
{missing_cond}
|
{missing_cond}
|
||||||
{metadata_provider_cond}
|
{metadata_provider_cond}
|
||||||
|
{author_cond}
|
||||||
ORDER BY {series_order_clause}
|
ORDER BY {series_order_clause}
|
||||||
LIMIT ${limit_p} OFFSET ${offset_p}
|
LIMIT ${limit_p} OFFSET ${offset_p}
|
||||||
"#
|
"#
|
||||||
@@ -524,6 +533,10 @@ pub async fn list_all_series(
|
|||||||
data_builder = data_builder.bind(mp);
|
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);
|
data_builder = data_builder.bind(limit).bind(offset);
|
||||||
|
|
||||||
|
|||||||
@@ -21,26 +21,19 @@ export default async function AuthorDetailPage({
|
|||||||
const page = typeof searchParamsAwaited.page === "string" ? parseInt(searchParamsAwaited.page) : 1;
|
const page = typeof searchParamsAwaited.page === "string" ? parseInt(searchParamsAwaited.page) : 1;
|
||||||
const limit = typeof searchParamsAwaited.limit === "string" ? parseInt(searchParamsAwaited.limit) : 20;
|
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([
|
const [booksPage, seriesPage] = await Promise.all([
|
||||||
fetchBooks(undefined, undefined, page, limit, undefined, undefined, authorName).catch(
|
fetchBooks(undefined, undefined, page, limit, undefined, undefined, authorName).catch(
|
||||||
() => ({ items: [], total: 0, page: 1, limit }) as BooksPageDto
|
() => ({ 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
|
() => ({ items: [], total: 0, page: 1, limit: 200 }) as SeriesPageDto
|
||||||
),
|
),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const totalPages = Math.ceil(booksPage.total / limit);
|
const totalPages = Math.ceil(booksPage.total / limit);
|
||||||
|
|
||||||
// Extract unique series names from this author's books
|
const authorSeries = seriesPage.items;
|
||||||
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));
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -342,6 +342,7 @@ export async function fetchAllSeries(
|
|||||||
seriesStatus?: string,
|
seriesStatus?: string,
|
||||||
hasMissing?: boolean,
|
hasMissing?: boolean,
|
||||||
metadataProvider?: string,
|
metadataProvider?: string,
|
||||||
|
author?: string,
|
||||||
): Promise<SeriesPageDto> {
|
): Promise<SeriesPageDto> {
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
if (libraryId) params.set("library_id", libraryId);
|
if (libraryId) params.set("library_id", libraryId);
|
||||||
@@ -351,6 +352,7 @@ export async function fetchAllSeries(
|
|||||||
if (seriesStatus) params.set("series_status", seriesStatus);
|
if (seriesStatus) params.set("series_status", seriesStatus);
|
||||||
if (hasMissing) params.set("has_missing", "true");
|
if (hasMissing) params.set("has_missing", "true");
|
||||||
if (metadataProvider) params.set("metadata_provider", metadataProvider);
|
if (metadataProvider) params.set("metadata_provider", metadataProvider);
|
||||||
|
if (author) params.set("author", author);
|
||||||
params.set("page", page.toString());
|
params.set("page", page.toString());
|
||||||
params.set("limit", limit.toString());
|
params.set("limit", limit.toString());
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user