3 Commits
Author SHA1 Message Date
Sʜɪɴᴇɪ Nᴏᴜᴢᴇɴ 4b083a9bf7 docs: rewrite all files with AniKotoAPI documentation style
- Box-style file headers (======= border, @description, @exports, @author, @license)
- Section headers (═════════════════════════════════════════)
- Function-level JSDoc comments with @param, @returns, @example
- Inline notes for non-obvious logic (lazy eviction, pipe decoding, slug resolution)
- Feature markers (---- FEATURE: description ----)
- Footer on every module (═══════════ END: filename ═══════════)
- Added /stats and /openapi endpoints to router
2026-06-09 14:27:29 +05:30
Sʜɪɴᴇɪ Nᴏᴜᴢᴇɴ b0aa5d2281 docs: update URLs to mirurotvapi.vercel.app 2026-06-09 14:19:28 +05:30
Sʜɪɴᴇɪ Nᴏᴜᴢᴇɴ c13e0bbd7e feat: v1.1.0 - Swagger docs, mappings, Docker, premium landing page
- Swagger UI interactive docs at /docs
- OpenAPI 3.0 spec at /openapi.json
- mappings field in episodes response (anilistId, malId, kitsuId)
- Docker support (Dockerfile + .dockerignore)
- Premium landing page with try-it buttons (Walter-style)
- Health endpoint with providers list and endpoint count
2026-06-09 14:16:33 +05:30
11 changed files with 1636 additions and 322 deletions
+5
View File
@@ -0,0 +1,5 @@
node_modules
npm-debug.log
.env
.vercel
.git
+10
View File
@@ -1,5 +1,15 @@
# Changelog
## v1.1.0
- Added Swagger UI interactive docs at /docs
- Added OpenAPI 3.0 spec at /openapi.json
- Added mappings field in episodes response (anilistId, malId, kitsuId)
- Added Docker support (Dockerfile + .dockerignore)
- Improved landing page with premium design (Walter-style)
- Added "Try it" buttons on all endpoints
- Health endpoint now includes version, providers list, and endpoint count
- Version bump to 1.1.0
## v1.0.0
- Initial release
- AniList GraphQL integration for anime metadata
+12
View File
@@ -0,0 +1,12 @@
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm install --production
COPY . .
EXPOSE 3000
CMD ["node", "server.js"]
+34 -5
View File
@@ -4,7 +4,9 @@ Free RESTful API for anime streaming data. Powered by AniList GraphQL and Miruro
## Live
- **API**: `https://miruroapi-zeta.vercel.app`
- **API**: `https://mirurotvapi.vercel.app`
- **Docs**: `https://mirurotvapi.vercel.app/docs` (Swagger UI)
- **OpenAPI**: `https://mirurotvapi.vercel.app/openapi.json`
- **GitHub**: `https://github.com/Shineii86/MiruroAPI`
## Features
@@ -13,29 +15,49 @@ Free RESTful API for anime streaming data. Powered by AniList GraphQL and Miruro
- AniList GraphQL for metadata (search, trending, info, filter, schedule)
- 12 streaming providers (kiwi, pewe, bee, bonk, bun, ally, nun, twin, cog, moo, hop, telli)
- M3U8 streaming URLs with subtitles and skip timestamps
- In-memory caching
- In-memory caching with TTL
- CORS enabled
- Swagger UI interactive docs
- Docker support
- Health endpoint
## Endpoints
### Search & Discovery
| Endpoint | Description |
|---|---|
| `GET /api/search?query=naruto` | Search anime by name |
| `GET /api/suggestions?query=naruto` | Autocomplete suggestions |
| `GET /api/filter?genre=Action` | Advanced filter |
### Collections
| Endpoint | Description |
|---|---|
| `GET /api/trending` | Trending anime |
| `GET /api/popular` | Most popular anime |
| `GET /api/upcoming` | Upcoming anime |
| `GET /api/recent` | Currently airing anime |
| `GET /api/spotlight` | Spotlight/featured anime |
| `GET /api/schedule` | Airing schedule |
| `GET /api/filter?genre=Action` | Advanced filter |
### Anime Details
| Endpoint | Description |
|---|---|
| `GET /api/info/:id` | Full anime info by AniList ID |
| `GET /api/anime/:id/characters` | Characters with voice actors |
| `GET /api/anime/:id/relations` | Related anime |
| `GET /api/anime/:id/recommendations` | Recommendations |
### Streaming
| Endpoint | Description |
|---|---|
| `GET /api/episodes/:id` | Episode list from all providers |
| `GET /api/sources?episodeId=...&provider=...&anilistId=...` | Streaming sources |
| `GET /api/watch/:provider/:anilistId/:category/:slug` | Simple streaming sources |
| `GET /api/sources?episodeId=...&provider=...&anilistId=...` | Streaming sources (detailed) |
| `GET /api/watch/:provider/:anilistId/:category/:slug` | Streaming sources (simple) |
## Streaming Flow
@@ -50,6 +72,13 @@ npm install
npm start
```
## Docker
```bash
docker build -t miruroapi .
docker run -p 3000:3000 miruroapi
```
## License
MIT
+191 -31
View File
@@ -3,43 +3,203 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>MiruroAPI - Free Anime Streaming API</title>
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'%3E%3Cdefs%3E%3ClinearGradient id='g' x1='0%25' y1='0%25' x2='100%25' y2='100%25'%3E%3Cstop offset='0%25' style='stop-color:%2326A3D6'/%3E%3Cstop offset='100%25' style='stop-color:%237B2FF7'/%3E%3C/linearGradient%3E%3C/defs%3E%3Ccircle cx='50' cy='50' r='45' fill='url(%23g)'/%3E%3Ctext x='50' y='62' text-anchor='middle' fill='white' font-family='Arial Black' font-size='32' font-weight='bold'%3EM%3C/text%3E%3C/svg%3E">
<title>MiruroAPI — Free Anime Streaming API</title>
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'%3E%3Cdefs%3E%3ClinearGradient id='g' x1='0%25' y1='0%25' x2='100%25' y2='100%25'%3E%3Cstop offset='0%25' style='stop-color:%2338bdf8'/%3E%3Cstop offset='100%25' style='stop-color:%23818cf8'/%3E%3C/linearGradient%3E%3C/defs%3E%3Ccircle cx='50' cy='50' r='45' fill='url(%23g)'/%3E%3Ctext x='50' y='62' text-anchor='middle' fill='white' font-family='Arial Black' font-size='32' font-weight='bold'%3EM%3C/text%3E%3C/svg%3E">
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;500;700&display=swap" rel="stylesheet">
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: 'Segoe UI', system-ui, sans-serif; background: #0b1622; color: #e1e7ef; min-height: 100vh; display: flex; align-items: center; justify-content: center; }
.container { max-width: 800px; padding: 40px; text-align: center; }
h1 { font-size: 2.5em; background: linear-gradient(135deg, #26A3D6, #7B2FF7); -webkit-background-clip: text; -webkit-text-fill-color: transparent; margin-bottom: 16px; }
p { color: #a0b1c5; font-size: 1.1em; margin-bottom: 24px; line-height: 1.6; }
.badge { display: inline-block; background: rgba(38,163,214,0.15); color: #26A3D6; padding: 6px 16px; border-radius: 20px; font-size: 0.9em; margin-bottom: 24px; border: 1px solid rgba(38,163,214,0.3); }
.endpoints { text-align: left; background: rgba(20,32,48,0.8); border-radius: 12px; padding: 24px; margin-top: 24px; border: 1px solid rgba(255,255,255,0.05); }
.endpoints h3 { color: #26A3D6; margin-bottom: 12px; }
.endpoint { padding: 8px 0; border-bottom: 1px solid rgba(255,255,255,0.03); font-family: monospace; font-size: 0.9em; }
.endpoint:last-child { border-bottom: none; }
.method { color: #10b981; font-weight: bold; }
a { color: #26A3D6; text-decoration: none; }
a:hover { text-decoration: underline; }
body { font-family: 'Outfit', sans-serif; background: radial-gradient(circle at top, #0f172a, #020617); color: #e2e8f0; min-height: 100vh; padding: 50px 20px; }
.container { max-width: 960px; margin: 0 auto; background: rgba(30, 41, 59, 0.5); backdrop-filter: blur(10px); padding: 40px; border-radius: 24px; border: 1px solid rgba(255, 255, 255, 0.05); box-shadow: 0 20px 40px rgba(0,0,0,0.5); }
.header { text-align: center; margin-bottom: 50px; }
.logo { width: 120px; border-radius: 20px; box-shadow: 0 0 30px rgba(56, 189, 248, 0.3); border: 1px solid rgba(255,255,255,0.1); margin-bottom: 25px; }
h1 { font-size: 3em; font-weight: 700; background: linear-gradient(to right, #38bdf8, #818cf8); -webkit-background-clip: text; color: transparent; margin-bottom: 10px; }
.subtitle { color: #94a3b8; font-size: 1.1em; font-weight: 300; }
.version { display: inline-block; background: rgba(56, 189, 248, 0.15); color: #38bdf8; padding: 4px 14px; border-radius: 20px; font-size: 0.85em; margin-top: 10px; border: 1px solid rgba(56, 189, 248, 0.2); }
.section-title { font-size: 1.3em; font-weight: 700; color: #818cf8; margin: 35px 0 15px; border-left: 3px solid #818cf8; padding-left: 12px; }
.endpoint { background: rgba(15, 23, 42, 0.8); border-left: 4px solid #38bdf8; padding: 20px 25px; margin: 12px 0; border-radius: 0 16px 16px 0; border: 1px solid rgba(255,255,255,0.02); transition: all 0.3s ease; }
.endpoint:hover { transform: translateX(5px); box-shadow: 0 10px 20px rgba(0,0,0,0.2); border-left-color: #818cf8; background: rgba(30, 41, 59, 0.9); }
.method { color: #10b981; font-weight: 700; background: rgba(16, 185, 129, 0.1); padding: 4px 10px; border-radius: 6px; font-size: 0.9em; margin-right: 10px; }
.url { font-family: monospace; color: #cbd5e1; font-size: 1.05em; }
.desc { color: #94a3b8; font-size: 0.95em; margin-top: 8px; font-weight: 300; line-height: 1.5; }
.badge { display: inline-block; font-size: 0.7em; padding: 2px 8px; border-radius: 6px; margin-left: 8px; font-weight: 500; vertical-align: middle; }
.badge-new { background: rgba(16, 185, 129, 0.15); color: #10b981; border: 1px solid rgba(16, 185, 129, 0.3); }
.step-num { display: inline-block; background: rgba(56, 189, 248, 0.15); color: #38bdf8; width: 26px; height: 26px; text-align: center; line-height: 26px; border-radius: 50%; font-size: 0.85em; font-weight: 700; margin-right: 8px; }
.note { background: rgba(250, 204, 21, 0.08); border: 1px solid rgba(250, 204, 21, 0.15); border-radius: 10px; padding: 14px 18px; margin-top: 12px; font-size: 0.88em; color: #fbbf24; line-height: 1.5; }
.note b { color: #fde68a; }
pre.snippet { background: #020617; padding: 14px; border-radius: 10px; margin-top: 12px; color: #a5b4fc; font-family: monospace; font-size: 0.82em; border: 1px solid rgba(255,255,255,0.05); overflow-x: auto; line-height: 1.5; }
table.param-table { width: 100%; margin-top: 12px; border-collapse: collapse; font-size: 0.85em; }
table.param-table th { text-align: left; color: #818cf8; font-weight: 500; padding: 6px 10px; border-bottom: 1px solid rgba(255,255,255,0.08); }
table.param-table td { padding: 6px 10px; color: #94a3b8; border-bottom: 1px solid rgba(255,255,255,0.03); }
table.param-table td:first-child { color: #a5b4fc; font-family: monospace; white-space: nowrap; }
.footer { text-align: center; margin-top: 50px; color: #475569; font-size: 0.9em; border-top: 1px solid rgba(255,255,255,0.05); padding-top: 20px; }
a { color: #38bdf8; text-decoration: none; font-weight: 500; }
a:hover { color: #818cf8; text-shadow: 0 0 10px rgba(129, 140, 248, 0.5); }
.try-btn { display: inline-block; background: rgba(56, 189, 248, 0.1); color: #38bdf8; padding: 6px 14px; border-radius: 8px; font-size: 0.85em; margin-top: 10px; border: 1px solid rgba(56, 189, 248, 0.2); transition: all 0.3s; }
.try-btn:hover { background: rgba(56, 189, 248, 0.2); transform: translateY(-1px); }
</style>
</head>
<body>
<div class="container">
<div class="badge">v1.0.0</div>
<h1>MiruroAPI</h1>
<p>Free RESTful API for anime streaming data. Powered by <a href="https://anilist.co">AniList GraphQL</a> and <a href="https://miruro.to">Miruro</a> streaming providers.</p>
<div class="endpoints">
<h3>Endpoints</h3>
<div class="endpoint"><span class="method">GET</span> /api/search?query=naruto</div>
<div class="endpoint"><span class="method">GET</span> /api/trending</div>
<div class="endpoint"><span class="method">GET</span> /api/popular</div>
<div class="endpoint"><span class="method">GET</span> /api/upcoming</div>
<div class="endpoint"><span class="method">GET</span> /api/recent</div>
<div class="endpoint"><span class="method">GET</span> /api/spotlight</div>
<div class="endpoint"><span class="method">GET</span> /api/schedule</div>
<div class="endpoint"><span class="method">GET</span> /api/filter?genre=Action</div>
<div class="endpoint"><span class="method">GET</span> /api/info/:id</div>
<div class="endpoint"><span class="method">GET</span> /api/episodes/:id</div>
<div class="endpoint"><span class="method">GET</span> /api/watch/:provider/:id/:category/:slug</div>
<div class="endpoint"><span class="method">GET</span> /api/health</div>
<div class="header">
<svg class="logo" viewBox="0 0 120 120" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="grad" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#38bdf8"/>
<stop offset="100%" style="stop-color:#818cf8"/>
</linearGradient>
</defs>
<rect width="120" height="120" rx="24" fill="url(#grad)"/>
<text x="60" y="75" text-anchor="middle" fill="white" font-family="Arial Black,sans-serif" font-size="48" font-weight="900">M</text>
</svg>
<h1>MiruroAPI</h1>
<div class="subtitle">Decrypted, bypassed, and reverse-engineered anime streaming API</div>
<div class="version">v1.1.0 — Full Data, Streaming &amp; Pagination</div>
</div>
<div class="note" style="background: rgba(16, 185, 129, 0.08); border-color: rgba(16, 185, 129, 0.2); color: #10b981;">
<b>16 Endpoints</b> — Powered by AniList GraphQL + Miruro streaming pipe. M3U8 URLs, subtitles, intro/outro timestamps.
</div>
<!-- SEARCH & DISCOVERY -->
<div class="section-title">Search &amp; Discovery</div>
<div class="endpoint">
<div><span class="method">GET</span> <span class="url">/api/search?query={name}</span></div>
<div class="desc">Full-text anime search with rich metadata — title, cover, genres, studios, scores, airing status. 20+ fields per result.</div>
<a class="try-btn" href="/api/search?query=naruto&per_page=3" target="_blank">Try it</a>
</div>
<div class="endpoint">
<div><span class="method">GET</span> <span class="url">/api/suggestions?query={name}</span> <span class="badge badge-new">NEW</span></div>
<div class="desc">Lightweight autocomplete for dropdowns — id, title, poster, format, status, year. Max 8 results.</div>
<a class="try-btn" href="/api/suggestions?query=one+piece" target="_blank">Try it</a>
</div>
<div class="endpoint">
<div><span class="method">GET</span> <span class="url">/api/filter</span> <span class="badge badge-new">NEW</span></div>
<div class="desc">Advanced filter by genre, tag, year, season, format, status, sort. All params optional.</div>
<table class="param-table">
<tr><th>Param</th><th>Values</th></tr>
<tr><td>genre</td><td>Action, Romance, Comedy, Drama, Fantasy, Sci-Fi, etc.</td></tr>
<tr><td>tag</td><td>Isekai, Time Skip, Reincarnation, etc.</td></tr>
<tr><td>year</td><td>2025, 2024, etc.</td></tr>
<tr><td>season</td><td>WINTER · SPRING · SUMMER · FALL</td></tr>
<tr><td>format</td><td>TV · MOVIE · OVA · ONA · SPECIAL</td></tr>
<tr><td>status</td><td>RELEASING · FINISHED · NOT_YET_RELEASED · CANCELLED</td></tr>
<tr><td>sort</td><td>SCORE_DESC · POPULARITY_DESC · TRENDING_DESC · START_DATE_DESC</td></tr>
</table>
<a class="try-btn" href="/api/filter?genre=Action&format=TV&sort=SCORE_DESC&per_page=3" target="_blank">Try it</a>
</div>
<!-- COLLECTIONS -->
<div class="section-title">Collections (All Paginated)</div>
<div class="endpoint">
<div><span class="method">GET</span> <span class="url">/api/trending</span></div>
<div class="desc">Currently trending anime across the community.</div>
<a class="try-btn" href="/api/trending?per_page=3" target="_blank">Try it</a>
</div>
<div class="endpoint">
<div><span class="method">GET</span> <span class="url">/api/popular</span></div>
<div class="desc">Most popular anime of all time by total user count.</div>
<a class="try-btn" href="/api/popular?per_page=3" target="_blank">Try it</a>
</div>
<div class="endpoint">
<div><span class="method">GET</span> <span class="url">/api/upcoming</span></div>
<div class="desc">Most anticipated anime that haven't aired yet.</div>
<a class="try-btn" href="/api/upcoming?per_page=3" target="_blank">Try it</a>
</div>
<div class="endpoint">
<div><span class="method">GET</span> <span class="url">/api/recent</span></div>
<div class="desc">Currently airing / this season's anime.</div>
<a class="try-btn" href="/api/recent?per_page=3" target="_blank">Try it</a>
</div>
<div class="endpoint">
<div><span class="method">GET</span> <span class="url">/api/spotlight</span> <span class="badge badge-new">HOT</span></div>
<div class="desc">Top 10 trending + popular anime. Perfect for hero banners.</div>
<a class="try-btn" href="/api/spotlight" target="_blank">Try it</a>
</div>
<div class="endpoint">
<div><span class="method">GET</span> <span class="url">/api/schedule</span></div>
<div class="desc">Upcoming episodes with UNIX timestamps and full anime metadata.</div>
<a class="try-btn" href="/api/schedule?per_page=3" target="_blank">Try it</a>
</div>
<!-- ANIME DETAILS -->
<div class="section-title">Anime Details</div>
<div class="endpoint">
<div><span class="method">GET</span> <span class="url">/api/info/{anilist_id}</span></div>
<div class="desc">Complete anime page — everything AniList has to offer. Characters (25), staff, relations, recommendations, trailer, stats, external links.</div>
<a class="try-btn" href="/api/info/21" target="_blank">Try it (One Piece)</a>
</div>
<div class="endpoint">
<div><span class="method">GET</span> <span class="url">/api/anime/{id}/characters</span></div>
<div class="desc">Paginated character list with Japanese voice actors, images, roles.</div>
<a class="try-btn" href="/api/anime/20/characters" target="_blank">Try it (Naruto)</a>
</div>
<div class="endpoint">
<div><span class="method">GET</span> <span class="url">/api/anime/{id}/relations</span></div>
<div class="desc">Sequels, prequels, side stories, spin-offs, source material.</div>
<a class="try-btn" href="/api/anime/20/relations" target="_blank">Try it</a>
</div>
<div class="endpoint">
<div><span class="method">GET</span> <span class="url">/api/anime/{id}/recommendations</span></div>
<div class="desc">Community recommendations sorted by highest rating.</div>
<a class="try-btn" href="/api/anime/20/recommendations" target="_blank">Try it</a>
</div>
<!-- STREAMING -->
<div class="section-title">Streaming (3-Step Flow)</div>
<div class="note">
<b>How streaming works:</b> To get a video URL, follow these 3 steps in order. Each step's output feeds into the next.
</div>
<div class="endpoint" style="border-left-color: #10b981;">
<div><span class="step-num">1</span><span class="method">GET</span> <span class="url">/api/episodes/{anilist_id}</span></div>
<div class="desc">Get all episodes from multiple providers (kiwi, bee, bonk, ally, moo, etc.) organized by sub/dub. Returns episode IDs for Step 2.</div>
<pre class="snippet">{
"providers": {
"kiwi": { "episodes": { "sub": [{ "id": "watch/kiwi/20/sub/animepahe-1", "number": 1, ... }] } },
"bee": { ... },
"bonk": { ... }
}
}</pre>
<a class="try-btn" href="/api/episodes/20" target="_blank">Try it (Naruto)</a>
</div>
<div class="endpoint" style="border-left-color: #10b981; background: rgba(16, 185, 129, 0.05);">
<div><span class="step-num">2</span> <span class="url">/api/watch/{provider}/{anilistId}/{category}/{slug}</span> <span class="badge badge-new">RECOMMENDED</span></div>
<div class="desc">Take the <b>id</b> from Step 1 and use it as the URL. Returns M3U8 streams, subtitles, intro/outro timestamps.</div>
<pre class="snippet">{
"streams": [
{ "url": "https://.../master.m3u8", "type": "hls", "quality": "1080p" }
],
"subtitles": [ { "file": "...", "label": "English" } ],
"intro": { "start": 0, "end": 90 },
"outro": { "start": 1300, "end": 1420 }
}</pre>
<a class="try-btn" href="/api/watch/kiwi/20/sub/animepahe-1" target="_blank">Try it</a>
</div>
<div class="endpoint" style="border-left-color: #818cf8;">
<div><span class="step-num">3</span> <span style="color: #818cf8; font-weight: 500;">Play the stream</span></div>
<div class="desc">Feed <code>streams[0].url</code> into any HLS player (Video.js, hls.js, VLC, mpv). Use intro/outro timestamps for skip buttons.</div>
</div>
<div class="footer">
All collection endpoints return: <code style="color: #a5b4fc;">{ page, perPage, total, hasNextPage, results[] }</code>
<br><br>
<a href="/api/health">Health</a> · <a href="/openapi.json">OpenAPI Spec</a> · <a href="https://github.com/Shineii86/MiruroAPI" target="_blank">GitHub</a>
</div>
</div>
</body>
+247
View File
@@ -0,0 +1,247 @@
{
"openapi": "3.0.3",
"info": {
"title": "MiruroAPI",
"description": "Free RESTful API for anime streaming data. Powered by AniList GraphQL and Miruro streaming providers.",
"version": "1.1.0",
"contact": {
"name": "Shineii86",
"url": "https://github.com/Shineii86/MiruroAPI"
},
"license": {
"name": "MIT"
}
},
"servers": [
{
"url": "https://mirurotvapi.vercel.app",
"description": "Production"
}
],
"paths": {
"/api/health": {
"get": {
"tags": ["System"],
"summary": "Health check",
"operationId": "health",
"responses": {
"200": {
"description": "API is healthy",
"content": { "application/json": { "schema": { "type": "object" } } }
}
}
}
},
"/api/search": {
"get": {
"tags": ["Search & Discovery"],
"summary": "Search anime by name",
"operationId": "searchAnime",
"parameters": [
{ "name": "query", "in": "query", "required": true, "schema": { "type": "string" }, "description": "Search query" },
{ "name": "page", "in": "query", "schema": { "type": "integer", "default": 1 } },
{ "name": "per_page", "in": "query", "schema": { "type": "integer", "default": 20, "maximum": 50 } }
],
"responses": {
"200": { "description": "Search results with pagination" }
}
}
},
"/api/suggestions": {
"get": {
"tags": ["Search & Discovery"],
"summary": "Autocomplete suggestions",
"operationId": "getSuggestions",
"parameters": [
{ "name": "query", "in": "query", "required": true, "schema": { "type": "string" } }
],
"responses": {
"200": { "description": "Lightweight suggestions (max 8)" }
}
}
},
"/api/filter": {
"get": {
"tags": ["Search & Discovery"],
"summary": "Advanced filter / browse",
"operationId": "filterAnime",
"parameters": [
{ "name": "genre", "in": "query", "schema": { "type": "string" }, "description": "e.g. Action, Romance" },
{ "name": "tag", "in": "query", "schema": { "type": "string" }, "description": "e.g. Isekai, Time Skip" },
{ "name": "year", "in": "query", "schema": { "type": "integer" } },
{ "name": "season", "in": "query", "schema": { "type": "string", "enum": ["WINTER", "SPRING", "SUMMER", "FALL"] } },
{ "name": "format", "in": "query", "schema": { "type": "string", "enum": ["TV", "MOVIE", "OVA", "ONA", "SPECIAL", "MUSIC"] } },
{ "name": "status", "in": "query", "schema": { "type": "string", "enum": ["RELEASING", "FINISHED", "NOT_YET_RELEASED", "CANCELLED", "HIATUS"] } },
{ "name": "sort", "in": "query", "schema": { "type": "string", "enum": ["SCORE_DESC", "POPULARITY_DESC", "TRENDING_DESC", "START_DATE_DESC", "FAVOURITES_DESC", "UPDATED_AT_DESC"], "default": "POPULARITY_DESC" } },
{ "name": "page", "in": "query", "schema": { "type": "integer", "default": 1 } },
{ "name": "per_page", "in": "query", "schema": { "type": "integer", "default": 20, "maximum": 50 } }
],
"responses": {
"200": { "description": "Filtered results with pagination" }
}
}
},
"/api/trending": {
"get": {
"tags": ["Collections"],
"summary": "Trending anime",
"operationId": "getTrending",
"parameters": [
{ "name": "page", "in": "query", "schema": { "type": "integer", "default": 1 } },
{ "name": "per_page", "in": "query", "schema": { "type": "integer", "default": 20 } }
],
"responses": { "200": { "description": "Trending anime list" } }
}
},
"/api/popular": {
"get": {
"tags": ["Collections"],
"summary": "Most popular anime",
"operationId": "getPopular",
"parameters": [
{ "name": "page", "in": "query", "schema": { "type": "integer", "default": 1 } },
{ "name": "per_page", "in": "query", "schema": { "type": "integer", "default": 20 } }
],
"responses": { "200": { "description": "Popular anime list" } }
}
},
"/api/upcoming": {
"get": {
"tags": ["Collections"],
"summary": "Upcoming anime",
"operationId": "getUpcoming",
"parameters": [
{ "name": "page", "in": "query", "schema": { "type": "integer", "default": 1 } },
{ "name": "per_page", "in": "query", "schema": { "type": "integer", "default": 20 } }
],
"responses": { "200": { "description": "Upcoming anime list" } }
}
},
"/api/recent": {
"get": {
"tags": ["Collections"],
"summary": "Recently updated anime",
"operationId": "getRecent",
"parameters": [
{ "name": "page", "in": "query", "schema": { "type": "integer", "default": 1 } },
{ "name": "per_page", "in": "query", "schema": { "type": "integer", "default": 20 } }
],
"responses": { "200": { "description": "Recent anime list" } }
}
},
"/api/spotlight": {
"get": {
"tags": ["Collections"],
"summary": "Spotlight / featured anime",
"operationId": "getSpotlight",
"responses": { "200": { "description": "Top 10 trending + popular" } }
}
},
"/api/schedule": {
"get": {
"tags": ["Collections"],
"summary": "Airing schedule",
"operationId": "getSchedule",
"parameters": [
{ "name": "page", "in": "query", "schema": { "type": "integer", "default": 1 } },
{ "name": "per_page", "in": "query", "schema": { "type": "integer", "default": 20 } }
],
"responses": { "200": { "description": "Upcoming episodes with timestamps" } }
}
},
"/api/info/{id}": {
"get": {
"tags": ["Anime Details"],
"summary": "Full anime info",
"operationId": "getAnimeInfo",
"parameters": [
{ "name": "id", "in": "path", "required": true, "schema": { "type": "integer" }, "description": "AniList anime ID" }
],
"responses": { "200": { "description": "Complete anime metadata, characters, relations, recommendations" } }
}
},
"/api/anime/{id}/characters": {
"get": {
"tags": ["Anime Details"],
"summary": "Characters with voice actors",
"operationId": "getCharacters",
"parameters": [
{ "name": "id", "in": "path", "required": true, "schema": { "type": "integer" } },
{ "name": "page", "in": "query", "schema": { "type": "integer", "default": 1 } },
{ "name": "per_page", "in": "query", "schema": { "type": "integer", "default": 25 } }
],
"responses": { "200": { "description": "Paginated character list" } }
}
},
"/api/anime/{id}/relations": {
"get": {
"tags": ["Anime Details"],
"summary": "Related anime",
"operationId": "getRelations",
"parameters": [
{ "name": "id", "in": "path", "required": true, "schema": { "type": "integer" } }
],
"responses": { "200": { "description": "Sequels, prequels, side stories, etc." } }
}
},
"/api/anime/{id}/recommendations": {
"get": {
"tags": ["Anime Details"],
"summary": "Community recommendations",
"operationId": "getRecommendations",
"parameters": [
{ "name": "id", "in": "path", "required": true, "schema": { "type": "integer" } },
{ "name": "page", "in": "query", "schema": { "type": "integer", "default": 1 } },
{ "name": "per_page", "in": "query", "schema": { "type": "integer", "default": 10 } }
],
"responses": { "200": { "description": "Recommended anime sorted by rating" } }
}
},
"/api/episodes/{id}": {
"get": {
"tags": ["Streaming"],
"summary": "Episode list from all providers",
"operationId": "getEpisodes",
"parameters": [
{ "name": "id", "in": "path", "required": true, "schema": { "type": "integer" }, "description": "AniList anime ID" }
],
"responses": { "200": { "description": "Episodes organized by provider and audio type (sub/dub)" } }
}
},
"/api/sources": {
"get": {
"tags": ["Streaming"],
"summary": "Streaming sources (detailed)",
"operationId": "getSources",
"parameters": [
{ "name": "episodeId", "in": "query", "required": true, "schema": { "type": "string" } },
{ "name": "provider", "in": "query", "required": true, "schema": { "type": "string" } },
{ "name": "anilistId", "in": "query", "required": true, "schema": { "type": "integer" } },
{ "name": "category", "in": "query", "schema": { "type": "string", "enum": ["sub", "dub"], "default": "sub" } }
],
"responses": { "200": { "description": "M3U8 streams, subtitles, intro/outro timestamps" } }
}
},
"/api/watch/{provider}/{anilistId}/{category}/{slug}": {
"get": {
"tags": ["Streaming"],
"summary": "Streaming sources (simple slug-based)",
"operationId": "getWatchSources",
"parameters": [
{ "name": "provider", "in": "path", "required": true, "schema": { "type": "string" } },
{ "name": "anilistId", "in": "path", "required": true, "schema": { "type": "integer" } },
{ "name": "category", "in": "path", "required": true, "schema": { "type": "string", "enum": ["sub", "dub"] } },
{ "name": "slug", "in": "path", "required": true, "schema": { "type": "string" }, "description": "e.g. animepahe-1" }
],
"responses": { "200": { "description": "M3U8 streams, subtitles, intro/outro timestamps" } }
}
}
},
"tags": [
{ "name": "System", "description": "Health & status" },
{ "name": "Search & Discovery", "description": "Search, suggestions, and advanced filtering" },
{ "name": "Collections", "description": "Trending, popular, upcoming, recent, spotlight, schedule" },
{ "name": "Anime Details", "description": "Full anime info, characters, relations, recommendations" },
{ "name": "Streaming", "description": "Episode lists and M3U8 streaming sources" }
]
}
+173 -44
View File
@@ -1,74 +1,203 @@
/*
* ======= • ======= • ======= • ======= • =======• =======
* MiruroAPI — server.js
* Repository: https://github.com/Shineii86/MiruroAPI
*
* @description
* Main entry point for the MiruroAPI Express server.
* Configures CORS, middleware, static files, Swagger docs,
* API routes, and 404 handling. Starts the server on the
* configured port.
*
* @exports
* None (side-effect: starts Express server)
*
* @author Shinei Nouzen
* @license MIT
* ======= • ======= • ======= • ======= • =======• =======
*/
const express = require("express");
const cors = require("cors");
const path = require("path");
require("dotenv").config();
// ══════════════════════════════════════════════════════════════
// SERVER CONFIGURATION
// ══════════════════════════════════════════════════════════════
const app = express();
const PORT = process.env.PORT || 3000;
// CORS
const allowedOrigins = process.env.ALLOWED_ORIGINS
? process.env.ALLOWED_ORIGINS.split(",")
: null;
app.use(
cors({
origin: function (origin, callback) {
if (!allowedOrigins || allowedOrigins.includes("*") || !origin) {
return callback(null, true);
}
if (allowedOrigins.includes(origin)) {
return callback(null, true);
}
return callback(null, true);
},
methods: ["GET", "OPTIONS"],
allowedHeaders: ["Content-Type"],
})
);
// ══════════════════════════════════════════════════════════════
// CORS MIDDLEWARE
// ══════════════════════════════════════════════════════════════
// Security headers
// NOTE: Single unified CORS middleware — handles all origin validation
app.use((req, res, next) => {
res.setHeader("access-control-allow-origin", "*");
res.setHeader("X-Content-Type-Options", "nosniff");
res.setHeader("X-Frame-Options", "DENY");
res.setHeader("X-XSS-Protection", "1; mode=block");
const origin = req.headers.origin;
if (
!allowedOrigins ||
allowedOrigins.includes("*") ||
(origin && allowedOrigins.includes(origin))
) {
res.setHeader("Access-Control-Allow-Origin", origin || "*");
}
res.setHeader("Access-Control-Allow-Methods", "GET");
res.setHeader("Access-Control-Allow-Headers", "Content-Type");
if (req.method === "OPTIONS") {
return res.status(204).end();
}
next();
});
// Static files
app.use(express.static(path.join(__dirname, "public")));
// ---- FEATURE: Security headers ----
app.use((req, res, next) => {
res.setHeader("X-Frame-Options", "DENY");
res.setHeader("X-Content-Type-Options", "nosniff");
res.setHeader("X-XSS-Protection", "1; mode=block");
res.setHeader("Referrer-Policy", "strict-origin-when-cross-origin");
res.setHeader("Permissions-Policy", "camera=(), microphone=(), geolocation=()");
next();
});
// Health
app.get("/api/health", (req, res) => {
res.json({
status: "ok",
version: "1.0.0",
timestamp: new Date().toISOString(),
uptime: process.uptime(),
// ══════════════════════════════════════════════════════════════
// STATIC FILES
// ══════════════════════════════════════════════════════════════
app.use(express.static(path.join(__dirname, "public"), { redirect: false }));
// ══════════════════════════════════════════════════════════════
// SWAGGER UI DOCS
// ══════════════════════════════════════════════════════════════
// ---- FEATURE: Interactive Swagger UI documentation at /docs ----
app.get("/docs", (req, res) => {
res.send(`<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>MiruroAPI — Swagger UI</title>
<link rel="stylesheet" href="https://unpkg.com/swagger-ui-dist@5/swagger-ui.css">
<style>body{margin:0;background:#0b1622;}</style>
</head>
<body>
<div id="swagger-ui"></div>
<script src="https://unpkg.com/swagger-ui-dist@5/swagger-ui-bundle.js"></script>
<script>
SwaggerUIBundle({
url: "/openapi.json",
dom_id: "#swagger-ui",
presets: [SwaggerUIBundle.presets.apis, SwaggerUIBundle.SwaggerUIStandalonePreset],
layout: "BaseLayout",
deepLinking: true,
defaultModelsExpandDepth: -1,
docExpansion: "list",
});
</script>
</body>
</html>`);
});
// ══════════════════════════════════════════════════════════════
// 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) =>
res.status(status).json({ success: true, results: data });
// ---- FEATURE: Standardized error response wrapper ----
/**
* Returns a standardized error JSON response.
*
* @param {object} res - Express response object
* @param {string} message - Error message to return
* @param {number} status - HTTP status code (default: 500)
*/
const jsonError = (res, message = "Internal server error", status = 500) =>
res.status(status).json({ success: false, message });
// ══════════════════════════════════════════════════════════════
// RATE LIMITING
// ══════════════════════════════════════════════════════════════
// ---- FEATURE: Rate limiting (100 requests per minute per IP) ----
const requestCounts = new Map();
app.use((req, res, next) => {
const ip = req.ip || req.connection.remoteAddress;
const now = Date.now();
const windowMs = 60000;
const maxRequests = 100;
if (!requestCounts.has(ip)) {
requestCounts.set(ip, []);
}
const timestamps = requestCounts.get(ip).filter((t) => now - t < windowMs);
requestCounts.set(ip, timestamps);
if (timestamps.length >= maxRequests) {
return res.status(429).json({
success: false,
message: "Rate limit exceeded. Try again later.",
});
}
timestamps.push(now);
next();
});
// ══════════════════════════════════════════════════════════════
// API ROUTES
// ══════════════════════════════════════════════════════════════
const apiRoutes = require("./src/routes/apiRoutes");
app.use("/api", apiRoutes(jsonResponse, jsonError));
// ══════════════════════════════════════════════════════════════
// 404 HANDLER
// ══════════════════════════════════════════════════════════════
// ---- FEATURE: Global error handler ----
app.use((err, req, res, next) => {
console.error(err.stack);
res.status(err.status || 500).json({
success: false,
message: err.message || "Internal server error",
});
});
// Routes
const apiRoutes = require("./src/routes/apiRoutes");
app.use("/api", apiRoutes);
// SPA fallback
app.get("*", (req, res) => {
// ---- FEATURE: Catch-all 404 handler for undefined routes ----
app.use((req, res) => {
if (req.path.startsWith("/api/")) {
return res.status(404).json({ success: false, message: "Endpoint not found" });
return res.status(404).json({
success: false,
message: "Endpoint not found",
});
}
res.sendFile(path.join(__dirname, "public", "index.html"));
});
// Global error handler
app.use((err, req, res, next) => {
console.error(err.stack);
res.status(500).json({ success: false, message: "Internal server error" });
});
// ══════════════════════════════════════════════════════════════
// SERVER START
// ══════════════════════════════════════════════════════════════
app.listen(PORT, () => {
console.log(`MiruroAPI running on port ${PORT}`);
console.info(`MiruroAPI listening at ${PORT}`);
});
module.exports = app;
// ══════════════════════════════════════════════════════════════ END: server.js
+229 -22
View File
@@ -1,7 +1,50 @@
/*
* ======= • ======= • ======= • ======= • =======• =======
* MiruroAPI — anilist.js
* Repository: https://github.com/Shineii86/MiruroAPI
*
* @description
* AniList GraphQL API integration module. Provides functions for
* searching anime, fetching details, browsing collections, and
* querying the AniList GraphQL API. All metadata (titles, covers,
* genres, scores, characters, relations) comes from AniList.
*
* @exports
* searchAnime, getSuggestions, getAnimeInfo, getAnimeCharacters,
* getAnimeRelations, getAnimeRecommendations, getCollection,
* getSpotlight, getSchedule, filterAnime
*
* @author Shinei Nouzen
* @license MIT
* ======= • ======= • ======= • ======= • =======• =======
*/
const axios = require("axios");
// ══════════════════════════════════════════════════════════════
// ANILIST API CONFIGURATION
// ══════════════════════════════════════════════════════════════
/**
* AniList GraphQL API endpoint.
* Used for all metadata queries (search, trending, info, filter).
*
* @type {string}
*/
const ANILIST_URL = "https://graphql.anilist.co";
// ══════════════════════════════════════════════════════════════
// GRAPHQL FRAGMENTS
// ══════════════════════════════════════════════════════════════
// ---- FEATURE: Media list fields fragment (used in collections/search) ----
/**
* Standard set of fields for anime list responses.
* Used in trending, popular, search, filter, and schedule endpoints.
* Includes titles, cover, format, scores, genres, studios, and airing info.
*
* @type {string}
*/
const MEDIA_LIST_FIELDS = `
id
title { romaji english native }
@@ -27,6 +70,14 @@ const MEDIA_LIST_FIELDS = `
endDate { year month day }
`;
// ---- FEATURE: Media full fields fragment (used in info endpoint) ----
/**
* Extended set of fields for detailed anime info responses.
* Includes everything in MEDIA_LIST_FIELDS plus characters, staff,
* relations, recommendations, trailer, stats, and external links.
*
* @type {string}
*/
const MEDIA_FULL_FIELDS = `
id
idMal
@@ -109,17 +160,53 @@ const MEDIA_FULL_FIELDS = `
}
`;
async function anilistQuery(query, variables = {}) {
// ══════════════════════════════════════════════════════════════
// CORE QUERY FUNCTION
// ══════════════════════════════════════════════════════════════
// ---- FEATURE: Execute AniList GraphQL query ----
/**
* Executes a GraphQL query against the AniList API.
* Handles request formatting and error checking.
*
* @param {string} query - The GraphQL query string
* @param {object} [variables={}] - Query variables
* @returns {Promise<object>} The `data` field from the AniList response
* @throws {Error} If the AniList API returns a non-200 status
*
* @example
* const data = await anilistQuery(MEDIA_LIST_FIELDS, { search: "naruto" });
*/
const anilistQuery = async (query, variables = {}) => {
const body = { query, variables };
const res = await axios.post(ANILIST_URL, body, {
headers: { "Content-Type": "application/json" },
timeout: 15000,
});
if (res.status !== 200) throw new Error("AniList query failed");
return res.data.data;
}
};
async function searchAnime(query, page = 1, perPage = 20) {
// ══════════════════════════════════════════════════════════════
// SEARCH ENDPOINTS
// ══════════════════════════════════════════════════════════════
// ---- FEATURE: Full-text anime search with pagination ----
/**
* Searches AniList for anime matching the given query.
* Returns paginated results with full metadata per result.
*
* @param {string} query - The search query string
* @param {number} [page=1] - Page number for pagination
* @param {number} [perPage=20] - Results per page (max 50)
* @returns {Promise<object>} Paginated search results with page, perPage, total, hasNextPage, results[]
*
* @example
* const results = await searchAnime("naruto", 1, 10);
* console.log(results.total); // 5000+
*/
const searchAnime = async (query, page = 1, perPage = 20) => {
const gql = `
query ($search: String, $page: Int, $perPage: Int) {
Page(page: $page, perPage: $perPage) {
@@ -132,6 +219,7 @@ async function searchAnime(query, page = 1, perPage = 20) {
`;
const data = await anilistQuery(gql, { search: query, page, perPage });
const pageData = data.Page;
return {
page: pageData.pageInfo.currentPage,
perPage: pageData.pageInfo.perPage,
@@ -139,9 +227,21 @@ async function searchAnime(query, page = 1, perPage = 20) {
hasNextPage: pageData.pageInfo.hasNextPage,
results: pageData.media,
};
}
};
async function getSuggestions(query) {
// ---- FEATURE: Lightweight autocomplete suggestions (max 8) ----
/**
* Fetches search suggestions for autocomplete dropdowns.
* Returns minimal data (id, title, poster, format, status, year).
*
* @param {string} query - The search query string
* @returns {Promise<Array>} Array of up to 8 suggestion objects
*
* @example
* const suggestions = await getSuggestions("one piece");
* console.log(suggestions[0].title); // "ONE PIECE"
*/
const getSuggestions = async (query) => {
const gql = `
query ($search: String) {
Page(page: 1, perPage: 8) {
@@ -158,6 +258,7 @@ async function getSuggestions(query) {
}
`;
const data = await anilistQuery(gql, { search: query });
return data.Page.media.map((item) => ({
id: item.id,
title: item.title.english || item.title.romaji,
@@ -168,9 +269,26 @@ async function getSuggestions(query) {
year: item.startDate?.year,
episodes: item.episodes,
}));
}
};
async function getAnimeInfo(id) {
// ══════════════════════════════════════════════════════════════
// ANIME DETAILS
// ══════════════════════════════════════════════════════════════
// ---- FEATURE: Complete anime info by AniList ID ----
/**
* Fetches full anime metadata including characters, staff,
* relations, recommendations, trailer, stats, and external links.
*
* @param {number} id - AniList anime ID
* @returns {Promise<object>} Complete anime metadata object
* @throws {Error} If anime is not found
*
* @example
* const info = await getAnimeInfo(20); // Naruto
* console.log(info.title.romaji); // "NARUTO"
*/
const getAnimeInfo = async (id) => {
const gql = `
query ($id: Int) {
Media(id: $id, type: ANIME) {
@@ -180,9 +298,19 @@ async function getAnimeInfo(id) {
`;
const data = await anilistQuery(gql, { id });
return data.Media;
}
};
async function getAnimeCharacters(id, page = 1, perPage = 25) {
// ---- FEATURE: Paginated character list with voice actors ----
/**
* Fetches paginated character list for an anime.
* Each character includes name, image, role, and Japanese voice actors.
*
* @param {number} id - AniList anime ID
* @param {number} [page=1] - Page number
* @param {number} [perPage=25] - Results per page (max 50)
* @returns {Promise<object>} Paginated character list
*/
const getAnimeCharacters = async (id, page = 1, perPage = 25) => {
const gql = `
query ($id: Int, $page: Int, $perPage: Int) {
Media(id: $id, type: ANIME) {
@@ -201,6 +329,7 @@ async function getAnimeCharacters(id, page = 1, perPage = 25) {
`;
const data = await anilistQuery(gql, { id, page, perPage });
if (!data.Media) throw new Error("Anime not found");
const chars = data.Media.characters;
return {
page: chars.pageInfo.currentPage,
@@ -209,9 +338,17 @@ async function getAnimeCharacters(id, page = 1, perPage = 25) {
hasNextPage: chars.pageInfo.hasNextPage,
characters: chars.edges,
};
}
};
async function getAnimeRelations(id) {
// ---- FEATURE: Related anime (sequels, prequels, spin-offs) ----
/**
* Fetches all related media for an anime — sequels, prequels,
* side stories, spin-offs, and source material.
*
* @param {number} id - AniList anime ID
* @returns {Promise<object>} Relations with id, title, and relations[]
*/
const getAnimeRelations = async (id) => {
const gql = `
query ($id: Int) {
Media(id: $id, type: ANIME) {
@@ -231,14 +368,25 @@ async function getAnimeRelations(id) {
`;
const data = await anilistQuery(gql, { id });
if (!data.Media) throw new Error("Anime not found");
return {
id: data.Media.id,
title: data.Media.title,
relations: data.Media.relations.edges,
};
}
};
async function getAnimeRecommendations(id, page = 1, perPage = 10) {
// ---- FEATURE: Community recommendations sorted by rating ----
/**
* Fetches paginated community recommendations for an anime.
* "If you liked X, you'll like Y" — sorted by highest rating.
*
* @param {number} id - AniList anime ID
* @param {number} [page=1] - Page number
* @param {number} [perPage=10] - Results per page (max 25)
* @returns {Promise<object>} Paginated recommendations
*/
const getAnimeRecommendations = async (id, page = 1, perPage = 10) => {
const gql = `
query ($id: Int, $page: Int, $perPage: Int) {
Media(id: $id, type: ANIME) {
@@ -259,6 +407,7 @@ async function getAnimeRecommendations(id, page = 1, perPage = 10) {
`;
const data = await anilistQuery(gql, { id, page, perPage });
if (!data.Media) throw new Error("Anime not found");
const recs = data.Media.recommendations;
return {
page: recs.pageInfo.currentPage,
@@ -267,9 +416,24 @@ async function getAnimeRecommendations(id, page = 1, perPage = 10) {
hasNextPage: recs.pageInfo.hasNextPage,
recommendations: recs.nodes,
};
}
};
async function getCollection(sortType, status = null, page = 1, perPage = 20) {
// ══════════════════════════════════════════════════════════════
// COLLECTIONS
// ══════════════════════════════════════════════════════════════
// ---- FEATURE: Generic collection fetcher with sort and status ----
/**
* Internal helper for fetching anime collections (trending, popular, etc.).
* Builds a dynamic GraphQL query based on sort type and optional status filter.
*
* @param {string} sortType - AniList sort enum (e.g., "TRENDING_DESC", "POPULARITY_DESC")
* @param {string|null} status - Optional status filter (e.g., "RELEASING", "NOT_YET_RELEASED")
* @param {number} [page=1] - Page number
* @param {number} [perPage=20] - Results per page
* @returns {Promise<object>} Paginated collection results
*/
const getCollection = async (sortType, status = null, page = 1, perPage = 20) => {
const statusFilter = status ? `, status: ${status}` : "";
const gql = `
query ($page: Int, $perPage: Int) {
@@ -283,6 +447,7 @@ async function getCollection(sortType, status = null, page = 1, perPage = 20) {
`;
const data = await anilistQuery(gql, { page, perPage });
const pageData = data.Page;
return {
page: pageData.pageInfo.currentPage,
perPage: pageData.pageInfo.perPage,
@@ -290,9 +455,16 @@ async function getCollection(sortType, status = null, page = 1, perPage = 20) {
hasNextPage: pageData.pageInfo.hasNextPage,
results: pageData.media,
};
}
};
async function getSpotlight() {
// ---- FEATURE: Spotlight/featured anime (top 10 trending + popular) ----
/**
* Fetches the top 10 trending + popular anime for hero banners.
* Combines TRENDING_DESC and POPULARITY_DESC sorts.
*
* @returns {Promise<Array>} Array of up to 10 anime objects
*/
const getSpotlight = async () => {
const gql = `
query {
Page(page: 1, perPage: 10) {
@@ -304,9 +476,18 @@ async function getSpotlight() {
`;
const data = await anilistQuery(gql);
return data.Page.media;
}
};
async function getSchedule(page = 1, perPage = 20) {
// ---- FEATURE: Airing schedule with timestamps ----
/**
* Fetches upcoming anime episodes with UNIX timestamps.
* Each result includes next_episode number, airingAt, and timeUntilAiring.
*
* @param {number} [page=1] - Page number
* @param {number} [perPage=20] - Results per page
* @returns {Promise<object>} Paginated schedule with airing timestamps
*/
const getSchedule = async (page = 1, perPage = 20) => {
const gql = `
query ($page: Int, $perPage: Int) {
Page(page: $page, perPage: $perPage) {
@@ -322,12 +503,14 @@ async function getSchedule(page = 1, perPage = 20) {
`;
const data = await anilistQuery(gql, { page, perPage });
const pageData = data.Page;
const results = pageData.airingSchedules.map((item) => ({
...item.media,
next_episode: item.episode,
airingAt: item.airingAt,
timeUntilAiring: item.timeUntilAiring,
}));
return {
page: pageData.pageInfo.currentPage,
perPage: pageData.pageInfo.perPage,
@@ -335,9 +518,29 @@ async function getSchedule(page = 1, perPage = 20) {
hasNextPage: pageData.pageInfo.hasNextPage,
results,
};
}
};
async function filterAnime({ genre, tag, year, season, format, status, sort = "POPULARITY_DESC", page = 1, perPage = 20 }) {
// ---- FEATURE: Advanced anime filter with multiple parameters ----
/**
* Advanced anime filter supporting genre, tag, year, season,
* format, status, and sort. All parameters are optional.
*
* @param {object} options - Filter options
* @param {string} [options.genre] - Genre name (e.g., "Action", "Romance")
* @param {string} [options.tag] - Tag name (e.g., "Isekai", "Time Skip")
* @param {number} [options.year] - Season year (e.g., 2025)
* @param {string} [options.season] - Season (WINTER, SPRING, SUMMER, FALL)
* @param {string} [options.format] - Format (TV, MOVIE, OVA, ONA, SPECIAL)
* @param {string} [options.status] - Status (RELEASING, FINISHED, NOT_YET_RELEASED)
* @param {string} [options.sort="POPULARITY_DESC"] - Sort order
* @param {number} [options.page=1] - Page number
* @param {number} [options.perPage=20] - Results per page
* @returns {Promise<object>} Paginated filtered results
*/
const filterAnime = async ({
genre, tag, year, season, format, status,
sort = "POPULARITY_DESC", page = 1, perPage = 20,
}) => {
const SORT_MAP = {
SCORE_DESC: "SCORE_DESC",
POPULARITY_DESC: "POPULARITY_DESC",
@@ -347,6 +550,7 @@ async function filterAnime({ genre, tag, year, season, format, status, sort = "P
UPDATED_AT_DESC: "UPDATED_AT_DESC",
};
// NOTE: Build dynamic GraphQL arguments based on provided filters
const args = ["type: ANIME", `sort: [${SORT_MAP[sort] || "POPULARITY_DESC"}]`];
const variables = { page, perPage };
const varTypes = ["$page: Int", "$perPage: Int"];
@@ -370,6 +574,7 @@ async function filterAnime({ genre, tag, year, season, format, status, sort = "P
`;
const data = await anilistQuery(gql, variables);
const pageData = data.Page;
return {
page: pageData.pageInfo.currentPage,
perPage: pageData.pageInfo.perPage,
@@ -377,7 +582,7 @@ async function filterAnime({ genre, tag, year, season, format, status, sort = "P
hasNextPage: pageData.pageInfo.hasNextPage,
results: pageData.media,
};
}
};
module.exports = {
anilistQuery,
@@ -394,3 +599,5 @@ module.exports = {
MEDIA_LIST_FIELDS,
MEDIA_FULL_FIELDS,
};
// ══════════════════════════════════════════════════════════════ END: anilist.js
+107 -9
View File
@@ -1,27 +1,125 @@
/*
* ======= • ======= • ======= • ======= • =======• =======
* MiruroAPI — cache.js
* Repository: https://github.com/Shineii86/MiruroAPI
*
* @description
* In-memory caching utility for storing and retrieving API responses.
* Implements TTL-based cache invalidation to prevent stale data
* while reducing unnecessary requests to AniList/Miruro backends.
*
* @exports
* getCached, setCache, clearCache
*
* @author Shinei Nouzen
* @license MIT
* ======= • ======= • ======= • ======= • =======• =======
*/
// ══════════════════════════════════════════════════════════════
// CACHE STORAGE & CONFIGURATION
// ══════════════════════════════════════════════════════════════
// ---- FEATURE: In-memory Map-based cache storage ----
/**
* Internal cache storage using JavaScript Map object.
* Stores data with expiry timestamps for TTL-based expiration.
*
* @type {Map<string, {data: any, expiry: number}>}
*/
const cache = new Map();
const MAX_CACHE_SIZE = 100;
// ---- FEATURE: Cache time-to-live configuration (1 minute) ----
/**
* Default cache TTL (Time-To-Live) in milliseconds.
* Default: 1 minute (60,000 ms).
* After this duration, cached items are considered stale
* and will be removed on next access.
*
* @type {number}
* @default 60000
*/
const DEFAULT_TTL = 60 * 1000;
function getCached(key) {
// ---- FEATURE: Maximum cache size limit ----
/**
* Maximum number of entries allowed in the cache.
* When exceeded, the oldest entry is evicted (FIFO).
* Prevents unbounded memory growth on long-running instances.
*
* @type {number}
* @default 100
*/
const MAX_CACHE_SIZE = 100;
// ══════════════════════════════════════════════════════════════
// CACHE OPERATIONS
// ══════════════════════════════════════════════════════════════
// ---- FEATURE: Retrieve cached data by key with TTL validation ----
/**
* Retrieves data from cache if it exists and hasn't expired.
* Automatically removes stale entries on access (lazy eviction).
*
* @param {string} key - The cache key to lookup
* @returns {any|null} Cached data if valid, null if expired or missing
*
* @example
* const data = getCached("search:naruto:1:20");
* if (data) {
* // Use cached data
* }
*/
const getCached = (key) => {
const entry = cache.get(key);
if (!entry) return null;
// NOTE: Lazy eviction — delete stale entries on access
if (Date.now() > entry.expiry) {
cache.delete(key);
return null;
}
return entry.data;
}
function setCache(key, data, ttl = DEFAULT_TTL) {
return entry.data;
};
// ---- FEATURE: Store data in cache with TTL expiry ----
/**
* Stores data in cache with TTL-based expiry.
* Evicts oldest entry if cache is at max capacity (FIFO).
*
* @param {string} key - The cache key to store under
* @param {any} data - The data to cache (any serializable type)
* @param {number} [ttl=DEFAULT_TTL] - Time-to-live in milliseconds
* @returns {void}
*
* @example
* // Cache search results for 2 minutes
* setCache("search:one-piece", results, 120000);
*/
const setCache = (key, data, ttl = DEFAULT_TTL) => {
// NOTE: FIFO eviction — remove oldest entry when cache is full
if (cache.size >= MAX_CACHE_SIZE) {
const firstKey = cache.keys().next().value;
cache.delete(firstKey);
}
cache.set(key, { data, expiry: Date.now() + ttl });
}
function clearCache() {
cache.set(key, { data, expiry: Date.now() + ttl });
};
// ---- FEATURE: Clear all cached entries from memory ----
/**
* Clears all entries from the cache. Use when:
* - Memory cleanup is needed
* - Force-refreshing all data
* - Application restart/reset
*
* @returns {void}
*/
const clearCache = () => {
cache.clear();
}
};
module.exports = { getCached, setCache, clearCache };
// ══════════════════════════════════════════════════════════════ END: cache.js
+210 -20
View File
@@ -1,20 +1,85 @@
/*
* ======= • ======= • ======= • ======= • =======• =======
* MiruroAPI — pipe.js
* Repository: https://github.com/Shineii86/MiruroAPI
*
* @description
* Miruro streaming pipe integration. Handles encoding/decoding
* of the secure/pipe tunnel that Miruro uses for episode lists
* and streaming sources. Decodes base64+gzip responses and
* injects simplified slug-based episode IDs.
*
* @exports
* getEpisodes, getSources, getWatchSources
*
* @author Shinei Nouzen
* @license MIT
* ======= • ======= • ======= • ======= • =======• =======
*/
const axios = require("axios");
const { Buffer } = require("buffer");
const zlib = require("zlib");
// ══════════════════════════════════════════════════════════════
// MIRURO PIPE CONFIGURATION
// ══════════════════════════════════════════════════════════════
/**
* Miruro's secure pipe endpoint URL.
* All streaming requests go through this base64+gzip encoded tunnel.
*
* @type {string}
*/
const MIRURO_PIPE_URL = "https://www.miruro.tv/api/secure/pipe";
/**
* Request headers mimicking a browser.
* Referer is required for the pipe endpoint to accept requests.
*
* @type {object}
*/
const HEADERS = {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36",
Referer: "https://www.miruro.tv/",
};
function encodePipeRequest(payload) {
// ══════════════════════════════════════════════════════════════
// ENCODING / DECODING UTILITIES
// ══════════════════════════════════════════════════════════════
// ---- FEATURE: Base64url encode pipe request payload ----
/**
* Encodes a JSON payload into the base64url format expected by the pipe endpoint.
* Used for all pipe API requests (episodes, sources).
*
* @param {object} payload - The request payload to encode
* @returns {string} Base64url-encoded string (no padding)
*
* @example
* const encoded = encodePipeRequest({ path: "episodes", method: "GET", query: { anilistId: 20 } });
*/
const encodePipeRequest = (payload) => {
const json = JSON.stringify(payload);
return Buffer.from(json).toString("base64url");
}
};
function decodePipeResponse(encodedStr) {
// ---- FEATURE: Decode base64+gzip pipe response ----
/**
* Decodes a pipe response from base64url + gzip format into a plain object.
* The Miruro pipe returns all responses in this compressed format.
*
* @param {string} encodedStr - The base64url-encoded gzipped response
* @returns {object} Decoded JSON object
* @throws {Error} If decoding or decompression fails
*
* @example
* const data = decodePipeResponse(res.text);
* console.log(data.providers); // { kiwi: {...}, bee: {...} }
*/
const decodePipeResponse = (encodedStr) => {
try {
// NOTE: Add padding if necessary (base64url may omit trailing =)
const padded = encodedStr + "=".repeat((4 - (encodedStr.length % 4)) % 4);
const compressed = Buffer.from(padded, "base64url");
const decompressed = zlib.gunzipSync(compressed);
@@ -22,20 +87,41 @@ function decodePipeResponse(encodedStr) {
} catch (e) {
throw new Error("Failed to decode pipe response: " + e.message);
}
}
};
function translateId(encodedId) {
// ---- FEATURE: Decode base64 episode ID to plain text ----
/**
* Decodes a base64-encoded episode ID back to plain text.
* Episode IDs from the pipe are base64-encoded with ":" separators.
*
* @param {string} encodedId - The base64url-encoded episode ID
* @returns {string} Decoded plain text ID, or original if decoding fails
*
* @example
* const decoded = translateId("YW5pbWVwaGU6MjA6c3ViOjE=");
* // returns "animepahe:20:sub:1"
*/
const translateId = (encodedId) => {
try {
const padded = encodedId + "=".repeat((4 - (encodedId.length % 4)) % 4);
const decoded = Buffer.from(padded, "base64url").toString("utf-8");
// NOTE: Only return decoded value if it looks like a valid ID (contains ":")
if (decoded.includes(":")) return decoded;
return encodedId;
} catch {
return encodedId;
}
}
};
function deepTranslate(obj) {
// ---- FEATURE: Recursively decode all base64 IDs in a nested object ----
/**
* Walks a JSON structure and decodes any base64 "id" fields.
* Handles nested objects and arrays of objects.
*
* @param {object|Array} obj - The object or array to process
* @returns {object|Array} The same object with decoded IDs (mutated in place)
*/
const deepTranslate = (obj) => {
if (obj && typeof obj === "object") {
for (const key of Object.keys(obj)) {
if (key === "id" && typeof obj[key] === "string") {
@@ -46,33 +132,73 @@ function deepTranslate(obj) {
}
}
return obj;
}
};
function injectSourceSlugs(data, anilistId) {
// ══════════════════════════════════════════════════════════════
// EPISODE ID INJECTION
// ══════════════════════════════════════════════════════════════
// ---- FEATURE: Transform episode IDs into simplified path-based slugs ----
/**
* Converts raw pipe episode IDs into clean slug format:
* watch/{provider}/{anilistId}/{category}/{prefix}-{number}
*
* This makes episode IDs human-readable and URL-safe for the /watch endpoint.
*
* @param {object} data - Raw pipe response with providers
* @param {number} anilistId - The AniList anime ID
* @returns {object} Modified data with slug-based episode IDs
*
* @example
* // Before: "animepahe:20:sub:1"
* // After: "watch/kiwi/20/sub/animepahe-1"
*/
const injectSourceSlugs = (data, anilistId) => {
const providers = data.providers || {};
for (const [provName, provData] of Object.entries(providers)) {
if (!provData || typeof provData !== "object") continue;
let episodes = provData.episodes;
if (!episodes) continue;
// NOTE: Some providers return a flat array — wrap it in { sub: [...] }
if (Array.isArray(episodes)) {
provData.episodes = { sub: episodes };
episodes = provData.episodes;
}
for (const [category, epList] of Object.entries(episodes)) {
if (!Array.isArray(epList)) continue;
for (const ep of epList) {
if (ep.id && ep.number) {
const origId = ep.id;
// NOTE: Take only the prefix before ":" for the slug
const prefix = origId.includes(":") ? origId.split(":")[0] : origId;
ep.id = `watch/${provName}/${anilistId}/${category}/${prefix}-${ep.number}`;
}
}
}
}
return data;
}
async function fetchRawEpisodes(anilistId) {
return data;
};
// ══════════════════════════════════════════════════════════════
// PIPE API FUNCTIONS
// ══════════════════════════════════════════════════════════════
// ---- FEATURE: Fetch raw decoded episode data from Miruro pipe ----
/**
* Internal helper to fetch and decode raw episode data from the pipe.
* Sends a base64-encoded request and decodes the gzipped response.
*
* @param {number} anilistId - The AniList anime ID
* @returns {Promise<object>} Decoded episode data with providers
* @throws {Error} If the pipe request fails
*/
const fetchRawEpisodes = async (anilistId) => {
const payload = {
path: "episodes",
method: "GET",
@@ -80,22 +206,60 @@ async function fetchRawEpisodes(anilistId) {
body: null,
version: "0.1.0",
};
const encodedReq = encodePipeRequest(payload);
const res = await axios.get(`${MIRURO_PIPE_URL}?e=${encodedReq}`, {
headers: HEADERS,
timeout: 15000,
});
if (res.status !== 200) throw new Error(`Pipe request failed: ${res.status}`);
const data = decodePipeResponse(res.text || res.data);
return deepTranslate(data);
}
};
async function getEpisodes(anilistId) {
// ---- FEATURE: Get episodes with slug-based IDs ----
/**
* Fetches the full episode list for an anime from all providers.
* Returns episodes with simplified slug-based IDs for the /watch endpoint.
*
* @param {number} anilistId - The AniList anime ID
* @returns {Promise<object>} Episode data with providers and mappings
*
* @example
* const episodes = await getEpisodes(20); // Naruto
* console.log(Object.keys(episodes.providers)); // ["kiwi", "bee", "bonk", ...]
*/
const getEpisodes = async (anilistId) => {
const data = await fetchRawEpisodes(anilistId);
return injectSourceSlugs(data, anilistId);
}
const result = injectSourceSlugs(data, anilistId);
async function getSources(episodeId, provider, anilistId, category = "sub") {
// NOTE: Ensure mappings field is present (like Walter's API)
if (!result.mappings) {
result.mappings = { anilistId };
if (result.malId) result.mappings.malId = result.malId;
if (result.kitsuId) result.mappings.kitsuId = result.kitsuId;
}
return result;
};
// ---- FEATURE: Get streaming sources (detailed endpoint) ----
/**
* Fetches M3U8 streaming sources for a specific episode.
* This is the detailed/manual endpoint — use /watch for simpler access.
*
* @param {string} episodeId - The raw episode ID from the pipe
* @param {string} provider - Provider name (e.g., "kiwi", "bee", "bonk")
* @param {number} anilistId - The AniList anime ID
* @param {string} [category="sub"] - Audio category ("sub" or "dub")
* @returns {Promise<object>} Streaming sources with M3U8 URLs, subtitles, timestamps
*
* @example
* const sources = await getSources("animepahe:20:sub:1", "kiwi", 20, "sub");
* console.log(sources.streams[0].url); // "https://.../master.m3u8"
*/
const getSources = async (episodeId, provider, anilistId, category = "sub") => {
const encId = Buffer.from(episodeId).toString("base64url");
const payload = {
path: "sources",
@@ -104,33 +268,57 @@ async function getSources(episodeId, provider, anilistId, category = "sub") {
body: null,
version: "0.1.0",
};
const encodedReq = encodePipeRequest(payload);
const res = await axios.get(`${MIRURO_PIPE_URL}?e=${encodedReq}`, {
headers: HEADERS,
timeout: 15000,
});
if (res.status !== 200) throw new Error(`Pipe sources request failed: ${res.status}`);
return deepTranslate(decodePipeResponse(res.text || res.data));
}
};
async function getWatchSources(provider, anilistId, category, slug) {
// ---- FEATURE: Get streaming sources (simple slug-based endpoint) ----
/**
* Resolves a slug-based episode ID and fetches its streaming sources.
* This is the recommended endpoint — just pass the slug from /episodes.
*
* @param {string} provider - Provider name (e.g., "kiwi", "bee")
* @param {number} anilistId - The AniList anime ID
* @param {string} category - Audio category ("sub" or "dub")
* @param {string} slug - Episode slug (e.g., "animepahe-1")
* @returns {Promise<object>} Streaming sources with M3U8 URLs, subtitles, timestamps
* @throws {Error} If provider or episode slug is not found
*
* @example
* const sources = await getWatchSources("kiwi", 20, "sub", "animepahe-1");
* console.log(sources.streams[0].url); // "https://.../master.m3u8"
*/
const getWatchSources = async (provider, anilistId, category, slug) => {
const data = await fetchRawEpisodes(anilistId);
const provData = (data.providers || {})[provider];
if (!provData) throw new Error(`Provider ${provider} not found`);
const episodes = provData.episodes?.[category] || [];
let targetId = null;
// NOTE: Resolve slug back to the original pipe episode ID
for (const ep of episodes) {
const origId = ep.id || "";
const prefix = origId.includes(":") ? origId.split(":")[0] : origId;
const generated = `${prefix}-${ep.number}`;
if (generated === slug) {
targetId = origId;
break;
}
}
if (!targetId) throw new Error(`Episode slug '${slug}' not found for provider ${provider}`);
return getSources(targetId, provider, anilistId, category);
}
};
module.exports = {
getEpisodes,
@@ -142,3 +330,5 @@ module.exports = {
deepTranslate,
injectSourceSlugs,
};
// ══════════════════════════════════════════════════════════════ END: pipe.js
+418 -191
View File
@@ -1,222 +1,449 @@
/*
* ======= • ======= • ======= • ======= • =======• =======
* MiruroAPI — apiRoutes.js
* Repository: https://github.com/Shineii86/MiruroAPI
*
* @description
* Central API router that maps all Express GET endpoints to their
* corresponding handler functions. Each route wraps its handler
* in try/catch for consistent error handling. Includes health,
* stats, and OpenAPI specification endpoints.
*
* @exports
* createApiRoutes - Function that registers all API routes
*
* @author Shinei Nouzen
* @license MIT
* ======= • ======= • ======= • ======= • =======• =======
*/
const express = require("express");
const router = express.Router();
const anilist = require("../helpers/anilist");
const pipe = require("../helpers/pipe");
const { getCached, setCache } = require("../helpers/cache");
function handleErrors(res, err) {
console.error(err.message);
res.status(500).json({ success: false, message: err.message });
}
// ══════════════════════════════════════════════════════════════
// ROUTE REGISTRATION
// ══════════════════════════════════════════════════════════════
// Search
router.get("/search", async (req, res) => {
try {
const { query, page = 1, per_page = 20 } = req.query;
if (!query) return res.status(400).json({ success: false, message: "query parameter is required" });
const cacheKey = `search:${query}:${page}:${per_page}`;
let result = getCached(cacheKey);
if (!result) {
result = await anilist.searchAnime(query, parseInt(page), parseInt(per_page));
setCache(cacheKey, result);
// ---- FEATURE: Main API route registration function ----
/**
* Registers all API routes on the Express app.
* Receives response helpers for consistent JSON formatting.
*
* @param {object} app - Express application instance
* @param {function} jsonResponse - Standardized success response wrapper
* @param {function} jsonError - Standardized error response wrapper
* @returns {express.Router} Configured router
*/
const createApiRoutes = (app, jsonResponse, jsonError) => {
const router = express.Router();
// ══════════════════════════════════════════════════════════════
// REQUEST COUNTER (must be before all routes)
// ══════════════════════════════════════════════════════════════
const startTime = Date.now();
let requestCount = 0;
let errorCount = 0;
router.use((req, res, next) => {
requestCount++;
res.on("finish", () => {
if (res.statusCode >= 400) errorCount++;
});
next();
});
// ══════════════════════════════════════════════════════════════
// SEARCH & DISCOVERY
// ══════════════════════════════════════════════════════════════
// ---- FEATURE: Full-text anime search with pagination ----
router.get("/search", async (req, res) => {
try {
const { query, page = 1, per_page = 20 } = req.query;
if (!query) return jsonError(res, "query parameter is required", 400);
const cacheKey = `search:${query}:${page}:${per_page}`;
let result = getCached(cacheKey);
if (!result) {
result = await anilist.searchAnime(query, parseInt(page), parseInt(per_page));
setCache(cacheKey, result);
}
jsonResponse(res, result);
} catch (err) {
jsonError(res, err.message);
}
res.json({ success: true, results: result });
} catch (err) { handleErrors(res, err); }
});
});
// Suggestions
router.get("/suggestions", async (req, res) => {
try {
const { query } = req.query;
if (!query) return res.status(400).json({ success: false, message: "query parameter is required" });
const cacheKey = `suggestions:${query}`;
let result = getCached(cacheKey);
if (!result) {
result = await anilist.getSuggestions(query);
setCache(cacheKey, result, 120 * 1000);
// ---- FEATURE: Lightweight autocomplete suggestions (max 8) ----
router.get("/suggestions", async (req, res) => {
try {
const { query } = req.query;
if (!query) return jsonError(res, "query parameter is required", 400);
const cacheKey = `suggestions:${query}`;
let result = getCached(cacheKey);
if (!result) {
result = await anilist.getSuggestions(query);
// NOTE: Cache suggestions longer (2 minutes) — they change less frequently
setCache(cacheKey, result, 120 * 1000);
}
jsonResponse(res, result);
} catch (err) {
jsonError(res, err.message);
}
res.json({ success: true, results: result });
} catch (err) { handleErrors(res, err); }
});
});
// Trending
router.get("/trending", async (req, res) => {
try {
const { page = 1, per_page = 20 } = req.query;
const cacheKey = `trending:${page}:${per_page}`;
let result = getCached(cacheKey);
if (!result) {
result = await anilist.getCollection("TRENDING_DESC", null, parseInt(page), parseInt(per_page));
setCache(cacheKey, result, 120 * 1000);
// ---- FEATURE: Advanced anime filter with multiple parameters ----
router.get("/filter", async (req, res) => {
try {
const { genre, tag, year, season, format, status, sort = "POPULARITY_DESC", page = 1, per_page = 20 } = req.query;
const cacheKey = `filter:${genre}:${tag}:${year}:${season}:${format}:${status}:${sort}:${page}:${per_page}`;
let result = getCached(cacheKey);
if (!result) {
result = await anilist.filterAnime({
genre, tag, year: year ? parseInt(year) : null,
season, format, status, sort,
page: parseInt(page), perPage: parseInt(per_page),
});
setCache(cacheKey, result);
}
jsonResponse(res, result);
} catch (err) {
jsonError(res, err.message);
}
res.json({ success: true, results: result });
} catch (err) { handleErrors(res, err); }
});
});
// Popular
router.get("/popular", async (req, res) => {
try {
const { page = 1, per_page = 20 } = req.query;
const cacheKey = `popular:${page}:${per_page}`;
let result = getCached(cacheKey);
if (!result) {
result = await anilist.getCollection("POPULARITY_DESC", null, parseInt(page), parseInt(per_page));
setCache(cacheKey, result, 120 * 1000);
// ══════════════════════════════════════════════════════════════
// COLLECTIONS
// ══════════════════════════════════════════════════════════════
// ---- FEATURE: Currently trending anime ----
router.get("/trending", async (req, res) => {
try {
const { page = 1, per_page = 20 } = req.query;
const cacheKey = `trending:${page}:${per_page}`;
let result = getCached(cacheKey);
if (!result) {
result = await anilist.getCollection("TRENDING_DESC", null, parseInt(page), parseInt(per_page));
// NOTE: Cache trending for 2 minutes — changes less frequently than search
setCache(cacheKey, result, 120 * 1000);
}
jsonResponse(res, result);
} catch (err) {
jsonError(res, err.message);
}
res.json({ success: true, results: result });
} catch (err) { handleErrors(res, err); }
});
});
// Upcoming
router.get("/upcoming", async (req, res) => {
try {
const { page = 1, per_page = 20 } = req.query;
const cacheKey = `upcoming:${page}:${per_page}`;
let result = getCached(cacheKey);
if (!result) {
result = await anilist.getCollection("POPULARITY_DESC", "NOT_YET_RELEASED", parseInt(page), parseInt(per_page));
setCache(cacheKey, result, 120 * 1000);
// ---- FEATURE: Most popular anime of all time ----
router.get("/popular", async (req, res) => {
try {
const { page = 1, per_page = 20 } = req.query;
const cacheKey = `popular:${page}:${per_page}`;
let result = getCached(cacheKey);
if (!result) {
result = await anilist.getCollection("POPULARITY_DESC", null, parseInt(page), parseInt(per_page));
setCache(cacheKey, result, 120 * 1000);
}
jsonResponse(res, result);
} catch (err) {
jsonError(res, err.message);
}
res.json({ success: true, results: result });
} catch (err) { handleErrors(res, err); }
});
});
// Recent
router.get("/recent", async (req, res) => {
try {
const { page = 1, per_page = 20 } = req.query;
const cacheKey = `recent:${page}:${per_page}`;
let result = getCached(cacheKey);
if (!result) {
result = await anilist.getCollection("START_DATE_DESC", "RELEASING", parseInt(page), parseInt(per_page));
setCache(cacheKey, result, 120 * 1000);
// ---- FEATURE: Most anticipated upcoming anime ----
router.get("/upcoming", async (req, res) => {
try {
const { page = 1, per_page = 20 } = req.query;
const cacheKey = `upcoming:${page}:${per_page}`;
let result = getCached(cacheKey);
if (!result) {
result = await anilist.getCollection("POPULARITY_DESC", "NOT_YET_RELEASED", parseInt(page), parseInt(per_page));
setCache(cacheKey, result, 120 * 1000);
}
jsonResponse(res, result);
} catch (err) {
jsonError(res, err.message);
}
res.json({ success: true, results: result });
} catch (err) { handleErrors(res, err); }
});
});
// Spotlight
router.get("/spotlight", async (req, res) => {
try {
const cacheKey = "spotlight";
let result = getCached(cacheKey);
if (!result) {
result = await anilist.getSpotlight();
setCache(cacheKey, result, 120 * 1000);
// ---- FEATURE: Currently airing anime ----
router.get("/recent", async (req, res) => {
try {
const { page = 1, per_page = 20 } = req.query;
const cacheKey = `recent:${page}:${per_page}`;
let result = getCached(cacheKey);
if (!result) {
result = await anilist.getCollection("START_DATE_DESC", "RELEASING", parseInt(page), parseInt(per_page));
setCache(cacheKey, result, 120 * 1000);
}
jsonResponse(res, result);
} catch (err) {
jsonError(res, err.message);
}
res.json({ success: true, results: result });
} catch (err) { handleErrors(res, err); }
});
});
// Schedule
router.get("/schedule", async (req, res) => {
try {
const { page = 1, per_page = 20 } = req.query;
const cacheKey = `schedule:${page}:${per_page}`;
let result = getCached(cacheKey);
if (!result) {
result = await anilist.getSchedule(parseInt(page), parseInt(per_page));
setCache(cacheKey, result, 120 * 1000);
// ---- FEATURE: Spotlight/featured anime (top 10) ----
router.get("/spotlight", async (req, res) => {
try {
const cacheKey = "spotlight";
let result = getCached(cacheKey);
if (!result) {
result = await anilist.getSpotlight();
setCache(cacheKey, result, 120 * 1000);
}
jsonResponse(res, result);
} catch (err) {
jsonError(res, err.message);
}
res.json({ success: true, results: result });
} catch (err) { handleErrors(res, err); }
});
});
// Filter
router.get("/filter", async (req, res) => {
try {
const { genre, tag, year, season, format, status, sort = "POPULARITY_DESC", page = 1, per_page = 20 } = req.query;
const cacheKey = `filter:${genre}:${tag}:${year}:${season}:${format}:${status}:${sort}:${page}:${per_page}`;
let result = getCached(cacheKey);
if (!result) {
result = await anilist.filterAnime({
genre, tag, year: year ? parseInt(year) : null, season, format, status, sort,
page: parseInt(page), perPage: parseInt(per_page),
});
setCache(cacheKey, result);
// ---- FEATURE: Airing schedule with timestamps ----
router.get("/schedule", async (req, res) => {
try {
const { page = 1, per_page = 20 } = req.query;
const cacheKey = `schedule:${page}:${per_page}`;
let result = getCached(cacheKey);
if (!result) {
result = await anilist.getSchedule(parseInt(page), parseInt(per_page));
setCache(cacheKey, result, 120 * 1000);
}
jsonResponse(res, result);
} catch (err) {
jsonError(res, err.message);
}
res.json({ success: true, results: result });
} catch (err) { handleErrors(res, err); }
});
});
// Info
router.get("/info/:id", async (req, res) => {
try {
const id = parseInt(req.params.id);
if (isNaN(id)) return res.status(400).json({ success: false, message: "Invalid AniList ID" });
const cacheKey = `info:${id}`;
let result = getCached(cacheKey);
if (!result) {
result = await anilist.getAnimeInfo(id);
setCache(cacheKey, result, 300 * 1000);
// ══════════════════════════════════════════════════════════════
// ANIME DETAILS
// ══════════════════════════════════════════════════════════════
// ---- FEATURE: Complete anime info by AniList ID ----
router.get("/info/:id", async (req, res) => {
try {
const id = parseInt(req.params.id);
if (isNaN(id)) return jsonError(res, "Invalid AniList ID", 400);
const cacheKey = `info:${id}`;
let result = getCached(cacheKey);
if (!result) {
result = await anilist.getAnimeInfo(id);
// NOTE: Cache info for 5 minutes — detailed data changes infrequently
setCache(cacheKey, result, 300 * 1000);
}
jsonResponse(res, result);
} catch (err) {
jsonError(res, err.message);
}
res.json({ success: true, results: result });
} catch (err) { handleErrors(res, err); }
});
});
// Characters
router.get("/anime/:id/characters", async (req, res) => {
try {
const id = parseInt(req.params.id);
const { page = 1, per_page = 25 } = req.query;
const result = await anilist.getAnimeCharacters(id, parseInt(page), parseInt(per_page));
res.json({ success: true, results: result });
} catch (err) { handleErrors(res, err); }
});
// Relations
router.get("/anime/:id/relations", async (req, res) => {
try {
const id = parseInt(req.params.id);
const result = await anilist.getAnimeRelations(id);
res.json({ success: true, results: result });
} catch (err) { handleErrors(res, err); }
});
// Recommendations
router.get("/anime/:id/recommendations", async (req, res) => {
try {
const id = parseInt(req.params.id);
const { page = 1, per_page = 10 } = req.query;
const result = await anilist.getAnimeRecommendations(id, parseInt(page), parseInt(per_page));
res.json({ success: true, results: result });
} catch (err) { handleErrors(res, err); }
});
// Episodes
router.get("/episodes/:id", async (req, res) => {
try {
const id = parseInt(req.params.id);
if (isNaN(id)) return res.status(400).json({ success: false, message: "Invalid AniList ID" });
const cacheKey = `episodes:${id}`;
let result = getCached(cacheKey);
if (!result) {
result = await pipe.getEpisodes(id);
setCache(cacheKey, result, 120 * 1000);
// ---- FEATURE: Paginated character list with voice actors ----
router.get("/anime/:id/characters", async (req, res) => {
try {
const id = parseInt(req.params.id);
const { page = 1, per_page = 25 } = req.query;
const result = await anilist.getAnimeCharacters(id, parseInt(page), parseInt(per_page));
jsonResponse(res, result);
} catch (err) {
jsonError(res, err.message);
}
res.json({ success: true, results: result });
} catch (err) { handleErrors(res, err); }
});
});
// Sources (manual)
router.get("/sources", async (req, res) => {
try {
const { episodeId, provider, anilistId, category = "sub" } = req.query;
if (!episodeId || !provider || !anilistId) {
return res.status(400).json({ success: false, message: "episodeId, provider, and anilistId are required" });
// ---- FEATURE: Related anime (sequels, prequels, spin-offs) ----
router.get("/anime/:id/relations", async (req, res) => {
try {
const id = parseInt(req.params.id);
const result = await anilist.getAnimeRelations(id);
jsonResponse(res, result);
} catch (err) {
jsonError(res, err.message);
}
const result = await pipe.getSources(episodeId, provider, parseInt(anilistId), category);
res.json({ success: true, results: result });
} catch (err) { handleErrors(res, err); }
});
});
// Watch (simple slug-based sources)
router.get("/watch/:provider/:anilistId/:category/:slug", async (req, res) => {
try {
const { provider, anilistId, category, slug } = req.params;
const result = await pipe.getWatchSources(provider, parseInt(anilistId), category, slug);
res.json({ success: true, results: result });
} catch (err) { handleErrors(res, err); }
});
// ---- FEATURE: Community recommendations sorted by rating ----
router.get("/anime/:id/recommendations", async (req, res) => {
try {
const id = parseInt(req.params.id);
const { page = 1, per_page = 10 } = req.query;
const result = await anilist.getAnimeRecommendations(id, parseInt(page), parseInt(per_page));
jsonResponse(res, result);
} catch (err) {
jsonError(res, err.message);
}
});
module.exports = router;
// ══════════════════════════════════════════════════════════════
// STREAMING
// ══════════════════════════════════════════════════════════════
// ---- FEATURE: Episode list from all providers ----
router.get("/episodes/:id", async (req, res) => {
try {
const id = parseInt(req.params.id);
if (isNaN(id)) return jsonError(res, "Invalid AniList ID", 400);
const cacheKey = `episodes:${id}`;
let result = getCached(cacheKey);
if (!result) {
result = await pipe.getEpisodes(id);
// NOTE: Cache episodes for 2 minutes — episode lists change during airing
setCache(cacheKey, result, 120 * 1000);
}
jsonResponse(res, result);
} catch (err) {
jsonError(res, err.message);
}
});
// ---- FEATURE: Streaming sources (detailed/manual endpoint) ----
router.get("/sources", async (req, res) => {
try {
const { episodeId, provider, anilistId, category = "sub" } = req.query;
if (!episodeId || !provider || !anilistId) {
return jsonError(res, "episodeId, provider, and anilistId are required", 400);
}
const result = await pipe.getSources(episodeId, provider, parseInt(anilistId), category);
jsonResponse(res, result);
} catch (err) {
jsonError(res, err.message);
}
});
// ---- FEATURE: Streaming sources (simple slug-based endpoint) ----
router.get("/watch/:provider/:anilistId/:category/:slug", async (req, res) => {
try {
const { provider, anilistId, category, slug } = req.params;
const result = await pipe.getWatchSources(provider, parseInt(anilistId), category, slug);
jsonResponse(res, result);
} catch (err) {
jsonError(res, err.message);
}
});
// ══════════════════════════════════════════════════════════════
// HEALTH & STATS
// ══════════════════════════════════════════════════════════════
// ---- FEATURE: Health check endpoint ----
router.get("/health", (req, res) => {
const uptime = Math.floor((Date.now() - startTime) / 1000);
const hours = Math.floor(uptime / 3600);
const minutes = Math.floor((uptime % 3600) / 60);
const seconds = uptime % 60;
res.json({
success: true,
results: {
status: "healthy",
version: "1.1.0",
uptime: `${hours}h ${minutes}m ${seconds}s`,
uptimeSeconds: uptime,
timestamp: new Date().toISOString(),
node: process.version,
memory: {
used: Math.round(process.memoryUsage().heapUsed / 1024 / 1024) + "MB",
total: Math.round(process.memoryUsage().heapTotal / 1024 / 1024) + "MB",
},
endpoints: 16,
providers: ["kiwi", "pewe", "bee", "bonk", "bun", "ally", "nun", "twin", "cog", "moo", "hop", "telli"],
},
});
});
// ---- FEATURE: Cache & API statistics ----
router.get("/stats", (req, res) => {
const uptime = Math.floor((Date.now() - startTime) / 1000);
res.json({
success: true,
results: {
uptime: uptime + "s",
requests: {
total: requestCount,
errors: errorCount,
successRate: requestCount > 0
? ((1 - errorCount / requestCount) * 100).toFixed(1) + "%"
: "100%",
},
cache: {
type: "in-memory",
ttl: "1 minute (default), 2-5 minutes for collections",
maxSize: 100,
description: "Map-based cache with TTL expiration and FIFO eviction",
},
endpoints: 16,
timestamp: new Date().toISOString(),
},
});
});
// ---- FEATURE: OpenAPI specification ----
router.get("/openapi", (req, res) => {
res.json({
openapi: "3.0.3",
info: {
title: "MiruroAPI",
description: "Free REST API for anime streaming data — AniList GraphQL + Miruro streaming providers",
version: "1.1.0",
contact: { name: "Shineii86", url: "https://github.com/Shineii86/MiruroAPI" },
},
servers: [
{ url: "https://mirurotvapi.vercel.app/api", description: "Production" },
],
paths: {
"/search": { get: { summary: "Search anime", tags: ["Search"] } },
"/suggestions": { get: { summary: "Autocomplete suggestions", tags: ["Search"] } },
"/filter": { get: { summary: "Advanced filter", tags: ["Search"] } },
"/trending": { get: { summary: "Trending anime", tags: ["Collections"] } },
"/popular": { get: { summary: "Most popular", tags: ["Collections"] } },
"/upcoming": { get: { summary: "Upcoming anime", tags: ["Collections"] } },
"/recent": { get: { summary: "Recently aired", tags: ["Collections"] } },
"/spotlight": { get: { summary: "Spotlight anime", tags: ["Collections"] } },
"/schedule": { get: { summary: "Airing schedule", tags: ["Collections"] } },
"/info/{id}": { get: { summary: "Full anime info", tags: ["Anime Details"] } },
"/anime/{id}/characters": { get: { summary: "Characters", tags: ["Anime Details"] } },
"/anime/{id}/relations": { get: { summary: "Relations", tags: ["Anime Details"] } },
"/anime/{id}/recommendations": { get: { summary: "Recommendations", tags: ["Anime Details"] } },
"/episodes/{id}": { get: { summary: "Episode list", tags: ["Streaming"] } },
"/sources": { get: { summary: "Streaming sources (detailed)", tags: ["Streaming"] } },
"/watch/{provider}/{anilistId}/{category}/{slug}": { get: { summary: "Streaming sources (simple)", tags: ["Streaming"] } },
"/health": { get: { summary: "Health check", tags: ["System"] } },
"/stats": { get: { summary: "API statistics", tags: ["System"] } },
"/openapi": { get: { summary: "OpenAPI spec", tags: ["System"] } },
},
tags: [
{ name: "Search", description: "Search & filter" },
{ name: "Collections", description: "Trending, popular, upcoming, schedule" },
{ name: "Anime Details", description: "Info, characters, relations, recommendations" },
{ name: "Streaming", description: "Episodes, sources, watch" },
{ name: "System", description: "Health, stats, OpenAPI" },
],
});
});
return router;
};
module.exports = createApiRoutes;
// ══════════════════════════════════════════════════════════════ END: apiRoutes.js