feat: MiruroAPI v1.0.0 - Free anime streaming API

- 16 API endpoints powered by AniList GraphQL
- Miruro pipe integration for streaming sources
- 12 streaming providers (kiwi, pewe, bee, bonk, etc.)
- M3U8 streaming URLs with subtitles and skip timestamps
- In-memory caching with TTL
- CORS support and health endpoint
This commit is contained in:
Sʜɪɴᴇɪ Nᴏᴜᴢᴇɴ
2026-06-09 13:51:39 +05:30
commit 776e1b9649
12 changed files with 1463 additions and 0 deletions
+396
View File
@@ -0,0 +1,396 @@
const axios = require("axios");
const ANILIST_URL = "https://graphql.anilist.co";
const MEDIA_LIST_FIELDS = `
id
title { romaji english native }
coverImage { large extraLarge }
bannerImage
format
season
seasonYear
episodes
duration
status
averageScore
meanScore
popularity
favourites
genres
source
countryOfOrigin
isAdult
studios(isMain: true) { nodes { name isAnimationStudio } }
nextAiringEpisode { episode airingAt timeUntilAiring }
startDate { year month day }
endDate { year month day }
`;
const MEDIA_FULL_FIELDS = `
id
idMal
title { romaji english native }
description(asHtml: false)
coverImage { large extraLarge color }
bannerImage
format
season
seasonYear
episodes
duration
status
averageScore
meanScore
popularity
favourites
trending
genres
tags { name rank isMediaSpoiler }
source
countryOfOrigin
isAdult
hashtag
synonyms
siteUrl
trailer { id site thumbnail }
studios { nodes { id name isAnimationStudio siteUrl } }
nextAiringEpisode { episode airingAt timeUntilAiring }
startDate { year month day }
endDate { year month day }
characters(sort: [ROLE, RELEVANCE], perPage: 25) {
edges {
role
node { id name { full native } image { large } }
voiceActors(language: JAPANESE) { id name { full native } image { large } languageV2 }
}
}
staff(sort: RELEVANCE, perPage: 25) {
edges {
role
node { id name { full native } image { large } }
}
}
relations {
edges {
relationType(version: 2)
node {
id
title { romaji english native }
coverImage { large }
format
type
status
episodes
meanScore
}
}
}
recommendations(sort: RATING_DESC, perPage: 10) {
nodes {
rating
mediaRecommendation {
id
title { romaji english native }
coverImage { large }
format
episodes
status
meanScore
averageScore
}
}
}
externalLinks { url site type }
streamingEpisodes { title thumbnail url site }
stats {
scoreDistribution { score amount }
statusDistribution { status amount }
}
`;
async function anilistQuery(query, variables = {}) {
const body = { query, variables };
const res = await axios.post(ANILIST_URL, body, {
headers: { "Content-Type": "application/json" },
timeout: 15000,
});
if (res.status !== 200) throw new Error("AniList query failed");
return res.data.data;
}
async function searchAnime(query, page = 1, perPage = 20) {
const gql = `
query ($search: String, $page: Int, $perPage: Int) {
Page(page: $page, perPage: $perPage) {
pageInfo { total currentPage lastPage hasNextPage perPage }
media(search: $search, type: ANIME, sort: SEARCH_MATCH) {
${MEDIA_LIST_FIELDS}
}
}
}
`;
const data = await anilistQuery(gql, { search: query, page, perPage });
const pageData = data.Page;
return {
page: pageData.pageInfo.currentPage,
perPage: pageData.pageInfo.perPage,
total: pageData.pageInfo.total,
hasNextPage: pageData.pageInfo.hasNextPage,
results: pageData.media,
};
}
async function getSuggestions(query) {
const gql = `
query ($search: String) {
Page(page: 1, perPage: 8) {
media(search: $search, type: ANIME, sort: SEARCH_MATCH) {
id
title { romaji english }
coverImage { large }
format
status
startDate { year }
episodes
}
}
}
`;
const data = await anilistQuery(gql, { search: query });
return data.Page.media.map((item) => ({
id: item.id,
title: item.title.english || item.title.romaji,
title_romaji: item.title.romaji,
poster: item.coverImage.large,
format: item.format,
status: item.status,
year: item.startDate?.year,
episodes: item.episodes,
}));
}
async function getAnimeInfo(id) {
const gql = `
query ($id: Int) {
Media(id: $id, type: ANIME) {
${MEDIA_FULL_FIELDS}
}
}
`;
const data = await anilistQuery(gql, { id });
return data.Media;
}
async function getAnimeCharacters(id, page = 1, perPage = 25) {
const gql = `
query ($id: Int, $page: Int, $perPage: Int) {
Media(id: $id, type: ANIME) {
id
title { romaji english }
characters(sort: [ROLE, RELEVANCE], page: $page, perPage: $perPage) {
pageInfo { total currentPage lastPage hasNextPage perPage }
edges {
role
node { id name { full native userPreferred } image { large medium } description gender favourites siteUrl }
voiceActors { id name { full native } image { large } languageV2 }
}
}
}
}
`;
const data = await anilistQuery(gql, { id, page, perPage });
if (!data.Media) throw new Error("Anime not found");
const chars = data.Media.characters;
return {
page: chars.pageInfo.currentPage,
perPage: chars.pageInfo.perPage,
total: chars.pageInfo.total,
hasNextPage: chars.pageInfo.hasNextPage,
characters: chars.edges,
};
}
async function getAnimeRelations(id) {
const gql = `
query ($id: Int) {
Media(id: $id, type: ANIME) {
id
title { romaji english }
relations {
edges {
relationType(version: 2)
node {
id title { romaji english native } coverImage { large }
format type status episodes meanScore averageScore popularity
}
}
}
}
}
`;
const data = await anilistQuery(gql, { id });
if (!data.Media) throw new Error("Anime not found");
return {
id: data.Media.id,
title: data.Media.title,
relations: data.Media.relations.edges,
};
}
async function getAnimeRecommendations(id, page = 1, perPage = 10) {
const gql = `
query ($id: Int, $page: Int, $perPage: Int) {
Media(id: $id, type: ANIME) {
id
title { romaji english }
recommendations(sort: RATING_DESC, page: $page, perPage: $perPage) {
pageInfo { total currentPage lastPage hasNextPage perPage }
nodes {
rating
mediaRecommendation {
id title { romaji english native } coverImage { large extraLarge }
format episodes status meanScore averageScore popularity genres
}
}
}
}
}
`;
const data = await anilistQuery(gql, { id, page, perPage });
if (!data.Media) throw new Error("Anime not found");
const recs = data.Media.recommendations;
return {
page: recs.pageInfo.currentPage,
perPage: recs.pageInfo.perPage,
total: recs.pageInfo.total,
hasNextPage: recs.pageInfo.hasNextPage,
recommendations: recs.nodes,
};
}
async function getCollection(sortType, status = null, page = 1, perPage = 20) {
const statusFilter = status ? `, status: ${status}` : "";
const gql = `
query ($page: Int, $perPage: Int) {
Page(page: $page, perPage: $perPage) {
pageInfo { total currentPage lastPage hasNextPage perPage }
media(type: ANIME, sort: [${sortType}]${statusFilter}) {
${MEDIA_LIST_FIELDS}
}
}
}
`;
const data = await anilistQuery(gql, { page, perPage });
const pageData = data.Page;
return {
page: pageData.pageInfo.currentPage,
perPage: pageData.pageInfo.perPage,
total: pageData.pageInfo.total,
hasNextPage: pageData.pageInfo.hasNextPage,
results: pageData.media,
};
}
async function getSpotlight() {
const gql = `
query {
Page(page: 1, perPage: 10) {
media(sort: [TRENDING_DESC, POPULARITY_DESC], type: ANIME) {
${MEDIA_LIST_FIELDS}
}
}
}
`;
const data = await anilistQuery(gql);
return data.Page.media;
}
async function getSchedule(page = 1, perPage = 20) {
const gql = `
query ($page: Int, $perPage: Int) {
Page(page: $page, perPage: $perPage) {
pageInfo { total currentPage lastPage hasNextPage perPage }
airingSchedules(notYetAired: true, sort: TIME) {
episode
airingAt
timeUntilAiring
media { ${MEDIA_LIST_FIELDS} }
}
}
}
`;
const data = await anilistQuery(gql, { page, perPage });
const pageData = data.Page;
const results = pageData.airingSchedules.map((item) => ({
...item.media,
next_episode: item.episode,
airingAt: item.airingAt,
timeUntilAiring: item.timeUntilAiring,
}));
return {
page: pageData.pageInfo.currentPage,
perPage: pageData.pageInfo.perPage,
total: pageData.pageInfo.total,
hasNextPage: pageData.pageInfo.hasNextPage,
results,
};
}
async function filterAnime({ genre, tag, year, season, format, status, sort = "POPULARITY_DESC", page = 1, perPage = 20 }) {
const SORT_MAP = {
SCORE_DESC: "SCORE_DESC",
POPULARITY_DESC: "POPULARITY_DESC",
TRENDING_DESC: "TRENDING_DESC",
START_DATE_DESC: "START_DATE_DESC",
FAVOURITES_DESC: "FAVOURITES_DESC",
UPDATED_AT_DESC: "UPDATED_AT_DESC",
};
const args = ["type: ANIME", `sort: [${SORT_MAP[sort] || "POPULARITY_DESC"}]`];
const variables = { page, perPage };
const varTypes = ["$page: Int", "$perPage: Int"];
if (genre) { args.push("genre: $genre"); variables.genre = genre; varTypes.push("$genre: String"); }
if (tag) { args.push("tag: $tag"); variables.tag = tag; varTypes.push("$tag: String"); }
if (year) { args.push("seasonYear: $seasonYear"); variables.seasonYear = year; varTypes.push("$seasonYear: Int"); }
if (season) { args.push("season: $season"); variables.season = season.toUpperCase(); varTypes.push("$season: MediaSeason"); }
if (format) { args.push("format: $format"); variables.format = format.toUpperCase(); varTypes.push("$format: MediaFormat"); }
if (status) { args.push("status: $status"); variables.status = status.toUpperCase(); varTypes.push("$status: MediaStatus"); }
const gql = `
query (${varTypes.join(", ")}) {
Page(page: $page, perPage: $perPage) {
pageInfo { total currentPage lastPage hasNextPage perPage }
media(${args.join(", ")}) {
${MEDIA_LIST_FIELDS}
}
}
}
`;
const data = await anilistQuery(gql, variables);
const pageData = data.Page;
return {
page: pageData.pageInfo.currentPage,
perPage: pageData.pageInfo.perPage,
total: pageData.pageInfo.total,
hasNextPage: pageData.pageInfo.hasNextPage,
results: pageData.media,
};
}
module.exports = {
anilistQuery,
searchAnime,
getSuggestions,
getAnimeInfo,
getAnimeCharacters,
getAnimeRelations,
getAnimeRecommendations,
getCollection,
getSpotlight,
getSchedule,
filterAnime,
MEDIA_LIST_FIELDS,
MEDIA_FULL_FIELDS,
};
+27
View File
@@ -0,0 +1,27 @@
const cache = new Map();
const MAX_CACHE_SIZE = 100;
const DEFAULT_TTL = 60 * 1000;
function getCached(key) {
const entry = cache.get(key);
if (!entry) return null;
if (Date.now() > entry.expiry) {
cache.delete(key);
return null;
}
return entry.data;
}
function setCache(key, data, ttl = DEFAULT_TTL) {
if (cache.size >= MAX_CACHE_SIZE) {
const firstKey = cache.keys().next().value;
cache.delete(firstKey);
}
cache.set(key, { data, expiry: Date.now() + ttl });
}
function clearCache() {
cache.clear();
}
module.exports = { getCached, setCache, clearCache };
+144
View File
@@ -0,0 +1,144 @@
const axios = require("axios");
const { Buffer } = require("buffer");
const zlib = require("zlib");
const MIRURO_PIPE_URL = "https://www.miruro.tv/api/secure/pipe";
const HEADERS = {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36",
Referer: "https://www.miruro.tv/",
};
function encodePipeRequest(payload) {
const json = JSON.stringify(payload);
return Buffer.from(json).toString("base64url");
}
function decodePipeResponse(encodedStr) {
try {
const padded = encodedStr + "=".repeat((4 - (encodedStr.length % 4)) % 4);
const compressed = Buffer.from(padded, "base64url");
const decompressed = zlib.gunzipSync(compressed);
return JSON.parse(decompressed.toString("utf-8"));
} catch (e) {
throw new Error("Failed to decode pipe response: " + e.message);
}
}
function translateId(encodedId) {
try {
const padded = encodedId + "=".repeat((4 - (encodedId.length % 4)) % 4);
const decoded = Buffer.from(padded, "base64url").toString("utf-8");
if (decoded.includes(":")) return decoded;
return encodedId;
} catch {
return encodedId;
}
}
function deepTranslate(obj) {
if (obj && typeof obj === "object") {
for (const key of Object.keys(obj)) {
if (key === "id" && typeof obj[key] === "string") {
obj[key] = translateId(obj[key]);
} else if (typeof obj[key] === "object") {
deepTranslate(obj[key]);
}
}
}
return obj;
}
function injectSourceSlugs(data, anilistId) {
const providers = data.providers || {};
for (const [provName, provData] of Object.entries(providers)) {
if (!provData || typeof provData !== "object") continue;
let episodes = provData.episodes;
if (!episodes) continue;
if (Array.isArray(episodes)) {
provData.episodes = { sub: episodes };
episodes = provData.episodes;
}
for (const [category, epList] of Object.entries(episodes)) {
if (!Array.isArray(epList)) continue;
for (const ep of epList) {
if (ep.id && ep.number) {
const origId = ep.id;
const prefix = origId.includes(":") ? origId.split(":")[0] : origId;
ep.id = `watch/${provName}/${anilistId}/${category}/${prefix}-${ep.number}`;
}
}
}
}
return data;
}
async function fetchRawEpisodes(anilistId) {
const payload = {
path: "episodes",
method: "GET",
query: { anilistId },
body: null,
version: "0.1.0",
};
const encodedReq = encodePipeRequest(payload);
const res = await axios.get(`${MIRURO_PIPE_URL}?e=${encodedReq}`, {
headers: HEADERS,
timeout: 15000,
});
if (res.status !== 200) throw new Error(`Pipe request failed: ${res.status}`);
const data = decodePipeResponse(res.text || res.data);
return deepTranslate(data);
}
async function getEpisodes(anilistId) {
const data = await fetchRawEpisodes(anilistId);
return injectSourceSlugs(data, anilistId);
}
async function getSources(episodeId, provider, anilistId, category = "sub") {
const encId = Buffer.from(episodeId).toString("base64url");
const payload = {
path: "sources",
method: "GET",
query: { episodeId: encId, provider, category, anilistId },
body: null,
version: "0.1.0",
};
const encodedReq = encodePipeRequest(payload);
const res = await axios.get(`${MIRURO_PIPE_URL}?e=${encodedReq}`, {
headers: HEADERS,
timeout: 15000,
});
if (res.status !== 200) throw new Error(`Pipe sources request failed: ${res.status}`);
return deepTranslate(decodePipeResponse(res.text || res.data));
}
async function getWatchSources(provider, anilistId, category, slug) {
const data = await fetchRawEpisodes(anilistId);
const provData = (data.providers || {})[provider];
if (!provData) throw new Error(`Provider ${provider} not found`);
const episodes = provData.episodes?.[category] || [];
let targetId = null;
for (const ep of episodes) {
const origId = ep.id || "";
const prefix = origId.includes(":") ? origId.split(":")[0] : origId;
const generated = `${prefix}-${ep.number}`;
if (generated === slug) {
targetId = origId;
break;
}
}
if (!targetId) throw new Error(`Episode slug '${slug}' not found for provider ${provider}`);
return getSources(targetId, provider, anilistId, category);
}
module.exports = {
getEpisodes,
getSources,
getWatchSources,
encodePipeRequest,
decodePipeResponse,
translateId,
deepTranslate,
injectSourceSlugs,
};
+222
View File
@@ -0,0 +1,222 @@
const express = require("express");
const router = express.Router();
const anilist = require("../helpers/anilist");
const pipe = require("../helpers/pipe");
const { getCached, setCache } = require("../helpers/cache");
function handleErrors(res, err) {
console.error(err.message);
res.status(500).json({ success: false, message: err.message });
}
// Search
router.get("/search", async (req, res) => {
try {
const { query, page = 1, per_page = 20 } = req.query;
if (!query) return res.status(400).json({ success: false, message: "query parameter is required" });
const cacheKey = `search:${query}:${page}:${per_page}`;
let result = getCached(cacheKey);
if (!result) {
result = await anilist.searchAnime(query, parseInt(page), parseInt(per_page));
setCache(cacheKey, result);
}
res.json({ success: true, results: result });
} catch (err) { handleErrors(res, err); }
});
// Suggestions
router.get("/suggestions", async (req, res) => {
try {
const { query } = req.query;
if (!query) return res.status(400).json({ success: false, message: "query parameter is required" });
const cacheKey = `suggestions:${query}`;
let result = getCached(cacheKey);
if (!result) {
result = await anilist.getSuggestions(query);
setCache(cacheKey, result, 120 * 1000);
}
res.json({ success: true, results: result });
} catch (err) { handleErrors(res, err); }
});
// Trending
router.get("/trending", async (req, res) => {
try {
const { page = 1, per_page = 20 } = req.query;
const cacheKey = `trending:${page}:${per_page}`;
let result = getCached(cacheKey);
if (!result) {
result = await anilist.getCollection("TRENDING_DESC", null, parseInt(page), parseInt(per_page));
setCache(cacheKey, result, 120 * 1000);
}
res.json({ success: true, results: result });
} catch (err) { handleErrors(res, err); }
});
// Popular
router.get("/popular", async (req, res) => {
try {
const { page = 1, per_page = 20 } = req.query;
const cacheKey = `popular:${page}:${per_page}`;
let result = getCached(cacheKey);
if (!result) {
result = await anilist.getCollection("POPULARITY_DESC", null, parseInt(page), parseInt(per_page));
setCache(cacheKey, result, 120 * 1000);
}
res.json({ success: true, results: result });
} catch (err) { handleErrors(res, err); }
});
// Upcoming
router.get("/upcoming", async (req, res) => {
try {
const { page = 1, per_page = 20 } = req.query;
const cacheKey = `upcoming:${page}:${per_page}`;
let result = getCached(cacheKey);
if (!result) {
result = await anilist.getCollection("POPULARITY_DESC", "NOT_YET_RELEASED", parseInt(page), parseInt(per_page));
setCache(cacheKey, result, 120 * 1000);
}
res.json({ success: true, results: result });
} catch (err) { handleErrors(res, err); }
});
// Recent
router.get("/recent", async (req, res) => {
try {
const { page = 1, per_page = 20 } = req.query;
const cacheKey = `recent:${page}:${per_page}`;
let result = getCached(cacheKey);
if (!result) {
result = await anilist.getCollection("START_DATE_DESC", "RELEASING", parseInt(page), parseInt(per_page));
setCache(cacheKey, result, 120 * 1000);
}
res.json({ success: true, results: result });
} catch (err) { handleErrors(res, err); }
});
// Spotlight
router.get("/spotlight", async (req, res) => {
try {
const cacheKey = "spotlight";
let result = getCached(cacheKey);
if (!result) {
result = await anilist.getSpotlight();
setCache(cacheKey, result, 120 * 1000);
}
res.json({ success: true, results: result });
} catch (err) { handleErrors(res, err); }
});
// Schedule
router.get("/schedule", async (req, res) => {
try {
const { page = 1, per_page = 20 } = req.query;
const cacheKey = `schedule:${page}:${per_page}`;
let result = getCached(cacheKey);
if (!result) {
result = await anilist.getSchedule(parseInt(page), parseInt(per_page));
setCache(cacheKey, result, 120 * 1000);
}
res.json({ success: true, results: result });
} catch (err) { handleErrors(res, err); }
});
// Filter
router.get("/filter", async (req, res) => {
try {
const { genre, tag, year, season, format, status, sort = "POPULARITY_DESC", page = 1, per_page = 20 } = req.query;
const cacheKey = `filter:${genre}:${tag}:${year}:${season}:${format}:${status}:${sort}:${page}:${per_page}`;
let result = getCached(cacheKey);
if (!result) {
result = await anilist.filterAnime({
genre, tag, year: year ? parseInt(year) : null, season, format, status, sort,
page: parseInt(page), perPage: parseInt(per_page),
});
setCache(cacheKey, result);
}
res.json({ success: true, results: result });
} catch (err) { handleErrors(res, err); }
});
// Info
router.get("/info/:id", async (req, res) => {
try {
const id = parseInt(req.params.id);
if (isNaN(id)) return res.status(400).json({ success: false, message: "Invalid AniList ID" });
const cacheKey = `info:${id}`;
let result = getCached(cacheKey);
if (!result) {
result = await anilist.getAnimeInfo(id);
setCache(cacheKey, result, 300 * 1000);
}
res.json({ success: true, results: result });
} catch (err) { handleErrors(res, err); }
});
// Characters
router.get("/anime/:id/characters", async (req, res) => {
try {
const id = parseInt(req.params.id);
const { page = 1, per_page = 25 } = req.query;
const result = await anilist.getAnimeCharacters(id, parseInt(page), parseInt(per_page));
res.json({ success: true, results: result });
} catch (err) { handleErrors(res, err); }
});
// Relations
router.get("/anime/:id/relations", async (req, res) => {
try {
const id = parseInt(req.params.id);
const result = await anilist.getAnimeRelations(id);
res.json({ success: true, results: result });
} catch (err) { handleErrors(res, err); }
});
// Recommendations
router.get("/anime/:id/recommendations", async (req, res) => {
try {
const id = parseInt(req.params.id);
const { page = 1, per_page = 10 } = req.query;
const result = await anilist.getAnimeRecommendations(id, parseInt(page), parseInt(per_page));
res.json({ success: true, results: result });
} catch (err) { handleErrors(res, err); }
});
// Episodes
router.get("/episodes/:id", async (req, res) => {
try {
const id = parseInt(req.params.id);
if (isNaN(id)) return res.status(400).json({ success: false, message: "Invalid AniList ID" });
const cacheKey = `episodes:${id}`;
let result = getCached(cacheKey);
if (!result) {
result = await pipe.getEpisodes(id);
setCache(cacheKey, result, 120 * 1000);
}
res.json({ success: true, results: result });
} catch (err) { handleErrors(res, err); }
});
// Sources (manual)
router.get("/sources", async (req, res) => {
try {
const { episodeId, provider, anilistId, category = "sub" } = req.query;
if (!episodeId || !provider || !anilistId) {
return res.status(400).json({ success: false, message: "episodeId, provider, and anilistId are required" });
}
const result = await pipe.getSources(episodeId, provider, parseInt(anilistId), category);
res.json({ success: true, results: result });
} catch (err) { handleErrors(res, err); }
});
// Watch (simple slug-based sources)
router.get("/watch/:provider/:anilistId/:category/:slug", async (req, res) => {
try {
const { provider, anilistId, category, slug } = req.params;
const result = await pipe.getWatchSources(provider, parseInt(anilistId), category, slug);
res.json({ success: true, results: result });
} catch (err) { handleErrors(res, err); }
});
module.exports = router;