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:
Git Agent
2026-08-24 09:11:18 +02:00
parent 93bb40a8cd
commit d79f502dd2
25 changed files with 2812 additions and 231 deletions

View File

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