92 lines
3.3 KiB
TypeScript
92 lines
3.3 KiB
TypeScript
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;
|
|
}
|
|
}
|
|
}
|