Files
ReadaBook/apps/api/src/metadata/adapters/mangadex.provider.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

180 lines
7.6 KiB
TypeScript

import { Injectable } from "@nestjs/common";
import { MetadataLookup, MetadataMatch, MetadataProvider, MetadataProviderConfig, MetadataSearchQuery } from "../metadata.types.js";
import { extractSeriesVolume } from "../use-cases/extract-series-volume.js";
import { normalizePublishedDate } from "../use-cases/normalize-published-date.js";
import { providerFetch } from "./provider-fetch.js";
@Injectable()
export class MangaDexProvider implements MetadataProvider {
readonly id = "mangadex" as const;
async lookup(lookup: MetadataLookup, config: MetadataProviderConfig): Promise<MetadataMatch | null> {
const matches = await this.searchByMetadata({ title: lookup.title, author: lookup.author, year: lookup.local.year }, config);
return matches[0] ?? null;
}
async searchByMetadata(query: MetadataSearchQuery, config: MetadataProviderConfig): Promise<MetadataMatch[]> {
const titles = mangaDexTitleQueries(query.title);
const matches: Array<MetadataMatch & { mangaDexRank?: number }> = [];
const seen = new Map<string, number>();
for (const title of titles) {
for (const match of await searchManga(title, config)) {
const key = match.sourceId ?? `${match.title}|${match.author}`;
const ranked = { ...match, scoreTitle: title, mangaDexRank: mangaRank(query.title, title, match) };
const existingIndex = seen.get(key);
if (existingIndex === undefined) {
seen.set(key, matches.length);
matches.push(ranked);
continue;
}
if ((ranked.mangaDexRank ?? 0) > (matches[existingIndex]?.mangaDexRank ?? 0)) {
matches[existingIndex] = ranked;
}
}
}
return rankMatches(matches);
}
}
export class MangaDexProviderError extends Error {
constructor(
readonly code: "rate-limit" | "http",
readonly status: number,
message: string
) {
super(message);
this.name = "MangaDexProviderError";
}
}
async function searchManga(title: string, _config: MetadataProviderConfig): Promise<MetadataMatch[]> {
const url = new URL("https://api.mangadex.org/manga");
url.searchParams.set("title", title);
url.searchParams.set("limit", "10");
url.searchParams.set("includes[]", "cover_art");
url.searchParams.append("includes[]", "author");
url.searchParams.append("includes[]", "artist");
url.searchParams.set("contentRating[]", "safe");
url.searchParams.append("contentRating[]", "suggestive");
let response = await providerFetch("mangadex", url, {
headers: { "User-Agent": "ReadaBook/0.1 self-hosted metadata provider (MangaDex)" },
timeoutMs: 5000
});
if (response.status === 429) {
await sleep(retryDelayMs(response));
response = await providerFetch("mangadex", url, {
headers: { "User-Agent": "ReadaBook/0.1 self-hosted metadata provider (MangaDex)" },
timeoutMs: 5000
});
}
const data = (await parseMangaDexResponse(response)) as { data?: Array<Record<string, unknown>> };
return (data.data ?? []).map(mangaToMatch);
}
function retryDelayMs(response: Response): number {
const retryAfter = Number(response.headers.get("Retry-After"));
return Number.isFinite(retryAfter) && retryAfter > 0 ? Math.min(retryAfter * 1000, 2000) : 250;
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
async function parseMangaDexResponse(response: Response): Promise<unknown> {
const data = (await response.json().catch(() => ({}))) as { errors?: Array<{ detail?: string; title?: string }> };
if (response.ok) return data;
const message = data.errors?.map((error) => error.detail ?? error.title).filter(Boolean).join("; ") || `MangaDex HTTP ${response.status}`;
if (response.status === 429) throw new MangaDexProviderError("rate-limit", response.status, message);
throw new MangaDexProviderError("http", response.status, message);
}
function mangaToMatch(manga: Record<string, unknown>): MetadataMatch & { mangaDexRank?: number } {
const id = stringValue(manga.id);
const attributes = objectValue(manga.attributes);
const relationships = Array.isArray(manga.relationships) ? (manga.relationships as Array<Record<string, unknown>>) : [];
const cover = relationships.find((entry) => entry.type === "cover_art");
const coverFile = stringValue(objectValue(cover?.attributes).fileName);
return {
title: localizedText(attributes.title) ?? undefined,
author: relationshipNames(relationships),
description: localizedText(attributes.description),
publishedDate: normalizePublishedDate(stringValue(attributes.year)),
language: stringValue(attributes.originalLanguage),
coverUrl: id && coverFile ? `https://uploads.mangadex.org/covers/${id}/${coverFile}.512.jpg` : null,
sourceId: id
};
}
function mangaDexTitleQueries(title: string): string[] {
const cleaned = extractSeriesVolume(title).seriesTitle;
return [cleaned, ...mangaDexTitleAliases(cleaned)].filter(
(value, index, values): value is string => Boolean(value) && values.indexOf(value) === index
);
}
function mangaDexTitleAliases(title: string): string[] {
const normalized = normalizeTitle(title);
if (normalized === "demon slayer school days") return ["Demon Slayer Kimetsu Academy", "Kimetsu Academy"];
return [];
}
function mangaRank(originalTitle: string, searchedTitle: string, match: MetadataMatch): number {
const expectedVolume = volumeNumber(originalTitle);
let rank = tokenOverlap(searchedTitle, match.title ?? "") * 10;
if (expectedVolume) {
const candidateVolume = volumeNumber(match.title ?? "");
rank += candidateVolume === expectedVolume ? 4 : candidateVolume ? -2 : 0;
}
if (match.coverUrl) rank += 1;
if (match.description) rank += 1;
return rank;
}
function rankMatches(matches: Array<MetadataMatch & { mangaDexRank?: number }>): MetadataMatch[] {
return [...matches].sort((left, right) => (right.mangaDexRank ?? 0) - (left.mangaDexRank ?? 0)).map(({ mangaDexRank: _rank, ...match }) => match);
}
function volumeNumber(value: string): string | null {
return value.match(/\b(?:T|tome|vol(?:ume)?\.?)\s*0?(\d{1,3})\b/i)?.[1] ?? value.match(/\b0?(\d{1,3})\b/)?.[1] ?? null;
}
function tokenOverlap(left: string, right: string): number {
const leftTokens = new Set(normalizeTitle(left).split(" ").filter(Boolean));
const rightTokens = new Set(normalizeTitle(right).split(" ").filter(Boolean));
if (!leftTokens.size || !rightTokens.size) return 0;
return [...leftTokens].filter((token) => rightTokens.has(token)).length / leftTokens.size;
}
function normalizeTitle(value: string): string {
return value
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "")
.toLowerCase()
.replace(/[^a-z0-9]+/g, " ")
.replace(/\s+/g, " ")
.trim();
}
function localizedText(value: unknown): string | null {
if (!value || typeof value !== "object") return null;
const entries = value as Record<string, unknown>;
return stringValue(entries.en) ?? stringValue(entries.fr) ?? Object.values(entries).map(stringValue).find(Boolean) ?? null;
}
function relationshipNames(relationships: Array<Record<string, unknown>>): string | null {
const names = relationships
.filter((entry) => entry.type === "author" || entry.type === "artist")
.map((entry) => stringValue(objectValue(entry.attributes).name))
.filter((entry): entry is string => Boolean(entry));
return names.length ? [...new Set(names)].join(", ") : null;
}
function objectValue(value: unknown): Record<string, unknown> {
return value && typeof value === "object" && !Array.isArray(value) ? (value as Record<string, unknown>) : {};
}
function stringValue(value: unknown): string | null {
if (typeof value === "number") return String(value);
return typeof value === "string" && value.trim() ? value.trim() : null;
}