mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
feat(cover): add thumbnail generation and serve via thumbnail endpoint
This commit is contained in:
@@ -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)
|
||||
|
||||
|
||||
@@ -16,5 +16,8 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"concurrently": "^9.2.1"
|
||||
},
|
||||
"pnpm": {
|
||||
"onlyBuiltDependencies": ["@nestjs/core", "@swc/core", "esbuild", "sharp", "unrs-resolver"]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
Reference in New Issue
Block a user