chore: initial commit — monorepo ReadaBook (API NestJS, web PWA, Docker)
This commit is contained in:
18
apps/api/src/scanner/metadata.test.ts
Normal file
18
apps/api/src/scanner/metadata.test.ts
Normal file
@ -0,0 +1,18 @@
|
||||
import { mkdtempSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { extractMetadata } from "./metadata.js";
|
||||
|
||||
describe("pdf metadata extraction", () => {
|
||||
it("falls back to file name and reads simple PDF info fields", () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "readabook-"));
|
||||
const file = join(dir, "Example.pdf");
|
||||
writeFileSync(file, "%PDF-1.4\n1 0 obj << /Title (My Book) /Author (Ada) >> endobj");
|
||||
|
||||
const metadata = extractMetadata(file, dir);
|
||||
|
||||
expect(metadata.title).toBe("My Book");
|
||||
expect(metadata.author).toBe("Ada");
|
||||
});
|
||||
});
|
||||
149
apps/api/src/scanner/metadata.ts
Normal file
149
apps/api/src/scanner/metadata.ts
Normal file
@ -0,0 +1,149 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { basename, dirname, extname, join } from "node:path";
|
||||
import AdmZip from "adm-zip";
|
||||
import { XMLParser } from "fast-xml-parser";
|
||||
|
||||
export type BookMetadata = {
|
||||
title: string;
|
||||
author: string | null;
|
||||
description: string | null;
|
||||
isbn: string | null;
|
||||
language: string | null;
|
||||
publisher: string | null;
|
||||
publishedDate: string | null;
|
||||
coverPath: string | null;
|
||||
};
|
||||
|
||||
const xmlParser = new XMLParser({
|
||||
ignoreAttributes: false,
|
||||
attributeNamePrefix: "@_",
|
||||
textNodeName: "#text"
|
||||
});
|
||||
|
||||
export function extractMetadata(filePath: string, storageDir: string): BookMetadata {
|
||||
const extension = extname(filePath).toLowerCase();
|
||||
if (extension === ".epub") {
|
||||
return extractEpubMetadata(filePath, storageDir);
|
||||
}
|
||||
return extractPdfMetadata(filePath);
|
||||
}
|
||||
|
||||
function extractEpubMetadata(filePath: string, storageDir: string): BookMetadata {
|
||||
const zip = new AdmZip(filePath);
|
||||
const containerXml = zip.readAsText("META-INF/container.xml");
|
||||
const container = xmlParser.parse(containerXml);
|
||||
const rootfile = container?.container?.rootfiles?.rootfile;
|
||||
const opfPath = Array.isArray(rootfile) ? rootfile[0]?.["@_full-path"] : rootfile?.["@_full-path"];
|
||||
if (!opfPath) {
|
||||
return fallbackMetadata(filePath);
|
||||
}
|
||||
|
||||
const opf = xmlParser.parse(zip.readAsText(opfPath));
|
||||
const metadata = opf?.package?.metadata ?? {};
|
||||
const manifest = opf?.package?.manifest?.item;
|
||||
const opfDir = dirname(opfPath) === "." ? "" : dirname(opfPath);
|
||||
const title = firstText(metadata["dc:title"]) ?? basename(filePath, extname(filePath));
|
||||
const author = firstText(metadata["dc:creator"]);
|
||||
const isbn = findIsbn(metadata["dc:identifier"]);
|
||||
const coverHref = findCoverHref(manifest, metadata.meta);
|
||||
const coverPath = coverHref ? extractCover(zip, join(opfDir, coverHref), filePath, storageDir) : null;
|
||||
|
||||
return {
|
||||
title,
|
||||
author,
|
||||
description: firstText(metadata["dc:description"]),
|
||||
isbn,
|
||||
language: firstText(metadata["dc:language"]),
|
||||
publisher: firstText(metadata["dc:publisher"]),
|
||||
publishedDate: firstText(metadata["dc:date"]),
|
||||
coverPath
|
||||
};
|
||||
}
|
||||
|
||||
function extractPdfMetadata(filePath: string): BookMetadata {
|
||||
const buffer = readFileSync(filePath);
|
||||
const head = buffer.subarray(0, Math.min(buffer.length, 256 * 1024)).toString("latin1");
|
||||
const title = decodePdfString(matchPdfInfo(head, "Title")) ?? basename(filePath, extname(filePath));
|
||||
const author = decodePdfString(matchPdfInfo(head, "Author"));
|
||||
return {
|
||||
title,
|
||||
author,
|
||||
description: decodePdfString(matchPdfInfo(head, "Subject")),
|
||||
isbn: findIsbnInText(head),
|
||||
language: null,
|
||||
publisher: null,
|
||||
publishedDate: null,
|
||||
coverPath: null
|
||||
};
|
||||
}
|
||||
|
||||
function fallbackMetadata(filePath: string): BookMetadata {
|
||||
return {
|
||||
title: basename(filePath, extname(filePath)),
|
||||
author: null,
|
||||
description: null,
|
||||
isbn: null,
|
||||
language: null,
|
||||
publisher: null,
|
||||
publishedDate: null,
|
||||
coverPath: null
|
||||
};
|
||||
}
|
||||
|
||||
function firstText(value: unknown): string | null {
|
||||
if (!value) return null;
|
||||
const first = Array.isArray(value) ? value[0] : value;
|
||||
if (typeof first === "string") return first.trim() || null;
|
||||
if (typeof first === "object" && first !== null && "#text" in first) {
|
||||
const text = String((first as Record<string, unknown>)["#text"]).trim();
|
||||
return text || null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function findIsbn(value: unknown): string | null {
|
||||
const values = Array.isArray(value) ? value : value ? [value] : [];
|
||||
for (const candidate of values) {
|
||||
const text = firstText(candidate);
|
||||
const isbn = text ? findIsbnInText(text) : null;
|
||||
if (isbn) return isbn;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function findIsbnInText(text: string): string | null {
|
||||
const match = text.match(/(?:97[89][-\s]?)?(?:\d[-\s]?){9,12}[\dX]/i);
|
||||
return match ? match[0].replace(/[-\s]/g, "").toUpperCase() : null;
|
||||
}
|
||||
|
||||
function findCoverHref(manifestValue: unknown, metaValue: unknown): string | null {
|
||||
const manifest = Array.isArray(manifestValue) ? manifestValue : manifestValue ? [manifestValue] : [];
|
||||
const metas = Array.isArray(metaValue) ? metaValue : metaValue ? [metaValue] : [];
|
||||
const coverId = metas.find((meta) => meta?.["@_name"] === "cover")?.["@_content"];
|
||||
const cover =
|
||||
manifest.find((item) => coverId && item?.["@_id"] === coverId) ??
|
||||
manifest.find((item) => String(item?.["@_properties"] ?? "").includes("cover-image")) ??
|
||||
manifest.find((item) => String(item?.["@_media-type"] ?? "").startsWith("image/"));
|
||||
return cover?.["@_href"] ?? null;
|
||||
}
|
||||
|
||||
function extractCover(zip: AdmZip, coverPathInZip: string, filePath: string, storageDir: string): string | null {
|
||||
const entry = zip.getEntry(coverPathInZip.replace(/\\/g, "/"));
|
||||
if (!entry) return null;
|
||||
const extension = extname(entry.entryName) || ".jpg";
|
||||
const hash = createHash("sha256").update(filePath).digest("hex").slice(0, 24);
|
||||
const target = join(storageDir, "covers", `${hash}${extension}`);
|
||||
mkdirSync(dirname(target), { recursive: true });
|
||||
writeFileSync(target, entry.getData());
|
||||
return target;
|
||||
}
|
||||
|
||||
function matchPdfInfo(text: string, key: string): string | null {
|
||||
return text.match(new RegExp(`/${key}\\s*\\(([^)]{1,500})\\)`))?.[1] ?? null;
|
||||
}
|
||||
|
||||
function decodePdfString(value: string | null): string | null {
|
||||
if (!value) return null;
|
||||
return value.replace(/\\([()\\])/g, "$1").trim() || null;
|
||||
}
|
||||
34
apps/api/src/scanner/open-library.service.ts
Normal file
34
apps/api/src/scanner/open-library.service.ts
Normal file
@ -0,0 +1,34 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { BookMetadata } from "./metadata.js";
|
||||
|
||||
@Injectable()
|
||||
export class OpenLibraryService {
|
||||
async enrich(metadata: BookMetadata): Promise<Partial<BookMetadata>> {
|
||||
const query = metadata.isbn
|
||||
? `isbn:${encodeURIComponent(metadata.isbn)}`
|
||||
: `title:${encodeURIComponent(metadata.title)}`;
|
||||
const response = await fetch(`https://openlibrary.org/search.json?q=${query}&limit=1`, {
|
||||
headers: { "User-Agent": "ReadaBook/0.1 local-library-manager" },
|
||||
signal: AbortSignal.timeout(4000)
|
||||
});
|
||||
if (!response.ok) {
|
||||
return {};
|
||||
}
|
||||
const data = (await response.json()) as { docs?: Array<Record<string, unknown>> };
|
||||
const doc = data.docs?.[0];
|
||||
if (!doc) return {};
|
||||
|
||||
return {
|
||||
author: metadata.author ?? firstArrayValue(doc.author_name),
|
||||
language: metadata.language ?? firstArrayValue(doc.language),
|
||||
publisher: metadata.publisher ?? firstArrayValue(doc.publisher),
|
||||
publishedDate: metadata.publishedDate ?? (String(doc.first_publish_year ?? "") || null),
|
||||
isbn: metadata.isbn ?? firstArrayValue(doc.isbn)
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function firstArrayValue(value: unknown): string | null {
|
||||
if (!Array.isArray(value) || !value.length) return null;
|
||||
return String(value[0]);
|
||||
}
|
||||
12
apps/api/src/scanner/scanner.module.ts
Normal file
12
apps/api/src/scanner/scanner.module.ts
Normal file
@ -0,0 +1,12 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { DatabaseModule } from "../database/database.module.js";
|
||||
import { JobsModule } from "../jobs/jobs.module.js";
|
||||
import { OpenLibraryService } from "./open-library.service.js";
|
||||
import { ScannerService } from "./scanner.service.js";
|
||||
|
||||
@Module({
|
||||
imports: [DatabaseModule, JobsModule],
|
||||
providers: [ScannerService, OpenLibraryService],
|
||||
exports: [ScannerService]
|
||||
})
|
||||
export class ScannerModule {}
|
||||
91
apps/api/src/scanner/scanner.service.ts
Normal file
91
apps/api/src/scanner/scanner.service.ts
Normal file
@ -0,0 +1,91 @@
|
||||
import { Injectable, NotFoundException } from "@nestjs/common";
|
||||
import { readdirSync, statSync } from "node:fs";
|
||||
import { extname, join } from "node:path";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { DatabaseService } from "../database/database.service.js";
|
||||
import { books, libraries } from "../database/schema.js";
|
||||
import { JobsService } from "../jobs/jobs.service.js";
|
||||
import { extractMetadata } from "./metadata.js";
|
||||
import { OpenLibraryService } from "./open-library.service.js";
|
||||
|
||||
@Injectable()
|
||||
export class ScannerService {
|
||||
constructor(
|
||||
private readonly database: DatabaseService,
|
||||
private readonly jobs: JobsService,
|
||||
private readonly openLibrary: OpenLibraryService
|
||||
) {}
|
||||
|
||||
enqueueLibraryScan(libraryId: number) {
|
||||
const library = this.database.db.select().from(libraries).where(eq(libraries.id, libraryId)).get();
|
||||
if (!library) {
|
||||
throw new NotFoundException("Library not found");
|
||||
}
|
||||
const job = this.jobs.create("library-scan", `Scanning ${library.path}`);
|
||||
setImmediate(() => {
|
||||
void this.scanLibrary(job.id, library).catch((error) => this.jobs.markFailed(job.id, error));
|
||||
});
|
||||
return job;
|
||||
}
|
||||
|
||||
private async scanLibrary(jobId: number, library: typeof libraries.$inferSelect): Promise<void> {
|
||||
this.jobs.markRunning(jobId, `Scanning ${library.path}`);
|
||||
let count = 0;
|
||||
for (const filePath of walkBooks(library.path)) {
|
||||
await this.ingestFile(library.id, filePath);
|
||||
count += 1;
|
||||
}
|
||||
this.jobs.markSucceeded(jobId, `Scanned ${count} file(s)`);
|
||||
}
|
||||
|
||||
private async ingestFile(libraryId: number, filePath: string): Promise<void> {
|
||||
const stats = statSync(filePath);
|
||||
let metadata = extractMetadata(filePath, this.database.config.storageDir);
|
||||
if (this.database.config.openLibraryEnabled) {
|
||||
try {
|
||||
metadata = { ...metadata, ...(await this.openLibrary.enrich(metadata)) };
|
||||
} catch {
|
||||
// Remote enrichment is opportunistic; local ingestion must stay deterministic.
|
||||
}
|
||||
}
|
||||
|
||||
const now = this.database.now();
|
||||
const format: "epub" | "pdf" = extname(filePath).toLowerCase() === ".epub" ? "epub" : "pdf";
|
||||
const existing = this.database.db.select({ id: books.id }).from(books).where(eq(books.filePath, filePath)).get();
|
||||
const values = {
|
||||
libraryId,
|
||||
title: metadata.title,
|
||||
author: metadata.author,
|
||||
description: metadata.description,
|
||||
isbn: metadata.isbn,
|
||||
language: metadata.language,
|
||||
publisher: metadata.publisher,
|
||||
publishedDate: metadata.publishedDate,
|
||||
format,
|
||||
filePath,
|
||||
coverPath: metadata.coverPath,
|
||||
fileSize: stats.size,
|
||||
fileMtime: stats.mtime.toISOString(),
|
||||
updatedAt: now
|
||||
};
|
||||
|
||||
existing
|
||||
? this.database.db.update(books).set(values).where(eq(books.id, existing.id)).returning().get()
|
||||
: this.database.db.insert(books).values({ ...values, createdAt: now }).returning().get();
|
||||
}
|
||||
}
|
||||
|
||||
function* walkBooks(root: string): Generator<string> {
|
||||
for (const entry of readdirSync(root, { withFileTypes: true })) {
|
||||
const path = join(root, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
yield* walkBooks(path);
|
||||
continue;
|
||||
}
|
||||
if (!entry.isFile()) continue;
|
||||
const extension = extname(entry.name).toLowerCase();
|
||||
if (extension === ".epub" || extension === ".pdf") {
|
||||
yield path;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user