chore: initial commit — monorepo ReadaBook (API NestJS, web PWA, Docker)

This commit is contained in:
Git Agent
2026-08-23 09:56:53 +02:00
commit 8f1140127f
79 changed files with 6456 additions and 0 deletions

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

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

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

View 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 {}

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