fix(api,web): enrichissement métadonnées — hints locaux et scoring des correspondances

Recherche de fiches par nom peu fiable : sans ISBN, les providers
étaient interrogés avec des titres bruts peu discriminants.

- extraction de hints locaux (titre/auteur/année/isbn du fichier et du
  nom de fichier) persistés dans books.local_metadata_json
- nouveau use-case ScoreMetadataMatch : tri des résultats par score de
  correspondance avant sélection du meilleur candidat
- providers (google-books, open-library, bnf, local) durcis et nourris
  par les hints ; colonne + index local_metadata_json avec migrations
  idempotentes (création et rebuild legacy)
- web : nettoyage de la description de la fiche livre
  (cleanBookDescription, white-space pre-line)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Git Agent
2026-08-23 18:07:29 +02:00
parent 5de46a6f6d
commit 18d73a4db6
21 changed files with 829 additions and 56 deletions

View File

@ -0,0 +1,233 @@
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import { DatabaseService } from "../database/database.service.js";
import { BookMetadata } from "../scanner/metadata.js";
import { MetadataService } from "./metadata.service.js";
import { MetadataLookup, MetadataMatch, MetadataProvider, MetadataProviderConfig, MetadataSearchQuery } from "./metadata.types.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;
vi.restoreAllMocks();
for (const dir of tempDirs.splice(0)) {
rmSync(dir, { recursive: true, force: true });
}
});
describe("MetadataService", () => {
it.runIf(canLoadBetterSqlite())("backfills description from provider lookup after a metadata search hit yields an ISBN", async () => {
const database = createDatabase();
const localProvider = providerStub("local");
const openLibrarySearch = vi.fn<(_: MetadataSearchQuery, __: MetadataProviderConfig) => Promise<MetadataMatch[]>>(async () => [
{
title: "Harry Potter et le Prince de sang-mêlé",
author: "J. K. Rowling",
isbn: "9782070612383",
publishedDate: "2005"
}
]);
const openLibraryLookup = vi.fn<(_: MetadataLookup, __: MetadataProviderConfig) => Promise<MetadataMatch | null>>(async (lookup) => {
if (lookup.identifiers.isbn13 !== "9782070612383") return null;
return {
title: "Harry Potter et le Prince de sang-mêlé",
author: "J. K. Rowling",
isbn: "9782070612383",
publishedDate: "2005",
description: "Harry Potter découvre l'héritage du Prince de Sang-Mêlé."
};
});
const openLibraryProvider: MetadataProvider = {
id: "openlibrary",
searchByMetadata: openLibrarySearch,
lookup: openLibraryLookup
};
const service = new MetadataService(
database,
localProvider as never,
openLibraryProvider as never,
providerStub("googlebooks") as never,
providerStub("bnf") as never
);
const localMetadata: BookMetadata = {
title: "Harry Potter et le Prince de Sang Mele",
author: "J. K. Rowling",
description: null,
isbn: null,
language: null,
publisher: null,
publishedDate: null,
coverPath: null
};
const result = await service.enrichMetadata(localMetadata, "/library/HP/Harry Potter et le Prince de Sang Mele.epub", {
remote: true
});
expect(openLibrarySearch).toHaveBeenCalledOnce();
expect(openLibraryLookup).toHaveBeenCalledOnce();
expect(openLibraryLookup.mock.calls[0]?.[0].identifiers.isbn13).toBe("9782070612383");
expect(result).toMatchObject({
title: "Harry Potter et le Prince de sang-mêlé",
isbn: "9782070612383",
isbn13: "9782070612383",
description: "Harry Potter découvre l'héritage du Prince de Sang-Mêlé."
});
database.onModuleDestroy();
});
it.runIf(canLoadBetterSqlite())("looks up details from a title search hit even when the hit has no ISBN", async () => {
const database = createDatabase();
const localProvider = providerStub("local");
const openLibrarySearch = vi.fn<(_: MetadataSearchQuery, __: MetadataProviderConfig) => Promise<MetadataMatch[]>>(async () => [
{
title: "Harry Potter et le prince de sang-mele",
sourceId: "OL24333986M",
publishedDate: "2005"
}
]);
const openLibraryLookup = vi.fn<(_: MetadataLookup, __: MetadataProviderConfig) => Promise<MetadataMatch | null>>(async (lookup) => {
if (lookup.sourceId !== "OL24333986M") return null;
return {
title: "Harry Potter et le prince de sang-mêlé",
author: "J. K. Rowling",
isbn: "9782070612383",
publishedDate: "2005",
description: "Sixième année à Poudlard."
};
});
const openLibraryProvider: MetadataProvider = {
id: "openlibrary",
searchByMetadata: openLibrarySearch,
lookup: openLibraryLookup
};
const service = new MetadataService(
database,
localProvider as never,
openLibraryProvider as never,
providerStub("googlebooks") as never,
providerStub("bnf") as never
);
const localMetadata: BookMetadata = {
title: "Harry Potter et le prince de sang mele",
author: null,
description: null,
isbn: null,
language: null,
publisher: null,
publishedDate: null,
coverPath: null
};
const result = await service.enrichMetadata(localMetadata, "/library/Harry Potter et le prince de sang mele.epub", {
remote: true
});
expect(openLibrarySearch).toHaveBeenCalledOnce();
expect(openLibraryLookup).toHaveBeenCalledOnce();
expect(openLibraryLookup.mock.calls[0]?.[0].sourceId).toBe("OL24333986M");
expect(result).toMatchObject({
title: "Harry Potter et le prince de sang-mêlé",
author: "J. K. Rowling",
isbn: "9782070612383",
isbn13: "9782070612383",
description: "Sixième année à Poudlard."
});
database.onModuleDestroy();
});
it.runIf(canLoadBetterSqlite())("replaces an ambiguous title-only identification when a later provider supplies a described record", async () => {
const database = createDatabase();
database.sqlite.prepare("UPDATE metadata_source_config SET enabled = 1 WHERE provider = 'bnf'").run();
const openLibraryProvider: MetadataProvider = {
id: "openlibrary",
lookup: async () => null,
searchByMetadata: async () => [
{
title: "Daredevil",
author: "Rosemary Carter",
isbn: "9780373105601",
publisher: "Harlequin Books",
publishedDate: "1982"
}
]
};
const bnfProvider: MetadataProvider = {
id: "bnf",
lookup: async () => null,
searchByMetadata: async () => [
{
title: "Daredevil",
author: "scénario, Roy Thomas, Gary Friedrich",
isbn: "9782809476255",
description: "Daredevil affronte l'Homme aux échasses.",
language: "fre",
publisher: "Panini comics",
publishedDate: "2019"
}
]
};
const service = new MetadataService(
database,
providerStub("local") as never,
openLibraryProvider as never,
providerStub("googlebooks") as never,
bnfProvider as never
);
const localMetadata: BookMetadata = {
title: "Daredevil",
author: null,
description: null,
isbn: null,
language: null,
publisher: null,
publishedDate: null,
coverPath: null
};
const result = await service.enrichMetadata(localMetadata, "/library/Daredevil.cbz", { remote: true });
expect(result).toMatchObject({
title: "Daredevil",
author: "scénario, Roy Thomas, Gary Friedrich",
isbn: "9782809476255",
isbn13: "9782809476255",
description: "Daredevil affronte l'Homme aux échasses."
});
database.onModuleDestroy();
});
});
function createDatabase(): DatabaseService {
const dir = mkdtempSync(join(tmpdir(), "readabook-metadata-service-"));
tempDirs.push(dir);
process.env.DATABASE_PATH = join(dir, "readabook.sqlite");
process.env.STORAGE_DIR = join(dir, "storage");
return new DatabaseService();
}
function providerStub(id: MetadataProvider["id"]): MetadataProvider {
return {
id,
lookup: async () => null,
searchByMetadata: async () => []
};
}
function canLoadBetterSqlite(): boolean {
try {
const database = createDatabase();
database.onModuleDestroy();
return true;
} catch {
return false;
}
}