feat(api,web): providers ComicVine + MangaDex et pilotage de l'enrichissement
- 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>
This commit is contained in:
@ -1,5 +1,24 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { scanDigest } from "./scanner.service.js";
|
||||
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", () => {
|
||||
@ -17,4 +36,217 @@ describe("scan digest", () => {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user