From 776e1b96498789d9c3091d5cc3a85add0c773dfb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=CA=9C=C9=AA=C9=B4=E1=B4=87=C9=AA=20N=E1=B4=8F=E1=B4=9C?= =?UTF-8?q?=E1=B4=A2=E1=B4=87=C9=B4?= <157171073+Shineii86@users.noreply.github.com> Date: Tue, 9 Jun 2026 13:51:39 +0530 Subject: [PATCH] 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 --- .gitignore | 3 + CHANGELOG.md | 11 + README.md | 55 +++++ package.json | 28 +++ public/index.html | 46 +++++ server.js | 74 +++++++ server.py | 444 ++++++++++++++++++++++++++++++++++++++++ src/helpers/anilist.js | 396 +++++++++++++++++++++++++++++++++++ src/helpers/cache.js | 27 +++ src/helpers/pipe.js | 144 +++++++++++++ src/routes/apiRoutes.js | 222 ++++++++++++++++++++ vercel.json | 13 ++ 12 files changed, 1463 insertions(+) create mode 100644 .gitignore create mode 100644 CHANGELOG.md create mode 100644 README.md create mode 100644 package.json create mode 100644 public/index.html create mode 100644 server.js create mode 100644 server.py create mode 100644 src/helpers/anilist.js create mode 100644 src/helpers/cache.js create mode 100644 src/helpers/pipe.js create mode 100644 src/routes/apiRoutes.js create mode 100644 vercel.json 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" } + ] +}