mirror of
https://github.com/Shineii86/MiruroAPI.git
synced 2026-10-22 18:25:11 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
20041a8984 | ||
|
|
14d5c22fc7 | ||
|
|
240e90fe51 | ||
|
|
18a73c324e | ||
|
|
4b083a9bf7 | ||
|
|
b0aa5d2281 | ||
|
|
c13e0bbd7e |
@@ -0,0 +1,5 @@
|
|||||||
|
node_modules
|
||||||
|
npm-debug.log
|
||||||
|
.env
|
||||||
|
.vercel
|
||||||
|
.git
|
||||||
@@ -1,5 +1,22 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## v1.2.0
|
||||||
|
- Fixed critical response format bug: all endpoints now return {success: true, results: data}
|
||||||
|
- 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}
|
||||||
|
- 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
|
||||||
|
|
||||||
|
## 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
|
## v1.0.0
|
||||||
- Initial release
|
- Initial release
|
||||||
- AniList GraphQL integration for anime metadata
|
- AniList GraphQL integration for anime metadata
|
||||||
|
|||||||
+12
@@ -0,0 +1,12 @@
|
|||||||
|
FROM node:20-alpine
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY package*.json ./
|
||||||
|
RUN npm install --production
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
EXPOSE 3000
|
||||||
|
|
||||||
|
CMD ["node", "server.js"]
|
||||||
@@ -4,7 +4,9 @@ Free RESTful API for anime streaming data. Powered by AniList GraphQL and Miruro
|
|||||||
|
|
||||||
## Live
|
## 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`
|
- **GitHub**: `https://github.com/Shineii86/MiruroAPI`
|
||||||
|
|
||||||
## Features
|
## 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)
|
- AniList GraphQL for metadata (search, trending, info, filter, schedule)
|
||||||
- 12 streaming providers (kiwi, pewe, bee, bonk, bun, ally, nun, twin, cog, moo, hop, telli)
|
- 12 streaming providers (kiwi, pewe, bee, bonk, bun, ally, nun, twin, cog, moo, hop, telli)
|
||||||
- M3U8 streaming URLs with subtitles and skip timestamps
|
- M3U8 streaming URLs with subtitles and skip timestamps
|
||||||
- In-memory caching
|
- In-memory caching with TTL
|
||||||
- CORS enabled
|
- CORS enabled
|
||||||
|
- Swagger UI interactive docs
|
||||||
|
- Docker support
|
||||||
|
- Health endpoint
|
||||||
|
|
||||||
## Endpoints
|
## Endpoints
|
||||||
|
|
||||||
|
### Search & Discovery
|
||||||
|
|
||||||
| Endpoint | Description |
|
| Endpoint | Description |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `GET /api/search?query=naruto` | Search anime by name |
|
| `GET /api/search?query=naruto` | Search anime by name |
|
||||||
| `GET /api/suggestions?query=naruto` | Autocomplete suggestions |
|
| `GET /api/suggestions?query=naruto` | Autocomplete suggestions |
|
||||||
|
| `GET /api/filter?genre=Action` | Advanced filter |
|
||||||
|
|
||||||
|
### Collections
|
||||||
|
|
||||||
|
| Endpoint | Description |
|
||||||
|
|---|---|
|
||||||
| `GET /api/trending` | Trending anime |
|
| `GET /api/trending` | Trending anime |
|
||||||
| `GET /api/popular` | Most popular anime |
|
| `GET /api/popular` | Most popular anime |
|
||||||
| `GET /api/upcoming` | Upcoming anime |
|
| `GET /api/upcoming` | Upcoming anime |
|
||||||
| `GET /api/recent` | Currently airing anime |
|
| `GET /api/recent` | Currently airing anime |
|
||||||
| `GET /api/spotlight` | Spotlight/featured anime |
|
| `GET /api/spotlight` | Spotlight/featured anime |
|
||||||
| `GET /api/schedule` | Airing schedule |
|
| `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/info/:id` | Full anime info by AniList ID |
|
||||||
| `GET /api/anime/:id/characters` | Characters with voice actors |
|
| `GET /api/anime/:id/characters` | Characters with voice actors |
|
||||||
| `GET /api/anime/:id/relations` | Related anime |
|
| `GET /api/anime/:id/relations` | Related anime |
|
||||||
| `GET /api/anime/:id/recommendations` | Recommendations |
|
| `GET /api/anime/:id/recommendations` | Recommendations |
|
||||||
|
|
||||||
|
### Streaming
|
||||||
|
|
||||||
|
| Endpoint | Description |
|
||||||
|
|---|---|
|
||||||
| `GET /api/episodes/:id` | Episode list from all providers |
|
| `GET /api/episodes/:id` | Episode list from all providers |
|
||||||
| `GET /api/sources?episodeId=...&provider=...&anilistId=...` | Streaming sources |
|
| `GET /api/sources?episodeId=...&provider=...&anilistId=...` | Streaming sources (detailed) |
|
||||||
| `GET /api/watch/:provider/:anilistId/:category/:slug` | Simple streaming sources |
|
| `GET /api/watch/:provider/:anilistId/:category/:slug` | Streaming sources (simple) |
|
||||||
|
|
||||||
## Streaming Flow
|
## Streaming Flow
|
||||||
|
|
||||||
@@ -50,6 +72,13 @@ npm install
|
|||||||
npm start
|
npm start
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Docker
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker build -t miruroapi .
|
||||||
|
docker run -p 3000:3000 miruroapi
|
||||||
|
```
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
MIT
|
MIT
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "miruro-api",
|
"name": "miruro-api",
|
||||||
"version": "1.0.0",
|
"version": "1.2.0",
|
||||||
"description": "RESTful API for miruro.com - Anime streaming data powered by AniList GraphQL and Miruro streaming providers",
|
"description": "RESTful API for miruro.com - Anime streaming data powered by AniList GraphQL and Miruro streaming providers",
|
||||||
"main": "server.js",
|
"main": "server.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -0,0 +1,131 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en" prefix="og: https://ogp.me/ns#">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
|
||||||
|
<!-- ──────────────── OG Meta ──────────────── -->
|
||||||
|
<meta property="og:title" content="MiruroAPI — Swagger UI Documentation">
|
||||||
|
<meta property="og:type" content="website">
|
||||||
|
<meta property="og:url" content="https://mirurotvapi.vercel.app/docs">
|
||||||
|
<meta property="og:image" content="https://mirurotvapi.vercel.app/share.svg">
|
||||||
|
<meta property="og:description" content="Interactive Swagger UI documentation for MiruroAPI. Test all 16 endpoints directly from the browser.">
|
||||||
|
<meta property="og:site_name" content="MiruroAPI">
|
||||||
|
<meta property="og:locale" content="en_US">
|
||||||
|
<meta name="twitter:card" content="summary_large_image">
|
||||||
|
<meta name="twitter:title" content="MiruroAPI — Swagger UI Documentation">
|
||||||
|
<meta name="twitter:description" content="Interactive Swagger UI documentation for MiruroAPI.">
|
||||||
|
<meta name="twitter:image" content="https://mirurotvapi.vercel.app/share.svg">
|
||||||
|
<meta name="description" content="Interactive Swagger UI documentation for MiruroAPI. Test all 16 endpoints directly from the browser.">
|
||||||
|
<meta name="robots" content="index, follow">
|
||||||
|
<meta name="theme-color" content="#0b1622">
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
|
||||||
|
<link rel="canonical" href="https://mirurotvapi.vercel.app/docs">
|
||||||
|
|
||||||
|
<title>MiruroAPI — Swagger UI</title>
|
||||||
|
<link rel="stylesheet" href="https://unpkg.com/swagger-ui-dist@5/swagger-ui.css">
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--bg: #0b1622;
|
||||||
|
--bg2: #0f172a;
|
||||||
|
--accent: #38bdf8;
|
||||||
|
--accent2: #818cf8;
|
||||||
|
--text: #e2e8f0;
|
||||||
|
--muted: #94a3b8;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ──────────────── BASE ──────────────── */
|
||||||
|
html { box-sizing: border-box; }
|
||||||
|
*, *::before, *::after { box-sizing: inherit; }
|
||||||
|
body {
|
||||||
|
margin: 0; padding: 0;
|
||||||
|
background: var(--bg);
|
||||||
|
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ──────────────── CUSTOM HEADER ──────────────── */
|
||||||
|
.docs-header {
|
||||||
|
background: linear-gradient(135deg, var(--bg2), #1e1b4b);
|
||||||
|
border-bottom: 1px solid rgba(255,255,255,0.05);
|
||||||
|
padding: 20px 40px;
|
||||||
|
display: flex; align-items: center; justify-content: space-between;
|
||||||
|
}
|
||||||
|
.docs-brand { display: flex; align-items: center; gap: 12px; text-decoration: none; }
|
||||||
|
.docs-logo {
|
||||||
|
width: 32px; height: 32px; border-radius: 8px;
|
||||||
|
background: linear-gradient(135deg, var(--accent), var(--accent2));
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
color: white; font-weight: 900; font-size: 16px;
|
||||||
|
}
|
||||||
|
.docs-title { color: var(--text); font-weight: 700; font-size: 1em; }
|
||||||
|
.docs-subtitle { color: var(--muted); font-size: 0.8em; margin-left: 8px; }
|
||||||
|
.docs-links { display: flex; gap: 16px; }
|
||||||
|
.docs-links a {
|
||||||
|
color: var(--muted); text-decoration: none; font-size: 0.85em;
|
||||||
|
font-weight: 500; transition: color 0.2s;
|
||||||
|
}
|
||||||
|
.docs-links a:hover { color: var(--accent); }
|
||||||
|
|
||||||
|
/* ──────────────── SWAGGER UI OVERRIDES ──────────────── */
|
||||||
|
.swagger-ui .topbar { display: none; }
|
||||||
|
.swagger-ui .info .title { color: var(--text) !important; }
|
||||||
|
.swagger-ui .info .description p { color: var(--muted) !important; }
|
||||||
|
.swagger-ui .scheme-container { background: rgba(15, 23, 42, 0.5) !important; box-shadow: none !important; }
|
||||||
|
.swagger-ui .opblock-tag { color: var(--accent) !important; border-color: rgba(255,255,255,0.05) !important; }
|
||||||
|
.swagger-ui .opblock .opblock-summary-description { color: var(--muted) !important; }
|
||||||
|
.swagger-ui .btn { box-shadow: none !important; }
|
||||||
|
.swagger-ui .btn.execute { background: linear-gradient(135deg, var(--accent), var(--accent2)) !important; }
|
||||||
|
.swagger-ui .model-box { background: rgba(15, 23, 42, 0.3) !important; }
|
||||||
|
.swagger-ui .scheme-container .schemes > select { color: var(--text); background: var(--bg2); }
|
||||||
|
|
||||||
|
/* ──────────────── BACK LINK ──────────────── */
|
||||||
|
.back-link {
|
||||||
|
display: inline-flex; align-items: center; gap: 6px;
|
||||||
|
color: var(--accent); text-decoration: none;
|
||||||
|
font-size: 0.85em; font-weight: 500;
|
||||||
|
margin-left: 12px;
|
||||||
|
}
|
||||||
|
.back-link:hover { text-decoration: underline; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<!-- ──────────────── CUSTOM HEADER ──────────────── -->
|
||||||
|
<div class="docs-header">
|
||||||
|
<div style="display: flex; align-items: center;">
|
||||||
|
<a href="/" class="docs-brand">
|
||||||
|
<div class="docs-logo">M</div>
|
||||||
|
<span class="docs-title">MiruroAPI</span>
|
||||||
|
</a>
|
||||||
|
<span class="docs-subtitle">Swagger UI Documentation</span>
|
||||||
|
</div>
|
||||||
|
<div class="docs-links">
|
||||||
|
<a href="/">Home</a>
|
||||||
|
<a href="/openapi.json">OpenAPI Spec</a>
|
||||||
|
<a href="/api/health">Health</a>
|
||||||
|
<a href="https://github.com/Shineii86/MiruroAPI" target="_blank">GitHub</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ──────────────── SWAGGER UI ──────────────── -->
|
||||||
|
<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",
|
||||||
|
filter: true,
|
||||||
|
tryItOutEnabled: true,
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||||
|
<stop offset="0%" style="stop-color:#38bdf8"/>
|
||||||
|
<stop offset="50%" style="stop-color:#818cf8"/>
|
||||||
|
<stop offset="100%" style="stop-color:#a78bfa"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="shine" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||||
|
<stop offset="0%" style="stop-color:rgba(255,255,255,0.3)"/>
|
||||||
|
<stop offset="100%" style="stop-color:rgba(255,255,255,0)"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<rect width="512" height="512" rx="96" fill="url(#bg)"/>
|
||||||
|
<rect width="512" height="256" rx="96" fill="url(#shine)"/>
|
||||||
|
<text x="256" y="310" text-anchor="middle" fill="white" font-family="Arial Black,Helvetica Neue,sans-serif" font-size="260" font-weight="900" letter-spacing="-10">M</text>
|
||||||
|
<circle cx="390" cy="140" r="24" fill="rgba(255,255,255,0.9)"/>
|
||||||
|
<circle cx="390" cy="140" r="12" fill="url(#bg)"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 976 B |
+535
-33
@@ -1,46 +1,548 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="en" prefix="og: https://ogp.me/ns#">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<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">
|
<!-- ═══════════════════════════════════════════════════════════ -->
|
||||||
|
<!-- FULL OG META STACK — Every useful tag for social sharing -->
|
||||||
|
<!-- ═══════════════════════════════════════════════════════════ -->
|
||||||
|
|
||||||
|
<!-- ──────────────── 1. Basic OG Tags ──────────────── -->
|
||||||
|
<meta property="og:title" content="MiruroAPI — Free Anime Streaming API">
|
||||||
|
<meta property="og:type" content="website">
|
||||||
|
<meta property="og:url" content="https://mirurotvapi.vercel.app">
|
||||||
|
<meta property="og:image" content="https://mirurotvapi.vercel.app/share.svg">
|
||||||
|
<meta property="og:description" content="Free RESTful API for anime streaming data. 16 endpoints, 12 providers, M3U8 streaming URLs. Powered by AniList GraphQL and Miruro.">
|
||||||
|
|
||||||
|
<!-- ──────────────── 2. Optional but Recommended OG Tags ──────── -->
|
||||||
|
<meta property="og:site_name" content="MiruroAPI">
|
||||||
|
<meta property="og:locale" content="en_US">
|
||||||
|
|
||||||
|
<!-- ──────────────── 3. Image Optimization ──────────────── -->
|
||||||
|
<meta property="og:image:secure_url" content="https://mirurotvapi.vercel.app/share.svg">
|
||||||
|
<meta property="og:image:type" content="image/svg+xml">
|
||||||
|
<meta property="og:image:width" content="1200">
|
||||||
|
<meta property="og:image:height" content="630">
|
||||||
|
<meta property="og:image:alt" content="MiruroAPI - Free Anime Streaming API">
|
||||||
|
|
||||||
|
<!-- ──────────────── 6. Twitter Card Tags ──────── -->
|
||||||
|
<meta name="twitter:card" content="summary_large_image">
|
||||||
|
<meta name="twitter:title" content="MiruroAPI — Free Anime Streaming API">
|
||||||
|
<meta name="twitter:description" content="Free RESTful API for anime streaming data. 16 endpoints, 12 providers, M3U8 streaming URLs.">
|
||||||
|
<meta name="twitter:image" content="https://mirurotvapi.vercel.app/share.svg">
|
||||||
|
<meta name="twitter:image:alt" content="MiruroAPI - Free Anime Streaming API">
|
||||||
|
<meta name="twitter:domain" content="mirurotvapi.vercel.app">
|
||||||
|
|
||||||
|
<!-- ──────────────── 9. Generic Meta ──────────────── -->
|
||||||
|
<meta name="description" content="Free RESTful API for anime streaming data. 16 endpoints, 12 providers, M3U8 streaming URLs. Powered by AniList GraphQL and Miruro.">
|
||||||
|
<meta name="robots" content="index, follow">
|
||||||
|
<meta name="author" content="Shinei Nouzen">
|
||||||
|
<meta name="theme-color" content="#0b1622">
|
||||||
|
<link rel="canonical" href="https://mirurotvapi.vercel.app">
|
||||||
|
|
||||||
|
<!-- ──────────────── 10. JSON-LD Structured Data ──────── -->
|
||||||
|
<script type="application/ld+json">
|
||||||
|
{
|
||||||
|
"@context": "https://schema.org",
|
||||||
|
"@type": "WebAPI",
|
||||||
|
"name": "MiruroAPI",
|
||||||
|
"description": "Free RESTful API for anime streaming data. 16 endpoints, 12 providers, M3U8 streaming URLs.",
|
||||||
|
"url": "https://mirurotvapi.vercel.app",
|
||||||
|
"documentation": "https://mirurotvapi.vercel.app/docs",
|
||||||
|
"provider": {
|
||||||
|
"@type": "Organization",
|
||||||
|
"name": "MiruroAPI",
|
||||||
|
"url": "https://github.com/Shineii86/MiruroAPI"
|
||||||
|
},
|
||||||
|
"potentialAction": {
|
||||||
|
"@type": "SearchAction",
|
||||||
|
"target": "https://mirurotvapi.vercel.app/api/search?query={search_term_string}",
|
||||||
|
"query-input": "required name=search_term_string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<!-- ──────────────── Favicon ──────────────── -->
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
|
||||||
|
<link rel="icon" type="image/png" sizes="512x512" href="https://miruro.tv/icon-512x512.png">
|
||||||
|
<link rel="icon" type="image/png" sizes="192x192" href="https://miruro.tv/icon-192x192.png">
|
||||||
|
<link rel="apple-touch-icon" href="https://miruro.tv/icon-512x512.png">
|
||||||
|
<link rel="mask-icon" href="/favicon.svg" color="#38bdf8">
|
||||||
|
|
||||||
|
<title>MiruroAPI — Free Anime Streaming API</title>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800;900&display=swap" rel="stylesheet">
|
||||||
<style>
|
<style>
|
||||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
*, *::before, *::after { 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; }
|
:root {
|
||||||
.container { max-width: 800px; padding: 40px; text-align: center; }
|
--bg: #0b1622;
|
||||||
h1 { font-size: 2.5em; background: linear-gradient(135deg, #26A3D6, #7B2FF7); -webkit-background-clip: text; -webkit-text-fill-color: transparent; margin-bottom: 16px; }
|
--bg2: #0f172a;
|
||||||
p { color: #a0b1c5; font-size: 1.1em; margin-bottom: 24px; line-height: 1.6; }
|
--bg3: #1e293b;
|
||||||
.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); }
|
--accent: #38bdf8;
|
||||||
.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); }
|
--accent2: #818cf8;
|
||||||
.endpoints h3 { color: #26A3D6; margin-bottom: 12px; }
|
--text: #e2e8f0;
|
||||||
.endpoint { padding: 8px 0; border-bottom: 1px solid rgba(255,255,255,0.03); font-family: monospace; font-size: 0.9em; }
|
--muted: #94a3b8;
|
||||||
.endpoint:last-child { border-bottom: none; }
|
--dim: #64748b;
|
||||||
.method { color: #10b981; font-weight: bold; }
|
}
|
||||||
a { color: #26A3D6; text-decoration: none; }
|
body {
|
||||||
a:hover { text-decoration: underline; }
|
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
|
||||||
|
background: radial-gradient(ellipse at top, var(--bg2), var(--bg));
|
||||||
|
color: var(--text);
|
||||||
|
min-height: 100vh;
|
||||||
|
overflow-x: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ──────────────── NAV ──────────────── */
|
||||||
|
.nav {
|
||||||
|
position: fixed; top: 0; left: 0; right: 0; z-index: 100;
|
||||||
|
background: rgba(11, 22, 34, 0.85);
|
||||||
|
backdrop-filter: blur(20px);
|
||||||
|
border-bottom: 1px solid rgba(255,255,255,0.05);
|
||||||
|
padding: 16px 40px;
|
||||||
|
display: flex; align-items: center; justify-content: space-between;
|
||||||
|
}
|
||||||
|
.nav-brand { display: flex; align-items: center; gap: 12px; text-decoration: none; }
|
||||||
|
.nav-logo {
|
||||||
|
width: 36px; height: 36px; border-radius: 10px;
|
||||||
|
background: linear-gradient(135deg, var(--accent), var(--accent2));
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
color: white; font-weight: 900; font-size: 18px;
|
||||||
|
}
|
||||||
|
.nav-title { color: var(--text); font-weight: 700; font-size: 1.1em; }
|
||||||
|
.nav-links { display: flex; gap: 24px; }
|
||||||
|
.nav-links a {
|
||||||
|
color: var(--muted); text-decoration: none; font-size: 0.9em;
|
||||||
|
font-weight: 500; transition: color 0.2s;
|
||||||
|
}
|
||||||
|
.nav-links a:hover { color: var(--accent); }
|
||||||
|
.nav-badge {
|
||||||
|
background: rgba(56, 189, 248, 0.12); color: var(--accent);
|
||||||
|
padding: 4px 12px; border-radius: 20px; font-size: 0.8em;
|
||||||
|
font-weight: 600; border: 1px solid rgba(56, 189, 248, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ──────────────── HERO ──────────────── */
|
||||||
|
.hero {
|
||||||
|
padding: 160px 40px 80px;
|
||||||
|
text-align: center;
|
||||||
|
max-width: 900px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
.hero-badge {
|
||||||
|
display: inline-flex; align-items: center; gap: 8px;
|
||||||
|
background: rgba(56, 189, 248, 0.08);
|
||||||
|
border: 1px solid rgba(56, 189, 248, 0.15);
|
||||||
|
padding: 8px 20px; border-radius: 30px;
|
||||||
|
font-size: 0.85em; color: var(--accent); font-weight: 500;
|
||||||
|
margin-bottom: 32px;
|
||||||
|
}
|
||||||
|
.hero-badge .dot {
|
||||||
|
width: 8px; height: 8px; border-radius: 50%;
|
||||||
|
background: #10b981; animation: pulse 2s infinite;
|
||||||
|
}
|
||||||
|
@keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.4; } }
|
||||||
|
.hero h1 {
|
||||||
|
font-size: clamp(2.5em, 6vw, 4.5em);
|
||||||
|
font-weight: 900;
|
||||||
|
background: linear-gradient(135deg, var(--accent), var(--accent2), #a78bfa);
|
||||||
|
-webkit-background-clip: text; -webkit-text-fill-color: transparent;
|
||||||
|
line-height: 1.1; margin-bottom: 20px;
|
||||||
|
letter-spacing: -0.03em;
|
||||||
|
}
|
||||||
|
.hero p {
|
||||||
|
font-size: 1.2em; color: var(--muted); max-width: 650px;
|
||||||
|
margin: 0 auto 40px; line-height: 1.7; font-weight: 400;
|
||||||
|
}
|
||||||
|
.hero-actions { display: flex; gap: 16px; justify-content: center; flex-wrap: wrap; }
|
||||||
|
.btn {
|
||||||
|
display: inline-flex; align-items: center; gap: 8px;
|
||||||
|
padding: 14px 32px; border-radius: 12px;
|
||||||
|
font-size: 0.95em; font-weight: 600; text-decoration: none;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
.btn-primary {
|
||||||
|
background: linear-gradient(135deg, var(--accent), var(--accent2));
|
||||||
|
color: white; box-shadow: 0 4px 20px rgba(56, 189, 248, 0.3);
|
||||||
|
}
|
||||||
|
.btn-primary:hover { transform: translateY(-2px); box-shadow: 0 8px 30px rgba(56, 189, 248, 0.4); }
|
||||||
|
.btn-secondary {
|
||||||
|
background: rgba(255,255,255,0.05); color: var(--text);
|
||||||
|
border: 1px solid rgba(255,255,255,0.1);
|
||||||
|
}
|
||||||
|
.btn-secondary:hover { background: rgba(255,255,255,0.08); border-color: rgba(255,255,255,0.15); }
|
||||||
|
|
||||||
|
/* ──────────────── STATS ──────────────── */
|
||||||
|
.stats {
|
||||||
|
display: flex; justify-content: center; gap: 60px;
|
||||||
|
padding: 40px; margin: 0 auto 60px;
|
||||||
|
max-width: 800px;
|
||||||
|
}
|
||||||
|
.stat { text-align: center; }
|
||||||
|
.stat-num {
|
||||||
|
font-size: 2.5em; font-weight: 900;
|
||||||
|
background: linear-gradient(135deg, var(--accent), var(--accent2));
|
||||||
|
-webkit-background-clip: text; -webkit-text-fill-color: transparent;
|
||||||
|
}
|
||||||
|
.stat-label { color: var(--dim); font-size: 0.9em; margin-top: 4px; }
|
||||||
|
|
||||||
|
/* ──────────────── SECTIONS ──────────────── */
|
||||||
|
.section {
|
||||||
|
max-width: 1100px; margin: 0 auto; padding: 60px 40px;
|
||||||
|
}
|
||||||
|
.section-title {
|
||||||
|
font-size: 1.8em; font-weight: 800; margin-bottom: 12px;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
}
|
||||||
|
.section-sub { color: var(--muted); margin-bottom: 40px; font-size: 1.05em; }
|
||||||
|
|
||||||
|
/* ──────────────── ENDPOINT CARDS ──────────────── */
|
||||||
|
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap: 16px; }
|
||||||
|
.card {
|
||||||
|
background: rgba(15, 23, 42, 0.6);
|
||||||
|
border: 1px solid rgba(255,255,255,0.04);
|
||||||
|
border-radius: 16px; padding: 24px;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
.card:hover {
|
||||||
|
border-color: rgba(56, 189, 248, 0.2);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 8px 30px rgba(0,0,0,0.3);
|
||||||
|
}
|
||||||
|
.card-method {
|
||||||
|
display: inline-block; font-size: 0.75em; font-weight: 700;
|
||||||
|
padding: 3px 10px; border-radius: 6px; margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
.get { background: rgba(16, 185, 129, 0.12); color: #10b981; }
|
||||||
|
.card-path {
|
||||||
|
font-family: 'SF Mono', 'Fira Code', monospace;
|
||||||
|
font-size: 0.9em; color: var(--accent); font-weight: 500;
|
||||||
|
margin-bottom: 8px; word-break: break-all;
|
||||||
|
}
|
||||||
|
.card-desc { color: var(--muted); font-size: 0.9em; line-height: 1.5; }
|
||||||
|
|
||||||
|
/* ──────────────── STREAMING FLOW ──────────────── */
|
||||||
|
.flow {
|
||||||
|
display: grid; grid-template-columns: repeat(3, 1fr); gap: 24px;
|
||||||
|
margin-top: 40px;
|
||||||
|
}
|
||||||
|
.flow-step {
|
||||||
|
background: rgba(15, 23, 42, 0.6);
|
||||||
|
border: 1px solid rgba(255,255,255,0.04);
|
||||||
|
border-radius: 16px; padding: 32px 24px; text-align: center;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.flow-step::before {
|
||||||
|
content: attr(data-step);
|
||||||
|
position: absolute; top: -14px; left: 50%; transform: translateX(-50%);
|
||||||
|
width: 28px; height: 28px; border-radius: 50%;
|
||||||
|
background: linear-gradient(135deg, var(--accent), var(--accent2));
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
color: white; font-size: 0.8em; font-weight: 700;
|
||||||
|
}
|
||||||
|
.flow-step h3 { margin: 12px 0 8px; font-weight: 700; }
|
||||||
|
.flow-step p { color: var(--muted); font-size: 0.9em; }
|
||||||
|
.flow-step code {
|
||||||
|
display: block; margin-top: 12px; padding: 12px;
|
||||||
|
background: rgba(0,0,0,0.3); border-radius: 8px;
|
||||||
|
font-size: 0.8em; color: var(--accent); text-align: left;
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ──────────────── CODE BLOCK ──────────────── */
|
||||||
|
.code-block {
|
||||||
|
background: rgba(0,0,0,0.4); border-radius: 16px;
|
||||||
|
border: 1px solid rgba(255,255,255,0.05);
|
||||||
|
margin-top: 40px; overflow: hidden;
|
||||||
|
}
|
||||||
|
.code-header {
|
||||||
|
padding: 12px 20px; background: rgba(255,255,255,0.03);
|
||||||
|
border-bottom: 1px solid rgba(255,255,255,0.05);
|
||||||
|
font-size: 0.85em; color: var(--dim); font-weight: 500;
|
||||||
|
}
|
||||||
|
.code-block pre {
|
||||||
|
padding: 20px; margin: 0; overflow-x: auto;
|
||||||
|
font-family: 'SF Mono', 'Fira Code', monospace;
|
||||||
|
font-size: 0.85em; line-height: 1.6;
|
||||||
|
color: #a5b4fc;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ──────────────── PROVIDERS ──────────────── */
|
||||||
|
.providers {
|
||||||
|
display: flex; flex-wrap: wrap; gap: 10px;
|
||||||
|
justify-content: center; margin-top: 32px;
|
||||||
|
}
|
||||||
|
.provider {
|
||||||
|
background: rgba(255,255,255,0.04); border: 1px solid rgba(255,255,255,0.06);
|
||||||
|
padding: 8px 18px; border-radius: 10px;
|
||||||
|
font-size: 0.85em; color: var(--muted); font-weight: 500;
|
||||||
|
font-family: monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ──────────────── FOOTER ──────────────── */
|
||||||
|
.footer {
|
||||||
|
text-align: center; padding: 60px 40px;
|
||||||
|
border-top: 1px solid rgba(255,255,255,0.05);
|
||||||
|
color: var(--dim); font-size: 0.9em;
|
||||||
|
}
|
||||||
|
.footer a { color: var(--accent); text-decoration: none; }
|
||||||
|
.footer a:hover { text-decoration: underline; }
|
||||||
|
|
||||||
|
/* ──────────────── RESPONSIVE ──────────────── */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.nav { padding: 12px 20px; }
|
||||||
|
.nav-links { display: none; }
|
||||||
|
.hero { padding: 120px 20px 60px; }
|
||||||
|
.stats { gap: 30px; flex-wrap: wrap; }
|
||||||
|
.flow { grid-template-columns: 1fr; }
|
||||||
|
.section { padding: 40px 20px; }
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="container">
|
|
||||||
<div class="badge">v1.0.0</div>
|
<!-- ──────────────── NAV ──────────────── -->
|
||||||
|
<nav class="nav">
|
||||||
|
<a href="/" class="nav-brand">
|
||||||
|
<div class="nav-logo">M</div>
|
||||||
|
<span class="nav-title">MiruroAPI</span>
|
||||||
|
</a>
|
||||||
|
<div class="nav-links">
|
||||||
|
<a href="/docs">Swagger UI</a>
|
||||||
|
<a href="/openapi.json">OpenAPI Spec</a>
|
||||||
|
<a href="/api/health">Health</a>
|
||||||
|
<a href="https://github.com/Shineii86/MiruroAPI" target="_blank">GitHub</a>
|
||||||
|
<span class="nav-badge">v1.1.0</span>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<!-- ──────────────── HERO ──────────────── -->
|
||||||
|
<section class="hero">
|
||||||
|
<div class="hero-badge">
|
||||||
|
<span class="dot"></span>
|
||||||
|
All systems operational
|
||||||
|
</div>
|
||||||
<h1>MiruroAPI</h1>
|
<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>
|
<p>Free RESTful API for anime streaming data. 16 endpoints, 12 providers, M3U8 streaming URLs with subtitles and skip timestamps. Powered by AniList GraphQL and Miruro.</p>
|
||||||
<div class="endpoints">
|
<div class="hero-actions">
|
||||||
<h3>Endpoints</h3>
|
<a href="/docs" class="btn btn-primary">
|
||||||
<div class="endpoint"><span class="method">GET</span> /api/search?query=naruto</div>
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/></svg>
|
||||||
<div class="endpoint"><span class="method">GET</span> /api/trending</div>
|
Swagger UI Docs
|
||||||
<div class="endpoint"><span class="method">GET</span> /api/popular</div>
|
</a>
|
||||||
<div class="endpoint"><span class="method">GET</span> /api/upcoming</div>
|
<a href="/api/search?query=naruto&per_page=3" class="btn btn-secondary">
|
||||||
<div class="endpoint"><span class="method">GET</span> /api/recent</div>
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
|
||||||
<div class="endpoint"><span class="method">GET</span> /api/spotlight</div>
|
Try Search API
|
||||||
<div class="endpoint"><span class="method">GET</span> /api/schedule</div>
|
</a>
|
||||||
<div class="endpoint"><span class="method">GET</span> /api/filter?genre=Action</div>
|
<a href="https://github.com/Shineii86/MiruroAPI" class="btn btn-secondary" target="_blank">
|
||||||
<div class="endpoint"><span class="method">GET</span> /api/info/:id</div>
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/></svg>
|
||||||
<div class="endpoint"><span class="method">GET</span> /api/episodes/:id</div>
|
GitHub
|
||||||
<div class="endpoint"><span class="method">GET</span> /api/watch/:provider/:id/:category/:slug</div>
|
</a>
|
||||||
<div class="endpoint"><span class="method">GET</span> /api/health</div>
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ──────────────── STATS ──────────────── -->
|
||||||
|
<div class="stats">
|
||||||
|
<div class="stat">
|
||||||
|
<div class="stat-num">16</div>
|
||||||
|
<div class="stat-label">API Endpoints</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat">
|
||||||
|
<div class="stat-num">12</div>
|
||||||
|
<div class="stat-label">Providers</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat">
|
||||||
|
<div class="stat-num">5000+</div>
|
||||||
|
<div class="stat-label">Anime Indexed</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat">
|
||||||
|
<div class="stat-num">M3U8</div>
|
||||||
|
<div class="stat-label">Stream Format</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- ──────────────── STREAMING FLOW ──────────────── -->
|
||||||
|
<section class="section">
|
||||||
|
<h2 class="section-title">Streaming in 3 Steps</h2>
|
||||||
|
<p class="section-sub">Get M3U8 streaming URLs for any anime episode in 3 simple API calls.</p>
|
||||||
|
|
||||||
|
<div class="flow">
|
||||||
|
<div class="flow-step" data-step="1">
|
||||||
|
<h3>Get Episodes</h3>
|
||||||
|
<p>Fetch all episodes from multiple providers</p>
|
||||||
|
<code>GET /api/episodes/{anilistId}</code>
|
||||||
|
</div>
|
||||||
|
<div class="flow-step" data-step="2">
|
||||||
|
<h3>Get Sources</h3>
|
||||||
|
<p>Get M3U8 URL from episode ID</p>
|
||||||
|
<code>GET /api/watch/{provider}/{id}/{cat}/{slug}</code>
|
||||||
|
</div>
|
||||||
|
<div class="flow-step" data-step="3">
|
||||||
|
<h3>Play</h3>
|
||||||
|
<p>Feed M3U8 URL to any HLS player</p>
|
||||||
|
<code>hls.js / Video.js / VLC / mpv</code>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ──────────────── SEARCH ENDPOINTS ──────────────── -->
|
||||||
|
<section class="section">
|
||||||
|
<h2 class="section-title">Search & Discovery</h2>
|
||||||
|
<p class="section-sub">Full-text search, autocomplete, and advanced filtering powered by AniList GraphQL.</p>
|
||||||
|
|
||||||
|
<div class="grid">
|
||||||
|
<div class="card">
|
||||||
|
<span class="card-method get">GET</span>
|
||||||
|
<div class="card-path">/api/search?query={name}</div>
|
||||||
|
<div class="card-desc">Full-text anime search with 20+ fields per result. Paginated.</div>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<span class="card-method get">GET</span>
|
||||||
|
<div class="card-path">/api/suggestions?query={name}</div>
|
||||||
|
<div class="card-desc">Lightweight autocomplete for dropdowns. Max 8 results.</div>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<span class="card-method get">GET</span>
|
||||||
|
<div class="card-path">/api/filter?genre=Action&format=TV</div>
|
||||||
|
<div class="card-desc">Advanced filter by genre, tag, year, season, format, status, sort.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ──────────────── COLLECTIONS ──────────────── -->
|
||||||
|
<section class="section">
|
||||||
|
<h2 class="section-title">Collections</h2>
|
||||||
|
<p class="section-sub">Browse trending, popular, upcoming, and currently airing anime. All paginated.</p>
|
||||||
|
|
||||||
|
<div class="grid">
|
||||||
|
<div class="card">
|
||||||
|
<span class="card-method get">GET</span>
|
||||||
|
<div class="card-path">/api/trending</div>
|
||||||
|
<div class="card-desc">Currently trending anime across the community.</div>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<span class="card-method get">GET</span>
|
||||||
|
<div class="card-path">/api/popular</div>
|
||||||
|
<div class="card-desc">Most popular anime of all time by user count.</div>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<span class="card-method get">GET</span>
|
||||||
|
<div class="card-path">/api/upcoming</div>
|
||||||
|
<div class="card-desc">Most anticipated anime that haven't aired yet.</div>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<span class="card-method get">GET</span>
|
||||||
|
<div class="card-path">/api/recent</div>
|
||||||
|
<div class="card-desc">Currently airing / this season's anime.</div>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<span class="card-method get">GET</span>
|
||||||
|
<div class="card-path">/api/spotlight</div>
|
||||||
|
<div class="card-desc">Top 10 trending + popular. Perfect for hero banners.</div>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<span class="card-method get">GET</span>
|
||||||
|
<div class="card-path">/api/schedule</div>
|
||||||
|
<div class="card-desc">Upcoming episodes with UNIX timestamps.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ──────────────── ANIME DETAILS ──────────────── -->
|
||||||
|
<section class="section">
|
||||||
|
<h2 class="section-title">Anime Details</h2>
|
||||||
|
<p class="section-sub">Complete anime metadata — characters, staff, relations, recommendations, trailer, stats.</p>
|
||||||
|
|
||||||
|
<div class="grid">
|
||||||
|
<div class="card">
|
||||||
|
<span class="card-method get">GET</span>
|
||||||
|
<div class="card-path">/api/info/{anilistId}</div>
|
||||||
|
<div class="card-desc">Everything AniList has to offer. 25 characters, staff, relations, recommendations, trailer, stats.</div>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<span class="card-method get">GET</span>
|
||||||
|
<div class="card-path">/api/anime/{id}/characters</div>
|
||||||
|
<div class="card-desc">Paginated characters with Japanese voice actors.</div>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<span class="card-method get">GET</span>
|
||||||
|
<div class="card-path">/api/anime/{id}/relations</div>
|
||||||
|
<div class="card-desc">Sequels, prequels, side stories, spin-offs.</div>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<span class="card-method get">GET</span>
|
||||||
|
<div class="card-path">/api/anime/{id}/recommendations</div>
|
||||||
|
<div class="card-desc">Community recommendations sorted by rating.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ──────────────── PROVIDERS ──────────────── -->
|
||||||
|
<section class="section" style="text-align: center;">
|
||||||
|
<h2 class="section-title">Streaming Providers</h2>
|
||||||
|
<p class="section-sub">12 providers with different strengths — sub, dub, download, skip times, thumbnails.</p>
|
||||||
|
|
||||||
|
<div class="providers">
|
||||||
|
<div class="provider">kiwi</div>
|
||||||
|
<div class="provider">pewe</div>
|
||||||
|
<div class="provider">bee</div>
|
||||||
|
<div class="provider">bonk</div>
|
||||||
|
<div class="provider">bun</div>
|
||||||
|
<div class="provider">ally</div>
|
||||||
|
<div class="provider">nun</div>
|
||||||
|
<div class="provider">twin</div>
|
||||||
|
<div class="provider">cog</div>
|
||||||
|
<div class="provider">moo</div>
|
||||||
|
<div class="provider">hop</div>
|
||||||
|
<div class="provider">telli</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ──────────────── QUICK START ──────────────── -->
|
||||||
|
<section class="section">
|
||||||
|
<h2 class="section-title">Quick Start</h2>
|
||||||
|
<p class="section-sub">Get started in seconds. No API key required.</p>
|
||||||
|
|
||||||
|
<div class="code-block">
|
||||||
|
<div class="code-header">JavaScript — Fetch search results</div>
|
||||||
|
<pre>const res = await fetch("https://mirurotvapi.vercel.app/api/search?query=naruto");
|
||||||
|
const { success, results } = await res.json();
|
||||||
|
console.log(results.total); // 5000+
|
||||||
|
console.log(results.results[0].title.romaji); // "NARUTO"</pre>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="code-block" style="margin-top: 16px;">
|
||||||
|
<div class="code-header">JavaScript — Get streaming sources</div>
|
||||||
|
<pre>// Step 1: Get episodes
|
||||||
|
const eps = await fetch("https://mirurotvapi.vercel.app/api/episodes/20");
|
||||||
|
const { results } = await eps.json();
|
||||||
|
const firstEp = results.providers.kiwi.episodes.sub[0];
|
||||||
|
|
||||||
|
// Step 2: Get M3U8 URL
|
||||||
|
const stream = await fetch(`https://mirurotvapi.vercel.app${firstEp.id}`);
|
||||||
|
const { results: sources } = await stream.json();
|
||||||
|
console.log(sources.streams[0].url); // "https://.../master.m3u8"</pre>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="code-block" style="margin-top: 16px;">
|
||||||
|
<div class="code-header">cURL — Quick test</div>
|
||||||
|
<pre>curl "https://mirurotvapi.vercel.app/api/search?query=one+piece&per_page=3"</pre>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ──────────────── FOOTER ──────────────── -->
|
||||||
|
<footer class="footer">
|
||||||
|
<p>
|
||||||
|
<a href="/docs">Swagger UI</a> ·
|
||||||
|
<a href="/openapi.json">OpenAPI Spec</a> ·
|
||||||
|
<a href="/api/health">Health</a> ·
|
||||||
|
<a href="https://github.com/Shineii86/MiruroAPI" target="_blank">GitHub</a>
|
||||||
|
</p>
|
||||||
|
<p style="margin-top: 12px; color: #475569;">
|
||||||
|
Powered by <a href="https://anilist.co" target="_blank">AniList GraphQL</a> + <a href="https://miruro.to" target="_blank">Miruro</a> streaming providers
|
||||||
|
</p>
|
||||||
|
<p style="margin-top: 8px; color: #334155; font-size: 0.8em;">
|
||||||
|
v1.1.0 · MIT License · Built by <a href="https://github.com/Shineii86" target="_blank">Shinei Nouzen</a>
|
||||||
|
</p>
|
||||||
|
</footer>
|
||||||
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -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" }
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1200 630">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||||
|
<stop offset="0%" style="stop-color:#0b1622"/>
|
||||||
|
<stop offset="50%" style="stop-color:#0f172a"/>
|
||||||
|
<stop offset="100%" style="stop-color:#1e1b4b"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="accent" x1="0%" y1="0%" x2="100%" y2="0%">
|
||||||
|
<stop offset="0%" style="stop-color:#38bdf8"/>
|
||||||
|
<stop offset="100%" style="stop-color:#818cf8"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="icon" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||||
|
<stop offset="0%" style="stop-color:#38bdf8"/>
|
||||||
|
<stop offset="100%" style="stop-color:#a78bfa"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<rect width="1200" height="630" fill="url(#bg)"/>
|
||||||
|
<rect x="0" y="580" width="1200" height="50" fill="url(#accent)" opacity="0.3"/>
|
||||||
|
<rect x="60" y="180" width="6" height="200" rx="3" fill="url(#accent)"/>
|
||||||
|
<text x="90" y="270" fill="white" font-family="Arial Black,Helvetica Neue,sans-serif" font-size="80" font-weight="900">MiruroAPI</text>
|
||||||
|
<text x="90" y="340" fill="#94a3b8" font-family="Arial,Helvetica Neue,sans-serif" font-size="32">Free Anime Streaming API</text>
|
||||||
|
<text x="90" y="390" fill="#64748b" font-family="Arial,Helvetica Neue,sans-serif" font-size="24">AniList GraphQL + Miruro Streaming Providers + M3U8</text>
|
||||||
|
<rect x="60" y="430" width="280" height="50" rx="25" fill="url(#accent)" opacity="0.15"/>
|
||||||
|
<text x="200" y="462" text-anchor="middle" fill="#38bdf8" font-family="Arial,sans-serif" font-size="20" font-weight="600">16 Endpoints</text>
|
||||||
|
<rect x="360" y="430" width="280" height="50" rx="25" fill="url(#accent)" opacity="0.15"/>
|
||||||
|
<text x="500" y="462" text-anchor="middle" fill="#38bdf8" font-family="Arial,sans-serif" font-size="20" font-weight="600">12 Providers</text>
|
||||||
|
<rect x="660" y="430" width="280" height="50" rx="25" fill="url(#accent)" opacity="0.15"/>
|
||||||
|
<text x="800" y="462" text-anchor="middle" fill="#38bdf8" font-family="Arial,sans-serif" font-size="20" font-weight="600">M3U8 Streaming</text>
|
||||||
|
<rect x="960" y="430" width="200" height="50" rx="25" fill="url(#accent)" opacity="0.15"/>
|
||||||
|
<text x="1060" y="462" text-anchor="middle" fill="#38bdf8" font-family="Arial,sans-serif" font-size="20" font-weight="600">v1.1.0</text>
|
||||||
|
<rect x="900" y="120" width="200" height="200" rx="40" fill="url(#icon)" opacity="0.15"/>
|
||||||
|
<text x="1000" y="255" text-anchor="middle" fill="white" font-family="Arial Black,sans-serif" font-size="120" font-weight="900">M</text>
|
||||||
|
<text x="600" y="580" text-anchor="middle" fill="#475569" font-family="Arial,sans-serif" font-size="18">mirurotvapi.vercel.app</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.6 KiB |
@@ -1,74 +1,179 @@
|
|||||||
|
/*
|
||||||
|
* ======= • ======= • ======= • ======= • =======• =======
|
||||||
|
* 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 express = require("express");
|
||||||
const cors = require("cors");
|
const cors = require("cors");
|
||||||
const path = require("path");
|
const path = require("path");
|
||||||
require("dotenv").config();
|
require("dotenv").config();
|
||||||
|
|
||||||
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
// SERVER CONFIGURATION
|
||||||
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
const PORT = process.env.PORT || 3000;
|
const PORT = process.env.PORT || 3000;
|
||||||
|
|
||||||
// CORS
|
|
||||||
const allowedOrigins = process.env.ALLOWED_ORIGINS
|
const allowedOrigins = process.env.ALLOWED_ORIGINS
|
||||||
? process.env.ALLOWED_ORIGINS.split(",")
|
? process.env.ALLOWED_ORIGINS.split(",")
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
app.use(
|
// ══════════════════════════════════════════════════════════════
|
||||||
cors({
|
// CORS MIDDLEWARE
|
||||||
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"],
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
// Security headers
|
// NOTE: Single unified CORS middleware — handles all origin validation
|
||||||
app.use((req, res, next) => {
|
app.use((req, res, next) => {
|
||||||
res.setHeader("access-control-allow-origin", "*");
|
const origin = req.headers.origin;
|
||||||
res.setHeader("X-Content-Type-Options", "nosniff");
|
if (
|
||||||
res.setHeader("X-Frame-Options", "DENY");
|
!allowedOrigins ||
|
||||||
res.setHeader("X-XSS-Protection", "1; mode=block");
|
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();
|
next();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Static files
|
// ---- FEATURE: Security headers ----
|
||||||
app.use(express.static(path.join(__dirname, "public")));
|
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) => {
|
// STATIC FILES
|
||||||
res.json({
|
// ══════════════════════════════════════════════════════════════
|
||||||
status: "ok",
|
|
||||||
version: "1.0.0",
|
app.use(express.static(path.join(__dirname, "public"), { redirect: false }));
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
uptime: process.uptime(),
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
// SWAGGER UI DOCS
|
||||||
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
// ---- FEATURE: Interactive Swagger UI documentation at /docs ----
|
||||||
|
app.get("/docs", (req, res) => {
|
||||||
|
res.sendFile(path.join(__dirname, "public", "docs.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
|
// ---- FEATURE: Catch-all 404 handler for undefined routes ----
|
||||||
const apiRoutes = require("./src/routes/apiRoutes");
|
app.use((req, res) => {
|
||||||
app.use("/api", apiRoutes);
|
|
||||||
|
|
||||||
// SPA fallback
|
|
||||||
app.get("*", (req, res) => {
|
|
||||||
if (req.path.startsWith("/api/")) {
|
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"));
|
res.sendFile(path.join(__dirname, "public", "index.html"));
|
||||||
});
|
});
|
||||||
|
|
||||||
// Global error handler
|
// ══════════════════════════════════════════════════════════════
|
||||||
app.use((err, req, res, next) => {
|
// SERVER START
|
||||||
console.error(err.stack);
|
// ══════════════════════════════════════════════════════════════
|
||||||
res.status(500).json({ success: false, message: "Internal server error" });
|
|
||||||
});
|
|
||||||
|
|
||||||
app.listen(PORT, () => {
|
app.listen(PORT, () => {
|
||||||
console.log(`MiruroAPI running on port ${PORT}`);
|
console.info(`MiruroAPI listening at ${PORT}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
module.exports = app;
|
module.exports = app;
|
||||||
|
|
||||||
|
// ══════════════════════════════════════════════════════════════ END: server.js
|
||||||
|
|||||||
+229
-22
@@ -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");
|
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";
|
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 = `
|
const MEDIA_LIST_FIELDS = `
|
||||||
id
|
id
|
||||||
title { romaji english native }
|
title { romaji english native }
|
||||||
@@ -27,6 +70,14 @@ const MEDIA_LIST_FIELDS = `
|
|||||||
endDate { year month day }
|
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 = `
|
const MEDIA_FULL_FIELDS = `
|
||||||
id
|
id
|
||||||
idMal
|
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 body = { query, variables };
|
||||||
const res = await axios.post(ANILIST_URL, body, {
|
const res = await axios.post(ANILIST_URL, body, {
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
timeout: 15000,
|
timeout: 15000,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (res.status !== 200) throw new Error("AniList query failed");
|
if (res.status !== 200) throw new Error("AniList query failed");
|
||||||
return res.data.data;
|
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 = `
|
const gql = `
|
||||||
query ($search: String, $page: Int, $perPage: Int) {
|
query ($search: String, $page: Int, $perPage: Int) {
|
||||||
Page(page: $page, perPage: $perPage) {
|
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 data = await anilistQuery(gql, { search: query, page, perPage });
|
||||||
const pageData = data.Page;
|
const pageData = data.Page;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
page: pageData.pageInfo.currentPage,
|
page: pageData.pageInfo.currentPage,
|
||||||
perPage: pageData.pageInfo.perPage,
|
perPage: pageData.pageInfo.perPage,
|
||||||
@@ -139,9 +227,21 @@ async function searchAnime(query, page = 1, perPage = 20) {
|
|||||||
hasNextPage: pageData.pageInfo.hasNextPage,
|
hasNextPage: pageData.pageInfo.hasNextPage,
|
||||||
results: pageData.media,
|
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 = `
|
const gql = `
|
||||||
query ($search: String) {
|
query ($search: String) {
|
||||||
Page(page: 1, perPage: 8) {
|
Page(page: 1, perPage: 8) {
|
||||||
@@ -158,6 +258,7 @@ async function getSuggestions(query) {
|
|||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
const data = await anilistQuery(gql, { search: query });
|
const data = await anilistQuery(gql, { search: query });
|
||||||
|
|
||||||
return data.Page.media.map((item) => ({
|
return data.Page.media.map((item) => ({
|
||||||
id: item.id,
|
id: item.id,
|
||||||
title: item.title.english || item.title.romaji,
|
title: item.title.english || item.title.romaji,
|
||||||
@@ -168,9 +269,26 @@ async function getSuggestions(query) {
|
|||||||
year: item.startDate?.year,
|
year: item.startDate?.year,
|
||||||
episodes: item.episodes,
|
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 = `
|
const gql = `
|
||||||
query ($id: Int) {
|
query ($id: Int) {
|
||||||
Media(id: $id, type: ANIME) {
|
Media(id: $id, type: ANIME) {
|
||||||
@@ -180,9 +298,19 @@ async function getAnimeInfo(id) {
|
|||||||
`;
|
`;
|
||||||
const data = await anilistQuery(gql, { id });
|
const data = await anilistQuery(gql, { id });
|
||||||
return data.Media;
|
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 = `
|
const gql = `
|
||||||
query ($id: Int, $page: Int, $perPage: Int) {
|
query ($id: Int, $page: Int, $perPage: Int) {
|
||||||
Media(id: $id, type: ANIME) {
|
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 });
|
const data = await anilistQuery(gql, { id, page, perPage });
|
||||||
if (!data.Media) throw new Error("Anime not found");
|
if (!data.Media) throw new Error("Anime not found");
|
||||||
|
|
||||||
const chars = data.Media.characters;
|
const chars = data.Media.characters;
|
||||||
return {
|
return {
|
||||||
page: chars.pageInfo.currentPage,
|
page: chars.pageInfo.currentPage,
|
||||||
@@ -209,9 +338,17 @@ async function getAnimeCharacters(id, page = 1, perPage = 25) {
|
|||||||
hasNextPage: chars.pageInfo.hasNextPage,
|
hasNextPage: chars.pageInfo.hasNextPage,
|
||||||
characters: chars.edges,
|
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 = `
|
const gql = `
|
||||||
query ($id: Int) {
|
query ($id: Int) {
|
||||||
Media(id: $id, type: ANIME) {
|
Media(id: $id, type: ANIME) {
|
||||||
@@ -231,14 +368,25 @@ async function getAnimeRelations(id) {
|
|||||||
`;
|
`;
|
||||||
const data = await anilistQuery(gql, { id });
|
const data = await anilistQuery(gql, { id });
|
||||||
if (!data.Media) throw new Error("Anime not found");
|
if (!data.Media) throw new Error("Anime not found");
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: data.Media.id,
|
id: data.Media.id,
|
||||||
title: data.Media.title,
|
title: data.Media.title,
|
||||||
relations: data.Media.relations.edges,
|
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 = `
|
const gql = `
|
||||||
query ($id: Int, $page: Int, $perPage: Int) {
|
query ($id: Int, $page: Int, $perPage: Int) {
|
||||||
Media(id: $id, type: ANIME) {
|
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 });
|
const data = await anilistQuery(gql, { id, page, perPage });
|
||||||
if (!data.Media) throw new Error("Anime not found");
|
if (!data.Media) throw new Error("Anime not found");
|
||||||
|
|
||||||
const recs = data.Media.recommendations;
|
const recs = data.Media.recommendations;
|
||||||
return {
|
return {
|
||||||
page: recs.pageInfo.currentPage,
|
page: recs.pageInfo.currentPage,
|
||||||
@@ -267,9 +416,24 @@ async function getAnimeRecommendations(id, page = 1, perPage = 10) {
|
|||||||
hasNextPage: recs.pageInfo.hasNextPage,
|
hasNextPage: recs.pageInfo.hasNextPage,
|
||||||
recommendations: recs.nodes,
|
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 statusFilter = status ? `, status: ${status}` : "";
|
||||||
const gql = `
|
const gql = `
|
||||||
query ($page: Int, $perPage: Int) {
|
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 data = await anilistQuery(gql, { page, perPage });
|
||||||
const pageData = data.Page;
|
const pageData = data.Page;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
page: pageData.pageInfo.currentPage,
|
page: pageData.pageInfo.currentPage,
|
||||||
perPage: pageData.pageInfo.perPage,
|
perPage: pageData.pageInfo.perPage,
|
||||||
@@ -290,9 +455,16 @@ async function getCollection(sortType, status = null, page = 1, perPage = 20) {
|
|||||||
hasNextPage: pageData.pageInfo.hasNextPage,
|
hasNextPage: pageData.pageInfo.hasNextPage,
|
||||||
results: pageData.media,
|
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 = `
|
const gql = `
|
||||||
query {
|
query {
|
||||||
Page(page: 1, perPage: 10) {
|
Page(page: 1, perPage: 10) {
|
||||||
@@ -304,9 +476,18 @@ async function getSpotlight() {
|
|||||||
`;
|
`;
|
||||||
const data = await anilistQuery(gql);
|
const data = await anilistQuery(gql);
|
||||||
return data.Page.media;
|
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 = `
|
const gql = `
|
||||||
query ($page: Int, $perPage: Int) {
|
query ($page: Int, $perPage: Int) {
|
||||||
Page(page: $page, perPage: $perPage) {
|
Page(page: $page, perPage: $perPage) {
|
||||||
@@ -322,12 +503,14 @@ async function getSchedule(page = 1, perPage = 20) {
|
|||||||
`;
|
`;
|
||||||
const data = await anilistQuery(gql, { page, perPage });
|
const data = await anilistQuery(gql, { page, perPage });
|
||||||
const pageData = data.Page;
|
const pageData = data.Page;
|
||||||
|
|
||||||
const results = pageData.airingSchedules.map((item) => ({
|
const results = pageData.airingSchedules.map((item) => ({
|
||||||
...item.media,
|
...item.media,
|
||||||
next_episode: item.episode,
|
next_episode: item.episode,
|
||||||
airingAt: item.airingAt,
|
airingAt: item.airingAt,
|
||||||
timeUntilAiring: item.timeUntilAiring,
|
timeUntilAiring: item.timeUntilAiring,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
return {
|
return {
|
||||||
page: pageData.pageInfo.currentPage,
|
page: pageData.pageInfo.currentPage,
|
||||||
perPage: pageData.pageInfo.perPage,
|
perPage: pageData.pageInfo.perPage,
|
||||||
@@ -335,9 +518,29 @@ async function getSchedule(page = 1, perPage = 20) {
|
|||||||
hasNextPage: pageData.pageInfo.hasNextPage,
|
hasNextPage: pageData.pageInfo.hasNextPage,
|
||||||
results,
|
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 = {
|
const SORT_MAP = {
|
||||||
SCORE_DESC: "SCORE_DESC",
|
SCORE_DESC: "SCORE_DESC",
|
||||||
POPULARITY_DESC: "POPULARITY_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",
|
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 args = ["type: ANIME", `sort: [${SORT_MAP[sort] || "POPULARITY_DESC"}]`];
|
||||||
const variables = { page, perPage };
|
const variables = { page, perPage };
|
||||||
const varTypes = ["$page: Int", "$perPage: Int"];
|
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 data = await anilistQuery(gql, variables);
|
||||||
const pageData = data.Page;
|
const pageData = data.Page;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
page: pageData.pageInfo.currentPage,
|
page: pageData.pageInfo.currentPage,
|
||||||
perPage: pageData.pageInfo.perPage,
|
perPage: pageData.pageInfo.perPage,
|
||||||
@@ -377,7 +582,7 @@ async function filterAnime({ genre, tag, year, season, format, status, sort = "P
|
|||||||
hasNextPage: pageData.pageInfo.hasNextPage,
|
hasNextPage: pageData.pageInfo.hasNextPage,
|
||||||
results: pageData.media,
|
results: pageData.media,
|
||||||
};
|
};
|
||||||
}
|
};
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
anilistQuery,
|
anilistQuery,
|
||||||
@@ -394,3 +599,5 @@ module.exports = {
|
|||||||
MEDIA_LIST_FIELDS,
|
MEDIA_LIST_FIELDS,
|
||||||
MEDIA_FULL_FIELDS,
|
MEDIA_FULL_FIELDS,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ══════════════════════════════════════════════════════════════ END: anilist.js
|
||||||
|
|||||||
+107
-9
@@ -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 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;
|
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);
|
const entry = cache.get(key);
|
||||||
if (!entry) return null;
|
if (!entry) return null;
|
||||||
|
|
||||||
|
// NOTE: Lazy eviction — delete stale entries on access
|
||||||
if (Date.now() > entry.expiry) {
|
if (Date.now() > entry.expiry) {
|
||||||
cache.delete(key);
|
cache.delete(key);
|
||||||
return null;
|
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) {
|
if (cache.size >= MAX_CACHE_SIZE) {
|
||||||
const firstKey = cache.keys().next().value;
|
const firstKey = cache.keys().next().value;
|
||||||
cache.delete(firstKey);
|
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();
|
cache.clear();
|
||||||
}
|
};
|
||||||
|
|
||||||
module.exports = { getCached, setCache, clearCache };
|
module.exports = { getCached, setCache, clearCache };
|
||||||
|
|
||||||
|
// ══════════════════════════════════════════════════════════════ END: cache.js
|
||||||
|
|||||||
+210
-20
@@ -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 axios = require("axios");
|
||||||
const { Buffer } = require("buffer");
|
const { Buffer } = require("buffer");
|
||||||
const zlib = require("zlib");
|
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";
|
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 = {
|
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",
|
"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/",
|
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);
|
const json = JSON.stringify(payload);
|
||||||
return Buffer.from(json).toString("base64url");
|
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 {
|
try {
|
||||||
|
// NOTE: Add padding if necessary (base64url may omit trailing =)
|
||||||
const padded = encodedStr + "=".repeat((4 - (encodedStr.length % 4)) % 4);
|
const padded = encodedStr + "=".repeat((4 - (encodedStr.length % 4)) % 4);
|
||||||
const compressed = Buffer.from(padded, "base64url");
|
const compressed = Buffer.from(padded, "base64url");
|
||||||
const decompressed = zlib.gunzipSync(compressed);
|
const decompressed = zlib.gunzipSync(compressed);
|
||||||
@@ -22,20 +87,41 @@ function decodePipeResponse(encodedStr) {
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
throw new Error("Failed to decode pipe response: " + e.message);
|
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 {
|
try {
|
||||||
const padded = encodedId + "=".repeat((4 - (encodedId.length % 4)) % 4);
|
const padded = encodedId + "=".repeat((4 - (encodedId.length % 4)) % 4);
|
||||||
const decoded = Buffer.from(padded, "base64url").toString("utf-8");
|
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;
|
if (decoded.includes(":")) return decoded;
|
||||||
return encodedId;
|
return encodedId;
|
||||||
} catch {
|
} catch {
|
||||||
return encodedId;
|
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") {
|
if (obj && typeof obj === "object") {
|
||||||
for (const key of Object.keys(obj)) {
|
for (const key of Object.keys(obj)) {
|
||||||
if (key === "id" && typeof obj[key] === "string") {
|
if (key === "id" && typeof obj[key] === "string") {
|
||||||
@@ -46,33 +132,73 @@ function deepTranslate(obj) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
return 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 || {};
|
const providers = data.providers || {};
|
||||||
|
|
||||||
for (const [provName, provData] of Object.entries(providers)) {
|
for (const [provName, provData] of Object.entries(providers)) {
|
||||||
if (!provData || typeof provData !== "object") continue;
|
if (!provData || typeof provData !== "object") continue;
|
||||||
|
|
||||||
let episodes = provData.episodes;
|
let episodes = provData.episodes;
|
||||||
if (!episodes) continue;
|
if (!episodes) continue;
|
||||||
|
|
||||||
|
// NOTE: Some providers return a flat array — wrap it in { sub: [...] }
|
||||||
if (Array.isArray(episodes)) {
|
if (Array.isArray(episodes)) {
|
||||||
provData.episodes = { sub: episodes };
|
provData.episodes = { sub: episodes };
|
||||||
episodes = provData.episodes;
|
episodes = provData.episodes;
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const [category, epList] of Object.entries(episodes)) {
|
for (const [category, epList] of Object.entries(episodes)) {
|
||||||
if (!Array.isArray(epList)) continue;
|
if (!Array.isArray(epList)) continue;
|
||||||
|
|
||||||
for (const ep of epList) {
|
for (const ep of epList) {
|
||||||
if (ep.id && ep.number) {
|
if (ep.id && ep.number) {
|
||||||
const origId = ep.id;
|
const origId = ep.id;
|
||||||
|
// NOTE: Take only the prefix before ":" for the slug
|
||||||
const prefix = origId.includes(":") ? origId.split(":")[0] : origId;
|
const prefix = origId.includes(":") ? origId.split(":")[0] : origId;
|
||||||
ep.id = `watch/${provName}/${anilistId}/${category}/${prefix}-${ep.number}`;
|
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 = {
|
const payload = {
|
||||||
path: "episodes",
|
path: "episodes",
|
||||||
method: "GET",
|
method: "GET",
|
||||||
@@ -80,22 +206,60 @@ async function fetchRawEpisodes(anilistId) {
|
|||||||
body: null,
|
body: null,
|
||||||
version: "0.1.0",
|
version: "0.1.0",
|
||||||
};
|
};
|
||||||
|
|
||||||
const encodedReq = encodePipeRequest(payload);
|
const encodedReq = encodePipeRequest(payload);
|
||||||
const res = await axios.get(`${MIRURO_PIPE_URL}?e=${encodedReq}`, {
|
const res = await axios.get(`${MIRURO_PIPE_URL}?e=${encodedReq}`, {
|
||||||
headers: HEADERS,
|
headers: HEADERS,
|
||||||
timeout: 15000,
|
timeout: 15000,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (res.status !== 200) throw new Error(`Pipe request failed: ${res.status}`);
|
if (res.status !== 200) throw new Error(`Pipe request failed: ${res.status}`);
|
||||||
const data = decodePipeResponse(res.text || res.data);
|
const data = decodePipeResponse(res.text || res.data);
|
||||||
return deepTranslate(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);
|
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 encId = Buffer.from(episodeId).toString("base64url");
|
||||||
const payload = {
|
const payload = {
|
||||||
path: "sources",
|
path: "sources",
|
||||||
@@ -104,33 +268,57 @@ async function getSources(episodeId, provider, anilistId, category = "sub") {
|
|||||||
body: null,
|
body: null,
|
||||||
version: "0.1.0",
|
version: "0.1.0",
|
||||||
};
|
};
|
||||||
|
|
||||||
const encodedReq = encodePipeRequest(payload);
|
const encodedReq = encodePipeRequest(payload);
|
||||||
const res = await axios.get(`${MIRURO_PIPE_URL}?e=${encodedReq}`, {
|
const res = await axios.get(`${MIRURO_PIPE_URL}?e=${encodedReq}`, {
|
||||||
headers: HEADERS,
|
headers: HEADERS,
|
||||||
timeout: 15000,
|
timeout: 15000,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (res.status !== 200) throw new Error(`Pipe sources request failed: ${res.status}`);
|
if (res.status !== 200) throw new Error(`Pipe sources request failed: ${res.status}`);
|
||||||
return deepTranslate(decodePipeResponse(res.text || res.data));
|
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 data = await fetchRawEpisodes(anilistId);
|
||||||
const provData = (data.providers || {})[provider];
|
const provData = (data.providers || {})[provider];
|
||||||
|
|
||||||
if (!provData) throw new Error(`Provider ${provider} not found`);
|
if (!provData) throw new Error(`Provider ${provider} not found`);
|
||||||
|
|
||||||
const episodes = provData.episodes?.[category] || [];
|
const episodes = provData.episodes?.[category] || [];
|
||||||
let targetId = null;
|
let targetId = null;
|
||||||
|
|
||||||
|
// NOTE: Resolve slug back to the original pipe episode ID
|
||||||
for (const ep of episodes) {
|
for (const ep of episodes) {
|
||||||
const origId = ep.id || "";
|
const origId = ep.id || "";
|
||||||
const prefix = origId.includes(":") ? origId.split(":")[0] : origId;
|
const prefix = origId.includes(":") ? origId.split(":")[0] : origId;
|
||||||
const generated = `${prefix}-${ep.number}`;
|
const generated = `${prefix}-${ep.number}`;
|
||||||
|
|
||||||
if (generated === slug) {
|
if (generated === slug) {
|
||||||
targetId = origId;
|
targetId = origId;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!targetId) throw new Error(`Episode slug '${slug}' not found for provider ${provider}`);
|
if (!targetId) throw new Error(`Episode slug '${slug}' not found for provider ${provider}`);
|
||||||
return getSources(targetId, provider, anilistId, category);
|
return getSources(targetId, provider, anilistId, category);
|
||||||
}
|
};
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
getEpisodes,
|
getEpisodes,
|
||||||
@@ -142,3 +330,5 @@ module.exports = {
|
|||||||
deepTranslate,
|
deepTranslate,
|
||||||
injectSourceSlugs,
|
injectSourceSlugs,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ══════════════════════════════════════════════════════════════ END: pipe.js
|
||||||
|
|||||||
+329
-102
@@ -1,102 +1,204 @@
|
|||||||
|
/*
|
||||||
|
* ======= • ======= • ======= • ======= • =======• =======
|
||||||
|
* 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 express = require("express");
|
||||||
const router = express.Router();
|
|
||||||
const anilist = require("../helpers/anilist");
|
const anilist = require("../helpers/anilist");
|
||||||
const pipe = require("../helpers/pipe");
|
const pipe = require("../helpers/pipe");
|
||||||
const { getCached, setCache } = require("../helpers/cache");
|
const { getCached, setCache } = require("../helpers/cache");
|
||||||
|
|
||||||
function handleErrors(res, err) {
|
// ══════════════════════════════════════════════════════════════
|
||||||
console.error(err.message);
|
// ROUTE REGISTRATION
|
||||||
res.status(500).json({ success: false, message: err.message });
|
// ══════════════════════════════════════════════════════════════
|
||||||
}
|
|
||||||
|
|
||||||
// Search
|
// ---- FEATURE: Main API route registration function ----
|
||||||
router.get("/search", async (req, res) => {
|
/**
|
||||||
|
* Registers all API routes on the Express app.
|
||||||
|
* Receives response helpers for consistent JSON formatting.
|
||||||
|
*
|
||||||
|
* @param {object} app - Express application instance
|
||||||
|
* @param {function} jsonResponse - Standardized success response wrapper
|
||||||
|
* @param {function} jsonError - Standardized error response wrapper
|
||||||
|
* @returns {express.Router} Configured router
|
||||||
|
*/
|
||||||
|
const createApiRoutes = (jsonResponse, jsonError) => {
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
// REQUEST COUNTER (must be before all routes)
|
||||||
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
const startTime = Date.now();
|
||||||
|
let requestCount = 0;
|
||||||
|
let errorCount = 0;
|
||||||
|
|
||||||
|
router.use((req, res, next) => {
|
||||||
|
requestCount++;
|
||||||
|
res.on("finish", () => {
|
||||||
|
if (res.statusCode >= 400) errorCount++;
|
||||||
|
});
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
// SEARCH & DISCOVERY
|
||||||
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
// ---- FEATURE: Full-text anime search with pagination ----
|
||||||
|
router.get("/search", async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { query, page = 1, per_page = 20 } = req.query;
|
const { query, page = 1, per_page = 20 } = req.query;
|
||||||
if (!query) return res.status(400).json({ success: false, message: "query parameter is required" });
|
if (!query) return jsonError(res, "query parameter is required", 400);
|
||||||
|
|
||||||
const cacheKey = `search:${query}:${page}:${per_page}`;
|
const cacheKey = `search:${query}:${page}:${per_page}`;
|
||||||
let result = getCached(cacheKey);
|
let result = getCached(cacheKey);
|
||||||
if (!result) {
|
if (!result) {
|
||||||
result = await anilist.searchAnime(query, parseInt(page), parseInt(per_page));
|
result = await anilist.searchAnime(query, parseInt(page), parseInt(per_page));
|
||||||
setCache(cacheKey, result);
|
setCache(cacheKey, result);
|
||||||
}
|
}
|
||||||
res.json({ success: true, results: result });
|
|
||||||
} catch (err) { handleErrors(res, err); }
|
|
||||||
});
|
|
||||||
|
|
||||||
// Suggestions
|
jsonResponse(res, result);
|
||||||
router.get("/suggestions", async (req, res) => {
|
} catch (err) {
|
||||||
|
jsonError(res, err.message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- FEATURE: Lightweight autocomplete suggestions (max 8) ----
|
||||||
|
router.get("/suggestions", async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { query } = req.query;
|
const { query } = req.query;
|
||||||
if (!query) return res.status(400).json({ success: false, message: "query parameter is required" });
|
if (!query) return jsonError(res, "query parameter is required", 400);
|
||||||
|
|
||||||
const cacheKey = `suggestions:${query}`;
|
const cacheKey = `suggestions:${query}`;
|
||||||
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);
|
||||||
}
|
}
|
||||||
res.json({ success: true, results: result });
|
|
||||||
} catch (err) { handleErrors(res, err); }
|
|
||||||
});
|
|
||||||
|
|
||||||
// Trending
|
jsonResponse(res, result);
|
||||||
router.get("/trending", async (req, res) => {
|
} catch (err) {
|
||||||
|
jsonError(res, err.message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- FEATURE: Advanced anime filter with multiple parameters ----
|
||||||
|
router.get("/filter", async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { genre, tag, year, season, format, status, sort = "POPULARITY_DESC", page = 1, per_page = 20 } = req.query;
|
||||||
|
const cacheKey = `filter:${genre}:${tag}:${year}:${season}:${format}:${status}:${sort}:${page}:${per_page}`;
|
||||||
|
|
||||||
|
let result = getCached(cacheKey);
|
||||||
|
if (!result) {
|
||||||
|
result = await anilist.filterAnime({
|
||||||
|
genre, tag, year: year ? parseInt(year) : null,
|
||||||
|
season, format, status, sort,
|
||||||
|
page: parseInt(page), perPage: parseInt(per_page),
|
||||||
|
});
|
||||||
|
setCache(cacheKey, result);
|
||||||
|
}
|
||||||
|
|
||||||
|
jsonResponse(res, result);
|
||||||
|
} catch (err) {
|
||||||
|
jsonError(res, err.message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
// COLLECTIONS
|
||||||
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
// ---- FEATURE: Currently trending anime ----
|
||||||
|
router.get("/trending", async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { page = 1, per_page = 20 } = req.query;
|
const { page = 1, per_page = 20 } = req.query;
|
||||||
const cacheKey = `trending:${page}:${per_page}`;
|
const cacheKey = `trending:${page}:${per_page}`;
|
||||||
|
|
||||||
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);
|
setCache(cacheKey, result, 120 * 1000);
|
||||||
}
|
}
|
||||||
res.json({ success: true, results: result });
|
|
||||||
} catch (err) { handleErrors(res, err); }
|
|
||||||
});
|
|
||||||
|
|
||||||
// Popular
|
jsonResponse(res, result);
|
||||||
router.get("/popular", async (req, res) => {
|
} catch (err) {
|
||||||
|
jsonError(res, err.message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- FEATURE: Most popular anime of all time ----
|
||||||
|
router.get("/popular", async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { page = 1, per_page = 20 } = req.query;
|
const { page = 1, per_page = 20 } = req.query;
|
||||||
const cacheKey = `popular:${page}:${per_page}`;
|
const cacheKey = `popular:${page}:${per_page}`;
|
||||||
|
|
||||||
let result = getCached(cacheKey);
|
let result = getCached(cacheKey);
|
||||||
if (!result) {
|
if (!result) {
|
||||||
result = await anilist.getCollection("POPULARITY_DESC", null, parseInt(page), parseInt(per_page));
|
result = await anilist.getCollection("POPULARITY_DESC", null, parseInt(page), parseInt(per_page));
|
||||||
setCache(cacheKey, result, 120 * 1000);
|
setCache(cacheKey, result, 120 * 1000);
|
||||||
}
|
}
|
||||||
res.json({ success: true, results: result });
|
|
||||||
} catch (err) { handleErrors(res, err); }
|
|
||||||
});
|
|
||||||
|
|
||||||
// Upcoming
|
jsonResponse(res, result);
|
||||||
router.get("/upcoming", async (req, res) => {
|
} catch (err) {
|
||||||
|
jsonError(res, err.message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- FEATURE: Most anticipated upcoming anime ----
|
||||||
|
router.get("/upcoming", async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { page = 1, per_page = 20 } = req.query;
|
const { page = 1, per_page = 20 } = req.query;
|
||||||
const cacheKey = `upcoming:${page}:${per_page}`;
|
const cacheKey = `upcoming:${page}:${per_page}`;
|
||||||
|
|
||||||
let result = getCached(cacheKey);
|
let result = getCached(cacheKey);
|
||||||
if (!result) {
|
if (!result) {
|
||||||
result = await anilist.getCollection("POPULARITY_DESC", "NOT_YET_RELEASED", parseInt(page), parseInt(per_page));
|
result = await anilist.getCollection("POPULARITY_DESC", "NOT_YET_RELEASED", parseInt(page), parseInt(per_page));
|
||||||
setCache(cacheKey, result, 120 * 1000);
|
setCache(cacheKey, result, 120 * 1000);
|
||||||
}
|
}
|
||||||
res.json({ success: true, results: result });
|
|
||||||
} catch (err) { handleErrors(res, err); }
|
|
||||||
});
|
|
||||||
|
|
||||||
// Recent
|
jsonResponse(res, result);
|
||||||
router.get("/recent", async (req, res) => {
|
} catch (err) {
|
||||||
|
jsonError(res, err.message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- FEATURE: Currently airing anime ----
|
||||||
|
router.get("/recent", async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { page = 1, per_page = 20 } = req.query;
|
const { page = 1, per_page = 20 } = req.query;
|
||||||
const cacheKey = `recent:${page}:${per_page}`;
|
const cacheKey = `recent:${page}:${per_page}`;
|
||||||
|
|
||||||
let result = getCached(cacheKey);
|
let result = getCached(cacheKey);
|
||||||
if (!result) {
|
if (!result) {
|
||||||
result = await anilist.getCollection("START_DATE_DESC", "RELEASING", parseInt(page), parseInt(per_page));
|
result = await anilist.getCollection("START_DATE_DESC", "RELEASING", parseInt(page), parseInt(per_page));
|
||||||
setCache(cacheKey, result, 120 * 1000);
|
setCache(cacheKey, result, 120 * 1000);
|
||||||
}
|
}
|
||||||
res.json({ success: true, results: result });
|
|
||||||
} catch (err) { handleErrors(res, err); }
|
|
||||||
});
|
|
||||||
|
|
||||||
// Spotlight
|
jsonResponse(res, result);
|
||||||
router.get("/spotlight", async (req, res) => {
|
} catch (err) {
|
||||||
|
jsonError(res, err.message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- FEATURE: Spotlight/featured anime (top 10) ----
|
||||||
|
router.get("/spotlight", async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const cacheKey = "spotlight";
|
const cacheKey = "spotlight";
|
||||||
let result = getCached(cacheKey);
|
let result = getCached(cacheKey);
|
||||||
@@ -104,119 +206,244 @@ router.get("/spotlight", async (req, res) => {
|
|||||||
result = await anilist.getSpotlight();
|
result = await anilist.getSpotlight();
|
||||||
setCache(cacheKey, result, 120 * 1000);
|
setCache(cacheKey, result, 120 * 1000);
|
||||||
}
|
}
|
||||||
res.json({ success: true, results: result });
|
|
||||||
} catch (err) { handleErrors(res, err); }
|
|
||||||
});
|
|
||||||
|
|
||||||
// Schedule
|
jsonResponse(res, result);
|
||||||
router.get("/schedule", async (req, res) => {
|
} catch (err) {
|
||||||
|
jsonError(res, err.message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- FEATURE: Airing schedule with timestamps ----
|
||||||
|
router.get("/schedule", async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { page = 1, per_page = 20 } = req.query;
|
const { page = 1, per_page = 20 } = req.query;
|
||||||
const cacheKey = `schedule:${page}:${per_page}`;
|
const cacheKey = `schedule:${page}:${per_page}`;
|
||||||
|
|
||||||
let result = getCached(cacheKey);
|
let result = getCached(cacheKey);
|
||||||
if (!result) {
|
if (!result) {
|
||||||
result = await anilist.getSchedule(parseInt(page), parseInt(per_page));
|
result = await anilist.getSchedule(parseInt(page), parseInt(per_page));
|
||||||
setCache(cacheKey, result, 120 * 1000);
|
setCache(cacheKey, result, 120 * 1000);
|
||||||
}
|
}
|
||||||
res.json({ success: true, results: result });
|
|
||||||
} catch (err) { handleErrors(res, err); }
|
|
||||||
});
|
|
||||||
|
|
||||||
// Filter
|
jsonResponse(res, result);
|
||||||
router.get("/filter", async (req, res) => {
|
} catch (err) {
|
||||||
try {
|
jsonError(res, err.message);
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
res.json({ success: true, results: result });
|
});
|
||||||
} catch (err) { handleErrors(res, err); }
|
|
||||||
});
|
|
||||||
|
|
||||||
// Info
|
// ══════════════════════════════════════════════════════════════
|
||||||
router.get("/info/:id", async (req, res) => {
|
// ANIME DETAILS
|
||||||
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
// ---- FEATURE: Complete anime info by AniList ID ----
|
||||||
|
router.get("/info/:id", async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const id = parseInt(req.params.id);
|
const id = parseInt(req.params.id);
|
||||||
if (isNaN(id)) return res.status(400).json({ success: false, message: "Invalid AniList ID" });
|
if (isNaN(id)) return jsonError(res, "Invalid AniList ID", 400);
|
||||||
|
|
||||||
const cacheKey = `info:${id}`;
|
const cacheKey = `info:${id}`;
|
||||||
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);
|
||||||
}
|
}
|
||||||
res.json({ success: true, results: result });
|
|
||||||
} catch (err) { handleErrors(res, err); }
|
|
||||||
});
|
|
||||||
|
|
||||||
// Characters
|
jsonResponse(res, result);
|
||||||
router.get("/anime/:id/characters", async (req, res) => {
|
} catch (err) {
|
||||||
|
jsonError(res, err.message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- FEATURE: Paginated character list with voice actors ----
|
||||||
|
router.get("/anime/:id/characters", async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const id = parseInt(req.params.id);
|
const id = parseInt(req.params.id);
|
||||||
const { page = 1, per_page = 25 } = req.query;
|
const { page = 1, per_page = 25 } = req.query;
|
||||||
const result = await anilist.getAnimeCharacters(id, parseInt(page), parseInt(per_page));
|
const result = await anilist.getAnimeCharacters(id, parseInt(page), parseInt(per_page));
|
||||||
res.json({ success: true, results: result });
|
jsonResponse(res, result);
|
||||||
} catch (err) { handleErrors(res, err); }
|
} catch (err) {
|
||||||
});
|
jsonError(res, err.message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Relations
|
// ---- FEATURE: Related anime (sequels, prequels, spin-offs) ----
|
||||||
router.get("/anime/:id/relations", async (req, res) => {
|
router.get("/anime/:id/relations", async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const id = parseInt(req.params.id);
|
const id = parseInt(req.params.id);
|
||||||
const result = await anilist.getAnimeRelations(id);
|
const result = await anilist.getAnimeRelations(id);
|
||||||
res.json({ success: true, results: result });
|
jsonResponse(res, result);
|
||||||
} catch (err) { handleErrors(res, err); }
|
} catch (err) {
|
||||||
});
|
jsonError(res, err.message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Recommendations
|
// ---- FEATURE: Community recommendations sorted by rating ----
|
||||||
router.get("/anime/:id/recommendations", async (req, res) => {
|
router.get("/anime/:id/recommendations", async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const id = parseInt(req.params.id);
|
const id = parseInt(req.params.id);
|
||||||
const { page = 1, per_page = 10 } = req.query;
|
const { page = 1, per_page = 10 } = req.query;
|
||||||
const result = await anilist.getAnimeRecommendations(id, parseInt(page), parseInt(per_page));
|
const result = await anilist.getAnimeRecommendations(id, parseInt(page), parseInt(per_page));
|
||||||
res.json({ success: true, results: result });
|
jsonResponse(res, result);
|
||||||
} catch (err) { handleErrors(res, err); }
|
} catch (err) {
|
||||||
});
|
jsonError(res, err.message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Episodes
|
// ══════════════════════════════════════════════════════════════
|
||||||
router.get("/episodes/:id", async (req, res) => {
|
// STREAMING
|
||||||
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
// ---- FEATURE: Episode list from all providers ----
|
||||||
|
router.get("/episodes/:id", async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const id = parseInt(req.params.id);
|
const id = parseInt(req.params.id);
|
||||||
if (isNaN(id)) return res.status(400).json({ success: false, message: "Invalid AniList ID" });
|
if (isNaN(id)) return jsonError(res, "Invalid AniList ID", 400);
|
||||||
|
|
||||||
const cacheKey = `episodes:${id}`;
|
const cacheKey = `episodes:${id}`;
|
||||||
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);
|
||||||
}
|
}
|
||||||
res.json({ success: true, results: result });
|
|
||||||
} catch (err) { handleErrors(res, err); }
|
|
||||||
});
|
|
||||||
|
|
||||||
// Sources (manual)
|
jsonResponse(res, result);
|
||||||
router.get("/sources", async (req, res) => {
|
} catch (err) {
|
||||||
|
jsonError(res, err.message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- FEATURE: Streaming sources (detailed/manual endpoint) ----
|
||||||
|
router.get("/sources", async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { episodeId, provider, anilistId, category = "sub" } = req.query;
|
const { episodeId, provider, anilistId, category = "sub" } = req.query;
|
||||||
if (!episodeId || !provider || !anilistId) {
|
if (!episodeId || !provider || !anilistId) {
|
||||||
return res.status(400).json({ success: false, message: "episodeId, provider, and anilistId are required" });
|
return jsonError(res, "episodeId, provider, and anilistId are required", 400);
|
||||||
}
|
}
|
||||||
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)
|
const result = await pipe.getSources(episodeId, provider, parseInt(anilistId), category);
|
||||||
router.get("/watch/:provider/:anilistId/:category/:slug", async (req, res) => {
|
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 {
|
try {
|
||||||
const { provider, anilistId, category, slug } = req.params;
|
const { provider, anilistId, category, slug } = req.params;
|
||||||
const result = await pipe.getWatchSources(provider, parseInt(anilistId), category, slug);
|
const result = await pipe.getWatchSources(provider, parseInt(anilistId), category, slug);
|
||||||
res.json({ success: true, results: result });
|
jsonResponse(res, result);
|
||||||
} catch (err) { handleErrors(res, err); }
|
} catch (err) {
|
||||||
});
|
jsonError(res, err.message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
module.exports = router;
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
// 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.2.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.2.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
|
||||||
|
|||||||
Reference in New Issue
Block a user