mirror of
https://github.com/Shineii86/MiruroAPI.git
synced 2026-10-22 10:15: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