diff --git a/apps/api/src/books.rs b/apps/api/src/books.rs index 7871867..3a49e73 100644 --- a/apps/api/src/books.rs +++ b/apps/api/src/books.rs @@ -14,6 +14,8 @@ pub struct ListBooksQuery { #[schema(value_type = Option)] pub kind: Option, #[schema(value_type = Option)] + pub series: Option, + #[schema(value_type = Option)] pub cursor: Option, #[schema(value_type = Option, example = 50)] pub limit: Option, @@ -69,6 +71,7 @@ pub struct BookDetails { params( ("library_id" = Option, Query, description = "Filter by library ID"), ("kind" = Option, Query, description = "Filter by book kind (cbz, cbr, pdf)"), + ("series" = Option, Query, description = "Filter by series name (use 'unclassified' for books without series)"), ("cursor" = Option, Query, description = "Cursor for pagination"), ("limit" = Option, Query, description = "Max items to return (max 200)"), ), @@ -83,23 +86,42 @@ pub async fn list_books( Query(query): Query, ) -> Result, ApiError> { let limit = query.limit.unwrap_or(50).clamp(1, 200); - let rows = sqlx::query( + + // Build series filter condition + let series_condition = match query.series.as_deref() { + Some("unclassified") => "AND (series IS NULL OR series = '')", + Some(_series_name) => "AND series = $5", + None => "", + }; + + let sql = format!( r#" SELECT id, library_id, kind, title, author, series, volume, language, page_count, updated_at FROM books WHERE ($1::uuid IS NULL OR library_id = $1) AND ($2::text IS NULL OR kind = $2) AND ($3::uuid IS NULL OR id > $3) + {} ORDER BY id ASC LIMIT $4 "#, - ) - .bind(query.library_id) - .bind(query.kind.as_deref()) - .bind(query.cursor) - .bind(limit + 1) - .fetch_all(&state.pool) - .await?; + series_condition + ); + + let mut query_builder = sqlx::query(&sql) + .bind(query.library_id) + .bind(query.kind.as_deref()) + .bind(query.cursor) + .bind(limit + 1); + + // Bind series parameter if it's not unclassified + if let Some(series) = query.series.as_deref() { + if series != "unclassified" { + query_builder = query_builder.bind(series); + } + } + + let rows = query_builder.fetch_all(&state.pool).await?; let mut items: Vec = rows .iter() @@ -184,3 +206,71 @@ pub async fn get_book( file_parse_status: row.get("parse_status"), })) } + +#[derive(Serialize, ToSchema)] +pub struct SeriesItem { + pub name: String, + pub book_count: i64, + #[schema(value_type = String)] + pub first_book_id: Uuid, +} + +/// List all series in a library +#[utoipa::path( + get, + path = "/libraries/{library_id}/series", + tag = "books", + params( + ("library_id" = String, Path, description = "Library UUID"), + ), + responses( + (status = 200, body = Vec), + (status = 401, description = "Unauthorized"), + ), + security(("Bearer" = [])) +)] +pub async fn list_series( + State(state): State, + Path(library_id): Path, +) -> Result>, ApiError> { + let rows = sqlx::query( + r#" + WITH series_books AS ( + SELECT + COALESCE(NULLIF(series, ''), 'unclassified') as name, + id, + ROW_NUMBER() OVER (PARTITION BY COALESCE(NULLIF(series, ''), 'unclassified') ORDER BY id) as rn + FROM books + WHERE library_id = $1 + ), + series_counts AS ( + SELECT + name, + COUNT(*) as book_count + FROM series_books + GROUP BY name + ) + SELECT + sc.name, + sc.book_count, + sb.id as first_book_id + FROM series_counts sc + JOIN series_books sb ON sb.name = sc.name AND sb.rn = 1 + ORDER BY sc.name ASC + "#, + ) + .bind(library_id) + .fetch_all(&state.pool) + .await?; + + let series: Vec = rows + .iter() + .map(|row| SeriesItem { + name: row.get("name"), + book_count: row.get("book_count"), + first_book_id: row.get("first_book_id"), + }) + .collect(); + + Ok(Json(series)) +} diff --git a/apps/api/src/main.rs b/apps/api/src/main.rs index 8918279..54f0a91 100644 --- a/apps/api/src/main.rs +++ b/apps/api/src/main.rs @@ -110,6 +110,7 @@ async fn main() -> anyhow::Result<()> { .route("/books", get(books::list_books)) .route("/books/:id", get(books::get_book)) .route("/books/:id/pages/:n", get(pages::get_page)) + .route("/libraries/:library_id/series", get(books::list_series)) .route("/search", get(search::search_books)) .route_layer(middleware::from_fn_with_state(state.clone(), read_rate_limit)) .route_layer(middleware::from_fn_with_state( diff --git a/apps/api/src/openapi.rs b/apps/api/src/openapi.rs index 55ea9f8..05beab0 100644 --- a/apps/api/src/openapi.rs +++ b/apps/api/src/openapi.rs @@ -6,6 +6,7 @@ use utoipa::OpenApi; paths( crate::books::list_books, crate::books::get_book, + crate::books::list_series, crate::pages::get_page, crate::search::search_books, crate::index_jobs::enqueue_rebuild, @@ -25,6 +26,7 @@ use utoipa::OpenApi; crate::books::BookItem, crate::books::BooksPage, crate::books::BookDetails, + crate::books::SeriesItem, crate::pages::PageQuery, crate::search::SearchQuery, crate::search::SearchResponse, diff --git a/apps/backoffice/app/api/books/[bookId]/pages/[pageNum]/route.ts b/apps/backoffice/app/api/books/[bookId]/pages/[pageNum]/route.ts new file mode 100644 index 0000000..5197c79 --- /dev/null +++ b/apps/backoffice/app/api/books/[bookId]/pages/[pageNum]/route.ts @@ -0,0 +1,56 @@ +import { NextRequest, NextResponse } from "next/server"; + +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ bookId: string; pageNum: string }> } +) { + const { bookId, pageNum } = await params; + + // Récupérer les query params (format, width, quality) + const { searchParams } = new URL(request.url); + const format = searchParams.get("format") || "webp"; + const width = searchParams.get("width") || ""; + const quality = searchParams.get("quality") || ""; + + // Construire l'URL vers l'API backend + const apiBaseUrl = process.env.API_BASE_URL || "http://api:8080"; + const apiUrl = new URL(`${apiBaseUrl}/books/${bookId}/pages/${pageNum}`); + apiUrl.searchParams.set("format", format); + if (width) apiUrl.searchParams.set("width", width); + if (quality) apiUrl.searchParams.set("quality", quality); + + // Faire la requête à l'API + const token = process.env.API_BOOTSTRAP_TOKEN; + if (!token) { + return new NextResponse("API token not configured", { status: 500 }); + } + + try { + const response = await fetch(apiUrl.toString(), { + headers: { + Authorization: `Bearer ${token}`, + }, + }); + + if (!response.ok) { + return new NextResponse(`Failed to fetch image: ${response.status}`, { + status: response.status + }); + } + + // Récupérer le content-type et les données + const contentType = response.headers.get("content-type") || "image/webp"; + const imageBuffer = await response.arrayBuffer(); + + // Retourner l'image avec le bon content-type + return new NextResponse(imageBuffer, { + headers: { + "Content-Type": contentType, + "Cache-Control": "public, max-age=300", + }, + }); + } catch (error) { + console.error("Error fetching image:", error); + return new NextResponse("Failed to fetch image", { status: 500 }); + } +} diff --git a/apps/backoffice/app/books/[id]/page.tsx b/apps/backoffice/app/books/[id]/page.tsx new file mode 100644 index 0000000..fb11f4e --- /dev/null +++ b/apps/backoffice/app/books/[id]/page.tsx @@ -0,0 +1,99 @@ +import { fetchLibraries, getBookCoverUrl, BookDto, apiFetch } from "../../../lib/api"; +import Image from "next/image"; +import Link from "next/link"; +import { notFound } from "next/navigation"; + +export const dynamic = "force-dynamic"; + +async function fetchBook(bookId: string): Promise { + try { + return await apiFetch(`/books/${bookId}`); + } catch { + return null; + } +} + +export default async function BookDetailPage({ + params +}: { + params: Promise<{ id: string }>; +}) { + const { id } = await params; + const [book, libraries] = await Promise.all([ + fetchBook(id), + fetchLibraries().catch(() => [] as { id: string; name: string }[]) + ]); + + if (!book) { + notFound(); + } + + const library = libraries.find(l => l.id === book.library_id); + + return ( + <> +
+ ← Back to books +
+ +
+
+ {`Cover +
+ +
+

{book.title}

+ + {book.author && ( +

by {book.author}

+ )} + + {book.series && ( +

+ {book.series} + {book.volume && Volume {book.volume}} +

+ )} + +
+
+ Format: + {book.kind.toUpperCase()} +
+ + {book.language && ( +
+ Language: + {book.language.toUpperCase()} +
+ )} + + {book.page_count && ( +
+ Pages: + {book.page_count} +
+ )} + +
+ Library: + {library?.name || book.library_id} +
+ +
+ ID: + {book.id} +
+
+
+
+ + ); +} diff --git a/apps/backoffice/app/books/page.tsx b/apps/backoffice/app/books/page.tsx new file mode 100644 index 0000000..dfa57a4 --- /dev/null +++ b/apps/backoffice/app/books/page.tsx @@ -0,0 +1,108 @@ +import { fetchBooks, searchBooks, fetchLibraries, BookDto, LibraryDto, getBookCoverUrl } from "../../lib/api"; +import { BooksGrid, EmptyState } from "../components/BookCard"; +import Link from "next/link"; + +export const dynamic = "force-dynamic"; + +export default async function BooksPage({ + searchParams +}: { + searchParams: Promise<{ [key: string]: string | string[] | undefined }>; +}) { + const searchParamsAwaited = await searchParams; + const libraryId = typeof searchParamsAwaited.library === "string" ? searchParamsAwaited.library : undefined; + const searchQuery = typeof searchParamsAwaited.q === "string" ? searchParamsAwaited.q : ""; + + const [libraries] = await Promise.all([ + fetchLibraries().catch(() => [] as LibraryDto[]) + ]); + + let books: BookDto[] = []; + let nextCursor: string | null = null; + let searchResults: BookDto[] | null = null; + let totalHits: number | null = null; + + if (searchQuery) { + // Mode recherche + const searchResponse = await searchBooks(searchQuery, libraryId).catch(() => null); + if (searchResponse) { + searchResults = searchResponse.hits.map(hit => ({ + id: hit.id, + library_id: hit.library_id, + kind: hit.kind, + title: hit.title, + author: hit.author, + series: hit.series, + volume: hit.volume, + language: hit.language, + page_count: null, + updated_at: "" + })); + totalHits = searchResponse.estimated_total_hits; + } + } else { + // Mode liste + const booksPage = await fetchBooks(libraryId).catch(() => ({ items: [] as BookDto[], next_cursor: null })); + books = booksPage.items; + nextCursor = booksPage.next_cursor; + } + + const displayBooks = searchResults || books; + + return ( + <> +

Books

+ + {/* Filtres et recherche */} +
+
+ + + + {searchQuery && ( + Clear + )} +
+
+ + {/* Résultats de recherche */} + {searchQuery && totalHits !== null && ( +

+ Found {totalHits} result{totalHits !== 1 ? 's' : ''} for "{searchQuery}" +

+ )} + + {/* Grille de livres */} + {displayBooks.length > 0 ? ( + <> + + + {/* Pagination */} + {!searchQuery && nextCursor && ( +
+
+ + + +
+
+ )} + + ) : ( + + )} + + ); +} diff --git a/apps/backoffice/app/components/BookCard.tsx b/apps/backoffice/app/components/BookCard.tsx new file mode 100644 index 0000000..0664fd8 --- /dev/null +++ b/apps/backoffice/app/components/BookCard.tsx @@ -0,0 +1,70 @@ +import Image from "next/image"; +import Link from "next/link"; +import { BookDto } from "../../lib/api"; + +interface BookCardProps { + book: BookDto; + getBookCoverUrl: (bookId: string) => string; +} + +export function BookCard({ book, getBookCoverUrl }: BookCardProps) { + return ( + +
+ {`Cover +
+
+

+ {book.title} +

+ {book.author && ( +

{book.author}

+ )} + {book.series && ( +

+ {book.series} + {book.volume && ` #${book.volume}`} +

+ )} +
+ {book.kind.toUpperCase()} + {book.language && {book.language.toUpperCase()}} +
+
+ + ); +} + +interface BooksGridProps { + books: BookDto[]; + getBookCoverUrl: (bookId: string) => string; +} + +export function BooksGrid({ books, getBookCoverUrl }: BooksGridProps) { + return ( +
+ {books.map((book) => ( + + ))} +
+ ); +} + +interface EmptyStateProps { + message: string; +} + +export function EmptyState({ message }: EmptyStateProps) { + return ( +
+

{message}

+
+ ); +} diff --git a/apps/backoffice/app/globals.css b/apps/backoffice/app/globals.css index 2bb3a8b..be96f88 100644 --- a/apps/backoffice/app/globals.css +++ b/apps/backoffice/app/globals.css @@ -303,3 +303,406 @@ button:hover { padding: 18px 14px 30px; } } + +/* Books page styles */ +.search-form { + display: flex; + gap: 12px; + align-items: center; + flex-wrap: wrap; +} + +.search-input { + flex: 1; + min-width: 200px; +} + +.results-info { + color: var(--text-muted); + margin: 16px 0; + font-size: 0.95rem; +} + +.books-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); + gap: 24px; + margin: 24px 0; +} + +.book-card { + display: flex; + flex-direction: column; + text-decoration: none; + color: var(--foreground); + background: var(--card); + border: 1px solid var(--line); + border-radius: 12px; + overflow: hidden; + transition: transform 0.2s ease, box-shadow 0.2s ease; + box-shadow: var(--shadow-1); +} + +.book-card:hover { + transform: translateY(-4px); + box-shadow: var(--shadow-2); +} + +.book-cover { + position: relative; + aspect-ratio: 2/3; + background: linear-gradient(135deg, hsl(36 24% 93%), hsl(36 24% 88%)); + overflow: hidden; +} + +.cover-image { + width: 100%; + height: 100%; + object-fit: cover; +} + +.book-info { + padding: 12px; + flex: 1; + display: flex; + flex-direction: column; + gap: 4px; +} + +.book-title { + font-size: 0.95rem; + font-weight: 700; + margin: 0; + line-height: 1.3; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; + font-family: "Avenir Next", "Segoe UI", sans-serif; + letter-spacing: normal; +} + +.book-author { + font-size: 0.85rem; + color: var(--text-muted); + margin: 0; +} + +.book-series { + font-size: 0.8rem; + color: var(--primary); + margin: 0; +} + +.book-meta { + display: flex; + gap: 8px; + margin-top: auto; + padding-top: 8px; +} + +.book-kind { + font-size: 0.7rem; + padding: 2px 6px; + border-radius: 4px; + font-weight: 700; + background: hsl(198 52% 90%); + color: hsl(198 78% 37%); +} + +.book-kind.cbz { + background: hsl(142 60% 90%); + color: hsl(142 60% 35%); +} + +.book-kind.cbr { + background: hsl(45 93% 90%); + color: hsl(45 93% 35%); +} + +.book-kind.pdf { + background: hsl(2 72% 90%); + color: hsl(2 72% 45%); +} + +.book-lang { + font-size: 0.7rem; + padding: 2px 6px; + border-radius: 4px; + background: var(--line); + color: var(--text-muted); +} + +.pagination { + display: flex; + justify-content: center; + margin: 32px 0; +} + +.empty-state { + text-align: center; + padding: 48px 24px; + color: var(--text-muted); +} + +/* Book detail page */ +.breadcrumb { + margin-bottom: 24px; +} + +.breadcrumb a { + color: var(--text-muted); + font-size: 0.9rem; +} + +.breadcrumb a:hover { + color: var(--primary); +} + +.book-detail { + display: grid; + grid-template-columns: 300px 1fr; + gap: 32px; + align-items: start; +} + +@media (max-width: 720px) { + .book-detail { + grid-template-columns: 1fr; + } +} + +.book-detail-cover { + border-radius: 12px; + overflow: hidden; + box-shadow: var(--shadow-2); +} + +.detail-cover-image { + width: 100%; + height: auto; + display: block; +} + +.book-detail-info h1 { + margin: 0 0 16px 0; + font-size: 1.8rem; + line-height: 1.2; +} + +.detail-author { + font-size: 1.1rem; + color: var(--text-muted); + margin: 0 0 16px 0; +} + +.detail-series { + font-size: 1rem; + color: var(--primary); + margin: 0 0 24px 0; +} + +.detail-series .volume { + margin-left: 12px; + padding: 4px 10px; + background: var(--primary-soft); + border-radius: 6px; + font-size: 0.85rem; + color: var(--foreground); +} + +.detail-meta { + background: var(--card); + border: 1px solid var(--line); + border-radius: 12px; + padding: 20px; + box-shadow: var(--shadow-1); +} + +.meta-row { + display: flex; + gap: 12px; + padding: 8px 0; + border-bottom: 1px solid var(--line); +} + +.meta-row:last-child { + border-bottom: none; +} + +.meta-label { + font-weight: 700; + color: var(--text-muted); + min-width: 100px; +} + +.book-id { + font-size: 0.85rem; + color: var(--text-muted); +} + +.dark .book-cover { + background: linear-gradient(135deg, hsl(221 24% 20%), hsl(221 24% 15%)); +} + +.dark .book-kind { + background: hsl(198 52% 25%); + color: hsl(198 78% 75%); +} + +.dark .book-kind.cbz { + background: hsl(142 60% 25%); + color: hsl(142 60% 65%); +} + +.dark .book-kind.cbr { + background: hsl(45 93% 25%); + color: hsl(45 93% 65%); +} + +.dark .book-kind.pdf { + background: hsl(2 72% 25%); + color: hsl(2 72% 65%); +} + +/* Library info styles */ +.library-info { + background: var(--card); + border: 1px solid var(--line); + border-radius: 12px; + padding: 16px 20px; + margin: 16px 0 24px 0; + box-shadow: var(--shadow-1); +} + +.library-info p { + margin: 0; + display: flex; + align-items: center; + gap: 12px; + flex-wrap: wrap; +} + +.library-info code { + font-size: 0.9rem; +} + +.library-info .separator { + color: var(--line-strong); + font-weight: 300; +} + +.status-enabled { + color: hsl(142 60% 45%); + font-weight: 700; +} + +.status-disabled { + color: hsl(220 13% 40%); + font-weight: 700; +} + +.book-count-link { + color: var(--primary); + text-decoration: none; + font-weight: 700; + padding: 2px 8px; + border-radius: 6px; + transition: background 0.2s ease; +} + +.book-count-link:hover { + background: var(--primary-soft); +} + +.series-count-link { + color: var(--primary); + text-decoration: none; + font-weight: 700; + padding: 2px 8px; + border-radius: 6px; + transition: background 0.2s ease; +} + +.series-count-link:hover { + background: var(--primary-soft); +} + +.dark .status-enabled { + color: hsl(142 60% 65%); +} + +.dark .status-disabled { + color: hsl(218 17% 72%); +} + +/* Series grid styles */ +.series-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); + gap: 24px; + margin: 24px 0; +} + +.series-card { + display: flex; + flex-direction: column; + text-decoration: none; + color: var(--foreground); + background: var(--card); + border: 1px solid var(--line); + border-radius: 12px; + overflow: hidden; + transition: transform 0.2s ease, box-shadow 0.2s ease; + box-shadow: var(--shadow-1); +} + +.series-card:hover { + transform: translateY(-4px); + box-shadow: var(--shadow-2); +} + +.series-cover { + position: relative; + aspect-ratio: 2/3; + background: linear-gradient(135deg, hsl(36 24% 93%), hsl(36 24% 88%)); + overflow: hidden; +} + +.series-cover-image { + width: 100%; + height: 100%; + object-fit: cover; +} + +.series-info { + padding: 12px; + flex: 1; + display: flex; + flex-direction: column; + gap: 4px; +} + +.series-name { + font-size: 0.95rem; + font-weight: 700; + margin: 0; + line-height: 1.3; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; + font-family: "Avenir Next", "Segoe UI", sans-serif; + letter-spacing: normal; +} + +.series-count { + font-size: 0.85rem; + color: var(--text-muted); + margin: 0; + margin-top: auto; +} + +.dark .series-cover { + background: linear-gradient(135deg, hsl(221 24% 20%), hsl(221 24% 15%)); +} diff --git a/apps/backoffice/app/layout.tsx b/apps/backoffice/app/layout.tsx index 52f43cc..26215f2 100644 --- a/apps/backoffice/app/layout.tsx +++ b/apps/backoffice/app/layout.tsx @@ -25,6 +25,7 @@ export default function RootLayout({ children }: { children: ReactNode }) {
Dashboard + Books Libraries Jobs Tokens diff --git a/apps/backoffice/lib/api.ts b/apps/backoffice/lib/api.ts index 4751c26..def3402 100644 --- a/apps/backoffice/lib/api.ts +++ b/apps/backoffice/lib/api.ts @@ -30,6 +30,47 @@ export type FolderItem = { path: string; }; +export type BookDto = { + id: string; + library_id: string; + kind: string; + title: string; + author: string | null; + series: string | null; + volume: string | null; + language: string | null; + page_count: number | null; + updated_at: string; +}; + +export type BooksPageDto = { + items: BookDto[]; + next_cursor: string | null; +}; + +export type SearchHitDto = { + id: string; + library_id: string; + title: string; + author: string | null; + series: string | null; + volume: string | null; + kind: string; + language: string | null; +}; + +export type SearchResponseDto = { + hits: SearchHitDto[]; + estimated_total_hits: number | null; + processing_time_ms: number | null; +}; + +export type SeriesDto = { + name: string; + book_count: number; + first_book_id: string; +}; + function config() { const baseUrl = process.env.API_BASE_URL || "http://api:8080"; const token = process.env.API_BOOTSTRAP_TOKEN; @@ -113,3 +154,32 @@ export async function createToken(name: string, scope: string) { export async function revokeToken(id: string) { return apiFetch(`/admin/tokens/${id}`, { method: "DELETE" }); } + +export async function fetchBooks(libraryId?: string, series?: string, cursor?: string, limit: number = 50): Promise { + const params = new URLSearchParams(); + if (libraryId) params.set("library_id", libraryId); + if (series) params.set("series", series); + if (cursor) params.set("cursor", cursor); + params.set("limit", limit.toString()); + + return apiFetch(`/books?${params.toString()}`); +} + +export async function fetchSeries(libraryId: string): Promise { + return apiFetch(`/libraries/${libraryId}/series`); +} + +export async function searchBooks(query: string, libraryId?: string, limit: number = 20): Promise { + const params = new URLSearchParams(); + params.set("q", query); + if (libraryId) params.set("library_id", libraryId); + params.set("limit", limit.toString()); + + return apiFetch(`/search?${params.toString()}`); +} + +export function getBookCoverUrl(bookId: string): string { + // Utiliser une route API locale pour éviter les problèmes CORS + // Le navigateur ne peut pas accéder à http://api:8080 (hostname Docker interne) + return `/api/books/${bookId}/pages/1?format=webp&width=200`; +} diff --git a/apps/indexer/src/main.rs b/apps/indexer/src/main.rs index da47ccf..ff0d08a 100644 --- a/apps/indexer/src/main.rs +++ b/apps/indexer/src/main.rs @@ -228,14 +228,15 @@ async fn scan_library( continue; } - match parse_metadata(path, format) { + match parse_metadata(path, format, root) { Ok(parsed) => { sqlx::query( - "UPDATE books SET title = $2, kind = $3, page_count = $4, updated_at = NOW() WHERE id = $1", + "UPDATE books SET title = $2, kind = $3, series = $4, page_count = $5, updated_at = NOW() WHERE id = $1", ) .bind(book_id) .bind(parsed.title) .bind(kind_from_format(format)) + .bind(parsed.series) .bind(parsed.page_count) .execute(&state.pool) .await?; @@ -268,17 +269,18 @@ async fn scan_library( continue; } - match parse_metadata(path, format) { + match parse_metadata(path, format, root) { Ok(parsed) => { let book_id = Uuid::new_v4(); let file_id = Uuid::new_v4(); sqlx::query( - "INSERT INTO books (id, library_id, kind, title, page_count) VALUES ($1, $2, $3, $4, $5)", + "INSERT INTO books (id, library_id, kind, title, series, page_count) VALUES ($1, $2, $3, $4, $5, $6)", ) .bind(book_id) .bind(library_id) .bind(kind_from_format(format)) .bind(parsed.title) + .bind(parsed.series) .bind(parsed.page_count) .execute(&state.pool) .await?; diff --git a/crates/parsers/src/lib.rs b/crates/parsers/src/lib.rs index 7ebfe6f..ca3147e 100644 --- a/crates/parsers/src/lib.rs +++ b/crates/parsers/src/lib.rs @@ -21,6 +21,7 @@ impl BookFormat { #[derive(Debug, Clone)] pub struct ParsedMetadata { pub title: String, + pub series: Option, pub page_count: Option, } @@ -34,23 +35,47 @@ pub fn detect_format(path: &Path) -> Option { } } -pub fn parse_metadata(path: &Path, format: BookFormat) -> Result { +pub fn parse_metadata( + path: &Path, + format: BookFormat, + library_root: &Path, +) -> Result { let title = path .file_stem() .map(|s| s.to_string_lossy().to_string()) .unwrap_or_else(|| "Untitled".to_string()); + // Determine series from parent folder relative to library root + let series = path.parent().and_then(|parent| { + // Get the relative path from library root to parent + let relative = parent.strip_prefix(library_root).ok()?; + // If relative path is not empty, use first component as series + let first_component = relative.components().next()?; + let series_name = first_component.as_os_str().to_string_lossy().to_string(); + // Only if series_name is not empty + if series_name.is_empty() { + None + } else { + Some(series_name) + } + }); + let page_count = match format { BookFormat::Cbz => parse_cbz_page_count(path).ok(), BookFormat::Cbr => parse_cbr_page_count(path).ok(), BookFormat::Pdf => parse_pdf_page_count(path).ok(), }; - Ok(ParsedMetadata { title, page_count }) + Ok(ParsedMetadata { + title, + series, + page_count, + }) } fn parse_cbz_page_count(path: &Path) -> Result { - let file = std::fs::File::open(path).with_context(|| format!("cannot open cbz: {}", path.display()))?; + let file = std::fs::File::open(path) + .with_context(|| format!("cannot open cbz: {}", path.display()))?; let mut archive = zip::ZipArchive::new(file).context("invalid cbz archive")?; let mut count: i32 = 0; for i in 0..archive.len() { @@ -83,7 +108,8 @@ fn parse_cbr_page_count(path: &Path) -> Result { } fn parse_pdf_page_count(path: &Path) -> Result { - let doc = lopdf::Document::load(path).with_context(|| format!("cannot open pdf: {}", path.display()))?; + let doc = lopdf::Document::load(path) + .with_context(|| format!("cannot open pdf: {}", path.display()))?; Ok(doc.get_pages().len() as i32) }