commit 776e1b96498789d9c3091d5cc3a85add0c773dfb
Author: Sʜɪɴᴇɪ Nᴏᴜᴢᴇɴ <157171073+Shineii86@users.noreply.github.com>
Date: Tue Jun 9 13:51:39 2026 +0530
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
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..35417bd
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,3 @@
+node_modules/
+.env
+.vercel/
diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 0000000..13bde2e
--- /dev/null
+++ b/CHANGELOG.md
@@ -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
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..87e2b26
--- /dev/null
+++ b/README.md
@@ -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
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..2bb9544
--- /dev/null
+++ b/package.json
@@ -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"
+}
diff --git a/public/index.html b/public/index.html
new file mode 100644
index 0000000..c7f2588
--- /dev/null
+++ b/public/index.html
@@ -0,0 +1,46 @@
+
+
+
+
+
+ MiruroAPI - Free Anime Streaming API
+
+
+
+
+
+
v1.0.0
+
MiruroAPI
+
Free RESTful API for anime streaming data. Powered by AniList GraphQL and Miruro streaming providers.
+
+
Endpoints
+
GET /api/search?query=naruto
+
GET /api/trending
+
GET /api/popular
+
GET /api/upcoming
+
GET /api/recent
+
GET /api/spotlight
+
GET /api/schedule
+
GET /api/filter?genre=Action
+
GET /api/info/:id
+
GET /api/episodes/:id
+
GET /api/watch/:provider/:id/:category/:slug
+
GET /api/health
+
+
+
+
diff --git a/server.js b/server.js
new file mode 100644
index 0000000..70e87f7
--- /dev/null
+++ b/server.js
@@ -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;
diff --git a/server.py b/server.py
new file mode 100644
index 0000000..cc62c6f
--- /dev/null
+++ b/server.py
@@ -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/',
+ 'GET /api/info//episodes',
+ 'GET /api/characters/',
+ 'GET /api/recommendations/',
+ '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/')
+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//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/')
+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/')
+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/')
+ print(' GET /api/info//episodes')
+ print(' GET /api/characters/')
+ print(' GET /api/recommendations/')
+ print(' GET /api/genres\n')
+ app.run(host='0.0.0.0', port=3000, debug=True)
diff --git a/src/helpers/anilist.js b/src/helpers/anilist.js
new file mode 100644
index 0000000..a98bc7e
--- /dev/null
+++ b/src/helpers/anilist.js
@@ -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,
+};
diff --git a/src/helpers/cache.js b/src/helpers/cache.js
new file mode 100644
index 0000000..da95a84
--- /dev/null
+++ b/src/helpers/cache.js
@@ -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 };
diff --git a/src/helpers/pipe.js b/src/helpers/pipe.js
new file mode 100644
index 0000000..3b84dc4
--- /dev/null
+++ b/src/helpers/pipe.js
@@ -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,
+};
diff --git a/src/routes/apiRoutes.js b/src/routes/apiRoutes.js
new file mode 100644
index 0000000..e7bb2f6
--- /dev/null
+++ b/src/routes/apiRoutes.js
@@ -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;
diff --git a/vercel.json b/vercel.json
new file mode 100644
index 0000000..1440fb0
--- /dev/null
+++ b/vercel.json
@@ -0,0 +1,13 @@
+{
+ "version": 2,
+ "builds": [
+ {
+ "src": "server.js",
+ "use": "@vercel/node"
+ }
+ ],
+ "routes": [
+ { "src": "/api/(.*)", "dest": "server.js" },
+ { "src": "/(.*)", "dest": "server.js" }
+ ]
+}