- 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>
924 lines
30 KiB
TypeScript
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;
|
|
}
|
|
}
|