chore: initial commit — monorepo ReadaBook (API NestJS, web PWA, Docker)
This commit is contained in:
43
apps/api/src/books/books.controller.ts
Normal file
43
apps/api/src/books/books.controller.ts
Normal file
@ -0,0 +1,43 @@
|
||||
import { Controller, Get, Param, Query, Res, UseGuards } from "@nestjs/common";
|
||||
import { FastifyReply } from "fastify";
|
||||
import { lookup } from "mime-types";
|
||||
import { BookQueryDto, BookQuerySchema } from "@readabook/shared";
|
||||
import { AuthGuard } from "../auth/auth.guard.js";
|
||||
import { ZodValidationPipe } from "../common/zod-validation.pipe.js";
|
||||
import { BooksService } from "./books.service.js";
|
||||
|
||||
@Controller("books")
|
||||
@UseGuards(AuthGuard)
|
||||
export class BooksController {
|
||||
constructor(private readonly books: BooksService) {}
|
||||
|
||||
@Get()
|
||||
list(@Query(new ZodValidationPipe(BookQuerySchema)) query: BookQueryDto) {
|
||||
return this.books.list(query);
|
||||
}
|
||||
|
||||
@Get("search")
|
||||
search(@Query(new ZodValidationPipe(BookQuerySchema)) query: BookQueryDto) {
|
||||
return query.q ? this.books.search(query.q, query.limit, query.offset) : [];
|
||||
}
|
||||
|
||||
@Get(":id")
|
||||
get(@Param("id") id: string) {
|
||||
return this.books.get(Number(id));
|
||||
}
|
||||
|
||||
@Get(":id/file")
|
||||
file(@Param("id") id: string, @Res() reply: FastifyReply) {
|
||||
const { book, stream } = this.books.streamFile(Number(id));
|
||||
reply.header("Content-Type", lookup(book.filePath) || "application/octet-stream");
|
||||
reply.header("Content-Disposition", `inline; filename="${encodeURIComponent(book.title)}.${book.format}"`);
|
||||
return reply.send(stream);
|
||||
}
|
||||
|
||||
@Get(":id/cover")
|
||||
cover(@Param("id") id: string, @Res() reply: FastifyReply) {
|
||||
const { coverPath, stream } = this.books.streamCover(Number(id));
|
||||
reply.header("Content-Type", lookup(coverPath) || "image/jpeg");
|
||||
return reply.send(stream);
|
||||
}
|
||||
}
|
||||
13
apps/api/src/books/books.module.ts
Normal file
13
apps/api/src/books/books.module.ts
Normal file
@ -0,0 +1,13 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { AuthModule } from "../auth/auth.module.js";
|
||||
import { DatabaseModule } from "../database/database.module.js";
|
||||
import { BooksController } from "./books.controller.js";
|
||||
import { BooksService } from "./books.service.js";
|
||||
|
||||
@Module({
|
||||
imports: [AuthModule, DatabaseModule],
|
||||
controllers: [BooksController],
|
||||
providers: [BooksService],
|
||||
exports: [BooksService]
|
||||
})
|
||||
export class BooksModule {}
|
||||
97
apps/api/src/books/books.service.ts
Normal file
97
apps/api/src/books/books.service.ts
Normal file
@ -0,0 +1,97 @@
|
||||
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<Record<string, unknown>>).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<number>`count(*)` }).from(books).get()?.count ?? 0;
|
||||
}
|
||||
}
|
||||
|
||||
function mapBookRow(row: Record<string, unknown>) {
|
||||
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);
|
||||
}
|
||||
Reference in New Issue
Block a user