Files
ReadaBook/apps/api/src/scanner/scanner.service.ts
Git Agent 369fbb0e07 feat(api,web): support CBR — extraction RAR, scan et lecture d'albums
- api: cbr utilitaire (extraction RAR), scanner/métadonnées (+ tests), books, schéma
- web: ReaderPage/locators (+ tests), mockData, types partagés
- deps: pnpm-lock

Refs: #17
2026-08-23 12:08:05 +02:00

100 lines
3.5 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 = await 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 = bookFormatFromPath(filePath);
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" || extension === ".cbz" || extension === ".cbr") {
yield path;
}
}
}
function bookFormatFromPath(filePath: string): "epub" | "pdf" | "cbz" | "cbr" {
const extension = extname(filePath).toLowerCase();
if (extension === ".epub") return "epub";
if (extension === ".cbz") return "cbz";
if (extension === ".cbr") return "cbr";
return "pdf";
}