mirror of
https://github.com/Shineii86/MiruroAPI.git
synced 2026-10-22 02:05:10 +00:00
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:
@@ -0,0 +1,3 @@
|
||||
node_modules/
|
||||
.env
|
||||
.vercel/
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
@@ -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)
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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 };
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"version": 2,
|
||||
"builds": [
|
||||
{
|
||||
"src": "server.js",
|
||||
"use": "@vercel/node"
|
||||
}
|
||||
],
|
||||
"routes": [
|
||||
{ "src": "/api/(.*)", "dest": "server.js" },
|
||||
{ "src": "/(.*)", "dest": "server.js" }
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user