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