diff --git a/apps/api/src/books.rs b/apps/api/src/books.rs index f2aca16..10afdca 100644 --- a/apps/api/src/books.rs +++ b/apps/api/src/books.rs @@ -29,6 +29,9 @@ pub struct ListBooksQuery { /// Sort order: "title" (default) or "latest" (most recently added first) #[schema(value_type = Option, example = "latest")] pub sort: Option, + /// Filter by metadata provider: "linked" (any provider), "unlinked" (no provider), or a specific provider name + #[schema(value_type = Option, example = "linked")] + pub metadata_provider: Option, } #[derive(Serialize, ToSchema)] @@ -108,6 +111,7 @@ pub struct BookDetails { ("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)"), + ("metadata_provider" = Option, 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); diff --git a/apps/backoffice/app/books/page.tsx b/apps/backoffice/app/books/page.tsx index 5cb0cbc..c882f79 100644 --- a/apps/backoffice/app/books/page.tsx +++ b/apps/backoffice/app/books/page.tsx @@ -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 }, ]} /> diff --git a/apps/backoffice/app/components/LiveSearchForm.tsx b/apps/backoffice/app/components/LiveSearchForm.tsx index 477bb68..72f1e89 100644 --- a/apps/backoffice/app/components/LiveSearchForm.tsx +++ b/apps/backoffice/app/components/LiveSearchForm.tsx @@ -18,6 +18,10 @@ const FILTER_ICONS: Record = { 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 { diff --git a/apps/backoffice/lib/api.ts b/apps/backoffice/lib/api.ts index f772832..6da3421 100644 --- a/apps/backoffice/lib/api.ts +++ b/apps/backoffice/lib/api.ts @@ -286,6 +286,8 @@ export async function fetchBooks( readingStatus?: string, sort?: string, author?: string, + format?: string, + metadataProvider?: string, ): Promise { 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()); diff --git a/apps/backoffice/lib/i18n/en.ts b/apps/backoffice/lib/i18n/en.ts index 6c7208b..121dd09 100644 --- a/apps/backoffice/lib/i18n/en.ts +++ b/apps/backoffice/lib/i18n/en.ts @@ -100,6 +100,8 @@ const en: Record = { "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", diff --git a/apps/backoffice/lib/i18n/fr.ts b/apps/backoffice/lib/i18n/fr.ts index 870ea19..192996a 100644 --- a/apps/backoffice/lib/i18n/fr.ts +++ b/apps/backoffice/lib/i18n/fr.ts @@ -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",