feat(cover): add thumbnail generation and serve via thumbnail endpoint

This commit is contained in:
neon
2026-01-01 21:43:48 -06:00
parent 57b58a9404
commit c4f521abf9
9 changed files with 38 additions and 6 deletions
+1
View File
@@ -0,0 +1 @@
ignore-scripts=false
@@ -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)
+3
View File
@@ -16,5 +16,8 @@
},
"devDependencies": {
"concurrently": "^9.2.1"
},
"pnpm": {
"onlyBuiltDependencies": ["@nestjs/core", "@swc/core", "esbuild", "sharp", "unrs-resolver"]
}
}
+2
View File
@@ -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",
+4 -4
View File
@@ -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
}
@@ -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.
+6 -1
View File
@@ -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<string | null> {
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}`);
+8
View File
@@ -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;
}
@@ -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}`);