From c4f521abf9f71bf786bf91a46036ce3338e3375d Mon Sep 17 00:00:00 2001 From: neon <18559658+neonsolstice@users.noreply.github.com> Date: Thu, 1 Jan 2026 21:43:48 -0600 Subject: [PATCH] feat(cover): add thumbnail generation and serve via thumbnail endpoint --- .npmrc | 1 + client/src/features/book/components/BookCoverCard.vue | 2 +- package.json | 3 +++ server/package.json | 2 ++ server/src/db/migrations/meta/_journal.json | 8 ++++---- server/src/modules/book/book.controller.ts | 9 +++++++++ server/src/modules/book/book.service.ts | 7 ++++++- server/src/modules/metadata/lib/cover.ts | 8 ++++++++ server/src/modules/metadata/metadata.service.ts | 4 ++++ 9 files changed, 38 insertions(+), 6 deletions(-) create mode 100644 .npmrc diff --git a/.npmrc b/.npmrc new file mode 100644 index 000000000..e60ae7152 --- /dev/null +++ b/.npmrc @@ -0,0 +1 @@ +ignore-scripts=false diff --git a/client/src/features/book/components/BookCoverCard.vue b/client/src/features/book/components/BookCoverCard.vue index d1d99ecca..ab33128ae 100644 --- a/client/src/features/book/components/BookCoverCard.vue +++ b/client/src/features/book/components/BookCoverCard.vue @@ -20,7 +20,7 @@ const primaryFile = computed(() => props.book.files.find((f) => f.role === 'prim // Only show format chips when there are multiple files const extraFiles = computed(() => (props.book.files.length > 1 ? props.book.files : [])) -const coverUrl = `/api/books/${props.book.id}/cover` +const coverUrl = `/api/books/${props.book.id}/thumbnail` const coverLoaded = ref(false) const coverFailed = ref(false) diff --git a/package.json b/package.json index 9ba276c00..60998cf51 100644 --- a/package.json +++ b/package.json @@ -16,5 +16,8 @@ }, "devDependencies": { "concurrently": "^9.2.1" + }, + "pnpm": { + "onlyBuiltDependencies": ["@nestjs/core", "@swc/core", "esbuild", "sharp", "unrs-resolver"] } } diff --git a/server/package.json b/server/package.json index 63a28bb3f..6904f2957 100644 --- a/server/package.json +++ b/server/package.json @@ -40,6 +40,7 @@ "pg": "^8.16.3", "reflect-metadata": "^0.2.2", "rxjs": "^7.8.1", + "sharp": "^0.34.5", "unzipper": "^0.12.3" }, "devDependencies": { @@ -55,6 +56,7 @@ "@types/jest": "^30.0.0", "@types/node": "^22.10.7", "@types/pg": "^8.16.0", + "@types/sharp": "^0.32.0", "@types/supertest": "^6.0.2", "@types/unzipper": "^0.10.11", "eslint": "^9.18.0", diff --git a/server/src/db/migrations/meta/_journal.json b/server/src/db/migrations/meta/_journal.json index 9b005b04e..e55e89b9e 100644 --- a/server/src/db/migrations/meta/_journal.json +++ b/server/src/db/migrations/meta/_journal.json @@ -5,28 +5,28 @@ { "idx": 0, "version": "7", - "when": 1767368411691, + "when": 1767372084746, "tag": "0000_init_schema", "breakpoints": true }, { "idx": 1, "version": "7", - "when": 1767368411691, + "when": 1767372084746, "tag": "0001_lethal_pixie", "breakpoints": true }, { "idx": 2, "version": "7", - "when": 1767368411691, + "when": 1767372084746, "tag": "0002_powerful_thundra", "breakpoints": true }, { "idx": 3, "version": "7", - "when": 1767368411691, + "when": 1767372084746, "tag": "0003_reading_progress_by_file", "breakpoints": true } diff --git a/server/src/modules/book/book.controller.ts b/server/src/modules/book/book.controller.ts index bb2aa8b01..1c73b2bc5 100644 --- a/server/src/modules/book/book.controller.ts +++ b/server/src/modules/book/book.controller.ts @@ -25,6 +25,15 @@ export class BookController { reply.send(createReadStream(coverPath)); } + @Get(':id/thumbnail') + async getThumbnail(@Param('id', ParseIntPipe) id: number, @Res() reply: FastifyReply) { + const thumbnailPath = await this.bookService.getThumbnailPath(id); + if (!thumbnailPath) throw new NotFoundException(`No thumbnail for book ${id}`); + + reply.type('image/jpeg'); + reply.send(createReadStream(thumbnailPath)); + } + // Flat file routes — no bookId needed since fileId is globally unique. // These MUST come before `:id/*` routes to avoid NestJS matching 'files' as :id. diff --git a/server/src/modules/book/book.service.ts b/server/src/modules/book/book.service.ts index 0e70a23c5..3b4398539 100644 --- a/server/src/modules/book/book.service.ts +++ b/server/src/modules/book/book.service.ts @@ -1,7 +1,7 @@ import { Injectable, NotFoundException } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { createReadStream } from 'fs'; -import { readdir, stat } from 'fs/promises'; +import { access, readdir, stat } from 'fs/promises'; import { basename, join } from 'path'; import { BookRepository } from './book.repository'; @@ -62,6 +62,11 @@ export class BookService { } } + async getThumbnailPath(id: number): Promise { + const path = join(this.booksPath, 'covers', String(id), 'thumbnail.jpg'); + return access(path).then(() => path).catch(() => null); + } + async getFileInfo(fileId: number): Promise<{ path: string; size: number; format: string }> { const file = await this.bookRepo.findFileById(fileId); if (!file) throw new NotFoundException(`No file with id ${fileId}`); diff --git a/server/src/modules/metadata/lib/cover.ts b/server/src/modules/metadata/lib/cover.ts index cccf2bbe5..343e97ab0 100644 --- a/server/src/modules/metadata/lib/cover.ts +++ b/server/src/modules/metadata/lib/cover.ts @@ -1,10 +1,14 @@ import { mkdir, writeFile } from 'fs/promises'; import { join } from 'path'; +import sharp from 'sharp'; import { extractCbzCover } from './cover-cbz'; import { extractEpubCover } from './cover-epub'; import { extractMobiCover } from './mobi-parser'; +const THUMBNAIL_WIDTH = 400; +const THUMBNAIL_HEIGHT = 600; + /** Returns extension based on magic bytes, defaulting to 'jpg'. */ function imageExt(bytes: Buffer): string { if (bytes[0] === 0x89 && bytes[1] === 0x50) return 'png'; @@ -44,6 +48,10 @@ export async function extractAndSaveCover(absolutePath: string, format: string, const ext = imageExt(bytes); const filePath = join(dir, `cover.${ext}`); await writeFile(filePath, bytes); + + const thumbnail = await sharp(bytes).resize(THUMBNAIL_WIDTH, THUMBNAIL_HEIGHT, { fit: 'cover', position: 'top' }).jpeg({ quality: 90 }).toBuffer(); + await writeFile(join(dir, 'thumbnail.jpg'), thumbnail); + return filePath; } diff --git a/server/src/modules/metadata/metadata.service.ts b/server/src/modules/metadata/metadata.service.ts index be517b2b9..46d8a7c75 100644 --- a/server/src/modules/metadata/metadata.service.ts +++ b/server/src/modules/metadata/metadata.service.ts @@ -6,6 +6,8 @@ import { NodePgDatabase } from 'drizzle-orm/node-postgres'; import { DB } from '../../db'; import * as schema from '../../db/schema'; import { authors, bookAuthors, bookMetadata, bookTags, tags } from '../../db/schema'; +import sharp from 'sharp'; + import { extractCbzMetadata } from './lib/cbz-metadata'; import { extractAndSaveCover } from './lib/cover'; import { extractEpubMetadata } from './lib/epub'; @@ -164,6 +166,8 @@ export class MetadataService { const dir = join(this.booksPath, 'covers', String(bookId)); await mkdir(dir, { recursive: true }); await writeFile(join(dir, 'cover.jpg'), jpeg); + const thumbnail = await sharp(jpeg).resize(400, 600, { fit: 'cover', position: 'top' }).jpeg({ quality: 90 }).toBuffer(); + await writeFile(join(dir, 'thumbnail.jpg'), thumbnail); this.logger.debug(`PDF cover saved for book ${bookId}`); } catch (err) { this.logger.warn(`PDF cover save failed for book ${bookId}: ${err}`);