Files
MiruroAPI/src/routes/apiRoutes.js
T
Sʜɪɴᴇɪ Nᴏᴜᴢᴇɴ 14d5c22fc7 fix: remove unused 'app' param from createApiRoutes to fix jsonError/jsonResponse parameter shift
The function signature was (app, jsonResponse, jsonError) but called
with (jsonResponse, jsonError) from server.js, causing all parameters
to shift by one. jsonResponse() was actually calling jsonError(),
returning {success: false, message: data} instead of
{success: true, results: data}.
2026-06-09 15:00:58 +05:30

450 lines
17 KiB
JavaScript

/*
* ======= • ======= • ======= • ======= • =======• =======
* 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 anilist = require("../helpers/anilist");
const pipe = require("../helpers/pipe");
const { getCached, setCache } = require("../helpers/cache");
// ══════════════════════════════════════════════════════════════
// ROUTE REGISTRATION
// ══════════════════════════════════════════════════════════════
// ---- 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 = (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);
}
});
// ---- 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);
}
});
// ---- 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);
}
});
// ══════════════════════════════════════════════════════════════
// 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);
}
});
// ---- 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);
}
});
// ---- 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);
}
});
// ---- 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);
}
});
// ---- 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);
}
});
// ---- 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);
}
});
// ══════════════════════════════════════════════════════════════
// 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);
}
});
// ---- 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);
}
});
// ---- 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);
}
});
// ---- 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);
}
});
// ══════════════════════════════════════════════════════════════
// 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