Files
bookorbit/server/src/modules/metadata/metadata.service.ts
T

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 });
}
}
}