- Adaptateurs comic-vine et mangadex avec helper de fetch partagé, timeouts et fallback durcis sur les providers existants - Scoring des correspondances amélioré, normalisation de la date de publication, chaîne de résolution des providers étendue - Scanner : statuts par livre (scan/enrichissement) et page admin d'automatisation alignée Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
253 lines
7.6 KiB
TypeScript
253 lines
7.6 KiB
TypeScript
import { mkdtempSync, rmSync } from "node:fs";
|
|
import { tmpdir } from "node:os";
|
|
import { join } from "node:path";
|
|
import { afterEach, describe, expect, it } from "vitest";
|
|
import { DatabaseService } from "../database/database.service.js";
|
|
import { books, libraries } from "../database/schema.js";
|
|
import { JobsService } from "../jobs/jobs.service.js";
|
|
import { enrichmentDigest, preserveExistingBookValues, scanDigest } from "./scanner.service.js";
|
|
import { ScannerService } from "./scanner.service.js";
|
|
|
|
const previousDatabasePath = process.env.DATABASE_PATH;
|
|
const previousStorageDir = process.env.STORAGE_DIR;
|
|
const tempDirs: string[] = [];
|
|
|
|
afterEach(() => {
|
|
process.env.DATABASE_PATH = previousDatabasePath;
|
|
process.env.STORAGE_DIR = previousStorageDir;
|
|
for (const dir of tempDirs.splice(0)) {
|
|
rmSync(dir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
describe("scan digest", () => {
|
|
it("reports incomplete files without exposing huge traces", () => {
|
|
const detail = scanDigest(
|
|
19,
|
|
0,
|
|
[
|
|
{ filePath: "/library/broken.cbr", error: "x".repeat(300) },
|
|
{ filePath: "/library/broken.epub", error: "Invalid EPUB" }
|
|
]
|
|
);
|
|
|
|
expect(detail).toContain("Scanned 19 file(s)");
|
|
expect(detail).toContain("2 incomplete file(s)");
|
|
expect(detail).toContain("broken.cbr");
|
|
expect(detail.length).toBeLessThan(380);
|
|
});
|
|
|
|
it("reports metadata enrichment jobs as enrichment, not scans", () => {
|
|
expect(enrichmentDigest(35, [])).toBe("Enriched 35 book(s)");
|
|
});
|
|
|
|
it("does not erase existing metadata or cover when a rescan has less information", () => {
|
|
const existing: typeof books.$inferSelect = {
|
|
id: 1,
|
|
libraryId: 1,
|
|
seriesId: null,
|
|
title: "Daredevil",
|
|
author: "Roy Thomas",
|
|
description: "Existing description",
|
|
isbn: "9782809476255",
|
|
isbn13: "9782809476255",
|
|
identifiersJson: null,
|
|
localMetadataJson: null,
|
|
language: "fre",
|
|
publisher: "Panini comics",
|
|
publishedDate: "2019",
|
|
volumeNumber: null,
|
|
volumeLabel: null,
|
|
format: "cbz",
|
|
filePath: "/library/Daredevil.cbz",
|
|
coverPath: "/storage/covers/daredevil.jpg",
|
|
metadataStatus: "enriched",
|
|
metadataProvenanceJson: JSON.stringify({ author: "bnf", coverPath: "openlibrary" }),
|
|
scanStatus: "succeeded",
|
|
enrichmentStatus: "succeeded",
|
|
fileSize: 12,
|
|
fileMtime: "2026-08-23T00:00:00.000Z",
|
|
createdAt: "2026-08-23T00:00:00.000Z",
|
|
updatedAt: "2026-08-23T00:00:00.000Z"
|
|
};
|
|
|
|
const next = preserveExistingBookValues(
|
|
{
|
|
title: "Daredevil",
|
|
author: null,
|
|
description: null,
|
|
isbn: null,
|
|
isbn13: null,
|
|
language: null,
|
|
publisher: null,
|
|
publishedDate: null,
|
|
coverPath: null,
|
|
metadataStatus: "none",
|
|
metadataProvenanceJson: JSON.stringify({ title: "local" }),
|
|
scanStatus: "succeeded" as const
|
|
},
|
|
existing
|
|
);
|
|
|
|
expect(next).toMatchObject({
|
|
author: "Roy Thomas",
|
|
description: "Existing description",
|
|
isbn: "9782809476255",
|
|
coverPath: "/storage/covers/daredevil.jpg",
|
|
metadataStatus: "enriched"
|
|
});
|
|
expect(JSON.parse(String(next.metadataProvenanceJson))).toMatchObject({
|
|
title: "local",
|
|
author: "bnf",
|
|
coverPath: "openlibrary"
|
|
});
|
|
});
|
|
|
|
it("does not replace an existing valid publication date with a sentinel date", () => {
|
|
const existing = {
|
|
id: 1,
|
|
libraryId: 1,
|
|
seriesId: null,
|
|
title: "Lord of the Mysteries",
|
|
author: null,
|
|
description: null,
|
|
isbn: null,
|
|
isbn13: null,
|
|
identifiersJson: null,
|
|
localMetadataJson: null,
|
|
language: null,
|
|
publisher: null,
|
|
publishedDate: "2018",
|
|
volumeNumber: null,
|
|
volumeLabel: null,
|
|
format: "epub",
|
|
filePath: "/library/Lord of the Mysteries.epub",
|
|
coverPath: null,
|
|
metadataStatus: "partial",
|
|
metadataProvenanceJson: JSON.stringify({ publishedDate: "existing" }),
|
|
scanStatus: "succeeded",
|
|
enrichmentStatus: "succeeded",
|
|
fileSize: 12,
|
|
fileMtime: "2026-08-23T00:00:00.000Z",
|
|
createdAt: "2026-08-23T00:00:00.000Z",
|
|
updatedAt: "2026-08-23T00:00:00.000Z"
|
|
} satisfies typeof books.$inferSelect;
|
|
|
|
const next = preserveExistingBookValues(
|
|
{
|
|
title: "Lord of the Mysteries",
|
|
publishedDate: "0101-01-01T00:00:00+00:00",
|
|
metadataStatus: "partial",
|
|
metadataProvenanceJson: JSON.stringify({ title: "local", publishedDate: "openlibrary" })
|
|
},
|
|
existing
|
|
);
|
|
|
|
expect(next.publishedDate).toBe("2018");
|
|
});
|
|
|
|
it.runIf(canLoadBetterSqlite())("updates the existing book when an insert races with books.file_path uniqueness", () => {
|
|
const database = createDatabase();
|
|
const now = database.now();
|
|
const library = database.db
|
|
.insert(libraries)
|
|
.values({ name: "Corpus", path: "/library", enabled: true, createdAt: now, updatedAt: now })
|
|
.returning()
|
|
.get();
|
|
database.db
|
|
.insert(books)
|
|
.values({
|
|
libraryId: library.id,
|
|
seriesId: null,
|
|
title: "Daredevil",
|
|
author: null,
|
|
description: null,
|
|
isbn: null,
|
|
isbn13: null,
|
|
identifiersJson: null,
|
|
localMetadataJson: null,
|
|
language: null,
|
|
publisher: null,
|
|
publishedDate: null,
|
|
volumeNumber: null,
|
|
volumeLabel: null,
|
|
format: "cbz",
|
|
filePath: "/library/Daredevil.cbz",
|
|
coverPath: null,
|
|
metadataStatus: "none",
|
|
metadataProvenanceJson: null,
|
|
scanStatus: "succeeded",
|
|
enrichmentStatus: "succeeded",
|
|
fileSize: 1,
|
|
fileMtime: now,
|
|
createdAt: now,
|
|
updatedAt: now
|
|
})
|
|
.run();
|
|
const scanner = new ScannerService(database, new JobsService(database), {} as never);
|
|
const values: Omit<typeof books.$inferInsert, "createdAt"> = {
|
|
libraryId: library.id,
|
|
seriesId: null,
|
|
title: "Daredevil",
|
|
author: "Roy Thomas",
|
|
description: "Updated metadata",
|
|
isbn: null,
|
|
isbn13: null,
|
|
identifiersJson: null,
|
|
localMetadataJson: null,
|
|
language: null,
|
|
publisher: null,
|
|
publishedDate: null,
|
|
volumeNumber: null,
|
|
volumeLabel: null,
|
|
format: "cbz",
|
|
filePath: "/library/Daredevil.cbz",
|
|
coverPath: "/storage/covers/daredevil.jpg",
|
|
metadataStatus: "enriched",
|
|
metadataProvenanceJson: JSON.stringify({ author: "bnf", coverPath: "local" }),
|
|
scanStatus: "succeeded",
|
|
enrichmentStatus: "succeeded",
|
|
fileSize: 2,
|
|
fileMtime: now,
|
|
updatedAt: now
|
|
};
|
|
|
|
(scanner as unknown as {
|
|
upsertBookByFilePath(values: Omit<typeof books.$inferInsert, "createdAt">, existing: undefined, createdAt: string): void;
|
|
}).upsertBookByFilePath(
|
|
values,
|
|
undefined,
|
|
now
|
|
);
|
|
|
|
const rows = database.db.select().from(books).all();
|
|
expect(rows).toHaveLength(1);
|
|
expect(rows[0]).toMatchObject({
|
|
author: "Roy Thomas",
|
|
coverPath: "/storage/covers/daredevil.jpg",
|
|
metadataStatus: "enriched",
|
|
fileSize: 2
|
|
});
|
|
|
|
database.onModuleDestroy();
|
|
});
|
|
});
|
|
|
|
function createDatabase(): DatabaseService {
|
|
const dir = mkdtempSync(join(tmpdir(), "readabook-scanner-service-"));
|
|
tempDirs.push(dir);
|
|
process.env.DATABASE_PATH = join(dir, "readabook.sqlite");
|
|
process.env.STORAGE_DIR = join(dir, "storage");
|
|
return new DatabaseService();
|
|
}
|
|
|
|
function canLoadBetterSqlite(): boolean {
|
|
try {
|
|
const database = createDatabase();
|
|
database.onModuleDestroy();
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|