feat: add format and metadata filters to books page
Add two new filters to the books listing page: - Format filter (CBZ/CBR/PDF/EPUB) using existing API support - Metadata linked/unlinked filter with new API support via LEFT JOIN on external_metadata_links (using DISTINCT ON CTE matching the series endpoint pattern) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -29,6 +29,9 @@ pub struct ListBooksQuery {
|
||||
/// Sort order: "title" (default) or "latest" (most recently added first)
|
||||
#[schema(value_type = Option<String>, example = "latest")]
|
||||
pub sort: Option<String>,
|
||||
/// Filter by metadata provider: "linked" (any provider), "unlinked" (no provider), or a specific provider name
|
||||
#[schema(value_type = Option<String>, example = "linked")]
|
||||
pub metadata_provider: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, ToSchema)]
|
||||
@@ -108,6 +111,7 @@ pub struct BookDetails {
|
||||
("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)"),
|
||||
("metadata_provider" = Option<String>, Query, description = "Filter by metadata provider: 'linked' (any provider), 'unlinked' (no provider), or a specific provider name"),
|
||||
),
|
||||
responses(
|
||||
(status = 200, body = BooksPage),
|
||||
@@ -141,16 +145,34 @@ pub async fn list_books(
|
||||
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)))")
|
||||
} else { String::new() };
|
||||
let metadata_cond = match query.metadata_provider.as_deref() {
|
||||
Some("unlinked") => "AND eml.id IS NULL".to_string(),
|
||||
Some("linked") => "AND eml.id IS NOT NULL".to_string(),
|
||||
Some(_) => { p += 1; format!("AND eml.provider = ${p}") },
|
||||
None => String::new(),
|
||||
};
|
||||
|
||||
let metadata_links_cte = r#"
|
||||
metadata_links AS (
|
||||
SELECT DISTINCT ON (eml.series_name, eml.library_id)
|
||||
eml.series_name, eml.library_id, eml.provider, eml.id
|
||||
FROM external_metadata_links eml
|
||||
WHERE eml.status = 'approved'
|
||||
ORDER BY eml.series_name, eml.library_id, eml.created_at DESC
|
||||
)"#;
|
||||
|
||||
let count_sql = format!(
|
||||
r#"SELECT COUNT(*) FROM books b
|
||||
r#"WITH {metadata_links_cte}
|
||||
SELECT COUNT(*) FROM books b
|
||||
LEFT JOIN book_reading_progress brp ON brp.book_id = b.id
|
||||
LEFT JOIN metadata_links eml ON eml.series_name = b.series AND eml.library_id = b.library_id
|
||||
WHERE ($1::uuid IS NULL OR b.library_id = $1)
|
||||
AND ($2::text IS NULL OR b.kind = $2)
|
||||
AND ($3::text IS NULL OR b.format = $3)
|
||||
{series_cond}
|
||||
{rs_cond}
|
||||
{author_cond}"#
|
||||
{author_cond}
|
||||
{metadata_cond}"#
|
||||
);
|
||||
|
||||
let order_clause = if query.sort.as_deref() == Some("latest") {
|
||||
@@ -164,18 +186,21 @@ pub async fn list_books(
|
||||
let offset_p = p + 2;
|
||||
let data_sql = format!(
|
||||
r#"
|
||||
WITH {metadata_links_cte}
|
||||
SELECT b.id, b.library_id, b.kind, b.format, b.title, b.author, b.authors, b.series, b.volume, b.language, b.page_count, b.thumbnail_path, b.updated_at,
|
||||
COALESCE(brp.status, 'unread') AS reading_status,
|
||||
brp.current_page AS reading_current_page,
|
||||
brp.last_read_at AS reading_last_read_at
|
||||
FROM books b
|
||||
LEFT JOIN book_reading_progress brp ON brp.book_id = b.id
|
||||
LEFT JOIN metadata_links eml ON eml.series_name = b.series AND eml.library_id = b.library_id
|
||||
WHERE ($1::uuid IS NULL OR b.library_id = $1)
|
||||
AND ($2::text IS NULL OR b.kind = $2)
|
||||
AND ($3::text IS NULL OR b.format = $3)
|
||||
{series_cond}
|
||||
{rs_cond}
|
||||
{author_cond}
|
||||
{metadata_cond}
|
||||
ORDER BY {order_clause}
|
||||
LIMIT ${limit_p} OFFSET ${offset_p}
|
||||
"#
|
||||
@@ -204,6 +229,12 @@ pub async fn list_books(
|
||||
count_builder = count_builder.bind(author.clone());
|
||||
data_builder = data_builder.bind(author.clone());
|
||||
}
|
||||
if let Some(ref mp) = query.metadata_provider {
|
||||
if mp != "linked" && mp != "unlinked" {
|
||||
count_builder = count_builder.bind(mp.clone());
|
||||
data_builder = data_builder.bind(mp.clone());
|
||||
}
|
||||
}
|
||||
|
||||
data_builder = data_builder.bind(limit).bind(offset);
|
||||
|
||||
|
||||
@@ -18,6 +18,8 @@ export default async function BooksPage({
|
||||
const libraryId = typeof searchParamsAwaited.library === "string" ? searchParamsAwaited.library : undefined;
|
||||
const searchQuery = typeof searchParamsAwaited.q === "string" ? searchParamsAwaited.q : "";
|
||||
const readingStatus = typeof searchParamsAwaited.status === "string" ? searchParamsAwaited.status : undefined;
|
||||
const format = typeof searchParamsAwaited.format === "string" ? searchParamsAwaited.format : undefined;
|
||||
const metadataProvider = typeof searchParamsAwaited.metadata === "string" ? searchParamsAwaited.metadata : undefined;
|
||||
const sort = typeof searchParamsAwaited.sort === "string" ? searchParamsAwaited.sort : undefined;
|
||||
const page = typeof searchParamsAwaited.page === "string" ? parseInt(searchParamsAwaited.page) : 1;
|
||||
const limit = typeof searchParamsAwaited.limit === "string" ? parseInt(searchParamsAwaited.limit) : 20;
|
||||
@@ -62,7 +64,7 @@ export default async function BooksPage({
|
||||
totalHits = searchResponse.estimated_total_hits;
|
||||
}
|
||||
} else {
|
||||
const booksPage = await fetchBooks(libraryId, undefined, page, limit, readingStatus, sort).catch(() => ({
|
||||
const booksPage = await fetchBooks(libraryId, undefined, page, limit, readingStatus, sort, undefined, format, metadataProvider).catch(() => ({
|
||||
items: [] as BookDto[],
|
||||
total: 0,
|
||||
page: 1,
|
||||
@@ -91,12 +93,26 @@ export default async function BooksPage({
|
||||
{ value: "read", label: t("status.read") },
|
||||
];
|
||||
|
||||
const formatOptions = [
|
||||
{ value: "", label: t("books.allFormats") },
|
||||
{ value: "cbz", label: "CBZ" },
|
||||
{ value: "cbr", label: "CBR" },
|
||||
{ value: "pdf", label: "PDF" },
|
||||
{ value: "epub", label: "EPUB" },
|
||||
];
|
||||
|
||||
const metadataOptions = [
|
||||
{ value: "", label: t("series.metadataAll") },
|
||||
{ value: "linked", label: t("series.metadataLinked") },
|
||||
{ value: "unlinked", label: t("series.metadataUnlinked") },
|
||||
];
|
||||
|
||||
const sortOptions = [
|
||||
{ value: "", label: t("books.sortTitle") },
|
||||
{ value: "latest", label: t("books.sortLatest") },
|
||||
];
|
||||
|
||||
const hasFilters = searchQuery || libraryId || readingStatus || sort;
|
||||
const hasFilters = searchQuery || libraryId || readingStatus || format || metadataProvider || sort;
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -117,6 +133,8 @@ export default async function BooksPage({
|
||||
{ name: "q", type: "text", label: t("common.search"), placeholder: t("books.searchPlaceholder") },
|
||||
{ name: "library", type: "select", label: t("books.library"), options: libraryOptions },
|
||||
{ name: "status", type: "select", label: t("books.status"), options: statusOptions },
|
||||
{ name: "format", type: "select", label: t("books.format"), options: formatOptions },
|
||||
{ name: "metadata", type: "select", label: t("series.metadata"), options: metadataOptions },
|
||||
{ name: "sort", type: "select", label: t("books.sort"), options: sortOptions },
|
||||
]}
|
||||
/>
|
||||
|
||||
@@ -18,6 +18,10 @@ const FILTER_ICONS: Record<string, string> = {
|
||||
metadata_provider: "M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z",
|
||||
// Sort - arrows up/down
|
||||
sort: "M3 4h13M3 8h9m-9 4h6m4 0l4-4m0 0l4 4m-4-4v12",
|
||||
// Format - document/file
|
||||
format: "M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z",
|
||||
// Metadata - link/chain
|
||||
metadata: "M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1",
|
||||
};
|
||||
|
||||
interface FieldDef {
|
||||
|
||||
@@ -286,6 +286,8 @@ export async function fetchBooks(
|
||||
readingStatus?: string,
|
||||
sort?: string,
|
||||
author?: string,
|
||||
format?: string,
|
||||
metadataProvider?: string,
|
||||
): Promise<BooksPageDto> {
|
||||
const params = new URLSearchParams();
|
||||
if (libraryId) params.set("library_id", libraryId);
|
||||
@@ -293,6 +295,8 @@ export async function fetchBooks(
|
||||
if (readingStatus) params.set("reading_status", readingStatus);
|
||||
if (sort) params.set("sort", sort);
|
||||
if (author) params.set("author", author);
|
||||
if (format) params.set("format", format);
|
||||
if (metadataProvider) params.set("metadata_provider", metadataProvider);
|
||||
params.set("page", page.toString());
|
||||
params.set("limit", limit.toString());
|
||||
|
||||
|
||||
@@ -100,6 +100,8 @@ const en: Record<TranslationKey, string> = {
|
||||
"books.noResults": "No books found for \"{{query}}\"",
|
||||
"books.noBooks": "No books available",
|
||||
"books.coverOf": "Cover of {{name}}",
|
||||
"books.format": "Format",
|
||||
"books.allFormats": "All formats",
|
||||
|
||||
// Series page
|
||||
"series.title": "Series",
|
||||
|
||||
@@ -98,6 +98,8 @@ const fr = {
|
||||
"books.noResults": "Aucun livre trouvé pour \"{{query}}\"",
|
||||
"books.noBooks": "Aucun livre disponible",
|
||||
"books.coverOf": "Couverture de {{name}}",
|
||||
"books.format": "Format",
|
||||
"books.allFormats": "Tous les formats",
|
||||
|
||||
// Series page
|
||||
"series.title": "Séries",
|
||||
|
||||
Reference in New Issue
Block a user