import { Injectable, NotFoundException } from "@nestjs/common"; import { createReadStream, existsSync } from "node:fs"; import { and, eq, sql } from "drizzle-orm"; import { BookQueryDto } from "@readabook/shared"; import { DatabaseService } from "../database/database.service.js"; import { books } from "../database/schema.js"; @Injectable() export class BooksService { constructor(private readonly database: DatabaseService) {} list(query: BookQueryDto) { const filters = []; if (query.format) filters.push(eq(books.format, query.format)); if (query.libraryId) filters.push(eq(books.libraryId, query.libraryId)); if (query.q) { return this.search(query.q, query.limit, query.offset); } return this.database.db .select() .from(books) .where(filters.length ? and(...filters) : undefined) .orderBy(books.title) .limit(query.limit) .offset(query.offset) .all(); } search(q: string, limit = 50, offset = 0) { const rows = this.database.sqlite .prepare( ` SELECT books.* FROM book_fts JOIN books ON books.id = book_fts.rowid WHERE book_fts MATCH ? ORDER BY bm25(book_fts) LIMIT ? OFFSET ? ` ) .all(`${q.replace(/"/g, '""')}*`, limit, offset); return (rows as Array>).map(mapBookRow); } get(id: number) { const book = this.database.db.select().from(books).where(eq(books.id, id)).get(); if (!book) { throw new NotFoundException("Book not found"); } return book; } streamFile(id: number) { const book = this.get(id); if (!existsSync(book.filePath)) { throw new NotFoundException("Book file not found on disk"); } return { book, stream: createReadStream(book.filePath) }; } streamCover(id: number) { const book = this.get(id); if (!book.coverPath || !existsSync(book.coverPath)) { throw new NotFoundException("Cover not found"); } return { book, stream: createReadStream(book.coverPath), coverPath: book.coverPath }; } count() { return this.database.db.select({ count: sql`count(*)` }).from(books).get()?.count ?? 0; } } function mapBookRow(row: Record) { return { id: Number(row.id), libraryId: Number(row.library_id), title: String(row.title), author: nullable(row.author), description: nullable(row.description), isbn: nullable(row.isbn), language: nullable(row.language), publisher: nullable(row.publisher), publishedDate: nullable(row.published_date), format: row.format, filePath: String(row.file_path), coverPath: nullable(row.cover_path), fileSize: Number(row.file_size), fileMtime: String(row.file_mtime), createdAt: String(row.created_at), updatedAt: String(row.updated_at) }; } function nullable(value: unknown): string | null { return value === null || value === undefined ? null : String(value); }