feat: MiruroAPI v1.0.0 - Free anime streaming API

- 16 API endpoints powered by AniList GraphQL
- Miruro pipe integration for streaming sources
- 12 streaming providers (kiwi, pewe, bee, bonk, etc.)
- M3U8 streaming URLs with subtitles and skip timestamps
- In-memory caching with TTL
- CORS support and health endpoint
This commit is contained in:
Sʜɪɴᴇɪ Nᴏᴜᴢᴇɴ
2026-06-09 13:51:39 +05:30
commit 776e1b9649
12 changed files with 1463 additions and 0 deletions
+3
View File
@@ -0,0 +1,3 @@
node_modules/
.env
.vercel/
+11
View File
@@ -0,0 +1,11 @@
# Changelog
## v1.0.0
- Initial release
- AniList GraphQL integration for anime metadata
- Miruro pipe integration for streaming sources
- 16 API endpoints: search, suggestions, trending, popular, upcoming, recent, spotlight, schedule, filter, info, characters, relations, recommendations, episodes, sources, watch
- In-memory caching with TTL
- CORS support
- Health endpoint
- Deployed on Vercel
+55
View File
@@ -0,0 +1,55 @@
# MiruroAPI
Free RESTful API for anime streaming data. Powered by AniList GraphQL and Miruro streaming providers.
## Live
- **API**: `https://miruroapi.vercel.app`
- **GitHub**: `https://github.com/Shineii86/MiruroAPI`
## Features
- 16 API endpoints
- AniList GraphQL for metadata (search, trending, info, filter, schedule)
- 12 streaming providers (kiwi, pewe, bee, bonk, bun, ally, nun, twin, cog, moo, hop, telli)
- M3U8 streaming URLs with subtitles and skip timestamps
- In-memory caching
- CORS enabled
## Endpoints
| Endpoint | Description |
|---|---|
| `GET /api/search?query=naruto` | Search anime by name |
| `GET /api/suggestions?query=naruto` | Autocomplete suggestions |
| `GET /api/trending` | Trending anime |
| `GET /api/popular` | Most popular anime |
| `GET /api/upcoming` | Upcoming anime |
| `GET /api/recent` | Currently airing anime |
| `GET /api/spotlight` | Spotlight/featured anime |
| `GET /api/schedule` | Airing schedule |
| `GET /api/filter?genre=Action` | Advanced filter |
| `GET /api/info/:id` | Full anime info by AniList ID |
| `GET /api/anime/:id/characters` | Characters with voice actors |
| `GET /api/anime/:id/relations` | Related anime |
| `GET /api/anime/:id/recommendations` | Recommendations |
| `GET /api/episodes/:id` | Episode list from all providers |
| `GET /api/sources?episodeId=...&provider=...&anilistId=...` | Streaming sources |
| `GET /api/watch/:provider/:anilistId/:category/:slug` | Simple streaming sources |
## Streaming Flow
1. `GET /api/episodes/:anilistId` — get episode list with provider IDs
2. `GET /api/watch/:provider/:anilistId/:category/:slug` — get M3U8 URL
3. Play M3U8 in any HLS player
## Setup
```bash
npm install
npm start
```
## License
MIT
+28
View File
@@ -0,0 +1,28 @@
{
"name": "miruro-api",
"version": "1.0.0",
"description": "RESTful API for miruro.com - Anime streaming data powered by AniList GraphQL and Miruro streaming providers",
"main": "server.js",
"scripts": {
"start": "node server.js",
"dev": "node server.js",
"test": "node test.js"
},
"dependencies": {
"express": "^4.21.0",
"axios": "^1.8.0",
"cors": "^2.8.5",
"dotenv": "^16.4.0"
},
"keywords": [
"anime",
"api",
"miruro",
"anilist",
"streaming",
"m3u8",
"hls"
],
"author": "Sʜɪɴᴇɪ Nᴏᴜᴢᴇɴ <157171073+Shineii86@users.noreply.github.com>",
"license": "MIT"
}
+46
View File
@@ -0,0 +1,46 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>MiruroAPI - Free Anime Streaming API</title>
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'%3E%3Cdefs%3E%3ClinearGradient id='g' x1='0%25' y1='0%25' x2='100%25' y2='100%25'%3E%3Cstop offset='0%25' style='stop-color:%2326A3D6'/%3E%3Cstop offset='100%25' style='stop-color:%237B2FF7'/%3E%3C/linearGradient%3E%3C/defs%3E%3Ccircle cx='50' cy='50' r='45' fill='url(%23g)'/%3E%3Ctext x='50' y='62' text-anchor='middle' fill='white' font-family='Arial Black' font-size='32' font-weight='bold'%3EM%3C/text%3E%3C/svg%3E">
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: 'Segoe UI', system-ui, sans-serif; background: #0b1622; color: #e1e7ef; min-height: 100vh; display: flex; align-items: center; justify-content: center; }
.container { max-width: 800px; padding: 40px; text-align: center; }
h1 { font-size: 2.5em; background: linear-gradient(135deg, #26A3D6, #7B2FF7); -webkit-background-clip: text; -webkit-text-fill-color: transparent; margin-bottom: 16px; }
p { color: #a0b1c5; font-size: 1.1em; margin-bottom: 24px; line-height: 1.6; }
.badge { display: inline-block; background: rgba(38,163,214,0.15); color: #26A3D6; padding: 6px 16px; border-radius: 20px; font-size: 0.9em; margin-bottom: 24px; border: 1px solid rgba(38,163,214,0.3); }
.endpoints { text-align: left; background: rgba(20,32,48,0.8); border-radius: 12px; padding: 24px; margin-top: 24px; border: 1px solid rgba(255,255,255,0.05); }
.endpoints h3 { color: #26A3D6; margin-bottom: 12px; }
.endpoint { padding: 8px 0; border-bottom: 1px solid rgba(255,255,255,0.03); font-family: monospace; font-size: 0.9em; }
.endpoint:last-child { border-bottom: none; }
.method { color: #10b981; font-weight: bold; }
a { color: #26A3D6; text-decoration: none; }
a:hover { text-decoration: underline; }
</style>
</head>
<body>
<div class="container">
<div class="badge">v1.0.0</div>
<h1>MiruroAPI</h1>
<p>Free RESTful API for anime streaming data. Powered by <a href="https://anilist.co">AniList GraphQL</a> and <a href="https://miruro.to">Miruro</a> streaming providers.</p>
<div class="endpoints">
<h3>Endpoints</h3>
<div class="endpoint"><span class="method">GET</span> /api/search?query=naruto</div>
<div class="endpoint"><span class="method">GET</span> /api/trending</div>
<div class="endpoint"><span class="method">GET</span> /api/popular</div>
<div class="endpoint"><span class="method">GET</span> /api/upcoming</div>
<div class="endpoint"><span class="method">GET</span> /api/recent</div>
<div class="endpoint"><span class="method">GET</span> /api/spotlight</div>
<div class="endpoint"><span class="method">GET</span> /api/schedule</div>
<div class="endpoint"><span class="method">GET</span> /api/filter?genre=Action</div>
<div class="endpoint"><span class="method">GET</span> /api/info/:id</div>
<div class="endpoint"><span class="method">GET</span> /api/episodes/:id</div>
<div class="endpoint"><span class="method">GET</span> /api/watch/:provider/:id/:category/:slug</div>
<div class="endpoint"><span class="method">GET</span> /api/health</div>
</div>
</div>
</body>
</html>
+74
View File
@@ -0,0 +1,74 @@
const express = require("express");
const cors = require("cors");
const path = require("path");
require("dotenv").config();
const app = express();
const PORT = process.env.PORT || 3000;
// CORS
const allowedOrigins = process.env.ALLOWED_ORIGINS
? process.env.ALLOWED_ORIGINS.split(",")
: null;
app.use(
cors({
origin: function (origin, callback) {
if (!allowedOrigins || allowedOrigins.includes("*") || !origin) {
return callback(null, true);
}
if (allowedOrigins.includes(origin)) {
return callback(null, true);
}
return callback(null, true);
},
methods: ["GET", "OPTIONS"],
allowedHeaders: ["Content-Type"],
})
);
// Security headers
app.use((req, res, next) => {
res.setHeader("access-control-allow-origin", "*");
res.setHeader("X-Content-Type-Options", "nosniff");
res.setHeader("X-Frame-Options", "DENY");
res.setHeader("X-XSS-Protection", "1; mode=block");
next();
});
// Static files
app.use(express.static(path.join(__dirname, "public")));
// Health
app.get("/api/health", (req, res) => {
res.json({
status: "ok",
version: "1.0.0",
timestamp: new Date().toISOString(),
uptime: process.uptime(),
});
});
// Routes
const apiRoutes = require("./src/routes/apiRoutes");
app.use("/api", apiRoutes);
// SPA fallback
app.get("*", (req, res) => {
if (req.path.startsWith("/api/")) {
return res.status(404).json({ success: false, message: "Endpoint not found" });
}
res.sendFile(path.join(__dirname, "public", "index.html"));
});
// Global error handler
app.use((err, req, res, next) => {
console.error(err.stack);
res.status(500).json({ success: false, message: "Internal server error" });
});
app.listen(PORT, () => {
console.log(`MiruroAPI running on port ${PORT}`);
});
module.exports = app;
+444
View File
@@ -0,0 +1,444 @@
from flask import Flask, jsonify, request
import requests
import time
app = Flask(__name__)
ANILIST_API = 'https://graphql.anilist.co'
MEDIA_FIELDS = '''
id
idMal
title { romaji english native userPreferred }
description(asHtml: false)
coverImage { large medium color }
bannerImage
format
status
episodes
duration
season
seasonYear
genres
tags { name }
averageScore
meanScore
popularity
trending
favourites
startDate { year month day }
endDate { year month day }
nextAiringEpisode { episode timeUntilAiring airingAt }
studios(isMain: true) { edges { node { id name } isMain } }
trailer { id site thumbnail url }
rankings { rank type context year season allTime }
'''
def anilist_query(query, variables=None):
if variables is None:
variables = {}
res = requests.post(ANILIST_API, json={'query': query, 'variables': variables})
data = res.json()
if 'errors' in data:
raise Exception(data['errors'][0]['message'])
return data['data']
def format_media(m):
return {
'id': m['id'],
'idMal': m.get('idMal'),
'title': m.get('title', {}),
'description': m.get('description'),
'coverImage': m.get('coverImage', {}),
'bannerImage': m.get('bannerImage'),
'format': m.get('format'),
'status': m.get('status'),
'episodes': m.get('episodes'),
'duration': m.get('duration'),
'season': m.get('season'),
'seasonYear': m.get('seasonYear'),
'genres': m.get('genres', []),
'tags': [t['name'] for t in (m.get('tags') or [])],
'averageScore': m.get('averageScore'),
'meanScore': m.get('meanScore'),
'popularity': m.get('popularity'),
'trending': m.get('trending'),
'favourites': m.get('favourites'),
'startDate': m.get('startDate'),
'endDate': m.get('endDate'),
'nextAiringEpisode': m.get('nextAiringEpisode'),
'studios': [
{'id': e['node']['id'], 'name': e['node']['name'], 'isMain': e['isMain']}
for e in (m.get('studios', {}).get('edges') or [])
],
'trailer': m.get('trailer'),
'rankings': m.get('rankings', []),
}
@app.route('/')
def home():
return jsonify({
'name': 'Miruro Anime API',
'version': '1.0.0',
'status': 'running',
'endpoints': [
'GET /api/trending',
'GET /api/popular',
'GET /api/top',
'GET /api/seasonal?season=SPRING&year=2026',
'GET /api/schedule?day=MONDAY',
'GET /api/airing',
'GET /api/search?query=naruto&sort=SCORE_DESC&page=1',
'GET /api/info/<id>',
'GET /api/info/<id>/episodes',
'GET /api/characters/<id>',
'GET /api/recommendations/<id>',
'GET /api/genres',
],
})
@app.route('/api/trending')
def trending():
limit = request.args.get('limit', 20, type=int)
query = f'query {{ Page(perPage: {limit}) {{ media(sort: TRENDING_DESC, type: ANIME) {{ {MEDIA_FIELDS} }} }} }}'
data = anilist_query(query)
return jsonify({'success': True, 'data': [format_media(m) for m in data['Page']['media']]})
@app.route('/api/popular')
def popular():
page = request.args.get('page', 1, type=int)
limit = request.args.get('limit', 20, type=int)
query = f'query {{ Page(page: {page}, perPage: {limit}) {{ media(sort: POPULARITY_DESC, type: ANIME) {{ {MEDIA_FIELDS} }} pageInfo {{ total currentPage lastPage perPage }} }} }}'
data = anilist_query(query)
return jsonify({
'success': True,
'data': [format_media(m) for m in data['Page']['media']],
'pagination': data['Page']['pageInfo'],
})
@app.route('/api/top')
def top():
page = request.args.get('page', 1, type=int)
limit = request.args.get('limit', 20, type=int)
query = f'query {{ Page(page: {page}, perPage: {limit}) {{ media(sort: SCORE_DESC, type: ANIME) {{ {MEDIA_FIELDS} }} pageInfo {{ total currentPage lastPage perPage }} }} }}'
data = anilist_query(query)
return jsonify({
'success': True,
'data': [format_media(m) for m in data['Page']['media']],
'pagination': data['Page']['pageInfo'],
})
@app.route('/api/seasonal')
def seasonal():
season = request.args.get('season', 'SPRING').upper()
year = request.args.get('year', 2026, type=int)
limit = request.args.get('limit', 20, type=int)
query = f'''query ($season: MediaSeason, $seasonYear: Int) {{
Page(perPage: {limit}) {{
media(season: $season, seasonYear: $seasonYear, sort: POPULARITY_DESC, type: ANIME) {{ {MEDIA_FIELDS} }}
}}
}}'''
data = anilist_query(query, {'season': season, 'seasonYear': year})
return jsonify({'success': True, 'season': season, 'year': year, 'data': [format_media(m) for m in data['Page']['media']] })
@app.route('/api/schedule')
def schedule():
day = request.args.get('day', '').upper()
query = f'''query {{
Page(perPage: 50) {{
media(sort: TRENDING_DESC, type: ANIME, status: RELEASING) {{
id
title {{ romaji english native userPreferred }}
coverImage {{ large medium color }}
nextAiringEpisode {{ episode timeUntilAiring airingAt }}
}}
}}
}}'''
data = anilist_query(query)
episodes = []
for m in data['Page']['media']:
if not m.get('nextAiringEpisode'):
continue
ep = m['nextAiringEpisode']
air_date = time.gmtime(ep['airingAt'])
dow = ['SUNDAY','MONDAY','TUESDAY','WEDNESDAY','THURSDAY','FRIDAY','SATURDAY']
entry = {
'id': m['id'],
'title': m['title']['userPreferred'],
'coverImage': m.get('coverImage', {}).get('large'),
'nextEpisode': ep['episode'],
'airingAt': ep['airingAt'],
'dayOfWeek': dow[air_date.tm_wday],
'timeUntilAiring': ep['timeUntilAiring'],
}
if day and dow[air_date.tm_wday] != day:
continue
episodes.append(entry)
episodes.sort(key=lambda x: x['airingAt'])
return jsonify({'success': True, 'data': episodes})
@app.route('/api/airing')
def airing():
query = f'''query {{
Page(perPage: 25) {{
media(sort: TRENDING_DESC, type: ANIME, status: RELEASING) {{
id
title {{ romaji english native userPreferred }}
coverImage {{ large medium color }}
bannerImage
format
episodes
averageScore
nextAiringEpisode {{ episode timeUntilAiring airingAt }}
}}
}}
}}'''
data = anilist_query(query)
result = []
for m in data['Page']['media']:
if not m.get('nextAiringEpisode'):
continue
result.append({
'id': m['id'],
'title': m['title']['userPreferred'],
'coverImage': m.get('coverImage', {}).get('large'),
'bannerImage': m.get('bannerImage'),
'format': m.get('format'),
'episodes': m.get('episodes'),
'averageScore': m.get('averageScore'),
'nextEpisode': m['nextAiringEpisode']['episode'],
'airingAt': m['nextAiringEpisode']['airingAt'],
'timeUntilAiring': m['nextAiringEpisode']['timeUntilAiring'],
})
return jsonify({'success': True, 'data': result})
@app.route('/api/search')
def search():
q = request.args.get('query', '')
sort = request.args.get('sort', 'POPULARITY_DESC').upper()
genre = request.args.get('genre', '')
fmt = request.args.get('format', '').upper()
status = request.args.get('status', '').upper()
season = request.args.get('season', '').upper()
year = request.args.get('year', '', type=str)
page = request.args.get('page', 1, type=int)
limit = request.args.get('limit', 20, type=int)
if not q and not genre:
return jsonify({'success': False, 'error': 'query or genre parameter required'}), 400
sort_map = {
'SCORE_DESC': 'SCORE_DESC', 'POPULARITY_DESC': 'POPULARITY_DESC',
'TRENDING_DESC': 'TRENDING_DESC', 'FAVOURITES_DESC': 'FAVOURITES_DESC',
'START_DATE_DESC': 'START_DATE_DESC', 'UPDATED_AT_DESC': 'UPDATED_AT_DESC',
}
s = sort_map.get(sort, 'POPULARITY_DESC')
filters = ''
variables = {'search': q or None, 'sort': s, 'page': page, 'perPage': limit}
if genre:
filters += ', genre: $genre'
variables['genre'] = genre
if fmt:
filters += ', format: $format'
variables['format'] = fmt
if status:
filters += ', status: $status'
variables['status'] = status
if season:
filters += ', season: $season'
variables['season'] = season
if year:
filters += ', seasonYear: $seasonYear'
variables['seasonYear'] = int(year)
query = f'''query ($search: String, $sort: [MediaSort], $genre: String, $format: MediaFormat, $status: MediaStatus, $season: MediaSeason, $seasonYear: Int, $page: Int, $perPage: Int) {{
Page(page: $page, perPage: $perPage) {{
media(search: $search, type: ANIME, sort: $sort{filters}) {{ {MEDIA_FIELDS} }}
pageInfo {{ total currentPage lastPage perPage }}
}}
}}'''
data = anilist_query(query, variables)
return jsonify({
'success': True,
'data': [format_media(m) for m in data['Page']['media']],
'pagination': data['Page']['pageInfo'],
})
@app.route('/api/info/<int:anime_id>')
def info(anime_id):
query = f'''query ($id: Int) {{
Media(id: $id, type: ANIME) {{
{MEDIA_FIELDS}
relations {{
edges {{
node {{ id title {{ romaji english native userPreferred }} format coverImage {{ large medium }} }}
relationType
}}
}}
externalLinks {{ id site url type language }}
streamingEpisodes {{ title thumbnail url site }}
}}
}}'''
data = anilist_query(query, {'id': anime_id})
if not data.get('Media'):
return jsonify({'success': False, 'error': 'Anime not found'}), 404
m = data['Media']
result = format_media(m)
result['relations'] = [
{
'id': e['node']['id'],
'title': e['node']['title']['userPreferred'],
'format': e['node']['format'],
'coverImage': e['node']['coverImage'].get('large') if e['node'].get('coverImage') else None,
'relationType': e['relationType'],
}
for e in (m.get('relations', {}).get('edges') or [])
]
result['externalLinks'] = m.get('externalLinks') or []
result['streamingEpisodes'] = m.get('streamingEpisodes') or []
return jsonify({'success': True, 'data': result})
@app.route('/api/info/<int:anime_id>/episodes')
def episodes(anime_id):
query = f'''query ($id: Int) {{
Media(id: $id, type: ANIME) {{
id
title {{ romaji english native userPreferred }}
episodes
streamingEpisodes {{ title thumbnail url site }}
externalLinks {{ id site url type }}
}}
}}'''
data = anilist_query(query, {'id': anime_id})
if not data.get('Media'):
return jsonify({'success': False, 'error': 'Anime not found'}), 404
m = data['Media']
eps = []
for i, ep in enumerate(m.get('streamingEpisodes') or []):
eps.append({
'number': i + 1,
'title': ep.get('title'),
'thumbnail': ep.get('thumbnail'),
'url': ep.get('url'),
'site': ep.get('site'),
})
return jsonify({
'success': True,
'animeId': m['id'],
'title': m['title']['userPreferred'],
'totalEpisodes': m.get('episodes'),
'episodes': eps,
'providers': ['kiwi', 'pewe', 'bee', 'bonk', 'bun', 'ally', 'nun', 'twin', 'cog', 'moo', 'hop'],
})
@app.route('/api/characters/<int:anime_id>')
def characters(anime_id):
query = f'''query ($id: Int) {{
Media(id: $id, type: ANIME) {{
id
characters(sort: ROLE, perPage: 25) {{
edges {{
node {{ id name {{ full native alternative }} image {{ large medium }} gender age bloodType }}
role
}}
}}
}}
}}'''
data = anilist_query(query, {'id': anime_id})
if not data.get('Media'):
return jsonify({'success': False, 'error': 'Anime not found'}), 404
return jsonify({
'success': True,
'animeId': data['Media']['id'],
'characters': [
{
'id': e['node']['id'],
'name': e['node']['name'],
'image': e['node']['image'],
'role': e['role'],
'gender': e['node'].get('gender'),
'age': e['node'].get('age'),
'bloodType': e['node'].get('bloodType'),
}
for e in data['Media']['characters']['edges']
],
})
@app.route('/api/recommendations/<int:anime_id>')
def recommendations(anime_id):
query = f'''query ($id: Int) {{
Media(id: $id, type: ANIME) {{
id
recommendations(perPage: 10, sort: RATING_DESC) {{
edges {{
node {{
id rating
mediaRecommendation {{
id
title {{ romaji english native userPreferred }}
coverImage {{ large medium color }}
format episodes averageScore popularity
}}
}}
}}
}}
}}
}}'''
data = anilist_query(query, {'id': anime_id})
if not data.get('Media'):
return jsonify({'success': False, 'error': 'Anime not found'}), 404
return jsonify({
'success': True,
'animeId': data['Media']['id'],
'recommendations': [
{
'id': e['node']['mediaRecommendation']['id'],
'title': e['node']['mediaRecommendation']['title']['userPreferred'],
'coverImage': e['node']['mediaRecommendation']['coverImage'].get('large'),
'format': e['node']['mediaRecommendation']['format'],
'episodes': e['node']['mediaRecommendation']['episodes'],
'averageScore': e['node']['mediaRecommendation']['averageScore'],
'rating': e['node']['rating'],
}
for e in data['Media']['recommendations']['edges']
],
})
@app.route('/api/genres')
def genres():
data = anilist_query('{ GenreCollection }')
return jsonify({'success': True, 'data': data['GenreCollection']})
if __name__ == '__main__':
print('\n 🎬 Miruro Anime API running on http://localhost:3000\n')
print(' Endpoints:')
print(' GET /api/trending')
print(' GET /api/popular')
print(' GET /api/top')
print(' GET /api/seasonal?season=SPRING&year=2026')
print(' GET /api/schedule?day=MONDAY')
print(' GET /api/airing')
print(' GET /api/search?query=naruto')
print(' GET /api/info/<id>')
print(' GET /api/info/<id>/episodes')
print(' GET /api/characters/<id>')
print(' GET /api/recommendations/<id>')
print(' GET /api/genres\n')
app.run(host='0.0.0.0', port=3000, debug=True)
+396
View File
@@ -0,0 +1,396 @@
const axios = require("axios");
const ANILIST_URL = "https://graphql.anilist.co";
const MEDIA_LIST_FIELDS = `
id
title { romaji english native }
coverImage { large extraLarge }
bannerImage
format
season
seasonYear
episodes
duration
status
averageScore
meanScore
popularity
favourites
genres
source
countryOfOrigin
isAdult
studios(isMain: true) { nodes { name isAnimationStudio } }
nextAiringEpisode { episode airingAt timeUntilAiring }
startDate { year month day }
endDate { year month day }
`;
const MEDIA_FULL_FIELDS = `
id
idMal
title { romaji english native }
description(asHtml: false)
coverImage { large extraLarge color }
bannerImage
format
season
seasonYear
episodes
duration
status
averageScore
meanScore
popularity
favourites
trending
genres
tags { name rank isMediaSpoiler }
source
countryOfOrigin
isAdult
hashtag
synonyms
siteUrl
trailer { id site thumbnail }
studios { nodes { id name isAnimationStudio siteUrl } }
nextAiringEpisode { episode airingAt timeUntilAiring }
startDate { year month day }
endDate { year month day }
characters(sort: [ROLE, RELEVANCE], perPage: 25) {
edges {
role
node { id name { full native } image { large } }
voiceActors(language: JAPANESE) { id name { full native } image { large } languageV2 }
}
}
staff(sort: RELEVANCE, perPage: 25) {
edges {
role
node { id name { full native } image { large } }
}
}
relations {
edges {
relationType(version: 2)
node {
id
title { romaji english native }
coverImage { large }
format
type
status
episodes
meanScore
}
}
}
recommendations(sort: RATING_DESC, perPage: 10) {
nodes {
rating
mediaRecommendation {
id
title { romaji english native }
coverImage { large }
format
episodes
status
meanScore
averageScore
}
}
}
externalLinks { url site type }
streamingEpisodes { title thumbnail url site }
stats {
scoreDistribution { score amount }
statusDistribution { status amount }
}
`;
async function anilistQuery(query, variables = {}) {
const body = { query, variables };
const res = await axios.post(ANILIST_URL, body, {
headers: { "Content-Type": "application/json" },
timeout: 15000,
});
if (res.status !== 200) throw new Error("AniList query failed");
return res.data.data;
}
async function searchAnime(query, page = 1, perPage = 20) {
const gql = `
query ($search: String, $page: Int, $perPage: Int) {
Page(page: $page, perPage: $perPage) {
pageInfo { total currentPage lastPage hasNextPage perPage }
media(search: $search, type: ANIME, sort: SEARCH_MATCH) {
${MEDIA_LIST_FIELDS}
}
}
}
`;
const data = await anilistQuery(gql, { search: query, page, perPage });
const pageData = data.Page;
return {
page: pageData.pageInfo.currentPage,
perPage: pageData.pageInfo.perPage,
total: pageData.pageInfo.total,
hasNextPage: pageData.pageInfo.hasNextPage,
results: pageData.media,
};
}
async function getSuggestions(query) {
const gql = `
query ($search: String) {
Page(page: 1, perPage: 8) {
media(search: $search, type: ANIME, sort: SEARCH_MATCH) {
id
title { romaji english }
coverImage { large }
format
status
startDate { year }
episodes
}
}
}
`;
const data = await anilistQuery(gql, { search: query });
return data.Page.media.map((item) => ({
id: item.id,
title: item.title.english || item.title.romaji,
title_romaji: item.title.romaji,
poster: item.coverImage.large,
format: item.format,
status: item.status,
year: item.startDate?.year,
episodes: item.episodes,
}));
}
async function getAnimeInfo(id) {
const gql = `
query ($id: Int) {
Media(id: $id, type: ANIME) {
${MEDIA_FULL_FIELDS}
}
}
`;
const data = await anilistQuery(gql, { id });
return data.Media;
}
async function getAnimeCharacters(id, page = 1, perPage = 25) {
const gql = `
query ($id: Int, $page: Int, $perPage: Int) {
Media(id: $id, type: ANIME) {
id
title { romaji english }
characters(sort: [ROLE, RELEVANCE], page: $page, perPage: $perPage) {
pageInfo { total currentPage lastPage hasNextPage perPage }
edges {
role
node { id name { full native userPreferred } image { large medium } description gender favourites siteUrl }
voiceActors { id name { full native } image { large } languageV2 }
}
}
}
}
`;
const data = await anilistQuery(gql, { id, page, perPage });
if (!data.Media) throw new Error("Anime not found");
const chars = data.Media.characters;
return {
page: chars.pageInfo.currentPage,
perPage: chars.pageInfo.perPage,
total: chars.pageInfo.total,
hasNextPage: chars.pageInfo.hasNextPage,
characters: chars.edges,
};
}
async function getAnimeRelations(id) {
const gql = `
query ($id: Int) {
Media(id: $id, type: ANIME) {
id
title { romaji english }
relations {
edges {
relationType(version: 2)
node {
id title { romaji english native } coverImage { large }
format type status episodes meanScore averageScore popularity
}
}
}
}
}
`;
const data = await anilistQuery(gql, { id });
if (!data.Media) throw new Error("Anime not found");
return {
id: data.Media.id,
title: data.Media.title,
relations: data.Media.relations.edges,
};
}
async function getAnimeRecommendations(id, page = 1, perPage = 10) {
const gql = `
query ($id: Int, $page: Int, $perPage: Int) {
Media(id: $id, type: ANIME) {
id
title { romaji english }
recommendations(sort: RATING_DESC, page: $page, perPage: $perPage) {
pageInfo { total currentPage lastPage hasNextPage perPage }
nodes {
rating
mediaRecommendation {
id title { romaji english native } coverImage { large extraLarge }
format episodes status meanScore averageScore popularity genres
}
}
}
}
}
`;
const data = await anilistQuery(gql, { id, page, perPage });
if (!data.Media) throw new Error("Anime not found");
const recs = data.Media.recommendations;
return {
page: recs.pageInfo.currentPage,
perPage: recs.pageInfo.perPage,
total: recs.pageInfo.total,
hasNextPage: recs.pageInfo.hasNextPage,
recommendations: recs.nodes,
};
}
async function getCollection(sortType, status = null, page = 1, perPage = 20) {
const statusFilter = status ? `, status: ${status}` : "";
const gql = `
query ($page: Int, $perPage: Int) {
Page(page: $page, perPage: $perPage) {
pageInfo { total currentPage lastPage hasNextPage perPage }
media(type: ANIME, sort: [${sortType}]${statusFilter}) {
${MEDIA_LIST_FIELDS}
}
}
}
`;
const data = await anilistQuery(gql, { page, perPage });
const pageData = data.Page;
return {
page: pageData.pageInfo.currentPage,
perPage: pageData.pageInfo.perPage,
total: pageData.pageInfo.total,
hasNextPage: pageData.pageInfo.hasNextPage,
results: pageData.media,
};
}
async function getSpotlight() {
const gql = `
query {
Page(page: 1, perPage: 10) {
media(sort: [TRENDING_DESC, POPULARITY_DESC], type: ANIME) {
${MEDIA_LIST_FIELDS}
}
}
}
`;
const data = await anilistQuery(gql);
return data.Page.media;
}
async function getSchedule(page = 1, perPage = 20) {
const gql = `
query ($page: Int, $perPage: Int) {
Page(page: $page, perPage: $perPage) {
pageInfo { total currentPage lastPage hasNextPage perPage }
airingSchedules(notYetAired: true, sort: TIME) {
episode
airingAt
timeUntilAiring
media { ${MEDIA_LIST_FIELDS} }
}
}
}
`;
const data = await anilistQuery(gql, { page, perPage });
const pageData = data.Page;
const results = pageData.airingSchedules.map((item) => ({
...item.media,
next_episode: item.episode,
airingAt: item.airingAt,
timeUntilAiring: item.timeUntilAiring,
}));
return {
page: pageData.pageInfo.currentPage,
perPage: pageData.pageInfo.perPage,
total: pageData.pageInfo.total,
hasNextPage: pageData.pageInfo.hasNextPage,
results,
};
}
async function filterAnime({ genre, tag, year, season, format, status, sort = "POPULARITY_DESC", page = 1, perPage = 20 }) {
const SORT_MAP = {
SCORE_DESC: "SCORE_DESC",
POPULARITY_DESC: "POPULARITY_DESC",
TRENDING_DESC: "TRENDING_DESC",
START_DATE_DESC: "START_DATE_DESC",
FAVOURITES_DESC: "FAVOURITES_DESC",
UPDATED_AT_DESC: "UPDATED_AT_DESC",
};
const args = ["type: ANIME", `sort: [${SORT_MAP[sort] || "POPULARITY_DESC"}]`];
const variables = { page, perPage };
const varTypes = ["$page: Int", "$perPage: Int"];
if (genre) { args.push("genre: $genre"); variables.genre = genre; varTypes.push("$genre: String"); }
if (tag) { args.push("tag: $tag"); variables.tag = tag; varTypes.push("$tag: String"); }
if (year) { args.push("seasonYear: $seasonYear"); variables.seasonYear = year; varTypes.push("$seasonYear: Int"); }
if (season) { args.push("season: $season"); variables.season = season.toUpperCase(); varTypes.push("$season: MediaSeason"); }
if (format) { args.push("format: $format"); variables.format = format.toUpperCase(); varTypes.push("$format: MediaFormat"); }
if (status) { args.push("status: $status"); variables.status = status.toUpperCase(); varTypes.push("$status: MediaStatus"); }
const gql = `
query (${varTypes.join(", ")}) {
Page(page: $page, perPage: $perPage) {
pageInfo { total currentPage lastPage hasNextPage perPage }
media(${args.join(", ")}) {
${MEDIA_LIST_FIELDS}
}
}
}
`;
const data = await anilistQuery(gql, variables);
const pageData = data.Page;
return {
page: pageData.pageInfo.currentPage,
perPage: pageData.pageInfo.perPage,
total: pageData.pageInfo.total,
hasNextPage: pageData.pageInfo.hasNextPage,
results: pageData.media,
};
}
module.exports = {
anilistQuery,
searchAnime,
getSuggestions,
getAnimeInfo,
getAnimeCharacters,
getAnimeRelations,
getAnimeRecommendations,
getCollection,
getSpotlight,
getSchedule,
filterAnime,
MEDIA_LIST_FIELDS,
MEDIA_FULL_FIELDS,
};
+27
View File
@@ -0,0 +1,27 @@
const cache = new Map();
const MAX_CACHE_SIZE = 100;
const DEFAULT_TTL = 60 * 1000;
function getCached(key) {
const entry = cache.get(key);
if (!entry) return null;
if (Date.now() > entry.expiry) {
cache.delete(key);
return null;
}
return entry.data;
}
function setCache(key, data, ttl = DEFAULT_TTL) {
if (cache.size >= MAX_CACHE_SIZE) {
const firstKey = cache.keys().next().value;
cache.delete(firstKey);
}
cache.set(key, { data, expiry: Date.now() + ttl });
}
function clearCache() {
cache.clear();
}
module.exports = { getCached, setCache, clearCache };
+144
View File
@@ -0,0 +1,144 @@
const axios = require("axios");
const { Buffer } = require("buffer");
const zlib = require("zlib");
const MIRURO_PIPE_URL = "https://www.miruro.tv/api/secure/pipe";
const HEADERS = {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36",
Referer: "https://www.miruro.tv/",
};
function encodePipeRequest(payload) {
const json = JSON.stringify(payload);
return Buffer.from(json).toString("base64url");
}
function decodePipeResponse(encodedStr) {
try {
const padded = encodedStr + "=".repeat((4 - (encodedStr.length % 4)) % 4);
const compressed = Buffer.from(padded, "base64url");
const decompressed = zlib.gunzipSync(compressed);
return JSON.parse(decompressed.toString("utf-8"));
} catch (e) {
throw new Error("Failed to decode pipe response: " + e.message);
}
}
function translateId(encodedId) {
try {
const padded = encodedId + "=".repeat((4 - (encodedId.length % 4)) % 4);
const decoded = Buffer.from(padded, "base64url").toString("utf-8");
if (decoded.includes(":")) return decoded;
return encodedId;
} catch {
return encodedId;
}
}
function deepTranslate(obj) {
if (obj && typeof obj === "object") {
for (const key of Object.keys(obj)) {
if (key === "id" && typeof obj[key] === "string") {
obj[key] = translateId(obj[key]);
} else if (typeof obj[key] === "object") {
deepTranslate(obj[key]);
}
}
}
return obj;
}
function injectSourceSlugs(data, anilistId) {
const providers = data.providers || {};
for (const [provName, provData] of Object.entries(providers)) {
if (!provData || typeof provData !== "object") continue;
let episodes = provData.episodes;
if (!episodes) continue;
if (Array.isArray(episodes)) {
provData.episodes = { sub: episodes };
episodes = provData.episodes;
}
for (const [category, epList] of Object.entries(episodes)) {
if (!Array.isArray(epList)) continue;
for (const ep of epList) {
if (ep.id && ep.number) {
const origId = ep.id;
const prefix = origId.includes(":") ? origId.split(":")[0] : origId;
ep.id = `watch/${provName}/${anilistId}/${category}/${prefix}-${ep.number}`;
}
}
}
}
return data;
}
async function fetchRawEpisodes(anilistId) {
const payload = {
path: "episodes",
method: "GET",
query: { anilistId },
body: null,
version: "0.1.0",
};
const encodedReq = encodePipeRequest(payload);
const res = await axios.get(`${MIRURO_PIPE_URL}?e=${encodedReq}`, {
headers: HEADERS,
timeout: 15000,
});
if (res.status !== 200) throw new Error(`Pipe request failed: ${res.status}`);
const data = decodePipeResponse(res.text || res.data);
return deepTranslate(data);
}
async function getEpisodes(anilistId) {
const data = await fetchRawEpisodes(anilistId);
return injectSourceSlugs(data, anilistId);
}
async function getSources(episodeId, provider, anilistId, category = "sub") {
const encId = Buffer.from(episodeId).toString("base64url");
const payload = {
path: "sources",
method: "GET",
query: { episodeId: encId, provider, category, anilistId },
body: null,
version: "0.1.0",
};
const encodedReq = encodePipeRequest(payload);
const res = await axios.get(`${MIRURO_PIPE_URL}?e=${encodedReq}`, {
headers: HEADERS,
timeout: 15000,
});
if (res.status !== 200) throw new Error(`Pipe sources request failed: ${res.status}`);
return deepTranslate(decodePipeResponse(res.text || res.data));
}
async function getWatchSources(provider, anilistId, category, slug) {
const data = await fetchRawEpisodes(anilistId);
const provData = (data.providers || {})[provider];
if (!provData) throw new Error(`Provider ${provider} not found`);
const episodes = provData.episodes?.[category] || [];
let targetId = null;
for (const ep of episodes) {
const origId = ep.id || "";
const prefix = origId.includes(":") ? origId.split(":")[0] : origId;
const generated = `${prefix}-${ep.number}`;
if (generated === slug) {
targetId = origId;
break;
}
}
if (!targetId) throw new Error(`Episode slug '${slug}' not found for provider ${provider}`);
return getSources(targetId, provider, anilistId, category);
}
module.exports = {
getEpisodes,
getSources,
getWatchSources,
encodePipeRequest,
decodePipeResponse,
translateId,
deepTranslate,
injectSourceSlugs,
};
+222
View File
@@ -0,0 +1,222 @@
const express = require("express");
const router = express.Router();
const anilist = require("../helpers/anilist");
const pipe = require("../helpers/pipe");
const { getCached, setCache } = require("../helpers/cache");
function handleErrors(res, err) {
console.error(err.message);
res.status(500).json({ success: false, message: err.message });
}
// Search
router.get("/search", async (req, res) => {
try {
const { query, page = 1, per_page = 20 } = req.query;
if (!query) return res.status(400).json({ success: false, message: "query parameter is required" });
const cacheKey = `search:${query}:${page}:${per_page}`;
let result = getCached(cacheKey);
if (!result) {
result = await anilist.searchAnime(query, parseInt(page), parseInt(per_page));
setCache(cacheKey, result);
}
res.json({ success: true, results: result });
} catch (err) { handleErrors(res, err); }
});
// Suggestions
router.get("/suggestions", async (req, res) => {
try {
const { query } = req.query;
if (!query) return res.status(400).json({ success: false, message: "query parameter is required" });
const cacheKey = `suggestions:${query}`;
let result = getCached(cacheKey);
if (!result) {
result = await anilist.getSuggestions(query);
setCache(cacheKey, result, 120 * 1000);
}
res.json({ success: true, results: result });
} catch (err) { handleErrors(res, err); }
});
// Trending
router.get("/trending", async (req, res) => {
try {
const { page = 1, per_page = 20 } = req.query;
const cacheKey = `trending:${page}:${per_page}`;
let result = getCached(cacheKey);
if (!result) {
result = await anilist.getCollection("TRENDING_DESC", null, parseInt(page), parseInt(per_page));
setCache(cacheKey, result, 120 * 1000);
}
res.json({ success: true, results: result });
} catch (err) { handleErrors(res, err); }
});
// Popular
router.get("/popular", async (req, res) => {
try {
const { page = 1, per_page = 20 } = req.query;
const cacheKey = `popular:${page}:${per_page}`;
let result = getCached(cacheKey);
if (!result) {
result = await anilist.getCollection("POPULARITY_DESC", null, parseInt(page), parseInt(per_page));
setCache(cacheKey, result, 120 * 1000);
}
res.json({ success: true, results: result });
} catch (err) { handleErrors(res, err); }
});
// Upcoming
router.get("/upcoming", async (req, res) => {
try {
const { page = 1, per_page = 20 } = req.query;
const cacheKey = `upcoming:${page}:${per_page}`;
let result = getCached(cacheKey);
if (!result) {
result = await anilist.getCollection("POPULARITY_DESC", "NOT_YET_RELEASED", parseInt(page), parseInt(per_page));
setCache(cacheKey, result, 120 * 1000);
}
res.json({ success: true, results: result });
} catch (err) { handleErrors(res, err); }
});
// Recent
router.get("/recent", async (req, res) => {
try {
const { page = 1, per_page = 20 } = req.query;
const cacheKey = `recent:${page}:${per_page}`;
let result = getCached(cacheKey);
if (!result) {
result = await anilist.getCollection("START_DATE_DESC", "RELEASING", parseInt(page), parseInt(per_page));
setCache(cacheKey, result, 120 * 1000);
}
res.json({ success: true, results: result });
} catch (err) { handleErrors(res, err); }
});
// Spotlight
router.get("/spotlight", async (req, res) => {
try {
const cacheKey = "spotlight";
let result = getCached(cacheKey);
if (!result) {
result = await anilist.getSpotlight();
setCache(cacheKey, result, 120 * 1000);
}
res.json({ success: true, results: result });
} catch (err) { handleErrors(res, err); }
});
// Schedule
router.get("/schedule", async (req, res) => {
try {
const { page = 1, per_page = 20 } = req.query;
const cacheKey = `schedule:${page}:${per_page}`;
let result = getCached(cacheKey);
if (!result) {
result = await anilist.getSchedule(parseInt(page), parseInt(per_page));
setCache(cacheKey, result, 120 * 1000);
}
res.json({ success: true, results: result });
} catch (err) { handleErrors(res, err); }
});
// Filter
router.get("/filter", async (req, res) => {
try {
const { genre, tag, year, season, format, status, sort = "POPULARITY_DESC", page = 1, per_page = 20 } = req.query;
const cacheKey = `filter:${genre}:${tag}:${year}:${season}:${format}:${status}:${sort}:${page}:${per_page}`;
let result = getCached(cacheKey);
if (!result) {
result = await anilist.filterAnime({
genre, tag, year: year ? parseInt(year) : null, season, format, status, sort,
page: parseInt(page), perPage: parseInt(per_page),
});
setCache(cacheKey, result);
}
res.json({ success: true, results: result });
} catch (err) { handleErrors(res, err); }
});
// Info
router.get("/info/:id", async (req, res) => {
try {
const id = parseInt(req.params.id);
if (isNaN(id)) return res.status(400).json({ success: false, message: "Invalid AniList ID" });
const cacheKey = `info:${id}`;
let result = getCached(cacheKey);
if (!result) {
result = await anilist.getAnimeInfo(id);
setCache(cacheKey, result, 300 * 1000);
}
res.json({ success: true, results: result });
} catch (err) { handleErrors(res, err); }
});
// Characters
router.get("/anime/:id/characters", async (req, res) => {
try {
const id = parseInt(req.params.id);
const { page = 1, per_page = 25 } = req.query;
const result = await anilist.getAnimeCharacters(id, parseInt(page), parseInt(per_page));
res.json({ success: true, results: result });
} catch (err) { handleErrors(res, err); }
});
// Relations
router.get("/anime/:id/relations", async (req, res) => {
try {
const id = parseInt(req.params.id);
const result = await anilist.getAnimeRelations(id);
res.json({ success: true, results: result });
} catch (err) { handleErrors(res, err); }
});
// Recommendations
router.get("/anime/:id/recommendations", async (req, res) => {
try {
const id = parseInt(req.params.id);
const { page = 1, per_page = 10 } = req.query;
const result = await anilist.getAnimeRecommendations(id, parseInt(page), parseInt(per_page));
res.json({ success: true, results: result });
} catch (err) { handleErrors(res, err); }
});
// Episodes
router.get("/episodes/:id", async (req, res) => {
try {
const id = parseInt(req.params.id);
if (isNaN(id)) return res.status(400).json({ success: false, message: "Invalid AniList ID" });
const cacheKey = `episodes:${id}`;
let result = getCached(cacheKey);
if (!result) {
result = await pipe.getEpisodes(id);
setCache(cacheKey, result, 120 * 1000);
}
res.json({ success: true, results: result });
} catch (err) { handleErrors(res, err); }
});
// Sources (manual)
router.get("/sources", async (req, res) => {
try {
const { episodeId, provider, anilistId, category = "sub" } = req.query;
if (!episodeId || !provider || !anilistId) {
return res.status(400).json({ success: false, message: "episodeId, provider, and anilistId are required" });
}
const result = await pipe.getSources(episodeId, provider, parseInt(anilistId), category);
res.json({ success: true, results: result });
} catch (err) { handleErrors(res, err); }
});
// Watch (simple slug-based sources)
router.get("/watch/:provider/:anilistId/:category/:slug", async (req, res) => {
try {
const { provider, anilistId, category, slug } = req.params;
const result = await pipe.getWatchSources(provider, parseInt(anilistId), category, slug);
res.json({ success: true, results: result });
} catch (err) { handleErrors(res, err); }
});
module.exports = router;
+13
View File
@@ -0,0 +1,13 @@
{
"version": 2,
"builds": [
{
"src": "server.js",
"use": "@vercel/node"
}
],
"routes": [
{ "src": "/api/(.*)", "dest": "server.js" },
{ "src": "/(.*)", "dest": "server.js" }
]
}