mirror of
https://github.com/Shineii86/MiruroAPI.git
synced 2026-10-22 10:15:10 +00:00
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}.
450 lines
17 KiB
JavaScript
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
|