/* * ======= • ======= • ======= • ======= • =======• ======= * MiruroAPI — anilist.js * Repository: https://github.com/Shineii86/MiruroAPI * * @description * AniList GraphQL API integration module. Provides functions for * searching anime, fetching details, browsing collections, and * querying the AniList GraphQL API. All metadata (titles, covers, * genres, scores, characters, relations) comes from AniList. * * @exports * searchAnime, getSuggestions, getAnimeInfo, getAnimeCharacters, * getAnimeRelations, getAnimeRecommendations, getCollection, * getSpotlight, getSchedule, filterAnime * * @author Shinei Nouzen * @license MIT * ======= • ======= • ======= • ======= • =======• ======= */ const axios = require("axios"); // ══════════════════════════════════════════════════════════════ // ANILIST API CONFIGURATION // ══════════════════════════════════════════════════════════════ /** * AniList GraphQL API endpoint. * Used for all metadata queries (search, trending, info, filter). * * @type {string} */ const ANILIST_URL = "https://graphql.anilist.co"; // ══════════════════════════════════════════════════════════════ // GRAPHQL FRAGMENTS // ══════════════════════════════════════════════════════════════ // ---- FEATURE: Media list fields fragment (used in collections/search) ---- /** * Standard set of fields for anime list responses. * Used in trending, popular, search, filter, and schedule endpoints. * Includes titles, cover, format, scores, genres, studios, and airing info. * * @type {string} */ const MEDIA_LIST_FIELDS = ` id title { romaji english native } coverImage { large extraLarge } bannerImage format season seasonYear episodes duration status averageScore meanScore popularity favourites genres source countryOfOrigin isAdult studios(isMain: true) { nodes { name isAnimationStudio } } nextAiringEpisode { episode airingAt timeUntilAiring } startDate { year month day } endDate { year month day } `; // ---- FEATURE: Media full fields fragment (used in info endpoint) ---- /** * Extended set of fields for detailed anime info responses. * Includes everything in MEDIA_LIST_FIELDS plus characters, staff, * relations, recommendations, trailer, stats, and external links. * * @type {string} */ const MEDIA_FULL_FIELDS = ` id idMal title { romaji english native } description(asHtml: false) coverImage { large extraLarge color } bannerImage format season seasonYear episodes duration status averageScore meanScore popularity favourites trending genres tags { name rank isMediaSpoiler } source countryOfOrigin isAdult hashtag synonyms siteUrl trailer { id site thumbnail } studios { nodes { id name isAnimationStudio siteUrl } } nextAiringEpisode { episode airingAt timeUntilAiring } startDate { year month day } endDate { year month day } characters(sort: [ROLE, RELEVANCE], perPage: 25) { edges { role node { id name { full native } image { large } } voiceActors(language: JAPANESE) { id name { full native } image { large } languageV2 } } } staff(sort: RELEVANCE, perPage: 25) { edges { role node { id name { full native } image { large } } } } relations { edges { relationType(version: 2) node { id title { romaji english native } coverImage { large } format type status episodes meanScore } } } recommendations(sort: RATING_DESC, perPage: 10) { nodes { rating mediaRecommendation { id title { romaji english native } coverImage { large } format episodes status meanScore averageScore } } } externalLinks { url site type } streamingEpisodes { title thumbnail url site } stats { scoreDistribution { score amount } statusDistribution { status amount } } `; // ══════════════════════════════════════════════════════════════ // CORE QUERY FUNCTION // ══════════════════════════════════════════════════════════════ // ---- FEATURE: Execute AniList GraphQL query ---- /** * Executes a GraphQL query against the AniList API. * Handles request formatting and error checking. * * @param {string} query - The GraphQL query string * @param {object} [variables={}] - Query variables * @returns {Promise} The `data` field from the AniList response * @throws {Error} If the AniList API returns a non-200 status * * @example * const data = await anilistQuery(MEDIA_LIST_FIELDS, { search: "naruto" }); */ const anilistQuery = async (query, variables = {}) => { const body = { query, variables }; const res = await axios.post(ANILIST_URL, body, { headers: { "Content-Type": "application/json" }, timeout: 15000, }); if (res.status !== 200) throw new Error("AniList query failed"); return res.data.data; }; // ══════════════════════════════════════════════════════════════ // SEARCH ENDPOINTS // ══════════════════════════════════════════════════════════════ // ---- FEATURE: Full-text anime search with pagination ---- /** * Searches AniList for anime matching the given query. * Returns paginated results with full metadata per result. * * @param {string} query - The search query string * @param {number} [page=1] - Page number for pagination * @param {number} [perPage=20] - Results per page (max 50) * @returns {Promise} Paginated search results with page, perPage, total, hasNextPage, results[] * * @example * const results = await searchAnime("naruto", 1, 10); * console.log(results.total); // 5000+ */ const searchAnime = async (query, page = 1, perPage = 20) => { const gql = ` query ($search: String, $page: Int, $perPage: Int) { Page(page: $page, perPage: $perPage) { pageInfo { total currentPage lastPage hasNextPage perPage } media(search: $search, type: ANIME, sort: SEARCH_MATCH) { ${MEDIA_LIST_FIELDS} } } } `; const data = await anilistQuery(gql, { search: query, page, perPage }); const pageData = data.Page; return { page: pageData.pageInfo.currentPage, perPage: pageData.pageInfo.perPage, total: pageData.pageInfo.total, hasNextPage: pageData.pageInfo.hasNextPage, results: pageData.media, }; }; // ---- FEATURE: Lightweight autocomplete suggestions (max 8) ---- /** * Fetches search suggestions for autocomplete dropdowns. * Returns minimal data (id, title, poster, format, status, year). * * @param {string} query - The search query string * @returns {Promise} Array of up to 8 suggestion objects * * @example * const suggestions = await getSuggestions("one piece"); * console.log(suggestions[0].title); // "ONE PIECE" */ const getSuggestions = async (query) => { const gql = ` query ($search: String) { Page(page: 1, perPage: 8) { media(search: $search, type: ANIME, sort: SEARCH_MATCH) { id title { romaji english } coverImage { large } format status startDate { year } episodes } } } `; const data = await anilistQuery(gql, { search: query }); return data.Page.media.map((item) => ({ id: item.id, title: item.title.english || item.title.romaji, title_romaji: item.title.romaji, poster: item.coverImage.large, format: item.format, status: item.status, year: item.startDate?.year, episodes: item.episodes, })); }; // ══════════════════════════════════════════════════════════════ // ANIME DETAILS // ══════════════════════════════════════════════════════════════ // ---- FEATURE: Complete anime info by AniList ID ---- /** * Fetches full anime metadata including characters, staff, * relations, recommendations, trailer, stats, and external links. * * @param {number} id - AniList anime ID * @returns {Promise} Complete anime metadata object * @throws {Error} If anime is not found * * @example * const info = await getAnimeInfo(20); // Naruto * console.log(info.title.romaji); // "NARUTO" */ const getAnimeInfo = async (id) => { const gql = ` query ($id: Int) { Media(id: $id, type: ANIME) { ${MEDIA_FULL_FIELDS} } } `; const data = await anilistQuery(gql, { id }); return data.Media; }; // ---- FEATURE: Paginated character list with voice actors ---- /** * Fetches paginated character list for an anime. * Each character includes name, image, role, and Japanese voice actors. * * @param {number} id - AniList anime ID * @param {number} [page=1] - Page number * @param {number} [perPage=25] - Results per page (max 50) * @returns {Promise} Paginated character list */ const getAnimeCharacters = async (id, page = 1, perPage = 25) => { const gql = ` query ($id: Int, $page: Int, $perPage: Int) { Media(id: $id, type: ANIME) { id title { romaji english } characters(sort: [ROLE, RELEVANCE], page: $page, perPage: $perPage) { pageInfo { total currentPage lastPage hasNextPage perPage } edges { role node { id name { full native userPreferred } image { large medium } description gender favourites siteUrl } voiceActors { id name { full native } image { large } languageV2 } } } } } `; const data = await anilistQuery(gql, { id, page, perPage }); if (!data.Media) throw new Error("Anime not found"); const chars = data.Media.characters; return { page: chars.pageInfo.currentPage, perPage: chars.pageInfo.perPage, total: chars.pageInfo.total, hasNextPage: chars.pageInfo.hasNextPage, characters: chars.edges, }; }; // ---- FEATURE: Related anime (sequels, prequels, spin-offs) ---- /** * Fetches all related media for an anime — sequels, prequels, * side stories, spin-offs, and source material. * * @param {number} id - AniList anime ID * @returns {Promise} Relations with id, title, and relations[] */ const getAnimeRelations = async (id) => { const gql = ` query ($id: Int) { Media(id: $id, type: ANIME) { id title { romaji english } relations { edges { relationType(version: 2) node { id title { romaji english native } coverImage { large } format type status episodes meanScore averageScore popularity } } } } } `; const data = await anilistQuery(gql, { id }); if (!data.Media) throw new Error("Anime not found"); return { id: data.Media.id, title: data.Media.title, relations: data.Media.relations.edges, }; }; // ---- FEATURE: Community recommendations sorted by rating ---- /** * Fetches paginated community recommendations for an anime. * "If you liked X, you'll like Y" — sorted by highest rating. * * @param {number} id - AniList anime ID * @param {number} [page=1] - Page number * @param {number} [perPage=10] - Results per page (max 25) * @returns {Promise} Paginated recommendations */ const getAnimeRecommendations = async (id, page = 1, perPage = 10) => { const gql = ` query ($id: Int, $page: Int, $perPage: Int) { Media(id: $id, type: ANIME) { id title { romaji english } recommendations(sort: RATING_DESC, page: $page, perPage: $perPage) { pageInfo { total currentPage lastPage hasNextPage perPage } nodes { rating mediaRecommendation { id title { romaji english native } coverImage { large extraLarge } format episodes status meanScore averageScore popularity genres } } } } } `; const data = await anilistQuery(gql, { id, page, perPage }); if (!data.Media) throw new Error("Anime not found"); const recs = data.Media.recommendations; return { page: recs.pageInfo.currentPage, perPage: recs.pageInfo.perPage, total: recs.pageInfo.total, hasNextPage: recs.pageInfo.hasNextPage, recommendations: recs.nodes, }; }; // ══════════════════════════════════════════════════════════════ // COLLECTIONS // ══════════════════════════════════════════════════════════════ // ---- FEATURE: Generic collection fetcher with sort and status ---- /** * Internal helper for fetching anime collections (trending, popular, etc.). * Builds a dynamic GraphQL query based on sort type and optional status filter. * * @param {string} sortType - AniList sort enum (e.g., "TRENDING_DESC", "POPULARITY_DESC") * @param {string|null} status - Optional status filter (e.g., "RELEASING", "NOT_YET_RELEASED") * @param {number} [page=1] - Page number * @param {number} [perPage=20] - Results per page * @returns {Promise} Paginated collection results */ const getCollection = async (sortType, status = null, page = 1, perPage = 20) => { const statusFilter = status ? `, status: ${status}` : ""; const gql = ` query ($page: Int, $perPage: Int) { Page(page: $page, perPage: $perPage) { pageInfo { total currentPage lastPage hasNextPage perPage } media(type: ANIME, sort: [${sortType}]${statusFilter}) { ${MEDIA_LIST_FIELDS} } } } `; const data = await anilistQuery(gql, { page, perPage }); const pageData = data.Page; return { page: pageData.pageInfo.currentPage, perPage: pageData.pageInfo.perPage, total: pageData.pageInfo.total, hasNextPage: pageData.pageInfo.hasNextPage, results: pageData.media, }; }; // ---- FEATURE: Spotlight/featured anime (top 10 trending + popular) ---- /** * Fetches the top 10 trending + popular anime for hero banners. * Combines TRENDING_DESC and POPULARITY_DESC sorts. * * @returns {Promise} Array of up to 10 anime objects */ const getSpotlight = async () => { const gql = ` query { Page(page: 1, perPage: 10) { media(sort: [TRENDING_DESC, POPULARITY_DESC], type: ANIME) { ${MEDIA_LIST_FIELDS} } } } `; const data = await anilistQuery(gql); return data.Page.media; }; // ---- FEATURE: Airing schedule with timestamps ---- /** * Fetches upcoming anime episodes with UNIX timestamps. * Each result includes next_episode number, airingAt, and timeUntilAiring. * * @param {number} [page=1] - Page number * @param {number} [perPage=20] - Results per page * @returns {Promise} Paginated schedule with airing timestamps */ const getSchedule = async (page = 1, perPage = 20) => { const gql = ` query ($page: Int, $perPage: Int) { Page(page: $page, perPage: $perPage) { pageInfo { total currentPage lastPage hasNextPage perPage } airingSchedules(notYetAired: true, sort: TIME) { episode airingAt timeUntilAiring media { ${MEDIA_LIST_FIELDS} } } } } `; const data = await anilistQuery(gql, { page, perPage }); const pageData = data.Page; const results = pageData.airingSchedules.map((item) => ({ ...item.media, next_episode: item.episode, airingAt: item.airingAt, timeUntilAiring: item.timeUntilAiring, })); return { page: pageData.pageInfo.currentPage, perPage: pageData.pageInfo.perPage, total: pageData.pageInfo.total, hasNextPage: pageData.pageInfo.hasNextPage, results, }; }; // ---- FEATURE: Advanced anime filter with multiple parameters ---- /** * Advanced anime filter supporting genre, tag, year, season, * format, status, and sort. All parameters are optional. * * @param {object} options - Filter options * @param {string} [options.genre] - Genre name (e.g., "Action", "Romance") * @param {string} [options.tag] - Tag name (e.g., "Isekai", "Time Skip") * @param {number} [options.year] - Season year (e.g., 2025) * @param {string} [options.season] - Season (WINTER, SPRING, SUMMER, FALL) * @param {string} [options.format] - Format (TV, MOVIE, OVA, ONA, SPECIAL) * @param {string} [options.status] - Status (RELEASING, FINISHED, NOT_YET_RELEASED) * @param {string} [options.sort="POPULARITY_DESC"] - Sort order * @param {number} [options.page=1] - Page number * @param {number} [options.perPage=20] - Results per page * @returns {Promise} Paginated filtered results */ const filterAnime = async ({ genre, tag, year, season, format, status, sort = "POPULARITY_DESC", page = 1, perPage = 20, }) => { const SORT_MAP = { SCORE_DESC: "SCORE_DESC", POPULARITY_DESC: "POPULARITY_DESC", TRENDING_DESC: "TRENDING_DESC", START_DATE_DESC: "START_DATE_DESC", FAVOURITES_DESC: "FAVOURITES_DESC", UPDATED_AT_DESC: "UPDATED_AT_DESC", }; // NOTE: Build dynamic GraphQL arguments based on provided filters const args = ["type: ANIME", `sort: [${SORT_MAP[sort] || "POPULARITY_DESC"}]`]; const variables = { page, perPage }; const varTypes = ["$page: Int", "$perPage: Int"]; if (genre) { args.push("genre: $genre"); variables.genre = genre; varTypes.push("$genre: String"); } if (tag) { args.push("tag: $tag"); variables.tag = tag; varTypes.push("$tag: String"); } if (year) { args.push("seasonYear: $seasonYear"); variables.seasonYear = year; varTypes.push("$seasonYear: Int"); } if (season) { args.push("season: $season"); variables.season = season.toUpperCase(); varTypes.push("$season: MediaSeason"); } if (format) { args.push("format: $format"); variables.format = format.toUpperCase(); varTypes.push("$format: MediaFormat"); } if (status) { args.push("status: $status"); variables.status = status.toUpperCase(); varTypes.push("$status: MediaStatus"); } const gql = ` query (${varTypes.join(", ")}) { Page(page: $page, perPage: $perPage) { pageInfo { total currentPage lastPage hasNextPage perPage } media(${args.join(", ")}) { ${MEDIA_LIST_FIELDS} } } } `; const data = await anilistQuery(gql, variables); const pageData = data.Page; return { page: pageData.pageInfo.currentPage, perPage: pageData.pageInfo.perPage, total: pageData.pageInfo.total, hasNextPage: pageData.pageInfo.hasNextPage, results: pageData.media, }; }; module.exports = { anilistQuery, searchAnime, getSuggestions, getAnimeInfo, getAnimeCharacters, getAnimeRelations, getAnimeRecommendations, getCollection, getSpotlight, getSchedule, filterAnime, MEDIA_LIST_FIELDS, MEDIA_FULL_FIELDS, }; // ══════════════════════════════════════════════════════════════ END: anilist.js