mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
210 lines
7.9 KiB
TypeScript
210 lines
7.9 KiB
TypeScript
import { Inject, Injectable, Logger } from '@nestjs/common';
|
|
import { ConfigService } from '@nestjs/config';
|
|
import { eq } from 'drizzle-orm';
|
|
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 { extractCbzMetadata } from './lib/cbz-metadata';
|
|
import { extractAndSaveCover } from './lib/cover';
|
|
import { extractEpubMetadata } from './lib/epub';
|
|
import { parseMobiFile } from './lib/mobi-parser';
|
|
import { parsePdfFile } from './lib/pdf-parser';
|
|
|
|
type Db = NodePgDatabase<typeof schema>;
|
|
|
|
@Injectable()
|
|
export class MetadataService {
|
|
private readonly logger = new Logger(MetadataService.name);
|
|
private readonly booksPath: string;
|
|
|
|
constructor(
|
|
@Inject(DB) private readonly db: Db,
|
|
private readonly config: ConfigService,
|
|
) {
|
|
this.booksPath = this.config.get<string>('storage.booksPath')!;
|
|
}
|
|
|
|
// ── Public API ───────────────────────────────────────────────────────────────
|
|
|
|
async extractAndSave(bookId: number, absolutePath: string, format: string): Promise<void> {
|
|
await Promise.all([this.extractMetadata(bookId, absolutePath, format), this.extractCover(bookId, absolutePath, format)]);
|
|
}
|
|
|
|
// ── Metadata ─────────────────────────────────────────────────────────────────
|
|
|
|
private async extractMetadata(bookId: number, absolutePath: string, format: string): Promise<void> {
|
|
let parsed: {
|
|
title?: string | null;
|
|
subtitle?: string | null;
|
|
description?: string | null;
|
|
isbn10?: string | null;
|
|
isbn13?: string | null;
|
|
publisher?: string | null;
|
|
publishedYear?: number | null;
|
|
language?: string | null;
|
|
seriesName?: string | null;
|
|
seriesIndex?: number | null;
|
|
authors: { name: string; sortName: string | null }[];
|
|
tags: string[];
|
|
} | null = null;
|
|
|
|
if (format === 'epub') {
|
|
parsed = await extractEpubMetadata(absolutePath);
|
|
} else if (format === 'cbz' || format === 'cbr') {
|
|
const cbz = await extractCbzMetadata(absolutePath);
|
|
if (cbz) {
|
|
parsed = {
|
|
title: cbz.title,
|
|
subtitle: null,
|
|
description: cbz.description,
|
|
isbn10: null,
|
|
isbn13: null,
|
|
publisher: cbz.publisher,
|
|
publishedYear: cbz.publishedYear,
|
|
language: cbz.language,
|
|
seriesName: cbz.seriesName,
|
|
seriesIndex: cbz.seriesIndex,
|
|
authors: cbz.authors,
|
|
tags: cbz.tags,
|
|
};
|
|
}
|
|
} else if (format === 'mobi' || format === 'azw3' || format === 'azw') {
|
|
const mobi = await parseMobiFile(absolutePath);
|
|
if (mobi) {
|
|
const year = mobi.publishedDate ? parseInt(mobi.publishedDate.substring(0, 4), 10) || null : null;
|
|
parsed = {
|
|
title: mobi.title,
|
|
subtitle: null,
|
|
description: mobi.description,
|
|
isbn10: null,
|
|
isbn13: mobi.isbn,
|
|
publisher: mobi.publisher,
|
|
publishedYear: year,
|
|
language: mobi.language,
|
|
seriesName: null,
|
|
seriesIndex: null,
|
|
authors: mobi.authors.map((name) => ({ name, sortName: null })),
|
|
tags: mobi.tags,
|
|
};
|
|
}
|
|
} else if (format === 'pdf') {
|
|
const pdf = await parsePdfFile(absolutePath);
|
|
if (pdf) {
|
|
parsed = {
|
|
title: pdf.title,
|
|
subtitle: null,
|
|
description: pdf.subject,
|
|
isbn10: null,
|
|
isbn13: null,
|
|
publisher: null,
|
|
publishedYear: null,
|
|
language: null,
|
|
seriesName: null,
|
|
seriesIndex: null,
|
|
authors: pdf.authors,
|
|
tags: pdf.keywords,
|
|
};
|
|
if (pdf.pageCount !== null) {
|
|
await this.db.update(bookMetadata).set({ pageCount: pdf.pageCount }).where(eq(bookMetadata.bookId, bookId));
|
|
}
|
|
if (pdf.coverBuffer) {
|
|
await this.savePdfCover(bookId, pdf.coverBuffer);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!parsed) return;
|
|
|
|
await this.db
|
|
.update(bookMetadata)
|
|
.set({
|
|
title: parsed.title,
|
|
subtitle: parsed.subtitle,
|
|
description: parsed.description,
|
|
isbn10: parsed.isbn10,
|
|
isbn13: parsed.isbn13,
|
|
publisher: parsed.publisher,
|
|
publishedYear: parsed.publishedYear,
|
|
language: parsed.language,
|
|
seriesName: parsed.seriesName,
|
|
seriesIndex: parsed.seriesIndex,
|
|
updatedAt: new Date(),
|
|
})
|
|
.where(eq(bookMetadata.bookId, bookId));
|
|
|
|
await this.saveAuthors(bookId, parsed.authors);
|
|
await this.saveTags(bookId, parsed.tags);
|
|
|
|
this.logger.debug(`Metadata saved for book ${bookId}: "${parsed.title}"`);
|
|
}
|
|
|
|
// ── Cover ────────────────────────────────────────────────────────────────────
|
|
|
|
private async extractCover(bookId: number, absolutePath: string, format: string): Promise<void> {
|
|
try {
|
|
const saved = await extractAndSaveCover(absolutePath, format, bookId, this.booksPath);
|
|
if (saved) {
|
|
this.logger.debug(`Cover saved for book ${bookId}: ${saved}`);
|
|
}
|
|
} catch (err) {
|
|
this.logger.warn(`Cover extraction failed for book ${bookId}: ${err}`);
|
|
}
|
|
}
|
|
|
|
// ── PDF cover ────────────────────────────────────────────────────────────────
|
|
|
|
private async savePdfCover(bookId: number, jpeg: Buffer): Promise<void> {
|
|
try {
|
|
const { mkdir, writeFile } = await import('fs/promises');
|
|
const { join } = await import('path');
|
|
const dir = join(this.booksPath, 'covers', String(bookId));
|
|
await mkdir(dir, { recursive: true });
|
|
await writeFile(join(dir, 'cover.jpg'), jpeg);
|
|
this.logger.debug(`PDF cover saved for book ${bookId}`);
|
|
} catch (err) {
|
|
this.logger.warn(`PDF cover save failed for book ${bookId}: ${err}`);
|
|
}
|
|
}
|
|
|
|
// ── Authors ──────────────────────────────────────────────────────────────────
|
|
|
|
private async saveAuthors(bookId: number, parsedAuthors: { name: string; sortName: string | null }[]) {
|
|
if (parsedAuthors.length === 0) return;
|
|
|
|
await this.db.delete(bookAuthors).where(eq(bookAuthors.bookId, bookId));
|
|
|
|
for (let i = 0; i < parsedAuthors.length; i++) {
|
|
const { name, sortName } = parsedAuthors[i]!;
|
|
|
|
let [author] = await this.db.select().from(authors).where(eq(authors.name, name)).limit(1);
|
|
|
|
if (!author) {
|
|
[author] = await this.db.insert(authors).values({ name, sortName }).returning();
|
|
}
|
|
|
|
await this.db.insert(bookAuthors).values({ bookId, authorId: author!.id, displayOrder: i });
|
|
}
|
|
}
|
|
|
|
// ── Tags ─────────────────────────────────────────────────────────────────────
|
|
|
|
private async saveTags(bookId: number, parsedTags: string[]) {
|
|
await this.db.delete(bookTags).where(eq(bookTags.bookId, bookId));
|
|
const uniqueTags = [...new Set(parsedTags.map((t) => t.substring(0, 200)).filter(Boolean))];
|
|
if (uniqueTags.length === 0) return;
|
|
|
|
for (const rawName of uniqueTags) {
|
|
const name = rawName;
|
|
let [tag] = await this.db.select().from(tags).where(eq(tags.name, name)).limit(1);
|
|
|
|
if (!tag) {
|
|
[tag] = await this.db.insert(tags).values({ name }).returning();
|
|
}
|
|
|
|
await this.db.insert(bookTags).values({ bookId, tagId: tag!.id });
|
|
}
|
|
}
|
|
}
|