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
|
// Only show format chips when there are multiple files
|
||||||
const extraFiles = computed(() => (props.book.files.length > 1 ? props.book.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 coverLoaded = ref(false)
|
||||||
const coverFailed = ref(false)
|
const coverFailed = ref(false)
|
||||||
|
|
||||||
|
|||||||
@@ -16,5 +16,8 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"concurrently": "^9.2.1"
|
"concurrently": "^9.2.1"
|
||||||
|
},
|
||||||
|
"pnpm": {
|
||||||
|
"onlyBuiltDependencies": ["@nestjs/core", "@swc/core", "esbuild", "sharp", "unrs-resolver"]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,6 +40,7 @@
|
|||||||
"pg": "^8.16.3",
|
"pg": "^8.16.3",
|
||||||
"reflect-metadata": "^0.2.2",
|
"reflect-metadata": "^0.2.2",
|
||||||
"rxjs": "^7.8.1",
|
"rxjs": "^7.8.1",
|
||||||
|
"sharp": "^0.34.5",
|
||||||
"unzipper": "^0.12.3"
|
"unzipper": "^0.12.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -55,6 +56,7 @@
|
|||||||
"@types/jest": "^30.0.0",
|
"@types/jest": "^30.0.0",
|
||||||
"@types/node": "^22.10.7",
|
"@types/node": "^22.10.7",
|
||||||
"@types/pg": "^8.16.0",
|
"@types/pg": "^8.16.0",
|
||||||
|
"@types/sharp": "^0.32.0",
|
||||||
"@types/supertest": "^6.0.2",
|
"@types/supertest": "^6.0.2",
|
||||||
"@types/unzipper": "^0.10.11",
|
"@types/unzipper": "^0.10.11",
|
||||||
"eslint": "^9.18.0",
|
"eslint": "^9.18.0",
|
||||||
|
|||||||
@@ -5,28 +5,28 @@
|
|||||||
{
|
{
|
||||||
"idx": 0,
|
"idx": 0,
|
||||||
"version": "7",
|
"version": "7",
|
||||||
"when": 1767368411691,
|
"when": 1767372084746,
|
||||||
"tag": "0000_init_schema",
|
"tag": "0000_init_schema",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"idx": 1,
|
"idx": 1,
|
||||||
"version": "7",
|
"version": "7",
|
||||||
"when": 1767368411691,
|
"when": 1767372084746,
|
||||||
"tag": "0001_lethal_pixie",
|
"tag": "0001_lethal_pixie",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"idx": 2,
|
"idx": 2,
|
||||||
"version": "7",
|
"version": "7",
|
||||||
"when": 1767368411691,
|
"when": 1767372084746,
|
||||||
"tag": "0002_powerful_thundra",
|
"tag": "0002_powerful_thundra",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"idx": 3,
|
"idx": 3,
|
||||||
"version": "7",
|
"version": "7",
|
||||||
"when": 1767368411691,
|
"when": 1767372084746,
|
||||||
"tag": "0003_reading_progress_by_file",
|
"tag": "0003_reading_progress_by_file",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,6 +25,15 @@ export class BookController {
|
|||||||
reply.send(createReadStream(coverPath));
|
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.
|
// Flat file routes — no bookId needed since fileId is globally unique.
|
||||||
// These MUST come before `:id/*` routes to avoid NestJS matching 'files' as :id.
|
// These MUST come before `:id/*` routes to avoid NestJS matching 'files' as :id.
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
import { createReadStream } from 'fs';
|
import { createReadStream } from 'fs';
|
||||||
import { readdir, stat } from 'fs/promises';
|
import { access, readdir, stat } from 'fs/promises';
|
||||||
import { basename, join } from 'path';
|
import { basename, join } from 'path';
|
||||||
|
|
||||||
import { BookRepository } from './book.repository';
|
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 }> {
|
async getFileInfo(fileId: number): Promise<{ path: string; size: number; format: string }> {
|
||||||
const file = await this.bookRepo.findFileById(fileId);
|
const file = await this.bookRepo.findFileById(fileId);
|
||||||
if (!file) throw new NotFoundException(`No file with id ${fileId}`);
|
if (!file) throw new NotFoundException(`No file with id ${fileId}`);
|
||||||
|
|||||||
@@ -1,10 +1,14 @@
|
|||||||
import { mkdir, writeFile } from 'fs/promises';
|
import { mkdir, writeFile } from 'fs/promises';
|
||||||
import { join } from 'path';
|
import { join } from 'path';
|
||||||
|
import sharp from 'sharp';
|
||||||
|
|
||||||
import { extractCbzCover } from './cover-cbz';
|
import { extractCbzCover } from './cover-cbz';
|
||||||
import { extractEpubCover } from './cover-epub';
|
import { extractEpubCover } from './cover-epub';
|
||||||
import { extractMobiCover } from './mobi-parser';
|
import { extractMobiCover } from './mobi-parser';
|
||||||
|
|
||||||
|
const THUMBNAIL_WIDTH = 400;
|
||||||
|
const THUMBNAIL_HEIGHT = 600;
|
||||||
|
|
||||||
/** Returns extension based on magic bytes, defaulting to 'jpg'. */
|
/** Returns extension based on magic bytes, defaulting to 'jpg'. */
|
||||||
function imageExt(bytes: Buffer): string {
|
function imageExt(bytes: Buffer): string {
|
||||||
if (bytes[0] === 0x89 && bytes[1] === 0x50) return 'png';
|
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 ext = imageExt(bytes);
|
||||||
const filePath = join(dir, `cover.${ext}`);
|
const filePath = join(dir, `cover.${ext}`);
|
||||||
await writeFile(filePath, bytes);
|
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;
|
return filePath;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import { NodePgDatabase } from 'drizzle-orm/node-postgres';
|
|||||||
import { DB } from '../../db';
|
import { DB } from '../../db';
|
||||||
import * as schema from '../../db/schema';
|
import * as schema from '../../db/schema';
|
||||||
import { authors, bookAuthors, bookMetadata, bookTags, tags } from '../../db/schema';
|
import { authors, bookAuthors, bookMetadata, bookTags, tags } from '../../db/schema';
|
||||||
|
import sharp from 'sharp';
|
||||||
|
|
||||||
import { extractCbzMetadata } from './lib/cbz-metadata';
|
import { extractCbzMetadata } from './lib/cbz-metadata';
|
||||||
import { extractAndSaveCover } from './lib/cover';
|
import { extractAndSaveCover } from './lib/cover';
|
||||||
import { extractEpubMetadata } from './lib/epub';
|
import { extractEpubMetadata } from './lib/epub';
|
||||||
@@ -164,6 +166,8 @@ export class MetadataService {
|
|||||||
const dir = join(this.booksPath, 'covers', String(bookId));
|
const dir = join(this.booksPath, 'covers', String(bookId));
|
||||||
await mkdir(dir, { recursive: true });
|
await mkdir(dir, { recursive: true });
|
||||||
await writeFile(join(dir, 'cover.jpg'), jpeg);
|
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}`);
|
this.logger.debug(`PDF cover saved for book ${bookId}`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.logger.warn(`PDF cover save failed for book ${bookId}: ${err}`);
|
this.logger.warn(`PDF cover save failed for book ${bookId}: ${err}`);
|
||||||
|
|||||||
Reference in New Issue
Block a user