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:
179
apps/api/src/metadata/adapters/mangadex.provider.ts
Normal file
179
apps/api/src/metadata/adapters/mangadex.provider.ts
Normal file
@ -0,0 +1,179 @@
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user