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)
|
/// Sort order: "title" (default) or "latest" (most recently added first)
|
||||||
#[schema(value_type = Option<String>, example = "latest")]
|
#[schema(value_type = Option<String>, example = "latest")]
|
||||||
pub sort: Option<String>,
|
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)]
|
#[derive(Serialize, ToSchema)]
|
||||||
@@ -108,6 +111,7 @@ pub struct BookDetails {
|
|||||||
("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)"),
|
||||||
|
("metadata_provider" = Option<String>, Query, description = "Filter by metadata provider: 'linked' (any provider), 'unlinked' (no provider), or a specific provider name"),
|
||||||
),
|
),
|
||||||
responses(
|
responses(
|
||||||
(status = 200, body = BooksPage),
|
(status = 200, body = BooksPage),
|
||||||
@@ -141,16 +145,34 @@ pub async fn list_books(
|
|||||||
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)))")
|
||||||
} else { String::new() };
|
} 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!(
|
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 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)
|
WHERE ($1::uuid IS NULL OR b.library_id = $1)
|
||||||
AND ($2::text IS NULL OR b.kind = $2)
|
AND ($2::text IS NULL OR b.kind = $2)
|
||||||
AND ($3::text IS NULL OR b.format = $3)
|
AND ($3::text IS NULL OR b.format = $3)
|
||||||
{series_cond}
|
{series_cond}
|
||||||
{rs_cond}
|
{rs_cond}
|
||||||
{author_cond}"#
|
{author_cond}
|
||||||
|
{metadata_cond}"#
|
||||||
);
|
);
|
||||||
|
|
||||||
let order_clause = if query.sort.as_deref() == Some("latest") {
|
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 offset_p = p + 2;
|
||||||
let data_sql = format!(
|
let data_sql = format!(
|
||||||
r#"
|
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,
|
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,
|
COALESCE(brp.status, 'unread') AS reading_status,
|
||||||
brp.current_page AS reading_current_page,
|
brp.current_page AS reading_current_page,
|
||||||
brp.last_read_at AS reading_last_read_at
|
brp.last_read_at AS reading_last_read_at
|
||||||
FROM books b
|
FROM books b
|
||||||
LEFT JOIN book_reading_progress brp ON brp.book_id = b.id
|
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)
|
WHERE ($1::uuid IS NULL OR b.library_id = $1)
|
||||||
AND ($2::text IS NULL OR b.kind = $2)
|
AND ($2::text IS NULL OR b.kind = $2)
|
||||||
AND ($3::text IS NULL OR b.format = $3)
|
AND ($3::text IS NULL OR b.format = $3)
|
||||||
{series_cond}
|
{series_cond}
|
||||||
{rs_cond}
|
{rs_cond}
|
||||||
{author_cond}
|
{author_cond}
|
||||||
|
{metadata_cond}
|
||||||
ORDER BY {order_clause}
|
ORDER BY {order_clause}
|
||||||
LIMIT ${limit_p} OFFSET ${offset_p}
|
LIMIT ${limit_p} OFFSET ${offset_p}
|
||||||
"#
|
"#
|
||||||
@@ -204,6 +229,12 @@ pub async fn list_books(
|
|||||||
count_builder = count_builder.bind(author.clone());
|
count_builder = count_builder.bind(author.clone());
|
||||||
data_builder = data_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);
|
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 libraryId = typeof searchParamsAwaited.library === "string" ? searchParamsAwaited.library : undefined;
|
||||||
const searchQuery = typeof searchParamsAwaited.q === "string" ? searchParamsAwaited.q : "";
|
const searchQuery = typeof searchParamsAwaited.q === "string" ? searchParamsAwaited.q : "";
|
||||||
const readingStatus = typeof searchParamsAwaited.status === "string" ? searchParamsAwaited.status : undefined;
|
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 sort = typeof searchParamsAwaited.sort === "string" ? searchParamsAwaited.sort : undefined;
|
||||||
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;
|
||||||
@@ -62,7 +64,7 @@ export default async function BooksPage({
|
|||||||
totalHits = searchResponse.estimated_total_hits;
|
totalHits = searchResponse.estimated_total_hits;
|
||||||
}
|
}
|
||||||
} else {
|
} 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[],
|
items: [] as BookDto[],
|
||||||
total: 0,
|
total: 0,
|
||||||
page: 1,
|
page: 1,
|
||||||
@@ -91,12 +93,26 @@ export default async function BooksPage({
|
|||||||
{ value: "read", label: t("status.read") },
|
{ 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 = [
|
const sortOptions = [
|
||||||
{ value: "", label: t("books.sortTitle") },
|
{ value: "", label: t("books.sortTitle") },
|
||||||
{ value: "latest", label: t("books.sortLatest") },
|
{ value: "latest", label: t("books.sortLatest") },
|
||||||
];
|
];
|
||||||
|
|
||||||
const hasFilters = searchQuery || libraryId || readingStatus || sort;
|
const hasFilters = searchQuery || libraryId || readingStatus || format || metadataProvider || sort;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -117,6 +133,8 @@ export default async function BooksPage({
|
|||||||
{ name: "q", type: "text", label: t("common.search"), placeholder: t("books.searchPlaceholder") },
|
{ name: "q", type: "text", label: t("common.search"), placeholder: t("books.searchPlaceholder") },
|
||||||
{ name: "library", type: "select", label: t("books.library"), options: libraryOptions },
|
{ name: "library", type: "select", label: t("books.library"), options: libraryOptions },
|
||||||
{ name: "status", type: "select", label: t("books.status"), options: statusOptions },
|
{ 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 },
|
{ 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",
|
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 - arrows up/down
|
||||||
sort: "M3 4h13M3 8h9m-9 4h6m4 0l4-4m0 0l4 4m-4-4v12",
|
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 {
|
interface FieldDef {
|
||||||
|
|||||||
@@ -286,6 +286,8 @@ export async function fetchBooks(
|
|||||||
readingStatus?: string,
|
readingStatus?: string,
|
||||||
sort?: string,
|
sort?: string,
|
||||||
author?: string,
|
author?: string,
|
||||||
|
format?: string,
|
||||||
|
metadataProvider?: string,
|
||||||
): Promise<BooksPageDto> {
|
): Promise<BooksPageDto> {
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
if (libraryId) params.set("library_id", libraryId);
|
if (libraryId) params.set("library_id", libraryId);
|
||||||
@@ -293,6 +295,8 @@ export async function fetchBooks(
|
|||||||
if (readingStatus) params.set("reading_status", readingStatus);
|
if (readingStatus) params.set("reading_status", readingStatus);
|
||||||
if (sort) params.set("sort", sort);
|
if (sort) params.set("sort", sort);
|
||||||
if (author) params.set("author", author);
|
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("page", page.toString());
|
||||||
params.set("limit", limit.toString());
|
params.set("limit", limit.toString());
|
||||||
|
|
||||||
|
|||||||
@@ -100,6 +100,8 @@ const en: Record<TranslationKey, string> = {
|
|||||||
"books.noResults": "No books found for \"{{query}}\"",
|
"books.noResults": "No books found for \"{{query}}\"",
|
||||||
"books.noBooks": "No books available",
|
"books.noBooks": "No books available",
|
||||||
"books.coverOf": "Cover of {{name}}",
|
"books.coverOf": "Cover of {{name}}",
|
||||||
|
"books.format": "Format",
|
||||||
|
"books.allFormats": "All formats",
|
||||||
|
|
||||||
// Series page
|
// Series page
|
||||||
"series.title": "Series",
|
"series.title": "Series",
|
||||||
|
|||||||
@@ -98,6 +98,8 @@ const fr = {
|
|||||||
"books.noResults": "Aucun livre trouvé pour \"{{query}}\"",
|
"books.noResults": "Aucun livre trouvé pour \"{{query}}\"",
|
||||||
"books.noBooks": "Aucun livre disponible",
|
"books.noBooks": "Aucun livre disponible",
|
||||||
"books.coverOf": "Couverture de {{name}}",
|
"books.coverOf": "Couverture de {{name}}",
|
||||||
|
"books.format": "Format",
|
||||||
|
"books.allFormats": "Tous les formats",
|
||||||
|
|
||||||
// Series page
|
// Series page
|
||||||
"series.title": "Séries",
|
"series.title": "Séries",
|
||||||
|
|||||||
Reference in New Issue
Block a user