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 { 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 { const titles = mangaDexTitleQueries(query.title); const matches: Array = []; const seen = new Map(); 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 { 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> }; 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 { return new Promise((resolve) => setTimeout(resolve, ms)); } async function parseMangaDexResponse(response: Response): Promise { 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): MetadataMatch & { mangaDexRank?: number } { const id = stringValue(manga.id); const attributes = objectValue(manga.attributes); const relationships = Array.isArray(manga.relationships) ? (manga.relationships as Array>) : []; 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[] { 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; return stringValue(entries.en) ?? stringValue(entries.fr) ?? Object.values(entries).map(stringValue).find(Boolean) ?? null; } function relationshipNames(relationships: Array>): 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 { return value && typeof value === "object" && !Array.isArray(value) ? (value as Record) : {}; } function stringValue(value: unknown): string | null { if (typeof value === "number") return String(value); return typeof value === "string" && value.trim() ? value.trim() : null; }