Files
ReadaBook/apps/api/src/metadata/metadata.service.test.ts
Git Agent d79f502dd2 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>
2026-08-24 09:11:18 +02:00

924 lines
30 KiB
TypeScript

import { existsSync, mkdirSync, mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import AdmZip from "adm-zip";
import { afterEach, describe, expect, it, vi } from "vitest";
import { DatabaseService } from "../database/database.service.js";
import { books, libraries } from "../database/schema.js";
import { BookMetadata } from "../scanner/metadata.js";
import { MetadataProviderRequestError } from "./adapters/provider-fetch.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,
providerStub("mangadex") as never,
providerStub("comicvine") 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 Mele",
isbn: "9782070612383",
isbn13: "9782070612383",
description: "Harry Potter découvre l'héritage du Prince de Sang-Mêlé."
});
database.onModuleDestroy();
});
it.runIf(canLoadBetterSqlite())("continues enrichment after a provider DNS failure and logs the failure class", async () => {
const database = createDatabase();
database.sqlite.prepare("UPDATE metadata_source_config SET enabled = 1 WHERE provider = 'googlebooks'").run();
const warn = vi.spyOn(console, "warn").mockImplementation(() => undefined);
const openLibraryProvider: MetadataProvider = {
id: "openlibrary",
lookup: async () => null,
searchByMetadata: async () => {
throw new MetadataProviderRequestError("openlibrary", "dns", "EAI_AGAIN");
}
};
const googleProvider: MetadataProvider = {
id: "googlebooks",
lookup: async () => null,
searchByMetadata: async () => [
{
title: "Daredevil",
author: "Roy Thomas",
description: "Daredevil keeps moving even when another provider is unreachable.",
publishedDate: "2019"
}
]
};
const service = new MetadataService(
database,
providerStub("local") as never,
openLibraryProvider as never,
googleProvider as never,
providerStub("bnf") as never,
providerStub("mangadex") as never,
providerStub("comicvine") as never
);
const result = await service.enrichMetadata(
{
title: "Daredevil",
author: null,
description: null,
isbn: null,
language: null,
publisher: null,
publishedDate: null,
coverPath: null
},
"/library/Daredevil.cbz",
{ remote: true }
);
expect(warn).toHaveBeenCalledWith('[metadata] Provider openlibrary failed for "Daredevil": dns: EAI_AGAIN');
expect(result).toMatchObject({
title: "Daredevil",
author: "Roy Thomas",
description: "Daredevil keeps moving even when another provider is unreachable."
});
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,
providerStub("mangadex") as never,
providerStub("comicvine") 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 mele",
author: "J. K. Rowling",
isbn: "9782070612383",
isbn13: "9782070612383",
description: "Sixième année à Poudlard."
});
database.onModuleDestroy();
});
it.runIf(canLoadBetterSqlite())("backfills a missing ISBN lookup description from a high-confidence title search", async () => {
const database = createDatabase();
const openLibraryProvider: MetadataProvider = {
id: "openlibrary",
lookup: async () => ({
title: "Harry Potter et la coupe de feu",
author: "J. K. Rowling",
isbn: "9782070624553",
publisher: "Gallimard",
publishedDate: "2016"
}),
searchByMetadata: async () => [
{
title: "Harry Potter et la coupe de feu",
author: "J. K. Rowling",
isbn: "9782070619207",
description: "Harry est invité à assister à la Coupe du monde de Quidditch."
}
]
};
const service = new MetadataService(
database,
providerStub("local") as never,
openLibraryProvider as never,
providerStub("googlebooks") as never,
providerStub("bnf") as never,
providerStub("mangadex") as never,
providerStub("comicvine") as never
);
const result = await service.enrichMetadata(
{
title: "Harry Potter et la coupe de feu",
author: "J. K. Rowling",
description: null,
isbn: "9782070624553",
language: null,
publisher: null,
publishedDate: null,
coverPath: "/covers/local.jpg"
},
"/library/Harry Potter et la coupe de feu.epub",
{ remote: true }
);
expect(result).toMatchObject({
isbn: "9782070624553",
description: "Harry est invité à assister à la Coupe du monde de Quidditch.",
coverPath: "/covers/local.jpg"
});
database.onModuleDestroy();
});
it.runIf(canLoadBetterSqlite())("uses provider priority as the tie-breaker for high-confidence title matches", 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,
providerStub("mangadex") as never,
providerStub("comicvine") 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: "Rosemary Carter",
isbn: "9780373105601",
isbn13: "9780373105601",
description: "Daredevil affronte l'Homme aux échasses."
});
database.onModuleDestroy();
});
it.runIf(canLoadBetterSqlite())("re-enriches from stored local hints instead of a previously failed remote ISBN", async () => {
const database = createDatabase();
const now = database.now();
const library = database.db
.insert(libraries)
.values({ name: "Comics", path: "/library", enabled: true, createdAt: now, updatedAt: now })
.returning()
.get();
const localMetadataJson = JSON.stringify({
title: "Daredevil",
author: null,
year: null,
isbn: null,
fileTitle: "Daredevil",
raw: { title: "Daredevil", author: null, publishedDate: null, fileName: "Daredevil" }
});
const book = database.db
.insert(books)
.values({
libraryId: library.id,
title: "Daredevil",
author: "Rosemary Carter",
description: null,
isbn: "9780373105601",
isbn13: "9780373105601",
identifiersJson: JSON.stringify({ isbn10: null, isbn13: null, candidates: [] }),
localMetadataJson,
language: null,
publisher: "Harlequin Books",
publishedDate: "1982",
format: "cbz",
filePath: "/library/Daredevil.cbz",
coverPath: "/covers/daredevil.jpg",
scanStatus: "succeeded",
enrichmentStatus: "failed",
fileSize: 42,
fileMtime: now,
createdAt: now,
updatedAt: now
})
.returning()
.get();
const openLibraryLookup = vi.fn<(_: MetadataLookup, __: MetadataProviderConfig) => Promise<MetadataMatch | null>>(async () => null);
const bnfSearch = vi.fn<(_: MetadataSearchQuery, __: MetadataProviderConfig) => Promise<MetadataMatch[]>>(async () => [
{
title: "Daredevil",
author: "scénario, Roy Thomas, Gary Friedrich",
isbn: "9782809476255",
description: "Daredevil affronte l'Homme aux échasses.",
publisher: "Panini comics",
publishedDate: "2019"
}
]);
database.sqlite.prepare("UPDATE metadata_source_config SET enabled = 1 WHERE provider = 'bnf'").run();
const service = new MetadataService(
database,
providerStub("local") as never,
{ id: "openlibrary", lookup: openLibraryLookup, searchByMetadata: async () => [] } as never,
providerStub("googlebooks") as never,
{ id: "bnf", lookup: async () => null, searchByMetadata: bnfSearch } as never,
providerStub("mangadex") as never,
providerStub("comicvine") as never
);
const result = await service.enrichBook(book.id);
expect(openLibraryLookup).not.toHaveBeenCalled();
expect(bnfSearch.mock.calls[0]?.[0].title).toBe("Daredevil");
expect(result).toMatchObject({
author: "Rosemary Carter",
isbn: "9780373105601",
isbn13: "9780373105601",
publisher: "Harlequin Books",
coverPath: "/covers/daredevil.jpg"
});
database.onModuleDestroy();
});
it.runIf(canLoadBetterSqlite())("stores provider covers as local bytes and exposes field provenance with metadata status", async () => {
const database = createDatabase();
const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue({
ok: true,
headers: new Headers({ "content-type": "image/jpeg" }),
arrayBuffer: async () => new Uint8Array([1, 2, 3, 4]).buffer
} as Response);
const openLibraryProvider: MetadataProvider = {
id: "openlibrary",
lookup: async () => null,
searchByMetadata: async () => [
{
title: "Daredevil",
author: "Roy Thomas",
description: "Daredevil affronte une nouvelle menace.",
isbn: "9782809476255",
coverUrl: "https://covers.openlibrary.org/b/id/123-L.jpg"
}
]
};
const service = new MetadataService(
database,
providerStub("local") as never,
openLibraryProvider as never,
providerStub("googlebooks") as never,
providerStub("bnf") as never,
providerStub("mangadex") as never,
providerStub("comicvine") as never
);
const result = await service.enrichMetadata(
{
title: "Daredevil",
author: null,
description: null,
isbn: null,
language: null,
publisher: null,
publishedDate: null,
coverPath: null
},
"/library/Daredevil.cbz",
{ remote: true }
);
expect(fetchMock).toHaveBeenCalledWith("https://covers.openlibrary.org/b/id/123-L.jpg", expect.any(Object));
expect(result.coverPath).toMatch(/storage\/covers\/.+\.jpg$/);
expect(result.metadataStatus).toBe("enriched");
expect(JSON.parse(result.metadataProvenanceJson)).toMatchObject({
title: "local",
author: "openlibrary",
description: "openlibrary",
coverPath: "openlibrary"
});
database.onModuleDestroy();
});
it.runIf(canLoadBetterSqlite())("retrofits a local cover for an existing book during metadata enrichment", async () => {
const database = createDatabase();
mkdirSync(database.config.storageDir, { recursive: true });
const filePath = join(database.config.storageDir, "Demon.Slayer.School.Days.T01.FRENCH.CBZ");
const zip = new AdmZip();
zip.addFile("001.jpg", Buffer.from([0xff, 0xd8, 0xff, 0xd9]));
zip.writeZip(filePath);
const now = database.now();
const library = database.db
.insert(libraries)
.values({ name: "Comics", path: database.config.storageDir, enabled: true, createdAt: now, updatedAt: now })
.returning()
.get();
const book = database.db
.insert(books)
.values({
libraryId: library.id,
title: "Demon.Slayer.School.Days.T01.FRENCH.CBZ. -ebdz",
author: null,
description: null,
isbn: null,
isbn13: null,
identifiersJson: JSON.stringify({ isbn10: null, isbn13: null, candidates: [] }),
localMetadataJson: JSON.stringify({
title: "Demon Slayer School Days T01 FRENCH",
author: null,
year: null,
isbn: null,
fileTitle: "Demon Slayer School Days T01 FRENCH",
raw: { title: "Demon.Slayer.School.Days.T01.FRENCH.CBZ. -ebdz", author: null, publishedDate: null, fileName: "Demon.Slayer.School.Days.T01.FRENCH.CBZ. -ebdz" }
}),
language: null,
publisher: null,
publishedDate: null,
format: "cbz",
filePath,
coverPath: null,
metadataStatus: "none",
metadataProvenanceJson: JSON.stringify({ title: "local" }),
scanStatus: "succeeded",
enrichmentStatus: "succeeded",
fileSize: 42,
fileMtime: now,
createdAt: now,
updatedAt: now
})
.returning()
.get();
const service = new MetadataService(
database,
providerStub("local") as never,
providerStub("openlibrary") as never,
providerStub("googlebooks") as never,
providerStub("bnf") as never,
providerStub("mangadex") as never,
providerStub("comicvine") as never
);
const result = await service.enrichBook(book.id);
expect(result.coverPath).toMatch(/covers\/[a-f0-9]+\.jpg$/);
expect(result.coverPath && existsSync(result.coverPath)).toBe(true);
expect(result.metadataStatus).toBe("partial");
expect(JSON.parse(result.metadataProvenanceJson ?? "{}")).toMatchObject({ coverPath: "local" });
database.onModuleDestroy();
});
it.runIf(canLoadBetterSqlite())("drops sentinel publication dates from provider matches for real affected titles", async () => {
const database = createDatabase();
const openLibraryProvider: MetadataProvider = {
id: "openlibrary",
lookup: async () => null,
searchByMetadata: async () => [
{
title: "Harry Potter et les reliques de la mort",
author: "J. K. Rowling",
publishedDate: "0101-01-01T00:00:00+00:00",
description: "Septième année."
}
]
};
const service = new MetadataService(
database,
providerStub("local") as never,
openLibraryProvider as never,
providerStub("googlebooks") as never,
providerStub("bnf") as never,
providerStub("mangadex") as never,
providerStub("comicvine") as never
);
const result = await service.enrichMetadata(
{
title: "Harry Potter et les reliques de la mort",
author: "J. K. Rowling",
description: null,
isbn: null,
language: null,
publisher: null,
publishedDate: null,
coverPath: null
},
"/library/Harry Potter et les reliques de la mort.epub",
{ remote: true }
);
expect(result.publishedDate).toBeNull();
database.onModuleDestroy();
});
it.runIf(canLoadBetterSqlite())("does not overwrite an existing valid date with a provider sentinel", async () => {
const database = createDatabase();
const now = database.now();
const library = database.db
.insert(libraries)
.values({ name: "Novels", path: "/library", enabled: true, createdAt: now, updatedAt: now })
.returning()
.get();
const book = database.db
.insert(books)
.values({
libraryId: library.id,
title: "Lord of the Mysteries",
author: "Cuttlefish That Loves Diving",
description: null,
isbn: null,
isbn13: null,
identifiersJson: JSON.stringify({ isbn10: null, isbn13: null, candidates: [] }),
localMetadataJson: JSON.stringify({
title: "Lord of the Mysteries",
author: "Cuttlefish That Loves Diving",
year: null,
isbn: null,
fileTitle: "Lord of the Mysteries",
raw: { title: "Lord of the Mysteries", author: "Cuttlefish That Loves Diving", publishedDate: null, fileName: "Lord of the Mysteries" }
}),
language: null,
publisher: null,
publishedDate: "2018",
format: "epub",
filePath: "/library/Lord of the Mysteries.epub",
coverPath: null,
metadataStatus: "partial",
metadataProvenanceJson: JSON.stringify({ publishedDate: "existing" }),
scanStatus: "succeeded",
enrichmentStatus: "succeeded",
fileSize: 42,
fileMtime: now,
createdAt: now,
updatedAt: now
})
.returning()
.get();
const openLibraryProvider: MetadataProvider = {
id: "openlibrary",
lookup: async () => null,
searchByMetadata: async () => [
{
title: "Lord of the Mysteries",
author: "Cuttlefish That Loves Diving",
publishedDate: "0101-01-01T00:00:00+00:00",
description: "A mysterious sequence begins."
}
]
};
const service = new MetadataService(
database,
providerStub("local") as never,
openLibraryProvider as never,
providerStub("googlebooks") as never,
providerStub("bnf") as never,
providerStub("mangadex") as never,
providerStub("comicvine") as never
);
const result = await service.enrichBook(book.id);
expect(result.publishedDate).toBe("2018");
database.onModuleDestroy();
});
it.runIf(canLoadBetterSqlite())("records MangaDex provenance and stores its cover locally", async () => {
const database = createDatabase();
const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue({
ok: true,
headers: new Headers({ "content-type": "image/jpeg" }),
arrayBuffer: async () => new Uint8Array([9, 8, 7]).buffer
} as Response);
const mangaDexProvider: MetadataProvider = {
id: "mangadex",
lookup: async () => null,
searchByMetadata: async () => [
{
title: "Solo Leveling",
author: "Chugong",
description: "A hunter levels up alone.",
publishedDate: "2018",
coverUrl: "https://uploads.mangadex.org/covers/manga-1/cover.jpg.512.jpg"
}
]
};
const service = new MetadataService(
database,
providerStub("local") as never,
providerStub("openlibrary") as never,
providerStub("googlebooks") as never,
providerStub("bnf") as never,
mangaDexProvider as never,
providerStub("comicvine") as never
);
const result = await service.enrichMetadata(
{
title: "Solo Leveling T03",
author: null,
description: null,
isbn: null,
language: null,
publisher: null,
publishedDate: null,
coverPath: null
},
"/library/Solo Leveling T03.cbz",
{ remote: true }
);
expect(fetchMock).toHaveBeenCalledWith("https://uploads.mangadex.org/covers/manga-1/cover.jpg.512.jpg", expect.any(Object));
expect(JSON.parse(result.metadataProvenanceJson)).toMatchObject({
author: "mangadex",
description: "mangadex",
publishedDate: "mangadex",
coverPath: "mangadex"
});
expect(result.coverPath).toMatch(/storage\/covers\/.+\.jpg$/);
database.onModuleDestroy();
});
it.runIf(canLoadBetterSqlite())("scores MangaDex matches against the cleaned series title instead of the noisy archive title", async () => {
const database = createDatabase();
const mangaDexProvider: MetadataProvider = {
id: "mangadex",
lookup: async () => null,
searchByMetadata: async () => [
{
title: "Dragon Ball SD",
author: "Naho Ooishi",
description: "A super-deformed Dragon Ball spin-off.",
publishedDate: "2010",
sourceId: "dragon-ball-sd"
}
]
};
const service = new MetadataService(
database,
providerStub("local") as never,
providerStub("openlibrary") as never,
providerStub("googlebooks") as never,
providerStub("bnf") as never,
mangaDexProvider as never,
providerStub("comicvine") as never
);
const result = await service.enrichMetadata(
{
title: "Dragon.Ball.SD.T01.FRENCH.CBZ.eBook-Paprika+",
author: null,
description: null,
isbn: null,
language: null,
publisher: null,
publishedDate: null,
coverPath: null
},
"/library/Dragon.Ball.SD.T01.FRENCH.CBZ.eBook-Paprika+.cbz",
{ remote: true }
);
expect(result).toMatchObject({
title: "Dragon Ball SD",
author: "Naho Ooishi",
description: "A super-deformed Dragon Ball spin-off.",
publishedDate: "2010"
});
expect(JSON.parse(result.metadataProvenanceJson)).toMatchObject({
title: "local",
author: "mangadex",
description: "mangadex"
});
database.onModuleDestroy();
});
it.runIf(canLoadBetterSqlite())("accepts MangaDex alias matches through the provider score title", async () => {
const database = createDatabase();
const mangaDexProvider: MetadataProvider = {
id: "mangadex",
lookup: async () => null,
searchByMetadata: async () => [
{
title: "Demon Slayer: Kimetsu Academy",
scoreTitle: "Demon Slayer Kimetsu Academy",
author: "Natsuki Hokami",
description: "School spin-off.",
publishedDate: "2021",
sourceId: "kimetsu-academy"
}
]
};
const service = new MetadataService(
database,
providerStub("local") as never,
providerStub("openlibrary") as never,
providerStub("googlebooks") as never,
providerStub("bnf") as never,
mangaDexProvider as never,
providerStub("comicvine") as never
);
const result = await service.enrichMetadata(
{
title: "Demon.Slayer.School.Days.T01.FRENCH.CBZ. -ebdz",
author: null,
description: null,
isbn: null,
language: null,
publisher: null,
publishedDate: null,
coverPath: null
},
"/library/Demon.Slayer.School.Days.T01.FRENCH.CBZ. -ebdz.cbz",
{ remote: true }
);
expect(result).toMatchObject({
title: "Demon Slayer School Days",
author: "Natsuki Hokami",
description: "School spin-off.",
publishedDate: "2021"
});
database.onModuleDestroy();
});
it.runIf(canLoadBetterSqlite())("re-enriches existing manga with the cleaned series title on the live book path", async () => {
const database = createDatabase();
mkdirSync(database.config.storageDir, { recursive: true });
const filePath = join(database.config.storageDir, "Demon.Slayer.School.Days.T01.FRENCH.CBZ. -ebdz.cbz");
const zip = new AdmZip();
zip.addFile("001.jpg", Buffer.from([0xff, 0xd8, 0xff, 0xd9]));
zip.writeZip(filePath);
const now = database.now();
const library = database.db
.insert(libraries)
.values({ name: "Manga", path: database.config.storageDir, enabled: true, createdAt: now, updatedAt: now })
.returning()
.get();
const book = database.db
.insert(books)
.values({
libraryId: library.id,
title: "Demon.Slayer.School.Days.T01.FRENCH.CBZ. -ebdz",
author: null,
description: null,
isbn: null,
isbn13: null,
identifiersJson: JSON.stringify({ isbn10: null, isbn13: null, candidates: [] }),
localMetadataJson: JSON.stringify({
title: "Demon Slayer School Days T01 FRENCH",
author: null,
year: null,
isbn: null,
fileTitle: "Demon Slayer School Days T01 FRENCH",
raw: {
title: "Demon.Slayer.School.Days.T01.FRENCH.CBZ. -ebdz",
author: null,
publishedDate: null,
fileName: "Demon.Slayer.School.Days.T01.FRENCH.CBZ. -ebdz"
}
}),
language: null,
publisher: null,
publishedDate: null,
format: "cbz",
filePath,
coverPath: null,
metadataStatus: "none",
metadataProvenanceJson: JSON.stringify({ title: "local" }),
scanStatus: "succeeded",
enrichmentStatus: "succeeded",
fileSize: 42,
fileMtime: now,
createdAt: now,
updatedAt: now
})
.returning()
.get();
const mangaDexSearch = vi.fn<(_: MetadataSearchQuery, __: MetadataProviderConfig) => Promise<MetadataMatch[]>>(async () => [
{
title: "Demon Slayer School Days",
author: "Natsuki Hokami",
description: "School spin-off.",
publishedDate: "2021",
sourceId: "demon-slayer-school-days"
}
]);
const service = new MetadataService(
database,
providerStub("local") as never,
providerStub("openlibrary") as never,
providerStub("googlebooks") as never,
providerStub("bnf") as never,
{ id: "mangadex", lookup: async () => null, searchByMetadata: mangaDexSearch } as never,
providerStub("comicvine") as never
);
const result = await service.enrichBook(book.id);
expect(mangaDexSearch.mock.calls[0]?.[0].title).toBe("Demon Slayer School Days");
expect(result).toMatchObject({
title: "Demon Slayer School Days",
author: "Natsuki Hokami",
description: "School spin-off.",
publishedDate: "2021"
});
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;
}
}