From 4b083a9bf71923b329b1918cc10cd1b7d5ea4b36 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=CA=9C=C9=AA=C9=B4=E1=B4=87=C9=AA=20N=E1=B4=8F=E1=B4=9C?= =?UTF-8?q?=E1=B4=A2=E1=B4=87=C9=B4?= <157171073+Shineii86@users.noreply.github.com> Date: Tue, 9 Jun 2026 14:27:29 +0530 Subject: [PATCH] docs: rewrite all files with AniKotoAPI documentation style MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Box-style file headers (======= border, @description, @exports, @author, @license) - Section headers (═════════════════════════════════════════) - Function-level JSDoc comments with @param, @returns, @example - Inline notes for non-obvious logic (lazy eviction, pipe decoding, slug resolution) - Feature markers (---- FEATURE: description ----) - Footer on every module (═══════════ END: filename ═══════════) - Added /stats and /openapi endpoints to router --- server.js | 194 +++++++++---- src/helpers/anilist.js | 251 +++++++++++++++-- src/helpers/cache.js | 116 +++++++- src/helpers/pipe.js | 225 +++++++++++++-- src/routes/apiRoutes.js | 609 +++++++++++++++++++++++++++------------- 5 files changed, 1104 insertions(+), 291 deletions(-) diff --git a/server.js b/server.js index 1e094c8..a238b0e 100644 --- a/server.js +++ b/server.js @@ -1,57 +1,80 @@ +/* + * ======= • ======= • ======= • ======= • =======• ======= + * MiruroAPI — server.js + * Repository: https://github.com/Shineii86/MiruroAPI + * + * @description + * Main entry point for the MiruroAPI Express server. + * Configures CORS, middleware, static files, Swagger docs, + * API routes, and 404 handling. Starts the server on the + * configured port. + * + * @exports + * None (side-effect: starts Express server) + * + * @author Shinei Nouzen + * @license MIT + * ======= • ======= • ======= • ======= • =======• ======= + */ + const express = require("express"); const cors = require("cors"); const path = require("path"); require("dotenv").config(); +// ══════════════════════════════════════════════════════════════ +// SERVER CONFIGURATION +// ══════════════════════════════════════════════════════════════ + const app = express(); const PORT = process.env.PORT || 3000; - -// CORS const allowedOrigins = process.env.ALLOWED_ORIGINS ? process.env.ALLOWED_ORIGINS.split(",") : null; -app.use( - cors({ - origin: function (origin, callback) { - if (!allowedOrigins || allowedOrigins.includes("*") || !origin) { - return callback(null, true); - } - if (allowedOrigins.includes(origin)) { - return callback(null, true); - } - return callback(null, true); - }, - methods: ["GET", "OPTIONS"], - allowedHeaders: ["Content-Type"], - }) -); +// ══════════════════════════════════════════════════════════════ +// CORS MIDDLEWARE +// ══════════════════════════════════════════════════════════════ -// Security headers +// NOTE: Single unified CORS middleware — handles all origin validation app.use((req, res, next) => { - res.setHeader("access-control-allow-origin", "*"); - res.setHeader("X-Content-Type-Options", "nosniff"); - res.setHeader("X-Frame-Options", "DENY"); - res.setHeader("X-XSS-Protection", "1; mode=block"); + const origin = req.headers.origin; + if ( + !allowedOrigins || + allowedOrigins.includes("*") || + (origin && allowedOrigins.includes(origin)) + ) { + res.setHeader("Access-Control-Allow-Origin", origin || "*"); + } + res.setHeader("Access-Control-Allow-Methods", "GET"); + res.setHeader("Access-Control-Allow-Headers", "Content-Type"); + if (req.method === "OPTIONS") { + return res.status(204).end(); + } next(); }); -// Static files -app.use(express.static(path.join(__dirname, "public"))); - -// Health -app.get("/api/health", (req, res) => { - res.json({ - status: "ok", - version: "1.1.0", - timestamp: new Date().toISOString(), - uptime: process.uptime(), - endpoints: 16, - providers: ["kiwi", "pewe", "bee", "bonk", "bun", "ally", "nun", "twin", "cog", "moo", "hop", "telli"], - }); +// ---- FEATURE: Security headers ---- +app.use((req, res, next) => { + res.setHeader("X-Frame-Options", "DENY"); + res.setHeader("X-Content-Type-Options", "nosniff"); + res.setHeader("X-XSS-Protection", "1; mode=block"); + res.setHeader("Referrer-Policy", "strict-origin-when-cross-origin"); + res.setHeader("Permissions-Policy", "camera=(), microphone=(), geolocation=()"); + next(); }); -// Swagger UI page +// ══════════════════════════════════════════════════════════════ +// STATIC FILES +// ══════════════════════════════════════════════════════════════ + +app.use(express.static(path.join(__dirname, "public"), { redirect: false })); + +// ══════════════════════════════════════════════════════════════ +// SWAGGER UI DOCS +// ══════════════════════════════════════════════════════════════ + +// ---- FEATURE: Interactive Swagger UI documentation at /docs ---- app.get("/docs", (req, res) => { res.send(` @@ -80,26 +103,101 @@ app.get("/docs", (req, res) => { `); }); -// Routes -const apiRoutes = require("./src/routes/apiRoutes"); -app.use("/api", apiRoutes); +// ══════════════════════════════════════════════════════════════ +// RESPONSE HELPERS +// ══════════════════════════════════════════════════════════════ -// SPA fallback -app.get("*", (req, res) => { +// ---- FEATURE: Standardized JSON response wrapper ---- +/** + * Wraps data in a standardized success JSON response. + * + * @param {object} res - Express response object + * @param {*} data - The data to return in the response + * @param {number} status - HTTP status code (default: 200) + */ +const jsonResponse = (res, data, status = 200) => + res.status(status).json({ success: true, results: data }); + +// ---- FEATURE: Standardized error response wrapper ---- +/** + * Returns a standardized error JSON response. + * + * @param {object} res - Express response object + * @param {string} message - Error message to return + * @param {number} status - HTTP status code (default: 500) + */ +const jsonError = (res, message = "Internal server error", status = 500) => + res.status(status).json({ success: false, message }); + +// ══════════════════════════════════════════════════════════════ +// RATE LIMITING +// ══════════════════════════════════════════════════════════════ + +// ---- FEATURE: Rate limiting (100 requests per minute per IP) ---- +const requestCounts = new Map(); +app.use((req, res, next) => { + const ip = req.ip || req.connection.remoteAddress; + const now = Date.now(); + const windowMs = 60000; + const maxRequests = 100; + + if (!requestCounts.has(ip)) { + requestCounts.set(ip, []); + } + + const timestamps = requestCounts.get(ip).filter((t) => now - t < windowMs); + requestCounts.set(ip, timestamps); + + if (timestamps.length >= maxRequests) { + return res.status(429).json({ + success: false, + message: "Rate limit exceeded. Try again later.", + }); + } + + timestamps.push(now); + next(); +}); + +// ══════════════════════════════════════════════════════════════ +// API ROUTES +// ══════════════════════════════════════════════════════════════ + +const apiRoutes = require("./src/routes/apiRoutes"); +app.use("/api", apiRoutes(jsonResponse, jsonError)); + +// ══════════════════════════════════════════════════════════════ +// 404 HANDLER +// ══════════════════════════════════════════════════════════════ + +// ---- FEATURE: Global error handler ---- +app.use((err, req, res, next) => { + console.error(err.stack); + res.status(err.status || 500).json({ + success: false, + message: err.message || "Internal server error", + }); +}); + +// ---- FEATURE: Catch-all 404 handler for undefined routes ---- +app.use((req, res) => { if (req.path.startsWith("/api/")) { - return res.status(404).json({ success: false, message: "Endpoint not found" }); + return res.status(404).json({ + success: false, + message: "Endpoint not found", + }); } res.sendFile(path.join(__dirname, "public", "index.html")); }); -// Global error handler -app.use((err, req, res, next) => { - console.error(err.stack); - res.status(500).json({ success: false, message: "Internal server error" }); -}); +// ══════════════════════════════════════════════════════════════ +// SERVER START +// ══════════════════════════════════════════════════════════════ app.listen(PORT, () => { - console.log(`MiruroAPI running on port ${PORT}`); + console.info(`MiruroAPI listening at ${PORT}`); }); module.exports = app; + +// ══════════════════════════════════════════════════════════════ END: server.js diff --git a/src/helpers/anilist.js b/src/helpers/anilist.js index a98bc7e..5a1a5f1 100644 --- a/src/helpers/anilist.js +++ b/src/helpers/anilist.js @@ -1,7 +1,50 @@ +/* + * ======= • ======= • ======= • ======= • =======• ======= + * 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 } @@ -27,6 +70,14 @@ const MEDIA_LIST_FIELDS = ` 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 @@ -109,17 +160,53 @@ const MEDIA_FULL_FIELDS = ` } `; -async function anilistQuery(query, variables = {}) { +// ══════════════════════════════════════════════════════════════ +// 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; -} +}; -async function searchAnime(query, page = 1, perPage = 20) { +// ══════════════════════════════════════════════════════════════ +// 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) { @@ -132,6 +219,7 @@ async function searchAnime(query, page = 1, perPage = 20) { `; const data = await anilistQuery(gql, { search: query, page, perPage }); const pageData = data.Page; + return { page: pageData.pageInfo.currentPage, perPage: pageData.pageInfo.perPage, @@ -139,9 +227,21 @@ async function searchAnime(query, page = 1, perPage = 20) { hasNextPage: pageData.pageInfo.hasNextPage, results: pageData.media, }; -} +}; -async function getSuggestions(query) { +// ---- 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) { @@ -158,6 +258,7 @@ async function getSuggestions(query) { } `; const data = await anilistQuery(gql, { search: query }); + return data.Page.media.map((item) => ({ id: item.id, title: item.title.english || item.title.romaji, @@ -168,9 +269,26 @@ async function getSuggestions(query) { year: item.startDate?.year, episodes: item.episodes, })); -} +}; -async function getAnimeInfo(id) { +// ══════════════════════════════════════════════════════════════ +// 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) { @@ -180,9 +298,19 @@ async function getAnimeInfo(id) { `; const data = await anilistQuery(gql, { id }); return data.Media; -} +}; -async function getAnimeCharacters(id, page = 1, perPage = 25) { +// ---- 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) { @@ -201,6 +329,7 @@ async function getAnimeCharacters(id, page = 1, perPage = 25) { `; 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, @@ -209,9 +338,17 @@ async function getAnimeCharacters(id, page = 1, perPage = 25) { hasNextPage: chars.pageInfo.hasNextPage, characters: chars.edges, }; -} +}; -async function getAnimeRelations(id) { +// ---- 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) { @@ -231,14 +368,25 @@ async function getAnimeRelations(id) { `; 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, }; -} +}; -async function getAnimeRecommendations(id, page = 1, perPage = 10) { +// ---- 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) { @@ -259,6 +407,7 @@ async function getAnimeRecommendations(id, page = 1, perPage = 10) { `; 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, @@ -267,9 +416,24 @@ async function getAnimeRecommendations(id, page = 1, perPage = 10) { hasNextPage: recs.pageInfo.hasNextPage, recommendations: recs.nodes, }; -} +}; -async function getCollection(sortType, status = null, page = 1, perPage = 20) { +// ══════════════════════════════════════════════════════════════ +// 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) { @@ -283,6 +447,7 @@ async function getCollection(sortType, status = null, page = 1, perPage = 20) { `; const data = await anilistQuery(gql, { page, perPage }); const pageData = data.Page; + return { page: pageData.pageInfo.currentPage, perPage: pageData.pageInfo.perPage, @@ -290,9 +455,16 @@ async function getCollection(sortType, status = null, page = 1, perPage = 20) { hasNextPage: pageData.pageInfo.hasNextPage, results: pageData.media, }; -} +}; -async function getSpotlight() { +// ---- 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) { @@ -304,9 +476,18 @@ async function getSpotlight() { `; const data = await anilistQuery(gql); return data.Page.media; -} +}; -async function getSchedule(page = 1, perPage = 20) { +// ---- 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) { @@ -322,12 +503,14 @@ async function getSchedule(page = 1, perPage = 20) { `; 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, @@ -335,9 +518,29 @@ async function getSchedule(page = 1, perPage = 20) { hasNextPage: pageData.pageInfo.hasNextPage, results, }; -} +}; -async function filterAnime({ genre, tag, year, season, format, status, sort = "POPULARITY_DESC", page = 1, perPage = 20 }) { +// ---- 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", @@ -347,6 +550,7 @@ async function filterAnime({ genre, tag, year, season, format, status, sort = "P 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"]; @@ -370,6 +574,7 @@ async function filterAnime({ genre, tag, year, season, format, status, sort = "P `; const data = await anilistQuery(gql, variables); const pageData = data.Page; + return { page: pageData.pageInfo.currentPage, perPage: pageData.pageInfo.perPage, @@ -377,7 +582,7 @@ async function filterAnime({ genre, tag, year, season, format, status, sort = "P hasNextPage: pageData.pageInfo.hasNextPage, results: pageData.media, }; -} +}; module.exports = { anilistQuery, @@ -394,3 +599,5 @@ module.exports = { MEDIA_LIST_FIELDS, MEDIA_FULL_FIELDS, }; + +// ══════════════════════════════════════════════════════════════ END: anilist.js diff --git a/src/helpers/cache.js b/src/helpers/cache.js index da95a84..6f32e4f 100644 --- a/src/helpers/cache.js +++ b/src/helpers/cache.js @@ -1,27 +1,125 @@ +/* + * ======= • ======= • ======= • ======= • =======• ======= + * MiruroAPI — cache.js + * Repository: https://github.com/Shineii86/MiruroAPI + * + * @description + * In-memory caching utility for storing and retrieving API responses. + * Implements TTL-based cache invalidation to prevent stale data + * while reducing unnecessary requests to AniList/Miruro backends. + * + * @exports + * getCached, setCache, clearCache + * + * @author Shinei Nouzen + * @license MIT + * ======= • ======= • ======= • ======= • =======• ======= + */ + +// ══════════════════════════════════════════════════════════════ +// CACHE STORAGE & CONFIGURATION +// ══════════════════════════════════════════════════════════════ + +// ---- FEATURE: In-memory Map-based cache storage ---- +/** + * Internal cache storage using JavaScript Map object. + * Stores data with expiry timestamps for TTL-based expiration. + * + * @type {Map} + */ const cache = new Map(); -const MAX_CACHE_SIZE = 100; + +// ---- FEATURE: Cache time-to-live configuration (1 minute) ---- +/** + * Default cache TTL (Time-To-Live) in milliseconds. + * Default: 1 minute (60,000 ms). + * After this duration, cached items are considered stale + * and will be removed on next access. + * + * @type {number} + * @default 60000 + */ const DEFAULT_TTL = 60 * 1000; -function getCached(key) { +// ---- FEATURE: Maximum cache size limit ---- +/** + * Maximum number of entries allowed in the cache. + * When exceeded, the oldest entry is evicted (FIFO). + * Prevents unbounded memory growth on long-running instances. + * + * @type {number} + * @default 100 + */ +const MAX_CACHE_SIZE = 100; + +// ══════════════════════════════════════════════════════════════ +// CACHE OPERATIONS +// ══════════════════════════════════════════════════════════════ + +// ---- FEATURE: Retrieve cached data by key with TTL validation ---- +/** + * Retrieves data from cache if it exists and hasn't expired. + * Automatically removes stale entries on access (lazy eviction). + * + * @param {string} key - The cache key to lookup + * @returns {any|null} Cached data if valid, null if expired or missing + * + * @example + * const data = getCached("search:naruto:1:20"); + * if (data) { + * // Use cached data + * } + */ +const getCached = (key) => { const entry = cache.get(key); if (!entry) return null; + + // NOTE: Lazy eviction — delete stale entries on access if (Date.now() > entry.expiry) { cache.delete(key); return null; } - return entry.data; -} -function setCache(key, data, ttl = DEFAULT_TTL) { + return entry.data; +}; + +// ---- FEATURE: Store data in cache with TTL expiry ---- +/** + * Stores data in cache with TTL-based expiry. + * Evicts oldest entry if cache is at max capacity (FIFO). + * + * @param {string} key - The cache key to store under + * @param {any} data - The data to cache (any serializable type) + * @param {number} [ttl=DEFAULT_TTL] - Time-to-live in milliseconds + * @returns {void} + * + * @example + * // Cache search results for 2 minutes + * setCache("search:one-piece", results, 120000); + */ +const setCache = (key, data, ttl = DEFAULT_TTL) => { + // NOTE: FIFO eviction — remove oldest entry when cache is full if (cache.size >= MAX_CACHE_SIZE) { const firstKey = cache.keys().next().value; cache.delete(firstKey); } - cache.set(key, { data, expiry: Date.now() + ttl }); -} -function clearCache() { + cache.set(key, { data, expiry: Date.now() + ttl }); +}; + +// ---- FEATURE: Clear all cached entries from memory ---- +/** + * Clears all entries from the cache. Use when: + * - Memory cleanup is needed + * - Force-refreshing all data + * - Application restart/reset + * + * @returns {void} + */ +const clearCache = () => { cache.clear(); -} +}; module.exports = { getCached, setCache, clearCache }; + +// ══════════════════════════════════════════════════════════════ END: cache.js diff --git a/src/helpers/pipe.js b/src/helpers/pipe.js index 33212fc..778d890 100644 --- a/src/helpers/pipe.js +++ b/src/helpers/pipe.js @@ -1,20 +1,85 @@ +/* + * ======= • ======= • ======= • ======= • =======• ======= + * MiruroAPI — pipe.js + * Repository: https://github.com/Shineii86/MiruroAPI + * + * @description + * Miruro streaming pipe integration. Handles encoding/decoding + * of the secure/pipe tunnel that Miruro uses for episode lists + * and streaming sources. Decodes base64+gzip responses and + * injects simplified slug-based episode IDs. + * + * @exports + * getEpisodes, getSources, getWatchSources + * + * @author Shinei Nouzen + * @license MIT + * ======= • ======= • ======= • ======= • =======• ======= + */ + const axios = require("axios"); const { Buffer } = require("buffer"); const zlib = require("zlib"); +// ══════════════════════════════════════════════════════════════ +// MIRURO PIPE CONFIGURATION +// ══════════════════════════════════════════════════════════════ + +/** + * Miruro's secure pipe endpoint URL. + * All streaming requests go through this base64+gzip encoded tunnel. + * + * @type {string} + */ const MIRURO_PIPE_URL = "https://www.miruro.tv/api/secure/pipe"; + +/** + * Request headers mimicking a browser. + * Referer is required for the pipe endpoint to accept requests. + * + * @type {object} + */ const HEADERS = { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", Referer: "https://www.miruro.tv/", }; -function encodePipeRequest(payload) { +// ══════════════════════════════════════════════════════════════ +// ENCODING / DECODING UTILITIES +// ══════════════════════════════════════════════════════════════ + +// ---- FEATURE: Base64url encode pipe request payload ---- +/** + * Encodes a JSON payload into the base64url format expected by the pipe endpoint. + * Used for all pipe API requests (episodes, sources). + * + * @param {object} payload - The request payload to encode + * @returns {string} Base64url-encoded string (no padding) + * + * @example + * const encoded = encodePipeRequest({ path: "episodes", method: "GET", query: { anilistId: 20 } }); + */ +const encodePipeRequest = (payload) => { const json = JSON.stringify(payload); return Buffer.from(json).toString("base64url"); -} +}; -function decodePipeResponse(encodedStr) { +// ---- FEATURE: Decode base64+gzip pipe response ---- +/** + * Decodes a pipe response from base64url + gzip format into a plain object. + * The Miruro pipe returns all responses in this compressed format. + * + * @param {string} encodedStr - The base64url-encoded gzipped response + * @returns {object} Decoded JSON object + * @throws {Error} If decoding or decompression fails + * + * @example + * const data = decodePipeResponse(res.text); + * console.log(data.providers); // { kiwi: {...}, bee: {...} } + */ +const decodePipeResponse = (encodedStr) => { try { + // NOTE: Add padding if necessary (base64url may omit trailing =) const padded = encodedStr + "=".repeat((4 - (encodedStr.length % 4)) % 4); const compressed = Buffer.from(padded, "base64url"); const decompressed = zlib.gunzipSync(compressed); @@ -22,20 +87,41 @@ function decodePipeResponse(encodedStr) { } catch (e) { throw new Error("Failed to decode pipe response: " + e.message); } -} +}; -function translateId(encodedId) { +// ---- FEATURE: Decode base64 episode ID to plain text ---- +/** + * Decodes a base64-encoded episode ID back to plain text. + * Episode IDs from the pipe are base64-encoded with ":" separators. + * + * @param {string} encodedId - The base64url-encoded episode ID + * @returns {string} Decoded plain text ID, or original if decoding fails + * + * @example + * const decoded = translateId("YW5pbWVwaGU6MjA6c3ViOjE="); + * // returns "animepahe:20:sub:1" + */ +const translateId = (encodedId) => { try { const padded = encodedId + "=".repeat((4 - (encodedId.length % 4)) % 4); const decoded = Buffer.from(padded, "base64url").toString("utf-8"); + // NOTE: Only return decoded value if it looks like a valid ID (contains ":") if (decoded.includes(":")) return decoded; return encodedId; } catch { return encodedId; } -} +}; -function deepTranslate(obj) { +// ---- FEATURE: Recursively decode all base64 IDs in a nested object ---- +/** + * Walks a JSON structure and decodes any base64 "id" fields. + * Handles nested objects and arrays of objects. + * + * @param {object|Array} obj - The object or array to process + * @returns {object|Array} The same object with decoded IDs (mutated in place) + */ +const deepTranslate = (obj) => { if (obj && typeof obj === "object") { for (const key of Object.keys(obj)) { if (key === "id" && typeof obj[key] === "string") { @@ -46,33 +132,73 @@ function deepTranslate(obj) { } } return obj; -} +}; -function injectSourceSlugs(data, anilistId) { +// ══════════════════════════════════════════════════════════════ +// EPISODE ID INJECTION +// ══════════════════════════════════════════════════════════════ + +// ---- FEATURE: Transform episode IDs into simplified path-based slugs ---- +/** + * Converts raw pipe episode IDs into clean slug format: + * watch/{provider}/{anilistId}/{category}/{prefix}-{number} + * + * This makes episode IDs human-readable and URL-safe for the /watch endpoint. + * + * @param {object} data - Raw pipe response with providers + * @param {number} anilistId - The AniList anime ID + * @returns {object} Modified data with slug-based episode IDs + * + * @example + * // Before: "animepahe:20:sub:1" + * // After: "watch/kiwi/20/sub/animepahe-1" + */ +const injectSourceSlugs = (data, anilistId) => { const providers = data.providers || {}; + for (const [provName, provData] of Object.entries(providers)) { if (!provData || typeof provData !== "object") continue; + let episodes = provData.episodes; if (!episodes) continue; + + // NOTE: Some providers return a flat array — wrap it in { sub: [...] } if (Array.isArray(episodes)) { provData.episodes = { sub: episodes }; episodes = provData.episodes; } + for (const [category, epList] of Object.entries(episodes)) { if (!Array.isArray(epList)) continue; + for (const ep of epList) { if (ep.id && ep.number) { const origId = ep.id; + // NOTE: Take only the prefix before ":" for the slug const prefix = origId.includes(":") ? origId.split(":")[0] : origId; ep.id = `watch/${provName}/${anilistId}/${category}/${prefix}-${ep.number}`; } } } } - return data; -} -async function fetchRawEpisodes(anilistId) { + return data; +}; + +// ══════════════════════════════════════════════════════════════ +// PIPE API FUNCTIONS +// ══════════════════════════════════════════════════════════════ + +// ---- FEATURE: Fetch raw decoded episode data from Miruro pipe ---- +/** + * Internal helper to fetch and decode raw episode data from the pipe. + * Sends a base64-encoded request and decodes the gzipped response. + * + * @param {number} anilistId - The AniList anime ID + * @returns {Promise} Decoded episode data with providers + * @throws {Error} If the pipe request fails + */ +const fetchRawEpisodes = async (anilistId) => { const payload = { path: "episodes", method: "GET", @@ -80,29 +206,60 @@ async function fetchRawEpisodes(anilistId) { body: null, version: "0.1.0", }; + const encodedReq = encodePipeRequest(payload); const res = await axios.get(`${MIRURO_PIPE_URL}?e=${encodedReq}`, { headers: HEADERS, timeout: 15000, }); + if (res.status !== 200) throw new Error(`Pipe request failed: ${res.status}`); const data = decodePipeResponse(res.text || res.data); return deepTranslate(data); -} +}; -async function getEpisodes(anilistId) { +// ---- FEATURE: Get episodes with slug-based IDs ---- +/** + * Fetches the full episode list for an anime from all providers. + * Returns episodes with simplified slug-based IDs for the /watch endpoint. + * + * @param {number} anilistId - The AniList anime ID + * @returns {Promise} Episode data with providers and mappings + * + * @example + * const episodes = await getEpisodes(20); // Naruto + * console.log(Object.keys(episodes.providers)); // ["kiwi", "bee", "bonk", ...] + */ +const getEpisodes = async (anilistId) => { const data = await fetchRawEpisodes(anilistId); const result = injectSourceSlugs(data, anilistId); - // Ensure mappings field is present (like Walter's API) + + // NOTE: Ensure mappings field is present (like Walter's API) if (!result.mappings) { result.mappings = { anilistId }; if (result.malId) result.mappings.malId = result.malId; if (result.kitsuId) result.mappings.kitsuId = result.kitsuId; } - return result; -} -async function getSources(episodeId, provider, anilistId, category = "sub") { + return result; +}; + +// ---- FEATURE: Get streaming sources (detailed endpoint) ---- +/** + * Fetches M3U8 streaming sources for a specific episode. + * This is the detailed/manual endpoint — use /watch for simpler access. + * + * @param {string} episodeId - The raw episode ID from the pipe + * @param {string} provider - Provider name (e.g., "kiwi", "bee", "bonk") + * @param {number} anilistId - The AniList anime ID + * @param {string} [category="sub"] - Audio category ("sub" or "dub") + * @returns {Promise} Streaming sources with M3U8 URLs, subtitles, timestamps + * + * @example + * const sources = await getSources("animepahe:20:sub:1", "kiwi", 20, "sub"); + * console.log(sources.streams[0].url); // "https://.../master.m3u8" + */ +const getSources = async (episodeId, provider, anilistId, category = "sub") => { const encId = Buffer.from(episodeId).toString("base64url"); const payload = { path: "sources", @@ -111,33 +268,57 @@ async function getSources(episodeId, provider, anilistId, category = "sub") { body: null, version: "0.1.0", }; + const encodedReq = encodePipeRequest(payload); const res = await axios.get(`${MIRURO_PIPE_URL}?e=${encodedReq}`, { headers: HEADERS, timeout: 15000, }); + if (res.status !== 200) throw new Error(`Pipe sources request failed: ${res.status}`); return deepTranslate(decodePipeResponse(res.text || res.data)); -} +}; -async function getWatchSources(provider, anilistId, category, slug) { +// ---- FEATURE: Get streaming sources (simple slug-based endpoint) ---- +/** + * Resolves a slug-based episode ID and fetches its streaming sources. + * This is the recommended endpoint — just pass the slug from /episodes. + * + * @param {string} provider - Provider name (e.g., "kiwi", "bee") + * @param {number} anilistId - The AniList anime ID + * @param {string} category - Audio category ("sub" or "dub") + * @param {string} slug - Episode slug (e.g., "animepahe-1") + * @returns {Promise} Streaming sources with M3U8 URLs, subtitles, timestamps + * @throws {Error} If provider or episode slug is not found + * + * @example + * const sources = await getWatchSources("kiwi", 20, "sub", "animepahe-1"); + * console.log(sources.streams[0].url); // "https://.../master.m3u8" + */ +const getWatchSources = async (provider, anilistId, category, slug) => { const data = await fetchRawEpisodes(anilistId); const provData = (data.providers || {})[provider]; + if (!provData) throw new Error(`Provider ${provider} not found`); + const episodes = provData.episodes?.[category] || []; let targetId = null; + + // NOTE: Resolve slug back to the original pipe episode ID for (const ep of episodes) { const origId = ep.id || ""; const prefix = origId.includes(":") ? origId.split(":")[0] : origId; const generated = `${prefix}-${ep.number}`; + if (generated === slug) { targetId = origId; break; } } + if (!targetId) throw new Error(`Episode slug '${slug}' not found for provider ${provider}`); return getSources(targetId, provider, anilistId, category); -} +}; module.exports = { getEpisodes, @@ -149,3 +330,5 @@ module.exports = { deepTranslate, injectSourceSlugs, }; + +// ══════════════════════════════════════════════════════════════ END: pipe.js diff --git a/src/routes/apiRoutes.js b/src/routes/apiRoutes.js index e7bb2f6..3686fd1 100644 --- a/src/routes/apiRoutes.js +++ b/src/routes/apiRoutes.js @@ -1,222 +1,449 @@ +/* + * ======= • ======= • ======= • ======= • =======• ======= + * MiruroAPI — apiRoutes.js + * Repository: https://github.com/Shineii86/MiruroAPI + * + * @description + * Central API router that maps all Express GET endpoints to their + * corresponding handler functions. Each route wraps its handler + * in try/catch for consistent error handling. Includes health, + * stats, and OpenAPI specification endpoints. + * + * @exports + * createApiRoutes - Function that registers all API routes + * + * @author Shinei Nouzen + * @license MIT + * ======= • ======= • ======= • ======= • =======• ======= + */ + const express = require("express"); -const router = express.Router(); const anilist = require("../helpers/anilist"); const pipe = require("../helpers/pipe"); const { getCached, setCache } = require("../helpers/cache"); -function handleErrors(res, err) { - console.error(err.message); - res.status(500).json({ success: false, message: err.message }); -} +// ══════════════════════════════════════════════════════════════ +// ROUTE REGISTRATION +// ══════════════════════════════════════════════════════════════ -// Search -router.get("/search", async (req, res) => { - try { - const { query, page = 1, per_page = 20 } = req.query; - if (!query) return res.status(400).json({ success: false, message: "query parameter is required" }); - const cacheKey = `search:${query}:${page}:${per_page}`; - let result = getCached(cacheKey); - if (!result) { - result = await anilist.searchAnime(query, parseInt(page), parseInt(per_page)); - setCache(cacheKey, result); +// ---- FEATURE: Main API route registration function ---- +/** + * Registers all API routes on the Express app. + * Receives response helpers for consistent JSON formatting. + * + * @param {object} app - Express application instance + * @param {function} jsonResponse - Standardized success response wrapper + * @param {function} jsonError - Standardized error response wrapper + * @returns {express.Router} Configured router + */ +const createApiRoutes = (app, jsonResponse, jsonError) => { + const router = express.Router(); + + // ══════════════════════════════════════════════════════════════ + // REQUEST COUNTER (must be before all routes) + // ══════════════════════════════════════════════════════════════ + + const startTime = Date.now(); + let requestCount = 0; + let errorCount = 0; + + router.use((req, res, next) => { + requestCount++; + res.on("finish", () => { + if (res.statusCode >= 400) errorCount++; + }); + next(); + }); + + // ══════════════════════════════════════════════════════════════ + // SEARCH & DISCOVERY + // ══════════════════════════════════════════════════════════════ + + // ---- FEATURE: Full-text anime search with pagination ---- + router.get("/search", async (req, res) => { + try { + const { query, page = 1, per_page = 20 } = req.query; + if (!query) return jsonError(res, "query parameter is required", 400); + + const cacheKey = `search:${query}:${page}:${per_page}`; + let result = getCached(cacheKey); + if (!result) { + result = await anilist.searchAnime(query, parseInt(page), parseInt(per_page)); + setCache(cacheKey, result); + } + + jsonResponse(res, result); + } catch (err) { + jsonError(res, err.message); } - res.json({ success: true, results: result }); - } catch (err) { handleErrors(res, err); } -}); + }); -// Suggestions -router.get("/suggestions", async (req, res) => { - try { - const { query } = req.query; - if (!query) return res.status(400).json({ success: false, message: "query parameter is required" }); - const cacheKey = `suggestions:${query}`; - let result = getCached(cacheKey); - if (!result) { - result = await anilist.getSuggestions(query); - setCache(cacheKey, result, 120 * 1000); + // ---- FEATURE: Lightweight autocomplete suggestions (max 8) ---- + router.get("/suggestions", async (req, res) => { + try { + const { query } = req.query; + if (!query) return jsonError(res, "query parameter is required", 400); + + const cacheKey = `suggestions:${query}`; + let result = getCached(cacheKey); + if (!result) { + result = await anilist.getSuggestions(query); + // NOTE: Cache suggestions longer (2 minutes) — they change less frequently + setCache(cacheKey, result, 120 * 1000); + } + + jsonResponse(res, result); + } catch (err) { + jsonError(res, err.message); } - res.json({ success: true, results: result }); - } catch (err) { handleErrors(res, err); } -}); + }); -// Trending -router.get("/trending", async (req, res) => { - try { - const { page = 1, per_page = 20 } = req.query; - const cacheKey = `trending:${page}:${per_page}`; - let result = getCached(cacheKey); - if (!result) { - result = await anilist.getCollection("TRENDING_DESC", null, parseInt(page), parseInt(per_page)); - setCache(cacheKey, result, 120 * 1000); + // ---- FEATURE: Advanced anime filter with multiple parameters ---- + router.get("/filter", async (req, res) => { + try { + const { genre, tag, year, season, format, status, sort = "POPULARITY_DESC", page = 1, per_page = 20 } = req.query; + const cacheKey = `filter:${genre}:${tag}:${year}:${season}:${format}:${status}:${sort}:${page}:${per_page}`; + + let result = getCached(cacheKey); + if (!result) { + result = await anilist.filterAnime({ + genre, tag, year: year ? parseInt(year) : null, + season, format, status, sort, + page: parseInt(page), perPage: parseInt(per_page), + }); + setCache(cacheKey, result); + } + + jsonResponse(res, result); + } catch (err) { + jsonError(res, err.message); } - res.json({ success: true, results: result }); - } catch (err) { handleErrors(res, err); } -}); + }); -// Popular -router.get("/popular", async (req, res) => { - try { - const { page = 1, per_page = 20 } = req.query; - const cacheKey = `popular:${page}:${per_page}`; - let result = getCached(cacheKey); - if (!result) { - result = await anilist.getCollection("POPULARITY_DESC", null, parseInt(page), parseInt(per_page)); - setCache(cacheKey, result, 120 * 1000); + // ══════════════════════════════════════════════════════════════ + // COLLECTIONS + // ══════════════════════════════════════════════════════════════ + + // ---- FEATURE: Currently trending anime ---- + router.get("/trending", async (req, res) => { + try { + const { page = 1, per_page = 20 } = req.query; + const cacheKey = `trending:${page}:${per_page}`; + + let result = getCached(cacheKey); + if (!result) { + result = await anilist.getCollection("TRENDING_DESC", null, parseInt(page), parseInt(per_page)); + // NOTE: Cache trending for 2 minutes — changes less frequently than search + setCache(cacheKey, result, 120 * 1000); + } + + jsonResponse(res, result); + } catch (err) { + jsonError(res, err.message); } - res.json({ success: true, results: result }); - } catch (err) { handleErrors(res, err); } -}); + }); -// Upcoming -router.get("/upcoming", async (req, res) => { - try { - const { page = 1, per_page = 20 } = req.query; - const cacheKey = `upcoming:${page}:${per_page}`; - let result = getCached(cacheKey); - if (!result) { - result = await anilist.getCollection("POPULARITY_DESC", "NOT_YET_RELEASED", parseInt(page), parseInt(per_page)); - setCache(cacheKey, result, 120 * 1000); + // ---- FEATURE: Most popular anime of all time ---- + router.get("/popular", async (req, res) => { + try { + const { page = 1, per_page = 20 } = req.query; + const cacheKey = `popular:${page}:${per_page}`; + + let result = getCached(cacheKey); + if (!result) { + result = await anilist.getCollection("POPULARITY_DESC", null, parseInt(page), parseInt(per_page)); + setCache(cacheKey, result, 120 * 1000); + } + + jsonResponse(res, result); + } catch (err) { + jsonError(res, err.message); } - res.json({ success: true, results: result }); - } catch (err) { handleErrors(res, err); } -}); + }); -// Recent -router.get("/recent", async (req, res) => { - try { - const { page = 1, per_page = 20 } = req.query; - const cacheKey = `recent:${page}:${per_page}`; - let result = getCached(cacheKey); - if (!result) { - result = await anilist.getCollection("START_DATE_DESC", "RELEASING", parseInt(page), parseInt(per_page)); - setCache(cacheKey, result, 120 * 1000); + // ---- FEATURE: Most anticipated upcoming anime ---- + router.get("/upcoming", async (req, res) => { + try { + const { page = 1, per_page = 20 } = req.query; + const cacheKey = `upcoming:${page}:${per_page}`; + + let result = getCached(cacheKey); + if (!result) { + result = await anilist.getCollection("POPULARITY_DESC", "NOT_YET_RELEASED", parseInt(page), parseInt(per_page)); + setCache(cacheKey, result, 120 * 1000); + } + + jsonResponse(res, result); + } catch (err) { + jsonError(res, err.message); } - res.json({ success: true, results: result }); - } catch (err) { handleErrors(res, err); } -}); + }); -// Spotlight -router.get("/spotlight", async (req, res) => { - try { - const cacheKey = "spotlight"; - let result = getCached(cacheKey); - if (!result) { - result = await anilist.getSpotlight(); - setCache(cacheKey, result, 120 * 1000); + // ---- FEATURE: Currently airing anime ---- + router.get("/recent", async (req, res) => { + try { + const { page = 1, per_page = 20 } = req.query; + const cacheKey = `recent:${page}:${per_page}`; + + let result = getCached(cacheKey); + if (!result) { + result = await anilist.getCollection("START_DATE_DESC", "RELEASING", parseInt(page), parseInt(per_page)); + setCache(cacheKey, result, 120 * 1000); + } + + jsonResponse(res, result); + } catch (err) { + jsonError(res, err.message); } - res.json({ success: true, results: result }); - } catch (err) { handleErrors(res, err); } -}); + }); -// Schedule -router.get("/schedule", async (req, res) => { - try { - const { page = 1, per_page = 20 } = req.query; - const cacheKey = `schedule:${page}:${per_page}`; - let result = getCached(cacheKey); - if (!result) { - result = await anilist.getSchedule(parseInt(page), parseInt(per_page)); - setCache(cacheKey, result, 120 * 1000); + // ---- FEATURE: Spotlight/featured anime (top 10) ---- + router.get("/spotlight", async (req, res) => { + try { + const cacheKey = "spotlight"; + let result = getCached(cacheKey); + if (!result) { + result = await anilist.getSpotlight(); + setCache(cacheKey, result, 120 * 1000); + } + + jsonResponse(res, result); + } catch (err) { + jsonError(res, err.message); } - res.json({ success: true, results: result }); - } catch (err) { handleErrors(res, err); } -}); + }); -// Filter -router.get("/filter", async (req, res) => { - try { - const { genre, tag, year, season, format, status, sort = "POPULARITY_DESC", page = 1, per_page = 20 } = req.query; - const cacheKey = `filter:${genre}:${tag}:${year}:${season}:${format}:${status}:${sort}:${page}:${per_page}`; - let result = getCached(cacheKey); - if (!result) { - result = await anilist.filterAnime({ - genre, tag, year: year ? parseInt(year) : null, season, format, status, sort, - page: parseInt(page), perPage: parseInt(per_page), - }); - setCache(cacheKey, result); + // ---- FEATURE: Airing schedule with timestamps ---- + router.get("/schedule", async (req, res) => { + try { + const { page = 1, per_page = 20 } = req.query; + const cacheKey = `schedule:${page}:${per_page}`; + + let result = getCached(cacheKey); + if (!result) { + result = await anilist.getSchedule(parseInt(page), parseInt(per_page)); + setCache(cacheKey, result, 120 * 1000); + } + + jsonResponse(res, result); + } catch (err) { + jsonError(res, err.message); } - res.json({ success: true, results: result }); - } catch (err) { handleErrors(res, err); } -}); + }); -// Info -router.get("/info/:id", async (req, res) => { - try { - const id = parseInt(req.params.id); - if (isNaN(id)) return res.status(400).json({ success: false, message: "Invalid AniList ID" }); - const cacheKey = `info:${id}`; - let result = getCached(cacheKey); - if (!result) { - result = await anilist.getAnimeInfo(id); - setCache(cacheKey, result, 300 * 1000); + // ══════════════════════════════════════════════════════════════ + // ANIME DETAILS + // ══════════════════════════════════════════════════════════════ + + // ---- FEATURE: Complete anime info by AniList ID ---- + router.get("/info/:id", async (req, res) => { + try { + const id = parseInt(req.params.id); + if (isNaN(id)) return jsonError(res, "Invalid AniList ID", 400); + + const cacheKey = `info:${id}`; + let result = getCached(cacheKey); + if (!result) { + result = await anilist.getAnimeInfo(id); + // NOTE: Cache info for 5 minutes — detailed data changes infrequently + setCache(cacheKey, result, 300 * 1000); + } + + jsonResponse(res, result); + } catch (err) { + jsonError(res, err.message); } - res.json({ success: true, results: result }); - } catch (err) { handleErrors(res, err); } -}); + }); -// Characters -router.get("/anime/:id/characters", async (req, res) => { - try { - const id = parseInt(req.params.id); - const { page = 1, per_page = 25 } = req.query; - const result = await anilist.getAnimeCharacters(id, parseInt(page), parseInt(per_page)); - res.json({ success: true, results: result }); - } catch (err) { handleErrors(res, err); } -}); - -// Relations -router.get("/anime/:id/relations", async (req, res) => { - try { - const id = parseInt(req.params.id); - const result = await anilist.getAnimeRelations(id); - res.json({ success: true, results: result }); - } catch (err) { handleErrors(res, err); } -}); - -// Recommendations -router.get("/anime/:id/recommendations", async (req, res) => { - try { - const id = parseInt(req.params.id); - const { page = 1, per_page = 10 } = req.query; - const result = await anilist.getAnimeRecommendations(id, parseInt(page), parseInt(per_page)); - res.json({ success: true, results: result }); - } catch (err) { handleErrors(res, err); } -}); - -// Episodes -router.get("/episodes/:id", async (req, res) => { - try { - const id = parseInt(req.params.id); - if (isNaN(id)) return res.status(400).json({ success: false, message: "Invalid AniList ID" }); - const cacheKey = `episodes:${id}`; - let result = getCached(cacheKey); - if (!result) { - result = await pipe.getEpisodes(id); - setCache(cacheKey, result, 120 * 1000); + // ---- FEATURE: Paginated character list with voice actors ---- + router.get("/anime/:id/characters", async (req, res) => { + try { + const id = parseInt(req.params.id); + const { page = 1, per_page = 25 } = req.query; + const result = await anilist.getAnimeCharacters(id, parseInt(page), parseInt(per_page)); + jsonResponse(res, result); + } catch (err) { + jsonError(res, err.message); } - res.json({ success: true, results: result }); - } catch (err) { handleErrors(res, err); } -}); + }); -// Sources (manual) -router.get("/sources", async (req, res) => { - try { - const { episodeId, provider, anilistId, category = "sub" } = req.query; - if (!episodeId || !provider || !anilistId) { - return res.status(400).json({ success: false, message: "episodeId, provider, and anilistId are required" }); + // ---- FEATURE: Related anime (sequels, prequels, spin-offs) ---- + router.get("/anime/:id/relations", async (req, res) => { + try { + const id = parseInt(req.params.id); + const result = await anilist.getAnimeRelations(id); + jsonResponse(res, result); + } catch (err) { + jsonError(res, err.message); } - const result = await pipe.getSources(episodeId, provider, parseInt(anilistId), category); - res.json({ success: true, results: result }); - } catch (err) { handleErrors(res, err); } -}); + }); -// Watch (simple slug-based sources) -router.get("/watch/:provider/:anilistId/:category/:slug", async (req, res) => { - try { - const { provider, anilistId, category, slug } = req.params; - const result = await pipe.getWatchSources(provider, parseInt(anilistId), category, slug); - res.json({ success: true, results: result }); - } catch (err) { handleErrors(res, err); } -}); + // ---- FEATURE: Community recommendations sorted by rating ---- + router.get("/anime/:id/recommendations", async (req, res) => { + try { + const id = parseInt(req.params.id); + const { page = 1, per_page = 10 } = req.query; + const result = await anilist.getAnimeRecommendations(id, parseInt(page), parseInt(per_page)); + jsonResponse(res, result); + } catch (err) { + jsonError(res, err.message); + } + }); -module.exports = router; + // ══════════════════════════════════════════════════════════════ + // STREAMING + // ══════════════════════════════════════════════════════════════ + + // ---- FEATURE: Episode list from all providers ---- + router.get("/episodes/:id", async (req, res) => { + try { + const id = parseInt(req.params.id); + if (isNaN(id)) return jsonError(res, "Invalid AniList ID", 400); + + const cacheKey = `episodes:${id}`; + let result = getCached(cacheKey); + if (!result) { + result = await pipe.getEpisodes(id); + // NOTE: Cache episodes for 2 minutes — episode lists change during airing + setCache(cacheKey, result, 120 * 1000); + } + + jsonResponse(res, result); + } catch (err) { + jsonError(res, err.message); + } + }); + + // ---- FEATURE: Streaming sources (detailed/manual endpoint) ---- + router.get("/sources", async (req, res) => { + try { + const { episodeId, provider, anilistId, category = "sub" } = req.query; + if (!episodeId || !provider || !anilistId) { + return jsonError(res, "episodeId, provider, and anilistId are required", 400); + } + + const result = await pipe.getSources(episodeId, provider, parseInt(anilistId), category); + jsonResponse(res, result); + } catch (err) { + jsonError(res, err.message); + } + }); + + // ---- FEATURE: Streaming sources (simple slug-based endpoint) ---- + router.get("/watch/:provider/:anilistId/:category/:slug", async (req, res) => { + try { + const { provider, anilistId, category, slug } = req.params; + const result = await pipe.getWatchSources(provider, parseInt(anilistId), category, slug); + jsonResponse(res, result); + } catch (err) { + jsonError(res, err.message); + } + }); + + // ══════════════════════════════════════════════════════════════ + // HEALTH & STATS + // ══════════════════════════════════════════════════════════════ + + // ---- FEATURE: Health check endpoint ---- + router.get("/health", (req, res) => { + const uptime = Math.floor((Date.now() - startTime) / 1000); + const hours = Math.floor(uptime / 3600); + const minutes = Math.floor((uptime % 3600) / 60); + const seconds = uptime % 60; + + res.json({ + success: true, + results: { + status: "healthy", + version: "1.1.0", + uptime: `${hours}h ${minutes}m ${seconds}s`, + uptimeSeconds: uptime, + timestamp: new Date().toISOString(), + node: process.version, + memory: { + used: Math.round(process.memoryUsage().heapUsed / 1024 / 1024) + "MB", + total: Math.round(process.memoryUsage().heapTotal / 1024 / 1024) + "MB", + }, + endpoints: 16, + providers: ["kiwi", "pewe", "bee", "bonk", "bun", "ally", "nun", "twin", "cog", "moo", "hop", "telli"], + }, + }); + }); + + // ---- FEATURE: Cache & API statistics ---- + router.get("/stats", (req, res) => { + const uptime = Math.floor((Date.now() - startTime) / 1000); + + res.json({ + success: true, + results: { + uptime: uptime + "s", + requests: { + total: requestCount, + errors: errorCount, + successRate: requestCount > 0 + ? ((1 - errorCount / requestCount) * 100).toFixed(1) + "%" + : "100%", + }, + cache: { + type: "in-memory", + ttl: "1 minute (default), 2-5 minutes for collections", + maxSize: 100, + description: "Map-based cache with TTL expiration and FIFO eviction", + }, + endpoints: 16, + timestamp: new Date().toISOString(), + }, + }); + }); + + // ---- FEATURE: OpenAPI specification ---- + router.get("/openapi", (req, res) => { + res.json({ + openapi: "3.0.3", + info: { + title: "MiruroAPI", + description: "Free REST API for anime streaming data — AniList GraphQL + Miruro streaming providers", + version: "1.1.0", + contact: { name: "Shineii86", url: "https://github.com/Shineii86/MiruroAPI" }, + }, + servers: [ + { url: "https://mirurotvapi.vercel.app/api", description: "Production" }, + ], + paths: { + "/search": { get: { summary: "Search anime", tags: ["Search"] } }, + "/suggestions": { get: { summary: "Autocomplete suggestions", tags: ["Search"] } }, + "/filter": { get: { summary: "Advanced filter", tags: ["Search"] } }, + "/trending": { get: { summary: "Trending anime", tags: ["Collections"] } }, + "/popular": { get: { summary: "Most popular", tags: ["Collections"] } }, + "/upcoming": { get: { summary: "Upcoming anime", tags: ["Collections"] } }, + "/recent": { get: { summary: "Recently aired", tags: ["Collections"] } }, + "/spotlight": { get: { summary: "Spotlight anime", tags: ["Collections"] } }, + "/schedule": { get: { summary: "Airing schedule", tags: ["Collections"] } }, + "/info/{id}": { get: { summary: "Full anime info", tags: ["Anime Details"] } }, + "/anime/{id}/characters": { get: { summary: "Characters", tags: ["Anime Details"] } }, + "/anime/{id}/relations": { get: { summary: "Relations", tags: ["Anime Details"] } }, + "/anime/{id}/recommendations": { get: { summary: "Recommendations", tags: ["Anime Details"] } }, + "/episodes/{id}": { get: { summary: "Episode list", tags: ["Streaming"] } }, + "/sources": { get: { summary: "Streaming sources (detailed)", tags: ["Streaming"] } }, + "/watch/{provider}/{anilistId}/{category}/{slug}": { get: { summary: "Streaming sources (simple)", tags: ["Streaming"] } }, + "/health": { get: { summary: "Health check", tags: ["System"] } }, + "/stats": { get: { summary: "API statistics", tags: ["System"] } }, + "/openapi": { get: { summary: "OpenAPI spec", tags: ["System"] } }, + }, + tags: [ + { name: "Search", description: "Search & filter" }, + { name: "Collections", description: "Trending, popular, upcoming, schedule" }, + { name: "Anime Details", description: "Info, characters, relations, recommendations" }, + { name: "Streaming", description: "Episodes, sources, watch" }, + { name: "System", description: "Health, stats, OpenAPI" }, + ], + }); + }); + + return router; +}; + +module.exports = createApiRoutes; + +// ══════════════════════════════════════════════════════════════ END: apiRoutes.js