'use strict'; const fs = require('fs').promises; const path = require('path'); const crypto = require('crypto'); const { SlowHttp } = require('../lib/http'); const QUERY = `query ($search: String) { Media(search: $search, type: MANGA, format: MANGA) { id idMal format title { romaji english native } synonyms staff(perPage: 20) { edges { role node { name { full } } } } status startDate { year } genres description(asHtml: false) countryOfOrigin siteUrl coverImage { extraLarge } relations { edges { relationType node { id type format title { romaji english native } siteUrl } } } } }`; const QUERY_BY_ID = `query ($id: Int) { Media(id: $id, type: MANGA, format: MANGA) { id idMal format title { romaji english native } synonyms staff(perPage: 20) { edges { role node { name { full } } } } status startDate { year } genres description(asHtml: false) countryOfOrigin siteUrl coverImage { extraLarge } relations { edges { relationType node { id type format title { romaji english native } siteUrl } } } } }`; function stripHtml(value) { return String(value || '').replace(//gi, '\n').replace(/<[^>]+>/g, '').trim(); } function metadataFromMedia(media) { return { title: media.title.english || media.title.romaji, altTitles: [...new Set([media.title.romaji, media.title.native, ...(media.synonyms || [])].filter(Boolean))], authors: [...new Set((media.staff?.edges || []) .filter(edge => /^(?:original story|story(?: & art)?|art|illustration)$/i.test(edge.role || '')) .map(edge => edge.node?.name?.full).filter(Boolean))], status: media.status === 'FINISHED' ? 'completed' : media.status === 'RELEASING' ? 'ongoing' : 'unknown', year: media.startDate?.year || undefined, tags: media.genres || [], description: stripHtml(media.description), 'x-anilist': { id: media.id, idMal: media.idMal, format: media.format, url: media.siteUrl, coverUrl: media.coverImage?.extraLarge, relations: (media.relations?.edges || []).filter(edge => edge.node?.type === 'MANGA').map(edge => ({ id: edge.node.id, type: edge.relationType, format: edge.node.format, title: edge.node.title?.english || edge.node.title?.romaji || edge.node.title?.native, url: edge.node.siteUrl })) }, }; } async function lookup(query, variables, cacheKey, options = {}) { const cacheDir = path.resolve(options.cacheDir || process.env.METADATA_CACHE || path.join(__dirname, '..', '.cache', 'anilist')); await fs.mkdir(cacheDir, { recursive: true }); const cacheFile = path.join(cacheDir, `${cacheKey}.json`); try { const stat = await fs.stat(cacheFile); const cached = JSON.parse(await fs.readFile(cacheFile, 'utf8')); if (cached._cacheVersion === 4 && Date.now() - stat.mtimeMs < (options.maxAgeMs || 30 * 86400000)) return cached.metadata; } catch {} const http = new SlowHttp({ minMs: 0, maxMs: 0, maxRetries: 1 }); const response = await fetch('https://graphql.anilist.co', { method: 'POST', headers: { 'content-type': 'application/json', 'user-agent': http.userAgent }, body: JSON.stringify({ query, variables }), signal: AbortSignal.timeout(30000), }); if (!response.ok) throw Object.assign(new Error(`AniList HTTP ${response.status}`), { status: response.status }); const body = await response.json(); if (body.errors?.length) throw new Error(body.errors[0].message); const metadata = metadataFromMedia(body.data.Media); const temporary = `${cacheFile}.${crypto.randomUUID()}.tmp`; try { await fs.writeFile(temporary, JSON.stringify({ _cacheVersion: 4, metadata }, null, 2) + '\n'); await fs.rename(temporary, cacheFile); } finally { await fs.rm(temporary, { force: true }); } return metadata; } async function lookupAniList(search, options = {}) { const key = Buffer.from(search).toString('base64url'); return lookup(QUERY, { search }, key, options); } async function lookupAniListById(id, options = {}) { const value = Number(id); if (!Number.isInteger(value) || value < 1) throw new Error('AniList id must be a positive integer'); return lookup(QUERY_BY_ID, { id: value }, `id-${value}`, options); } module.exports = { lookupAniList, lookupAniListById, metadataFromMedia };