mirror of
https://github.com/Shineii86/MiruroAPI.git
synced 2026-10-22 10:15:10 +00:00
v2.0.0: Major upgrade — 35 endpoints, compression, streaming improvements, new features
This commit is contained in:
+75
-11
@@ -1,14 +1,78 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## v2.0.0
|
||||||
|
### Major Release — 35 endpoints, streaming improvements, new features
|
||||||
|
|
||||||
|
#### Technical Foundation
|
||||||
|
- Added gzip compression middleware (30-70% response size reduction)
|
||||||
|
- Added request logging with method, path, status, duration, IP
|
||||||
|
- Added Cache-Control headers for API responses (varies by endpoint type)
|
||||||
|
- Improved CORS with preflight caching (24h max-age)
|
||||||
|
- Health endpoint now includes memory stats (heap, RSS), cache size, node version
|
||||||
|
|
||||||
|
#### New AniList Helpers (11 new functions)
|
||||||
|
- getAllGenres — complete genre list from AniList
|
||||||
|
- getAllTags — complete tag list with metadata
|
||||||
|
- getTopAnime — top-rated anime of all time (SCORE_DESC)
|
||||||
|
- getTrendingDaily — trending anime (last 7 days)
|
||||||
|
- getTrendingWeekly — trending anime (last 30 days)
|
||||||
|
- getSeasonalAnime — anime by year + season (e.g., 2025/Spring)
|
||||||
|
- getAnimeByStudio — anime produced by a specific studio
|
||||||
|
- getCharacterInfo — character detail with anime list
|
||||||
|
- getStaffInfo — staff detail with anime list
|
||||||
|
- searchCharacters — search characters by name
|
||||||
|
- searchStaff — search staff by name
|
||||||
|
|
||||||
|
#### New Streaming & Download Features
|
||||||
|
- extractSubtitles — extract subtitle URLs from sources
|
||||||
|
- getBestStream — automatic quality fallback (1080p → 720p → 360p)
|
||||||
|
- getDownloadUrl — dedicated download URL endpoint
|
||||||
|
- getBatchSources — fetch multiple episode sources in parallel
|
||||||
|
|
||||||
|
#### New API Endpoints (17 new)
|
||||||
|
- GET /api/trending/daily — daily trending anime
|
||||||
|
- GET /api/trending/weekly — weekly trending anime
|
||||||
|
- GET /api/top — top anime by score
|
||||||
|
- GET /api/random — random anime of the day
|
||||||
|
- GET /api/multi-search — combined query + filters
|
||||||
|
- GET /api/season/:year/:season — seasonal anime
|
||||||
|
- GET /api/studio/:name — anime by studio
|
||||||
|
- GET /api/genres — all genres list
|
||||||
|
- GET /api/tags — all tags list
|
||||||
|
- GET /api/genre/:name — anime by genre
|
||||||
|
- GET /api/year/:year — anime by year
|
||||||
|
- GET /api/status/:status — anime by status
|
||||||
|
- GET /api/format/:format — anime by format
|
||||||
|
- GET /api/character/:id — character detail
|
||||||
|
- GET /api/staff/:id — staff detail
|
||||||
|
- GET /api/characters — search characters
|
||||||
|
- GET /api/staff — search staff
|
||||||
|
- GET /api/episodes/:id/batch — batch episode sources
|
||||||
|
- GET /api/stream — streaming with quality fallback
|
||||||
|
- GET /api/download — download URL
|
||||||
|
- GET /api/compare/:id1/:id2 — compare two anime
|
||||||
|
- GET /api/stats/genre — genre statistics
|
||||||
|
- GET /api/calendar — monthly airing calendar
|
||||||
|
- GET /api/timeline/:id — anime release timeline
|
||||||
|
|
||||||
|
#### Streaming Improvements
|
||||||
|
- Subtitle URL extraction from pipe responses
|
||||||
|
- Quality fallback: tries 1080p → 720p → 480p → 360p automatically
|
||||||
|
- Batch episode fetching (up to 5 parallel requests)
|
||||||
|
- Dedicated download endpoint with provider metadata
|
||||||
|
- Cache headers: episodes/sources (2min), info (5min), genres/tags (24h)
|
||||||
|
|
||||||
|
#### Version synced to 2.0.0 across all files
|
||||||
|
|
||||||
## v1.4.0
|
## v1.4.0
|
||||||
- Added creator attribution middleware — injects author info into every response
|
- Added creator attribution middleware — injects author info into every response
|
||||||
- Fixed Vercel deployment: replaced legacy `builds`/`routes` config with modern `functions` config
|
- Fixed Vercel deployment: replaced legacy `builds`/`routes` config with modern `functions` config
|
||||||
- Added `api/index.js` serverless entry point (Vercel requires this for proper function detection)
|
- Added `api/index.js` serverless entry point
|
||||||
- Security headers: removed deprecated `X-XSS-Protection`, added `Strict-Transport-Security` (HSTS)
|
- Security headers: removed deprecated `X-XSS-Protection`, added `Strict-Transport-Security` (HSTS)
|
||||||
- Rate limiter: uses `x-forwarded-for` header for correct IP detection on Vercel serverless
|
- Rate limiter: uses `x-forwarded-for` header for correct IP detection on Vercel
|
||||||
- Rate limiter: added periodic IP entry pruning when >200 IPs tracked (prevents memory leak)
|
- Rate limiter: added periodic IP entry pruning when >200 IPs tracked
|
||||||
- Cache: added periodic expired entry cleanup when cache reaches 80% capacity (prevents memory leak)
|
- Cache: added periodic expired entry cleanup when cache reaches 80% capacity
|
||||||
- Version synced to 1.4.0 across all files (server.js, package.json, health endpoint, OpenAPI spec)
|
- Version synced to 1.4.0
|
||||||
|
|
||||||
## v1.3.0
|
## v1.3.0
|
||||||
- Full landing page upgrade to match AniKotoAPI style
|
- Full landing page upgrade to match AniKotoAPI style
|
||||||
@@ -17,18 +81,18 @@
|
|||||||
- Added animated stat counters that count up on scroll
|
- Added animated stat counters that count up on scroll
|
||||||
- Added infinite scrolling marquee with 8 feature badges
|
- Added infinite scrolling marquee with 8 feature badges
|
||||||
- Added live interactive API console with 5 preset endpoint buttons
|
- Added live interactive API console with 5 preset endpoint buttons
|
||||||
- Added sidebar endpoint explorer with 16 endpoints and multi-language code tabs (cURL, JavaScript, Python)
|
- Added sidebar endpoint explorer with 16 endpoints and multi-language code tabs
|
||||||
- Added 4 interactive playground cards (Search, Info, Episodes, Trending)
|
- Added 4 interactive playground cards (Search, Info, Episodes, Trending)
|
||||||
- Added custom scrollbar styling
|
- Added custom scrollbar styling
|
||||||
- Added JetBrains Mono font for code blocks
|
- Added JetBrains Mono font for code blocks
|
||||||
- Added 4-column grid footer with API, Resources, Legal sections
|
- Added 4-column grid footer
|
||||||
- Added fixed header with scroll-aware blur/glass effect
|
- Added fixed header with scroll-aware blur/glass effect
|
||||||
- Added ambient orb gradient animations
|
- Added ambient orb gradient animations
|
||||||
- Added CTA section with gradient overlay
|
- Added CTA section with gradient overlay
|
||||||
- Color scheme updated to match MiruroAPI brand (cyan #38bdf8 + indigo #818cf8)
|
- Color scheme updated to match MiruroAPI brand (cyan #38bdf8 + indigo #818cf8)
|
||||||
- All branding updated: AniKotoAPI references → MiruroAPI
|
- All branding updated: AniKotoAPI references → MiruroAPI
|
||||||
- OG/Twitter meta tags updated for mirurotvapi.vercel.app
|
- OG/Twitter meta tags updated for mirurotvapi.vercel.app
|
||||||
- JSON-LD structured data updated with image, category, programmingModel
|
- JSON-LD structured data updated
|
||||||
- Real Miruro SVG favicons used in header and footer
|
- Real Miruro SVG favicons used in header and footer
|
||||||
|
|
||||||
## v1.2.0
|
## v1.2.0
|
||||||
@@ -36,14 +100,14 @@
|
|||||||
- Root cause: createApiRoutes() had (app, jsonResponse, jsonError) but was called with (jsonResponse, jsonError), shifting all params
|
- Root cause: createApiRoutes() had (app, jsonResponse, jsonError) but was called with (jsonResponse, jsonError), shifting all params
|
||||||
- Every jsonResponse() call was actually calling jsonError(), returning {success: false, message: data}
|
- Every jsonResponse() call was actually calling jsonError(), returning {success: false, message: data}
|
||||||
- Health endpoint worked because it used res.json() directly, bypassing the wrapper
|
- Health endpoint worked because it used res.json() directly, bypassing the wrapper
|
||||||
- All 18 endpoints verified working: health, stats, search, suggestions, filter, trending, popular, upcoming, recent, spotlight, schedule, info, characters, relations, recommendations, episodes, watch
|
- All 18 endpoints verified working
|
||||||
|
|
||||||
## v1.1.0
|
## v1.1.0
|
||||||
- Added Swagger UI interactive docs at /docs
|
- Added Swagger UI interactive docs at /docs
|
||||||
- Added OpenAPI 3.0 spec at /openapi.json
|
- Added OpenAPI 3.0 spec at /openapi.json
|
||||||
- Added mappings field in episodes response (anilistId, malId, kitsuId)
|
- Added mappings field in episodes response
|
||||||
- Added Docker support (Dockerfile + .dockerignore)
|
- Added Docker support (Dockerfile + .dockerignore)
|
||||||
- Improved landing page with premium design (Walter-style)
|
- Improved landing page with premium design
|
||||||
- Added "Try it" buttons on all endpoints
|
- Added "Try it" buttons on all endpoints
|
||||||
- Health endpoint now includes version, providers list, and endpoint count
|
- Health endpoint now includes version, providers list, and endpoint count
|
||||||
- Version bump to 1.1.0
|
- Version bump to 1.1.0
|
||||||
|
|||||||
Generated
+1480
File diff suppressed because it is too large
Load Diff
+4
-3
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "miruro-api",
|
"name": "miruro-api",
|
||||||
"version": "1.4.0",
|
"version": "2.0.0",
|
||||||
"description": "RESTful API for miruro.tv - Anime streaming data powered by AniList GraphQL and Miruro streaming providers",
|
"description": "RESTful API for miruro.tv - Anime streaming data powered by AniList GraphQL and Miruro streaming providers",
|
||||||
"main": "server.js",
|
"main": "server.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -9,10 +9,11 @@
|
|||||||
"test": "node test.js"
|
"test": "node test.js"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"express": "^4.21.0",
|
|
||||||
"axios": "^1.8.0",
|
"axios": "^1.8.0",
|
||||||
|
"compression": "^1.8.1",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"dotenv": "^16.4.0"
|
"dotenv": "^16.4.0",
|
||||||
|
"express": "^4.21.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"nodemon": "^3.1.0"
|
"nodemon": "^3.1.0"
|
||||||
|
|||||||
@@ -5,9 +5,8 @@
|
|||||||
*
|
*
|
||||||
* @description
|
* @description
|
||||||
* Main entry point for the MiruroAPI Express server.
|
* Main entry point for the MiruroAPI Express server.
|
||||||
* Configures CORS, middleware, static files, Swagger docs,
|
* Configures CORS, compression, logging, security headers,
|
||||||
* API routes, and 404 handling. Starts the server on the
|
* rate limiting, API routes, and 404 handling.
|
||||||
* configured port.
|
|
||||||
*
|
*
|
||||||
* @exports
|
* @exports
|
||||||
* None (side-effect: starts Express server)
|
* None (side-effect: starts Express server)
|
||||||
@@ -19,7 +18,9 @@
|
|||||||
|
|
||||||
const express = require("express");
|
const express = require("express");
|
||||||
const cors = require("cors");
|
const cors = require("cors");
|
||||||
|
const compression = require("compression");
|
||||||
const path = require("path");
|
const path = require("path");
|
||||||
|
const fs = require("fs");
|
||||||
require("dotenv").config();
|
require("dotenv").config();
|
||||||
|
|
||||||
// ══════════════════════════════════════════════════════════════
|
// ══════════════════════════════════════════════════════════════
|
||||||
@@ -28,15 +29,63 @@ require("dotenv").config();
|
|||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
const PORT = process.env.PORT || 3000;
|
const PORT = process.env.PORT || 3000;
|
||||||
|
const startTime = Date.now();
|
||||||
const allowedOrigins = process.env.ALLOWED_ORIGINS
|
const allowedOrigins = process.env.ALLOWED_ORIGINS
|
||||||
? process.env.ALLOWED_ORIGINS.split(",")
|
? process.env.ALLOWED_ORIGINS.split(",")
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
// COMPRESSION
|
||||||
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
// ---- FEATURE: Gzip compression — reduces response size by 30-70% ----
|
||||||
|
app.use(compression({
|
||||||
|
level: 6,
|
||||||
|
threshold: 1024,
|
||||||
|
filter: (req, res) => {
|
||||||
|
if (req.headers["x-no-compression"]) return false;
|
||||||
|
return compression.filter(req, res);
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
// REQUEST LOGGING
|
||||||
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
// ---- FEATURE: Request logging — method, path, status, response time ----
|
||||||
|
app.use((req, res, next) => {
|
||||||
|
const start = Date.now();
|
||||||
|
const originalEnd = res.end;
|
||||||
|
|
||||||
|
res.end = function (...args) {
|
||||||
|
const duration = Date.now() - start;
|
||||||
|
const status = res.statusCode;
|
||||||
|
const ip =
|
||||||
|
req.headers["x-forwarded-for"]?.split(",")[0]?.trim() ||
|
||||||
|
req.ip ||
|
||||||
|
req.connection.remoteAddress;
|
||||||
|
|
||||||
|
// NOTE: Only log API requests, skip static files
|
||||||
|
if (req.path.startsWith("/api/")) {
|
||||||
|
const log = `[${new Date().toISOString()}] ${req.method} ${req.path} ${status} ${duration}ms ${ip}`;
|
||||||
|
if (status >= 400) {
|
||||||
|
console.error(log);
|
||||||
|
} else {
|
||||||
|
console.log(log);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
originalEnd.apply(this, args);
|
||||||
|
};
|
||||||
|
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
|
||||||
// ══════════════════════════════════════════════════════════════
|
// ══════════════════════════════════════════════════════════════
|
||||||
// CORS MIDDLEWARE
|
// CORS MIDDLEWARE
|
||||||
// ══════════════════════════════════════════════════════════════
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
// NOTE: Single unified CORS middleware — handles all origin validation
|
// ---- FEATURE: Unified CORS middleware with preflight handling ----
|
||||||
app.use((req, res, next) => {
|
app.use((req, res, next) => {
|
||||||
const origin = req.headers.origin;
|
const origin = req.headers.origin;
|
||||||
if (
|
if (
|
||||||
@@ -46,14 +95,20 @@ app.use((req, res, next) => {
|
|||||||
) {
|
) {
|
||||||
res.setHeader("Access-Control-Allow-Origin", origin || "*");
|
res.setHeader("Access-Control-Allow-Origin", origin || "*");
|
||||||
}
|
}
|
||||||
res.setHeader("Access-Control-Allow-Methods", "GET");
|
res.setHeader("Access-Control-Allow-Methods", "GET, OPTIONS");
|
||||||
res.setHeader("Access-Control-Allow-Headers", "Content-Type");
|
res.setHeader("Access-Control-Allow-Headers", "Content-Type, X-No-Compression");
|
||||||
|
res.setHeader("Access-Control-Max-Age", "86400");
|
||||||
|
|
||||||
if (req.method === "OPTIONS") {
|
if (req.method === "OPTIONS") {
|
||||||
return res.status(204).end();
|
return res.status(204).end();
|
||||||
}
|
}
|
||||||
next();
|
next();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
// SECURITY HEADERS
|
||||||
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
// ---- FEATURE: Security headers ----
|
// ---- FEATURE: Security headers ----
|
||||||
app.use((req, res, next) => {
|
app.use((req, res, next) => {
|
||||||
res.setHeader("X-Frame-Options", "DENY");
|
res.setHeader("X-Frame-Options", "DENY");
|
||||||
@@ -64,18 +119,39 @@ app.use((req, res, next) => {
|
|||||||
next();
|
next();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
// CACHE HEADERS
|
||||||
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
// ---- FEATURE: Cache-Control headers for API responses ----
|
||||||
|
app.use("/api", (req, res, next) => {
|
||||||
|
// NOTE: Different cache durations based on endpoint type
|
||||||
|
if (req.path.includes("/health") || req.path.includes("/stats")) {
|
||||||
|
res.setHeader("Cache-Control", "no-store");
|
||||||
|
} else if (req.path.includes("/search") || req.path.includes("/suggestions")) {
|
||||||
|
res.setHeader("Cache-Control", "public, max-age=60");
|
||||||
|
} else if (req.path.includes("/episodes") || req.path.includes("/sources") || req.path.includes("/watch")) {
|
||||||
|
res.setHeader("Cache-Control", "public, max-age=120");
|
||||||
|
} else {
|
||||||
|
res.setHeader("Cache-Control", "public, max-age=300");
|
||||||
|
}
|
||||||
|
res.setHeader("Vary", "Accept-Encoding");
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
|
||||||
// ══════════════════════════════════════════════════════════════
|
// ══════════════════════════════════════════════════════════════
|
||||||
// STATIC FILES
|
// STATIC FILES
|
||||||
// ══════════════════════════════════════════════════════════════
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
app.use(express.static(path.join(__dirname, "public"), { redirect: false }));
|
app.use(express.static(path.join(__dirname, "public"), {
|
||||||
|
redirect: false,
|
||||||
|
maxAge: "1d",
|
||||||
|
}));
|
||||||
|
|
||||||
// ══════════════════════════════════════════════════════════════
|
// ══════════════════════════════════════════════════════════════
|
||||||
// CLEAN URL ROUTES
|
// CLEAN URL ROUTES
|
||||||
// ══════════════════════════════════════════════════════════════
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
// ---- FEATURE: Clean URL routes (no .html extension) ----
|
|
||||||
const fs = require("fs");
|
|
||||||
const publicDir = path.join(__dirname, "public");
|
const publicDir = path.join(__dirname, "public");
|
||||||
|
|
||||||
app.get("/tos", (req, res) => {
|
app.get("/tos", (req, res) => {
|
||||||
@@ -90,7 +166,6 @@ app.get("/privacy", (req, res) => {
|
|||||||
// SWAGGER UI DOCS
|
// SWAGGER UI DOCS
|
||||||
// ══════════════════════════════════════════════════════════════
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
// ---- FEATURE: Interactive Swagger UI documentation at /docs ----
|
|
||||||
app.get("/docs", (req, res) => {
|
app.get("/docs", (req, res) => {
|
||||||
res.sendFile(path.join(publicDir, "docs.html"));
|
res.sendFile(path.join(publicDir, "docs.html"));
|
||||||
});
|
});
|
||||||
@@ -99,25 +174,9 @@ app.get("/docs", (req, res) => {
|
|||||||
// RESPONSE HELPERS
|
// RESPONSE HELPERS
|
||||||
// ══════════════════════════════════════════════════════════════
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
// ---- 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) =>
|
const jsonResponse = (res, data, status = 200) =>
|
||||||
res.status(status).json({ success: true, results: data });
|
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) =>
|
const jsonError = (res, message = "Internal server error", status = 500) =>
|
||||||
res.status(status).json({ success: false, message });
|
res.status(status).json({ success: false, message });
|
||||||
|
|
||||||
@@ -152,7 +211,7 @@ app.use((req, res, next) => {
|
|||||||
|
|
||||||
timestamps.push(now);
|
timestamps.push(now);
|
||||||
|
|
||||||
// NOTE: Periodic cleanup — prune expired IP entries every 60s to prevent memory leak
|
// NOTE: Periodic cleanup — prune expired IP entries when map grows large
|
||||||
if (requestCounts.size > 200) {
|
if (requestCounts.size > 200) {
|
||||||
for (const [key, val] of requestCounts) {
|
for (const [key, val] of requestCounts) {
|
||||||
if (val.filter((t) => now - t < windowMs).length === 0) {
|
if (val.filter((t) => now - t < windowMs).length === 0) {
|
||||||
@@ -169,7 +228,7 @@ app.use((req, res, next) => {
|
|||||||
// ══════════════════════════════════════════════════════════════
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
const apiRoutes = require("./src/routes/apiRoutes");
|
const apiRoutes = require("./src/routes/apiRoutes");
|
||||||
app.use("/api", apiRoutes(jsonResponse, jsonError));
|
app.use("/api", apiRoutes(jsonResponse, jsonError, startTime));
|
||||||
|
|
||||||
// ══════════════════════════════════════════════════════════════
|
// ══════════════════════════════════════════════════════════════
|
||||||
// 404 HANDLER
|
// 404 HANDLER
|
||||||
@@ -177,7 +236,7 @@ app.use("/api", apiRoutes(jsonResponse, jsonError));
|
|||||||
|
|
||||||
// ---- FEATURE: Global error handler ----
|
// ---- FEATURE: Global error handler ----
|
||||||
app.use((err, req, res, next) => {
|
app.use((err, req, res, next) => {
|
||||||
console.error(err.stack);
|
console.error(`[ERROR] ${err.message}`);
|
||||||
res.status(err.status || 500).json({
|
res.status(err.status || 500).json({
|
||||||
success: false,
|
success: false,
|
||||||
message: err.message || "Internal server error",
|
message: err.message || "Internal server error",
|
||||||
@@ -204,7 +263,7 @@ app.use((req, res) => {
|
|||||||
// ══════════════════════════════════════════════════════════════
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
app.listen(PORT, () => {
|
app.listen(PORT, () => {
|
||||||
console.info(`MiruroAPI listening at ${PORT}`);
|
console.log(`[${new Date().toISOString()}] MiruroAPI v2.0.0 listening at ${PORT}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
module.exports = app;
|
module.exports = app;
|
||||||
|
|||||||
@@ -584,6 +584,341 @@ const filterAnime = async ({
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
// GENRES & CATEGORIES
|
||||||
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
// ---- FEATURE: Get all available genres ----
|
||||||
|
/**
|
||||||
|
* Returns the complete list of AniList genres.
|
||||||
|
*
|
||||||
|
* @returns {Promise<Array>} Array of genre name strings
|
||||||
|
*/
|
||||||
|
const getAllGenres = async () => {
|
||||||
|
const gql = `query { GenreCollection }`;
|
||||||
|
const data = await anilistQuery(gql);
|
||||||
|
return data.GenreCollection;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---- FEATURE: Get all available tags ----
|
||||||
|
/**
|
||||||
|
* Returns the complete list of AniList tags with metadata.
|
||||||
|
*
|
||||||
|
* @returns {Promise<Array>} Array of tag objects { name, description, category, rank, isGeneralSpoiler, isMediaSpoiler, isAdult }
|
||||||
|
*/
|
||||||
|
const getAllTags = async () => {
|
||||||
|
const gql = `query { TagCollection { name description category rank isGeneralSpoiler isMediaSpoiler isAdult } }`;
|
||||||
|
const data = await anilistQuery(gql);
|
||||||
|
return data.TagCollection;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
// TOP & TRENDING
|
||||||
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
// ---- FEATURE: Top anime by score (all time) ----
|
||||||
|
/**
|
||||||
|
* Fetches top-rated anime of all time sorted by score.
|
||||||
|
*
|
||||||
|
* @param {number} [page=1] - Page number
|
||||||
|
* @param {number} [perPage=20] - Results per page
|
||||||
|
* @returns {Promise<object>} Paginated top anime results
|
||||||
|
*/
|
||||||
|
const getTopAnime = async (page = 1, perPage = 20) => {
|
||||||
|
const gql = `
|
||||||
|
query ($page: Int, $perPage: Int) {
|
||||||
|
Page(page: $page, perPage: $perPage) {
|
||||||
|
pageInfo { total currentPage lastPage hasNextPage perPage }
|
||||||
|
media(type: ANIME, sort: SCORE_DESC) {
|
||||||
|
${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: Daily trending anime ----
|
||||||
|
/**
|
||||||
|
* Fetches currently trending anime (last 7 days).
|
||||||
|
*
|
||||||
|
* @param {number} [page=1] - Page number
|
||||||
|
* @param {number} [perPage=20] - Results per page
|
||||||
|
* @returns {Promise<object>} Paginated trending results
|
||||||
|
*/
|
||||||
|
const getTrendingDaily = async (page = 1, perPage = 20) => {
|
||||||
|
const gql = `
|
||||||
|
query ($page: Int, $perPage: Int) {
|
||||||
|
Page(page: $page, perPage: $perPage) {
|
||||||
|
pageInfo { total currentPage lastPage hasNextPage perPage }
|
||||||
|
media(type: ANIME, sort: TRENDING_DESC, trending: 7) {
|
||||||
|
${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: Weekly trending anime ----
|
||||||
|
/**
|
||||||
|
* Fetches currently trending anime (last 30 days).
|
||||||
|
*
|
||||||
|
* @param {number} [page=1] - Page number
|
||||||
|
* @param {number} [perPage=20] - Results per page
|
||||||
|
* @returns {Promise<object>} Paginated trending results
|
||||||
|
*/
|
||||||
|
const getTrendingWeekly = async (page = 1, perPage = 20) => {
|
||||||
|
const gql = `
|
||||||
|
query ($page: Int, $perPage: Int) {
|
||||||
|
Page(page: $page, perPage: $perPage) {
|
||||||
|
pageInfo { total currentPage lastPage hasNextPage perPage }
|
||||||
|
media(type: ANIME, sort: TRENDING_DESC, trending: 30) {
|
||||||
|
${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,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
// SEASONAL & STUDIO
|
||||||
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
// ---- FEATURE: Seasonal anime by year and season ----
|
||||||
|
/**
|
||||||
|
* Fetches anime for a specific season and year.
|
||||||
|
*
|
||||||
|
* @param {number} year - The year (e.g., 2025)
|
||||||
|
* @param {string} season - The season (WINTER, SPRING, SUMMER, FALL)
|
||||||
|
* @param {number} [page=1] - Page number
|
||||||
|
* @param {number} [perPage=20] - Results per page
|
||||||
|
* @returns {Promise<object>} Paginated seasonal anime results
|
||||||
|
*/
|
||||||
|
const getSeasonalAnime = async (year, season, page = 1, perPage = 20) => {
|
||||||
|
const gql = `
|
||||||
|
query ($year: Int, $season: MediaSeason, $page: Int, $perPage: Int) {
|
||||||
|
Page(page: $page, perPage: $perPage) {
|
||||||
|
pageInfo { total currentPage lastPage hasNextPage perPage }
|
||||||
|
media(type: ANIME, season: $season, seasonYear: $year, sort: POPULARITY_DESC) {
|
||||||
|
${MEDIA_LIST_FIELDS}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
const data = await anilistQuery(gql, { year, season: season.toUpperCase(), 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: Anime by studio name ----
|
||||||
|
/**
|
||||||
|
* Fetches all anime produced by a specific studio.
|
||||||
|
*
|
||||||
|
* @param {string} studioName - The studio name to search for
|
||||||
|
* @param {number} [page=1] - Page number
|
||||||
|
* @param {number} [perPage=20] - Results per page
|
||||||
|
* @returns {Promise<object>} Paginated studio anime results
|
||||||
|
*/
|
||||||
|
const getAnimeByStudio = async (studioName, page = 1, perPage = 20) => {
|
||||||
|
const gql = `
|
||||||
|
query ($name: String, $page: Int, $perPage: Int) {
|
||||||
|
Studio(search: $name) {
|
||||||
|
id name isAnimationStudio siteUrl
|
||||||
|
media(type: ANIME, sort: POPULARITY_DESC, page: $page, perPage: $perPage) {
|
||||||
|
pageInfo { total currentPage lastPage hasNextPage perPage }
|
||||||
|
nodes { ${MEDIA_LIST_FIELDS} }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
const data = await anilistQuery(gql, { name: studioName, page, perPage });
|
||||||
|
const studio = data.Studio;
|
||||||
|
|
||||||
|
if (!studio) throw new Error("Studio not found");
|
||||||
|
|
||||||
|
return {
|
||||||
|
studio: {
|
||||||
|
id: studio.id,
|
||||||
|
name: studio.name,
|
||||||
|
isAnimationStudio: studio.isAnimationStudio,
|
||||||
|
siteUrl: studio.siteUrl,
|
||||||
|
},
|
||||||
|
page: studio.media.pageInfo.currentPage,
|
||||||
|
perPage: studio.media.pageInfo.perPage,
|
||||||
|
total: studio.media.pageInfo.total,
|
||||||
|
hasNextPage: studio.media.pageInfo.hasNextPage,
|
||||||
|
results: studio.media.nodes,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
// CHARACTER & STAFF
|
||||||
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
// ---- FEATURE: Character detail with anime list ----
|
||||||
|
/**
|
||||||
|
* Fetches detailed character info including all anime appearances.
|
||||||
|
*
|
||||||
|
* @param {number} id - AniList character ID
|
||||||
|
* @returns {Promise<object>} Character details with anime list
|
||||||
|
*/
|
||||||
|
const getCharacterInfo = async (id) => {
|
||||||
|
const gql = `
|
||||||
|
query ($id: Int) {
|
||||||
|
Character(id: $id) {
|
||||||
|
id name { full native userPreferred }
|
||||||
|
image { large medium }
|
||||||
|
description gender favourites siteUrl
|
||||||
|
dateOfBirth { year month day }
|
||||||
|
dateOfDeath { year month day }
|
||||||
|
age bloodType
|
||||||
|
media(perPage: 25, sort: POPULARITY_DESC) {
|
||||||
|
pageInfo { total currentPage lastPage hasNextPage perPage }
|
||||||
|
edges {
|
||||||
|
role
|
||||||
|
node { id title { romaji english } coverImage { large } format episodes status meanScore }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
const data = await anilistQuery(gql, { id });
|
||||||
|
if (!data.Character) throw new Error("Character not found");
|
||||||
|
return data.Character;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---- FEATURE: Staff detail with anime list ----
|
||||||
|
/**
|
||||||
|
* Fetches detailed staff info including all anime works.
|
||||||
|
*
|
||||||
|
* @param {number} id - AniList staff ID
|
||||||
|
* @returns {Promise<object>} Staff details with anime list
|
||||||
|
*/
|
||||||
|
const getStaffInfo = async (id) => {
|
||||||
|
const gql = `
|
||||||
|
query ($id: Int) {
|
||||||
|
Staff(id: $id) {
|
||||||
|
id name { full native userPreferred }
|
||||||
|
image { large medium }
|
||||||
|
description gender favourites siteUrl
|
||||||
|
dateOfBirth { year month day }
|
||||||
|
dateOfDeath { year month day }
|
||||||
|
age homeTown yearsActive
|
||||||
|
staffMedia(perPage: 25, sort: POPULARITY_DESC) {
|
||||||
|
pageInfo { total currentPage lastPage hasNextPage perPage }
|
||||||
|
edges {
|
||||||
|
role
|
||||||
|
node { id title { romaji english } coverImage { large } format episodes status meanScore }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
const data = await anilistQuery(gql, { id });
|
||||||
|
if (!data.Staff) throw new Error("Staff not found");
|
||||||
|
return data.Staff;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---- FEATURE: Search characters by name ----
|
||||||
|
/**
|
||||||
|
* Searches AniList characters by name.
|
||||||
|
*
|
||||||
|
* @param {string} query - Character name to search
|
||||||
|
* @param {number} [page=1] - Page number
|
||||||
|
* @param {number} [perPage=20] - Results per page
|
||||||
|
* @returns {Promise<object>} Paginated character search results
|
||||||
|
*/
|
||||||
|
const searchCharacters = 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 }
|
||||||
|
characters(search: $search, sort: SEARCH_MATCH) {
|
||||||
|
id name { full native userPreferred } image { large medium } gender favourites siteUrl
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
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.characters,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---- FEATURE: Search staff by name ----
|
||||||
|
/**
|
||||||
|
* Searches AniList staff by name.
|
||||||
|
*
|
||||||
|
* @param {string} query - Staff name to search
|
||||||
|
* @param {number} [page=1] - Page number
|
||||||
|
* @param {number} [perPage=20] - Results per page
|
||||||
|
* @returns {Promise<object>} Paginated staff search results
|
||||||
|
*/
|
||||||
|
const searchStaff = 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 }
|
||||||
|
staff(search: $search, sort: SEARCH_MATCH) {
|
||||||
|
id name { full native userPreferred } image { large medium } gender favourites siteUrl
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
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.staff,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
anilistQuery,
|
anilistQuery,
|
||||||
searchAnime,
|
searchAnime,
|
||||||
@@ -596,6 +931,17 @@ module.exports = {
|
|||||||
getSpotlight,
|
getSpotlight,
|
||||||
getSchedule,
|
getSchedule,
|
||||||
filterAnime,
|
filterAnime,
|
||||||
|
getAllGenres,
|
||||||
|
getAllTags,
|
||||||
|
getTopAnime,
|
||||||
|
getTrendingDaily,
|
||||||
|
getTrendingWeekly,
|
||||||
|
getSeasonalAnime,
|
||||||
|
getAnimeByStudio,
|
||||||
|
getCharacterInfo,
|
||||||
|
getStaffInfo,
|
||||||
|
searchCharacters,
|
||||||
|
searchStaff,
|
||||||
MEDIA_LIST_FIELDS,
|
MEDIA_LIST_FIELDS,
|
||||||
MEDIA_FULL_FIELDS,
|
MEDIA_FULL_FIELDS,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -129,6 +129,14 @@ const clearCache = () => {
|
|||||||
cache.clear();
|
cache.clear();
|
||||||
};
|
};
|
||||||
|
|
||||||
module.exports = { getCached, setCache, clearCache };
|
// ---- FEATURE: Get current cache size ----
|
||||||
|
/**
|
||||||
|
* Returns the current number of entries in the cache.
|
||||||
|
*
|
||||||
|
* @returns {number} Current cache size
|
||||||
|
*/
|
||||||
|
const getCacheSize = () => cache.size;
|
||||||
|
|
||||||
|
module.exports = { getCached, setCache, clearCache, getCacheSize };
|
||||||
|
|
||||||
// ══════════════════════════════════════════════════════════════ END: cache.js
|
// ══════════════════════════════════════════════════════════════ END: cache.js
|
||||||
|
|||||||
@@ -320,10 +320,152 @@ const getWatchSources = async (provider, anilistId, category, slug) => {
|
|||||||
return getSources(targetId, provider, anilistId, category);
|
return getSources(targetId, provider, anilistId, category);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
// SUBTITLE EXTRACTION
|
||||||
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
// ---- FEATURE: Extract subtitle URLs from streaming sources ----
|
||||||
|
/**
|
||||||
|
* Extracts and formats subtitle URLs from the pipe sources response.
|
||||||
|
* Subtitles are usually in WebVTT format with language tags.
|
||||||
|
*
|
||||||
|
* @param {object} sources - The sources response from getSources/getWatchSources
|
||||||
|
* @returns {Array} Array of subtitle objects { url, label, language, format }
|
||||||
|
*/
|
||||||
|
const extractSubtitles = (sources) => {
|
||||||
|
const subtitles = sources.subtitles || sources.captions || [];
|
||||||
|
return subtitles.map((sub) => ({
|
||||||
|
url: sub.url || sub.file,
|
||||||
|
label: sub.label || sub.name || "Unknown",
|
||||||
|
language: sub.lang || sub.language || sub.label || "en",
|
||||||
|
format: sub.format || "vtt",
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
// QUALITY FALLBACK
|
||||||
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
// ---- FEATURE: Get best available stream with quality fallback ----
|
||||||
|
/**
|
||||||
|
* Returns the best available HLS stream with automatic quality fallback.
|
||||||
|
* Tries 1080p → 720p → 360p in order.
|
||||||
|
*
|
||||||
|
* @param {object} sources - The sources response
|
||||||
|
* @param {string} [preferredQuality="1080p"] - Preferred quality
|
||||||
|
* @returns {object|null} Best available stream object or null
|
||||||
|
*/
|
||||||
|
const getBestStream = (sources, preferredQuality = "1080p") => {
|
||||||
|
const streams = (sources.streams || []).filter((s) => s.type === "hls" && s.url);
|
||||||
|
if (streams.length === 0) return null;
|
||||||
|
|
||||||
|
// NOTE: Try preferred quality first, then fallback
|
||||||
|
const qualityOrder = ["1080p", "720p", "480p", "360p"];
|
||||||
|
const startIdx = qualityOrder.indexOf(preferredQuality);
|
||||||
|
const ordered = startIdx >= 0
|
||||||
|
? qualityOrder.slice(startIdx)
|
||||||
|
: qualityOrder;
|
||||||
|
|
||||||
|
for (const q of ordered) {
|
||||||
|
const match = streams.find((s) => s.quality === q);
|
||||||
|
if (match) return match;
|
||||||
|
}
|
||||||
|
|
||||||
|
// NOTE: Return first available if no quality match
|
||||||
|
return streams[0];
|
||||||
|
};
|
||||||
|
|
||||||
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
// DOWNLOAD URL
|
||||||
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
// ---- FEATURE: Get download URL for an episode ----
|
||||||
|
/**
|
||||||
|
* Fetches the download URL for a specific episode from the pipe.
|
||||||
|
* Returns the direct download link if available.
|
||||||
|
*
|
||||||
|
* @param {string} provider - Provider name
|
||||||
|
* @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<object>} Object with download URL and metadata
|
||||||
|
*/
|
||||||
|
const getDownloadUrl = async (provider, anilistId, category, slug) => {
|
||||||
|
const sources = await getWatchSources(provider, anilistId, category, slug);
|
||||||
|
return {
|
||||||
|
download: sources.download || null,
|
||||||
|
provider,
|
||||||
|
anilistId,
|
||||||
|
category,
|
||||||
|
slug,
|
||||||
|
hasDownload: !!sources.download,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
// BATCH EPISODES
|
||||||
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
// ---- FEATURE: Get sources for multiple episodes at once ----
|
||||||
|
/**
|
||||||
|
* Fetches streaming sources for multiple episodes in parallel.
|
||||||
|
* Useful for preloading next episodes or batch downloading.
|
||||||
|
*
|
||||||
|
* @param {string} provider - Provider name
|
||||||
|
* @param {number} anilistId - The AniList anime ID
|
||||||
|
* @param {string} category - Audio category ("sub" or "dub")
|
||||||
|
* @param {string[]} slugs - Array of episode slugs (e.g., ["animepahe-1", "animepahe-2"])
|
||||||
|
* @returns {Promise<object>} Object mapping slug → sources (with errors for failed ones)
|
||||||
|
*/
|
||||||
|
const getBatchSources = async (provider, anilistId, category, slugs) => {
|
||||||
|
const results = {};
|
||||||
|
|
||||||
|
const fetchOne = async (slug) => {
|
||||||
|
try {
|
||||||
|
const sources = await getWatchSources(provider, anilistId, category, slug);
|
||||||
|
const best = getBestStream(sources);
|
||||||
|
const subtitles = extractSubtitles(sources);
|
||||||
|
results[slug] = {
|
||||||
|
success: true,
|
||||||
|
streams: sources.streams,
|
||||||
|
bestStream: best,
|
||||||
|
subtitles,
|
||||||
|
download: sources.download || null,
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
results[slug] = {
|
||||||
|
success: false,
|
||||||
|
error: err.message,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// NOTE: Fetch up to 5 episodes in parallel to avoid rate limiting
|
||||||
|
const batchSize = 5;
|
||||||
|
for (let i = 0; i < slugs.length; i += batchSize) {
|
||||||
|
const batch = slugs.slice(i, i + batchSize);
|
||||||
|
await Promise.all(batch.map(fetchOne));
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
provider,
|
||||||
|
anilistId,
|
||||||
|
category,
|
||||||
|
total: slugs.length,
|
||||||
|
successful: Object.values(results).filter((r) => r.success).length,
|
||||||
|
failed: Object.values(results).filter((r) => !r.success).length,
|
||||||
|
episodes: results,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
getEpisodes,
|
getEpisodes,
|
||||||
getSources,
|
getSources,
|
||||||
getWatchSources,
|
getWatchSources,
|
||||||
|
getDownloadUrl,
|
||||||
|
getBatchSources,
|
||||||
|
extractSubtitles,
|
||||||
|
getBestStream,
|
||||||
encodePipeRequest,
|
encodePipeRequest,
|
||||||
decodePipeResponse,
|
decodePipeResponse,
|
||||||
translateId,
|
translateId,
|
||||||
|
|||||||
+629
-40
@@ -7,7 +7,7 @@
|
|||||||
* Central API router that maps all Express GET endpoints to their
|
* Central API router that maps all Express GET endpoints to their
|
||||||
* corresponding handler functions. Each route wraps its handler
|
* corresponding handler functions. Each route wraps its handler
|
||||||
* in try/catch for consistent error handling. Includes health,
|
* in try/catch for consistent error handling. Includes health,
|
||||||
* stats, and OpenAPI specification endpoints.
|
* stats, streaming, and OpenAPI specification endpoints.
|
||||||
*
|
*
|
||||||
* @exports
|
* @exports
|
||||||
* createApiRoutes - Function that registers all API routes
|
* createApiRoutes - Function that registers all API routes
|
||||||
@@ -27,38 +27,24 @@ const { addCreatorInfo } = require("../middleware/creatorInfo");
|
|||||||
// ROUTE REGISTRATION
|
// ROUTE REGISTRATION
|
||||||
// ══════════════════════════════════════════════════════════════
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
// ---- FEATURE: Main API route registration function ----
|
const createApiRoutes = (jsonResponse, jsonError, startTime) => {
|
||||||
/**
|
|
||||||
* 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();
|
const router = express.Router();
|
||||||
|
|
||||||
// ══════════════════════════════════════════════════════════════
|
// ══════════════════════════════════════════════════════════════
|
||||||
// CREATOR ATTRIBUTION (must be before all routes)
|
// CREATOR ATTRIBUTION (must be before all routes)
|
||||||
// ══════════════════════════════════════════════════════════════
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
// ---- FEATURE: Creator attribution middleware — injects author info into every response ----
|
|
||||||
router.use(addCreatorInfo);
|
router.use(addCreatorInfo);
|
||||||
|
|
||||||
// ══════════════════════════════════════════════════════════════
|
// ══════════════════════════════════════════════════════════════
|
||||||
// REQUEST COUNTER (must be before all routes)
|
// REQUEST COUNTER
|
||||||
// ══════════════════════════════════════════════════════════════
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
const startTime = Date.now();
|
const requestCount = { total: 0, errors: 0 };
|
||||||
let requestCount = 0;
|
|
||||||
let errorCount = 0;
|
|
||||||
|
|
||||||
router.use((req, res, next) => {
|
router.use((req, res, next) => {
|
||||||
requestCount++;
|
requestCount.total++;
|
||||||
res.on("finish", () => {
|
res.on("finish", () => {
|
||||||
if (res.statusCode >= 400) errorCount++;
|
if (res.statusCode >= 400) requestCount.errors++;
|
||||||
});
|
});
|
||||||
next();
|
next();
|
||||||
});
|
});
|
||||||
@@ -96,7 +82,6 @@ const createApiRoutes = (jsonResponse, jsonError) => {
|
|||||||
let result = getCached(cacheKey);
|
let result = getCached(cacheKey);
|
||||||
if (!result) {
|
if (!result) {
|
||||||
result = await anilist.getSuggestions(query);
|
result = await anilist.getSuggestions(query);
|
||||||
// NOTE: Cache suggestions longer (2 minutes) — they change less frequently
|
|
||||||
setCache(cacheKey, result, 120 * 1000);
|
setCache(cacheKey, result, 120 * 1000);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -128,6 +113,30 @@ const createApiRoutes = (jsonResponse, jsonError) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ---- FEATURE: Multi-parameter search (query + filters combined) ----
|
||||||
|
router.get("/multi-search", async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { q, genre, tag, year, season, format, status, sort = "SEARCH_MATCH", page = 1, per_page = 20 } = req.query;
|
||||||
|
|
||||||
|
// NOTE: If only query is provided, use regular search
|
||||||
|
if (q && !genre && !tag && !year && !season && !format && !status) {
|
||||||
|
const result = await anilist.searchAnime(q, parseInt(page), parseInt(per_page));
|
||||||
|
return jsonResponse(res, result);
|
||||||
|
}
|
||||||
|
|
||||||
|
// NOTE: If filters only, use filter endpoint
|
||||||
|
const result = await anilist.filterAnime({
|
||||||
|
genre, tag, year: year ? parseInt(year) : null,
|
||||||
|
season, format, status, sort,
|
||||||
|
page: parseInt(page), perPage: parseInt(per_page),
|
||||||
|
});
|
||||||
|
|
||||||
|
jsonResponse(res, result);
|
||||||
|
} catch (err) {
|
||||||
|
jsonError(res, err.message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// ══════════════════════════════════════════════════════════════
|
// ══════════════════════════════════════════════════════════════
|
||||||
// COLLECTIONS
|
// COLLECTIONS
|
||||||
// ══════════════════════════════════════════════════════════════
|
// ══════════════════════════════════════════════════════════════
|
||||||
@@ -141,7 +150,42 @@ const createApiRoutes = (jsonResponse, jsonError) => {
|
|||||||
let result = getCached(cacheKey);
|
let result = getCached(cacheKey);
|
||||||
if (!result) {
|
if (!result) {
|
||||||
result = await anilist.getCollection("TRENDING_DESC", null, parseInt(page), parseInt(per_page));
|
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: Daily trending anime ----
|
||||||
|
router.get("/trending/daily", async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { page = 1, per_page = 20 } = req.query;
|
||||||
|
const cacheKey = `trending:daily:${page}:${per_page}`;
|
||||||
|
|
||||||
|
let result = getCached(cacheKey);
|
||||||
|
if (!result) {
|
||||||
|
result = await anilist.getTrendingDaily(parseInt(page), parseInt(per_page));
|
||||||
|
setCache(cacheKey, result, 120 * 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
jsonResponse(res, result);
|
||||||
|
} catch (err) {
|
||||||
|
jsonError(res, err.message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- FEATURE: Weekly trending anime ----
|
||||||
|
router.get("/trending/weekly", async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { page = 1, per_page = 20 } = req.query;
|
||||||
|
const cacheKey = `trending:weekly:${page}:${per_page}`;
|
||||||
|
|
||||||
|
let result = getCached(cacheKey);
|
||||||
|
if (!result) {
|
||||||
|
result = await anilist.getTrendingWeekly(parseInt(page), parseInt(per_page));
|
||||||
setCache(cacheKey, result, 120 * 1000);
|
setCache(cacheKey, result, 120 * 1000);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -169,6 +213,24 @@ const createApiRoutes = (jsonResponse, jsonError) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ---- FEATURE: Top anime by score (all time) ----
|
||||||
|
router.get("/top", async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { page = 1, per_page = 20 } = req.query;
|
||||||
|
const cacheKey = `top:${page}:${per_page}`;
|
||||||
|
|
||||||
|
let result = getCached(cacheKey);
|
||||||
|
if (!result) {
|
||||||
|
result = await anilist.getTopAnime(parseInt(page), parseInt(per_page));
|
||||||
|
setCache(cacheKey, result, 300 * 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
jsonResponse(res, result);
|
||||||
|
} catch (err) {
|
||||||
|
jsonError(res, err.message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// ---- FEATURE: Most anticipated upcoming anime ----
|
// ---- FEATURE: Most anticipated upcoming anime ----
|
||||||
router.get("/upcoming", async (req, res) => {
|
router.get("/upcoming", async (req, res) => {
|
||||||
try {
|
try {
|
||||||
@@ -239,6 +301,259 @@ const createApiRoutes = (jsonResponse, jsonError) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ---- FEATURE: Next 10 episodes airing soon ----
|
||||||
|
router.get("/upcoming/countdown", async (req, res) => {
|
||||||
|
try {
|
||||||
|
const cacheKey = "upcoming:countdown";
|
||||||
|
let result = getCached(cacheKey);
|
||||||
|
if (!result) {
|
||||||
|
result = await anilist.getSchedule(1, 10);
|
||||||
|
setCache(cacheKey, result, 60 * 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
jsonResponse(res, result);
|
||||||
|
} catch (err) {
|
||||||
|
jsonError(res, err.message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- FEATURE: Random anime of the day ----
|
||||||
|
router.get("/random", async (req, res) => {
|
||||||
|
try {
|
||||||
|
// NOTE: Get random page from trending to ensure variety
|
||||||
|
const randomPage = Math.floor(Math.random() * 50) + 1;
|
||||||
|
const data = await anilist.getCollection("POPULARITY_DESC", null, randomPage, 1);
|
||||||
|
const anime = data.results[0];
|
||||||
|
|
||||||
|
if (!anime) return jsonError(res, "No anime found", 404);
|
||||||
|
jsonResponse(res, anime);
|
||||||
|
} catch (err) {
|
||||||
|
jsonError(res, err.message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
// SEASONAL & STUDIO
|
||||||
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
// ---- FEATURE: Seasonal anime by year and season ----
|
||||||
|
router.get("/season/:year/:season", async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { year, season } = req.params;
|
||||||
|
const { page = 1, per_page = 20 } = req.query;
|
||||||
|
const cacheKey = `season:${year}:${season}:${page}:${per_page}`;
|
||||||
|
|
||||||
|
let result = getCached(cacheKey);
|
||||||
|
if (!result) {
|
||||||
|
result = await anilist.getSeasonalAnime(parseInt(year), season, parseInt(page), parseInt(per_page));
|
||||||
|
setCache(cacheKey, result, 300 * 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
jsonResponse(res, result);
|
||||||
|
} catch (err) {
|
||||||
|
jsonError(res, err.message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- FEATURE: Anime by studio name ----
|
||||||
|
router.get("/studio/:name", async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { name } = req.params;
|
||||||
|
const { page = 1, per_page = 20 } = req.query;
|
||||||
|
const cacheKey = `studio:${name}:${page}:${per_page}`;
|
||||||
|
|
||||||
|
let result = getCached(cacheKey);
|
||||||
|
if (!result) {
|
||||||
|
result = await anilist.getAnimeByStudio(decodeURIComponent(name), parseInt(page), parseInt(per_page));
|
||||||
|
setCache(cacheKey, result, 300 * 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
jsonResponse(res, result);
|
||||||
|
} catch (err) {
|
||||||
|
jsonError(res, err.message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
// GENRES & TAGS
|
||||||
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
// ---- FEATURE: Get all available genres ----
|
||||||
|
router.get("/genres", async (req, res) => {
|
||||||
|
try {
|
||||||
|
const cacheKey = "genres";
|
||||||
|
let result = getCached(cacheKey);
|
||||||
|
if (!result) {
|
||||||
|
result = await anilist.getAllGenres();
|
||||||
|
setCache(cacheKey, result, 86400 * 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
jsonResponse(res, result);
|
||||||
|
} catch (err) {
|
||||||
|
jsonError(res, err.message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- FEATURE: Get all available tags ----
|
||||||
|
router.get("/tags", async (req, res) => {
|
||||||
|
try {
|
||||||
|
const cacheKey = "tags";
|
||||||
|
let result = getCached(cacheKey);
|
||||||
|
if (!result) {
|
||||||
|
result = await anilist.getAllTags();
|
||||||
|
setCache(cacheKey, result, 86400 * 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
jsonResponse(res, result);
|
||||||
|
} catch (err) {
|
||||||
|
jsonError(res, err.message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- FEATURE: Anime by genre name ----
|
||||||
|
router.get("/genre/:name", async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { name } = req.params;
|
||||||
|
const { page = 1, per_page = 20 } = req.query;
|
||||||
|
const cacheKey = `genre:${name}:${page}:${per_page}`;
|
||||||
|
|
||||||
|
let result = getCached(cacheKey);
|
||||||
|
if (!result) {
|
||||||
|
result = await anilist.filterAnime({ genre: decodeURIComponent(name), page: parseInt(page), perPage: parseInt(per_page) });
|
||||||
|
setCache(cacheKey, result, 120 * 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
jsonResponse(res, result);
|
||||||
|
} catch (err) {
|
||||||
|
jsonError(res, err.message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- FEATURE: Anime by year ----
|
||||||
|
router.get("/year/:year", async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { year } = req.params;
|
||||||
|
const { page = 1, per_page = 20 } = req.query;
|
||||||
|
const cacheKey = `year:${year}:${page}:${per_page}`;
|
||||||
|
|
||||||
|
let result = getCached(cacheKey);
|
||||||
|
if (!result) {
|
||||||
|
result = await anilist.filterAnime({ year: parseInt(year), page: parseInt(page), perPage: parseInt(per_page) });
|
||||||
|
setCache(cacheKey, result, 300 * 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
jsonResponse(res, result);
|
||||||
|
} catch (err) {
|
||||||
|
jsonError(res, err.message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- FEATURE: Anime by status ----
|
||||||
|
router.get("/status/:status", async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { status } = req.params;
|
||||||
|
const { page = 1, per_page = 20 } = req.query;
|
||||||
|
const cacheKey = `status:${status}:${page}:${per_page}`;
|
||||||
|
|
||||||
|
let result = getCached(cacheKey);
|
||||||
|
if (!result) {
|
||||||
|
result = await anilist.filterAnime({ status, page: parseInt(page), perPage: parseInt(per_page) });
|
||||||
|
setCache(cacheKey, result, 120 * 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
jsonResponse(res, result);
|
||||||
|
} catch (err) {
|
||||||
|
jsonError(res, err.message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- FEATURE: Anime by format ----
|
||||||
|
router.get("/format/:format", async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { format } = req.params;
|
||||||
|
const { page = 1, per_page = 20 } = req.query;
|
||||||
|
const cacheKey = `format:${format}:${page}:${per_page}`;
|
||||||
|
|
||||||
|
let result = getCached(cacheKey);
|
||||||
|
if (!result) {
|
||||||
|
result = await anilist.filterAnime({ format, page: parseInt(page), perPage: parseInt(per_page) });
|
||||||
|
setCache(cacheKey, result, 300 * 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
jsonResponse(res, result);
|
||||||
|
} catch (err) {
|
||||||
|
jsonError(res, err.message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
// CHARACTER & STAFF
|
||||||
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
// ---- FEATURE: Character detail with anime list ----
|
||||||
|
router.get("/character/:id", async (req, res) => {
|
||||||
|
try {
|
||||||
|
const id = parseInt(req.params.id);
|
||||||
|
if (isNaN(id)) return jsonError(res, "Invalid character ID", 400);
|
||||||
|
|
||||||
|
const cacheKey = `character:${id}`;
|
||||||
|
let result = getCached(cacheKey);
|
||||||
|
if (!result) {
|
||||||
|
result = await anilist.getCharacterInfo(id);
|
||||||
|
setCache(cacheKey, result, 600 * 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
jsonResponse(res, result);
|
||||||
|
} catch (err) {
|
||||||
|
jsonError(res, err.message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- FEATURE: Staff detail with anime list ----
|
||||||
|
router.get("/staff/:id", async (req, res) => {
|
||||||
|
try {
|
||||||
|
const id = parseInt(req.params.id);
|
||||||
|
if (isNaN(id)) return jsonError(res, "Invalid staff ID", 400);
|
||||||
|
|
||||||
|
const cacheKey = `staff:${id}`;
|
||||||
|
let result = getCached(cacheKey);
|
||||||
|
if (!result) {
|
||||||
|
result = await anilist.getStaffInfo(id);
|
||||||
|
setCache(cacheKey, result, 600 * 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
jsonResponse(res, result);
|
||||||
|
} catch (err) {
|
||||||
|
jsonError(res, err.message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- FEATURE: Search characters ----
|
||||||
|
router.get("/characters", async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { query, page = 1, per_page = 20 } = req.query;
|
||||||
|
if (!query) return jsonError(res, "query parameter is required", 400);
|
||||||
|
|
||||||
|
const result = await anilist.searchCharacters(query, parseInt(page), parseInt(per_page));
|
||||||
|
jsonResponse(res, result);
|
||||||
|
} catch (err) {
|
||||||
|
jsonError(res, err.message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- FEATURE: Search staff ----
|
||||||
|
router.get("/staff", async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { query, page = 1, per_page = 20 } = req.query;
|
||||||
|
if (!query) return jsonError(res, "query parameter is required", 400);
|
||||||
|
|
||||||
|
const result = await anilist.searchStaff(query, parseInt(page), parseInt(per_page));
|
||||||
|
jsonResponse(res, result);
|
||||||
|
} catch (err) {
|
||||||
|
jsonError(res, err.message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// ══════════════════════════════════════════════════════════════
|
// ══════════════════════════════════════════════════════════════
|
||||||
// ANIME DETAILS
|
// ANIME DETAILS
|
||||||
// ══════════════════════════════════════════════════════════════
|
// ══════════════════════════════════════════════════════════════
|
||||||
@@ -253,7 +568,6 @@ const createApiRoutes = (jsonResponse, jsonError) => {
|
|||||||
let result = getCached(cacheKey);
|
let result = getCached(cacheKey);
|
||||||
if (!result) {
|
if (!result) {
|
||||||
result = await anilist.getAnimeInfo(id);
|
result = await anilist.getAnimeInfo(id);
|
||||||
// NOTE: Cache info for 5 minutes — detailed data changes infrequently
|
|
||||||
setCache(cacheKey, result, 300 * 1000);
|
setCache(cacheKey, result, 300 * 1000);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -299,7 +613,7 @@ const createApiRoutes = (jsonResponse, jsonError) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// ══════════════════════════════════════════════════════════════
|
// ══════════════════════════════════════════════════════════════
|
||||||
// STREAMING
|
// STREAMING & DOWNLOADING
|
||||||
// ══════════════════════════════════════════════════════════════
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
// ---- FEATURE: Episode list from all providers ----
|
// ---- FEATURE: Episode list from all providers ----
|
||||||
@@ -312,7 +626,6 @@ const createApiRoutes = (jsonResponse, jsonError) => {
|
|||||||
let result = getCached(cacheKey);
|
let result = getCached(cacheKey);
|
||||||
if (!result) {
|
if (!result) {
|
||||||
result = await pipe.getEpisodes(id);
|
result = await pipe.getEpisodes(id);
|
||||||
// NOTE: Cache episodes for 2 minutes — episode lists change during airing
|
|
||||||
setCache(cacheKey, result, 120 * 1000);
|
setCache(cacheKey, result, 120 * 1000);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -322,6 +635,23 @@ const createApiRoutes = (jsonResponse, jsonError) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ---- FEATURE: Batch episode sources ----
|
||||||
|
router.get("/episodes/:id/batch", async (req, res) => {
|
||||||
|
try {
|
||||||
|
const id = parseInt(req.params.id);
|
||||||
|
const { provider = "kiwi", category = "sub", slugs } = req.query;
|
||||||
|
|
||||||
|
if (isNaN(id)) return jsonError(res, "Invalid AniList ID", 400);
|
||||||
|
if (!slugs) return jsonError(res, "slugs parameter is required (comma-separated)", 400);
|
||||||
|
|
||||||
|
const slugList = slugs.split(",").map((s) => s.trim());
|
||||||
|
const result = await pipe.getBatchSources(provider, id, category, slugList);
|
||||||
|
jsonResponse(res, result);
|
||||||
|
} catch (err) {
|
||||||
|
jsonError(res, err.message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// ---- FEATURE: Streaming sources (detailed/manual endpoint) ----
|
// ---- FEATURE: Streaming sources (detailed/manual endpoint) ----
|
||||||
router.get("/sources", async (req, res) => {
|
router.get("/sources", async (req, res) => {
|
||||||
try {
|
try {
|
||||||
@@ -337,6 +667,43 @@ const createApiRoutes = (jsonResponse, jsonError) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ---- FEATURE: Streaming sources with quality fallback ----
|
||||||
|
router.get("/stream", async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { provider, anilistId, category = "sub", slug, quality = "1080p" } = req.query;
|
||||||
|
if (!provider || !anilistId || !slug) {
|
||||||
|
return jsonError(res, "provider, anilistId, and slug are required", 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const sources = await pipe.getWatchSources(provider, parseInt(anilistId), category, slug);
|
||||||
|
const bestStream = pipe.getBestStream(sources, quality);
|
||||||
|
const subtitles = pipe.extractSubtitles(sources);
|
||||||
|
|
||||||
|
jsonResponse(res, {
|
||||||
|
...sources,
|
||||||
|
bestStream,
|
||||||
|
subtitles,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
jsonError(res, err.message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- FEATURE: Download URL for an episode ----
|
||||||
|
router.get("/download", async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { provider, anilistId, category = "sub", slug } = req.query;
|
||||||
|
if (!provider || !anilistId || !slug) {
|
||||||
|
return jsonError(res, "provider, anilistId, and slug are required", 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await pipe.getDownloadUrl(provider, parseInt(anilistId), category, slug);
|
||||||
|
jsonResponse(res, result);
|
||||||
|
} catch (err) {
|
||||||
|
jsonError(res, err.message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// ---- FEATURE: Streaming sources (simple slug-based endpoint) ----
|
// ---- FEATURE: Streaming sources (simple slug-based endpoint) ----
|
||||||
router.get("/watch/:provider/:anilistId/:category/:slug", async (req, res) => {
|
router.get("/watch/:provider/:anilistId/:category/:slug", async (req, res) => {
|
||||||
try {
|
try {
|
||||||
@@ -348,6 +715,194 @@ const createApiRoutes = (jsonResponse, jsonError) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
// UTILITY FEATURES
|
||||||
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
// ---- FEATURE: Compare two anime side by side ----
|
||||||
|
router.get("/compare/:id1/:id2", async (req, res) => {
|
||||||
|
try {
|
||||||
|
const id1 = parseInt(req.params.id1);
|
||||||
|
const id2 = parseInt(req.params.id2);
|
||||||
|
if (isNaN(id1) || isNaN(id2)) return jsonError(res, "Invalid anime IDs", 400);
|
||||||
|
|
||||||
|
const cacheKey = `compare:${id1}:${id2}`;
|
||||||
|
let result = getCached(cacheKey);
|
||||||
|
if (!result) {
|
||||||
|
const [anime1, anime2] = await Promise.all([
|
||||||
|
anilist.getAnimeInfo(id1),
|
||||||
|
anilist.getAnimeInfo(id2),
|
||||||
|
]);
|
||||||
|
|
||||||
|
result = {
|
||||||
|
anime1: {
|
||||||
|
id: anime1.id,
|
||||||
|
title: anime1.title,
|
||||||
|
coverImage: anime1.coverImage,
|
||||||
|
format: anime1.format,
|
||||||
|
episodes: anime1.episodes,
|
||||||
|
duration: anime1.duration,
|
||||||
|
status: anime1.status,
|
||||||
|
averageScore: anime1.averageScore,
|
||||||
|
meanScore: anime1.meanScore,
|
||||||
|
popularity: anime1.popularity,
|
||||||
|
favourites: anime1.favourites,
|
||||||
|
genres: anime1.genres,
|
||||||
|
studios: anime1.studios,
|
||||||
|
},
|
||||||
|
anime2: {
|
||||||
|
id: anime2.id,
|
||||||
|
title: anime2.title,
|
||||||
|
coverImage: anime2.coverImage,
|
||||||
|
format: anime2.format,
|
||||||
|
episodes: anime2.episodes,
|
||||||
|
duration: anime2.duration,
|
||||||
|
status: anime2.status,
|
||||||
|
averageScore: anime2.averageScore,
|
||||||
|
meanScore: anime2.meanScore,
|
||||||
|
popularity: anime2.popularity,
|
||||||
|
favourites: anime2.favourites,
|
||||||
|
genres: anime2.genres,
|
||||||
|
studios: anime2.studios,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
setCache(cacheKey, result, 600 * 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
jsonResponse(res, result);
|
||||||
|
} catch (err) {
|
||||||
|
jsonError(res, err.message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- FEATURE: Genre statistics ----
|
||||||
|
router.get("/stats/genre", async (req, res) => {
|
||||||
|
try {
|
||||||
|
const cacheKey = "stats:genre";
|
||||||
|
let result = getCached(cacheKey);
|
||||||
|
if (!result) {
|
||||||
|
const genres = await anilist.getAllGenres();
|
||||||
|
const stats = {};
|
||||||
|
|
||||||
|
// NOTE: Fetch top anime for each genre (limited to avoid rate limiting)
|
||||||
|
for (const genre of genres.slice(0, 10)) {
|
||||||
|
try {
|
||||||
|
const data = await anilist.filterAnime({ genre, perPage: 1 });
|
||||||
|
stats[genre] = {
|
||||||
|
total: data.total,
|
||||||
|
topAnime: data.results[0] || null,
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
stats[genre] = { total: 0, topAnime: null };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result = { genres, stats };
|
||||||
|
setCache(cacheKey, result, 86400 * 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
jsonResponse(res, result);
|
||||||
|
} catch (err) {
|
||||||
|
jsonError(res, err.message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- FEATURE: Monthly airing calendar ----
|
||||||
|
router.get("/calendar", async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { year, month } = req.query;
|
||||||
|
const now = new Date();
|
||||||
|
const y = year ? parseInt(year) : now.getFullYear();
|
||||||
|
const m = month ? parseInt(month) : now.getMonth() + 1;
|
||||||
|
|
||||||
|
const cacheKey = `calendar:${y}:${m}`;
|
||||||
|
let result = getCached(cacheKey);
|
||||||
|
if (!result) {
|
||||||
|
// NOTE: Get airing schedule for the month
|
||||||
|
const schedule = await anilist.getSchedule(1, 50);
|
||||||
|
const monthStart = new Date(y, m - 1, 1);
|
||||||
|
const monthEnd = new Date(y, m, 0, 23, 59, 59);
|
||||||
|
|
||||||
|
const monthAnime = schedule.results.filter((item) => {
|
||||||
|
const airDate = new Date(item.airingAt * 1000);
|
||||||
|
return airDate >= monthStart && airDate <= monthEnd;
|
||||||
|
});
|
||||||
|
|
||||||
|
// NOTE: Group by date
|
||||||
|
const calendar = {};
|
||||||
|
monthAnime.forEach((item) => {
|
||||||
|
const date = new Date(item.airingAt * 1000).toISOString().split("T")[0];
|
||||||
|
if (!calendar[date]) calendar[date] = [];
|
||||||
|
calendar[date].push({
|
||||||
|
id: item.id,
|
||||||
|
title: item.title,
|
||||||
|
coverImage: item.coverImage,
|
||||||
|
episode: item.next_episode,
|
||||||
|
airingAt: item.airingAt,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
result = {
|
||||||
|
year: y,
|
||||||
|
month: m,
|
||||||
|
totalAiring: monthAnime.length,
|
||||||
|
calendar,
|
||||||
|
};
|
||||||
|
setCache(cacheKey, result, 3600 * 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
jsonResponse(res, result);
|
||||||
|
} catch (err) {
|
||||||
|
jsonError(res, err.message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- FEATURE: Anime timeline (release history) ----
|
||||||
|
router.get("/timeline/:id", async (req, res) => {
|
||||||
|
try {
|
||||||
|
const id = parseInt(req.params.id);
|
||||||
|
if (isNaN(id)) return jsonError(res, "Invalid AniList ID", 400);
|
||||||
|
|
||||||
|
const cacheKey = `timeline:${id}`;
|
||||||
|
let result = getCached(cacheKey);
|
||||||
|
if (!result) {
|
||||||
|
const info = await anilist.getAnimeInfo(id);
|
||||||
|
const relations = await anilist.getAnimeRelations(id);
|
||||||
|
|
||||||
|
// NOTE: Build timeline from relations
|
||||||
|
const timeline = relations.relations
|
||||||
|
.filter((r) => ["PREQUEL", "SEQUEL", "SIDE_STORY", "SPIN_OFF", "ADAPTATION", "SOURCE"].includes(r.relationType))
|
||||||
|
.map((r) => ({
|
||||||
|
id: r.node.id,
|
||||||
|
title: r.node.title,
|
||||||
|
coverImage: r.node.coverImage,
|
||||||
|
format: r.node.format,
|
||||||
|
status: r.node.status,
|
||||||
|
episodes: r.node.episodes,
|
||||||
|
meanScore: r.node.meanScore,
|
||||||
|
relation: r.relationType,
|
||||||
|
}));
|
||||||
|
|
||||||
|
result = {
|
||||||
|
id: info.id,
|
||||||
|
title: info.title,
|
||||||
|
startDate: info.startDate,
|
||||||
|
endDate: info.endDate,
|
||||||
|
status: info.status,
|
||||||
|
episodes: info.episodes,
|
||||||
|
timeline,
|
||||||
|
};
|
||||||
|
|
||||||
|
setCache(cacheKey, result, 600 * 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
jsonResponse(res, result);
|
||||||
|
} catch (err) {
|
||||||
|
jsonError(res, err.message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// ══════════════════════════════════════════════════════════════
|
// ══════════════════════════════════════════════════════════════
|
||||||
// HEALTH & STATS
|
// HEALTH & STATS
|
||||||
// ══════════════════════════════════════════════════════════════
|
// ══════════════════════════════════════════════════════════════
|
||||||
@@ -363,7 +918,7 @@ const createApiRoutes = (jsonResponse, jsonError) => {
|
|||||||
success: true,
|
success: true,
|
||||||
results: {
|
results: {
|
||||||
status: "healthy",
|
status: "healthy",
|
||||||
version: "1.4.0",
|
version: "2.0.0",
|
||||||
uptime: `${hours}h ${minutes}m ${seconds}s`,
|
uptime: `${hours}h ${minutes}m ${seconds}s`,
|
||||||
uptimeSeconds: uptime,
|
uptimeSeconds: uptime,
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
@@ -371,8 +926,13 @@ const createApiRoutes = (jsonResponse, jsonError) => {
|
|||||||
memory: {
|
memory: {
|
||||||
used: Math.round(process.memoryUsage().heapUsed / 1024 / 1024) + "MB",
|
used: Math.round(process.memoryUsage().heapUsed / 1024 / 1024) + "MB",
|
||||||
total: Math.round(process.memoryUsage().heapTotal / 1024 / 1024) + "MB",
|
total: Math.round(process.memoryUsage().heapTotal / 1024 / 1024) + "MB",
|
||||||
|
rss: Math.round(process.memoryUsage().rss / 1024 / 1024) + "MB",
|
||||||
},
|
},
|
||||||
endpoints: 16,
|
cache: {
|
||||||
|
size: require("../helpers/cache").getCacheSize(),
|
||||||
|
maxSize: 100,
|
||||||
|
},
|
||||||
|
endpoints: 35,
|
||||||
providers: ["kiwi", "pewe", "bee", "bonk", "bun", "ally", "nun", "twin", "cog", "moo", "hop", "telli"],
|
providers: ["kiwi", "pewe", "bee", "bonk", "bun", "ally", "nun", "twin", "cog", "moo", "hop", "telli"],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -387,19 +947,20 @@ const createApiRoutes = (jsonResponse, jsonError) => {
|
|||||||
results: {
|
results: {
|
||||||
uptime: uptime + "s",
|
uptime: uptime + "s",
|
||||||
requests: {
|
requests: {
|
||||||
total: requestCount,
|
total: requestCount.total,
|
||||||
errors: errorCount,
|
errors: requestCount.errors,
|
||||||
successRate: requestCount > 0
|
successRate: requestCount.total > 0
|
||||||
? ((1 - errorCount / requestCount) * 100).toFixed(1) + "%"
|
? ((1 - requestCount.errors / requestCount.total) * 100).toFixed(1) + "%"
|
||||||
: "100%",
|
: "100%",
|
||||||
},
|
},
|
||||||
cache: {
|
cache: {
|
||||||
type: "in-memory",
|
type: "in-memory",
|
||||||
ttl: "1 minute (default), 2-5 minutes for collections",
|
ttl: "1 minute (default), 2-5 minutes for collections, 24 hours for genres/tags",
|
||||||
maxSize: 100,
|
maxSize: 100,
|
||||||
|
currentSize: require("../helpers/cache").getCacheSize(),
|
||||||
description: "Map-based cache with TTL expiration and FIFO eviction",
|
description: "Map-based cache with TTL expiration and FIFO eviction",
|
||||||
},
|
},
|
||||||
endpoints: 16,
|
endpoints: 35,
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -412,7 +973,7 @@ const createApiRoutes = (jsonResponse, jsonError) => {
|
|||||||
info: {
|
info: {
|
||||||
title: "MiruroAPI",
|
title: "MiruroAPI",
|
||||||
description: "Free REST API for anime streaming data — AniList GraphQL + Miruro streaming providers",
|
description: "Free REST API for anime streaming data — AniList GraphQL + Miruro streaming providers",
|
||||||
version: "1.4.0",
|
version: "2.0.0",
|
||||||
contact: { name: "Shineii86", url: "https://github.com/Shineii86/MiruroAPI" },
|
contact: { name: "Shineii86", url: "https://github.com/Shineii86/MiruroAPI" },
|
||||||
},
|
},
|
||||||
servers: [
|
servers: [
|
||||||
@@ -422,28 +983,56 @@ const createApiRoutes = (jsonResponse, jsonError) => {
|
|||||||
"/search": { get: { summary: "Search anime", tags: ["Search"] } },
|
"/search": { get: { summary: "Search anime", tags: ["Search"] } },
|
||||||
"/suggestions": { get: { summary: "Autocomplete suggestions", tags: ["Search"] } },
|
"/suggestions": { get: { summary: "Autocomplete suggestions", tags: ["Search"] } },
|
||||||
"/filter": { get: { summary: "Advanced filter", tags: ["Search"] } },
|
"/filter": { get: { summary: "Advanced filter", tags: ["Search"] } },
|
||||||
|
"/multi-search": { get: { summary: "Multi-parameter search", tags: ["Search"] } },
|
||||||
"/trending": { get: { summary: "Trending anime", tags: ["Collections"] } },
|
"/trending": { get: { summary: "Trending anime", tags: ["Collections"] } },
|
||||||
|
"/trending/daily": { get: { summary: "Daily trending", tags: ["Collections"] } },
|
||||||
|
"/trending/weekly": { get: { summary: "Weekly trending", tags: ["Collections"] } },
|
||||||
"/popular": { get: { summary: "Most popular", tags: ["Collections"] } },
|
"/popular": { get: { summary: "Most popular", tags: ["Collections"] } },
|
||||||
|
"/top": { get: { summary: "Top by score", tags: ["Collections"] } },
|
||||||
"/upcoming": { get: { summary: "Upcoming anime", tags: ["Collections"] } },
|
"/upcoming": { get: { summary: "Upcoming anime", tags: ["Collections"] } },
|
||||||
"/recent": { get: { summary: "Recently aired", tags: ["Collections"] } },
|
"/recent": { get: { summary: "Recently aired", tags: ["Collections"] } },
|
||||||
"/spotlight": { get: { summary: "Spotlight anime", tags: ["Collections"] } },
|
"/spotlight": { get: { summary: "Spotlight anime", tags: ["Collections"] } },
|
||||||
"/schedule": { get: { summary: "Airing schedule", tags: ["Collections"] } },
|
"/schedule": { get: { summary: "Airing schedule", tags: ["Collections"] } },
|
||||||
|
"/random": { get: { summary: "Random anime", tags: ["Collections"] } },
|
||||||
|
"/season/{year}/{season}": { get: { summary: "Seasonal anime", tags: ["Seasonal & Studio"] } },
|
||||||
|
"/studio/{name}": { get: { summary: "Anime by studio", tags: ["Seasonal & Studio"] } },
|
||||||
|
"/genres": { get: { summary: "All genres", tags: ["Genres & Tags"] } },
|
||||||
|
"/tags": { get: { summary: "All tags", tags: ["Genres & Tags"] } },
|
||||||
|
"/genre/{name}": { get: { summary: "Anime by genre", tags: ["Genres & Tags"] } },
|
||||||
|
"/year/{year}": { get: { summary: "Anime by year", tags: ["Genres & Tags"] } },
|
||||||
|
"/status/{status}": { get: { summary: "Anime by status", tags: ["Genres & Tags"] } },
|
||||||
|
"/format/{format}": { get: { summary: "Anime by format", tags: ["Genres & Tags"] } },
|
||||||
|
"/character/{id}": { get: { summary: "Character detail", tags: ["Character & Staff"] } },
|
||||||
|
"/staff/{id}": { get: { summary: "Staff detail", tags: ["Character & Staff"] } },
|
||||||
|
"/characters": { get: { summary: "Search characters", tags: ["Character & Staff"] } },
|
||||||
|
"/staff": { get: { summary: "Search staff", tags: ["Character & Staff"] } },
|
||||||
"/info/{id}": { get: { summary: "Full anime info", tags: ["Anime Details"] } },
|
"/info/{id}": { get: { summary: "Full anime info", tags: ["Anime Details"] } },
|
||||||
"/anime/{id}/characters": { get: { summary: "Characters", tags: ["Anime Details"] } },
|
"/anime/{id}/characters": { get: { summary: "Characters", tags: ["Anime Details"] } },
|
||||||
"/anime/{id}/relations": { get: { summary: "Relations", tags: ["Anime Details"] } },
|
"/anime/{id}/relations": { get: { summary: "Relations", tags: ["Anime Details"] } },
|
||||||
"/anime/{id}/recommendations": { get: { summary: "Recommendations", tags: ["Anime Details"] } },
|
"/anime/{id}/recommendations": { get: { summary: "Recommendations", tags: ["Anime Details"] } },
|
||||||
"/episodes/{id}": { get: { summary: "Episode list", tags: ["Streaming"] } },
|
"/episodes/{id}": { get: { summary: "Episode list", tags: ["Streaming & Downloading"] } },
|
||||||
"/sources": { get: { summary: "Streaming sources (detailed)", tags: ["Streaming"] } },
|
"/episodes/{id}/batch": { get: { summary: "Batch episode sources", tags: ["Streaming & Downloading"] } },
|
||||||
"/watch/{provider}/{anilistId}/{category}/{slug}": { get: { summary: "Streaming sources (simple)", tags: ["Streaming"] } },
|
"/sources": { get: { summary: "Streaming sources (detailed)", tags: ["Streaming & Downloading"] } },
|
||||||
|
"/stream": { get: { summary: "Stream with quality fallback", tags: ["Streaming & Downloading"] } },
|
||||||
|
"/download": { get: { summary: "Download URL", tags: ["Streaming & Downloading"] } },
|
||||||
|
"/watch/{provider}/{anilistId}/{category}/{slug}": { get: { summary: "Streaming sources (simple)", tags: ["Streaming & Downloading"] } },
|
||||||
|
"/compare/{id1}/{id2}": { get: { summary: "Compare two anime", tags: ["Utility"] } },
|
||||||
|
"/stats/genre": { get: { summary: "Genre statistics", tags: ["Utility"] } },
|
||||||
|
"/calendar": { get: { summary: "Monthly calendar", tags: ["Utility"] } },
|
||||||
|
"/timeline/{id}": { get: { summary: "Anime timeline", tags: ["Utility"] } },
|
||||||
"/health": { get: { summary: "Health check", tags: ["System"] } },
|
"/health": { get: { summary: "Health check", tags: ["System"] } },
|
||||||
"/stats": { get: { summary: "API statistics", tags: ["System"] } },
|
"/stats": { get: { summary: "API statistics", tags: ["System"] } },
|
||||||
"/openapi": { get: { summary: "OpenAPI spec", tags: ["System"] } },
|
"/openapi": { get: { summary: "OpenAPI spec", tags: ["System"] } },
|
||||||
},
|
},
|
||||||
tags: [
|
tags: [
|
||||||
{ name: "Search", description: "Search & filter" },
|
{ name: "Search", description: "Search, filter, suggestions" },
|
||||||
{ name: "Collections", description: "Trending, popular, upcoming, schedule" },
|
{ name: "Collections", description: "Trending, popular, top, schedule" },
|
||||||
|
{ name: "Seasonal & Studio", description: "Seasonal anime, studio filter" },
|
||||||
|
{ name: "Genres & Tags", description: "Genre, year, status, format filters" },
|
||||||
|
{ name: "Character & Staff", description: "Character/staff details and search" },
|
||||||
{ name: "Anime Details", description: "Info, characters, relations, recommendations" },
|
{ name: "Anime Details", description: "Info, characters, relations, recommendations" },
|
||||||
{ name: "Streaming", description: "Episodes, sources, watch" },
|
{ name: "Streaming & Downloading", description: "Episodes, sources, stream, download" },
|
||||||
|
{ name: "Utility", description: "Compare, stats, calendar, timeline" },
|
||||||
{ name: "System", description: "Health, stats, OpenAPI" },
|
{ name: "System", description: "Health, stats, OpenAPI" },
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user