mirror of
https://github.com/Shineii86/MiruroAPI.git
synced 2026-10-22 02:05:10 +00:00
- Added /api/sources/:id path-based endpoint for streaming sources
- Improved Cloudflare detection with proper 503 status codes
- Added timeout handling (15s) with exponential backoff for pipe requests
- Enhanced CORS with Authorization header and rate limit expose headers
- All streaming endpoints now return descriptive error messages
- Pipe health check reports skipped/unconfigured methods separately
- Updated OpenAPI spec with new /sources/{id} route
- Version bumped to 2.3.3
Fixed endpoints: /search, /info/:id, /trending, /filter, /episodes/:id,
/sources/:id (new), /stream, /watch, /download
All endpoints return consistent { success, results/message } format
Proper 400/404/503 responses for invalid inputs and failures
613 lines
22 KiB
JavaScript
613 lines
22 KiB
JavaScript
/*
|
|
* ======= • ======= • ======= • ======= • =======• =======
|
|
* MiruroAPI — pipe.js
|
|
* Repository: https://github.com/Shineii86/MiruroAPI
|
|
*
|
|
* @description
|
|
* Miruro streaming pipe integration with self-healing fallback system.
|
|
* Automatically tries multiple methods to reach the pipe endpoint:
|
|
* 1. Direct request with mirror rotation
|
|
* 2. ScraperAPI proxy (if SCRAPER_API_KEY is set)
|
|
* 3. FlareSolverr browser proxy (if FLARESOLVERR_URL is set)
|
|
* Decodes base64+gzip responses and injects simplified slug-based episode IDs.
|
|
*
|
|
* @exports
|
|
* getEpisodes, getSources, getWatchSources, pipeHealthCheck
|
|
*
|
|
* @author Shinei Nouzen
|
|
* @license MIT
|
|
* ======= • ======= • ======= • ======= • =======• =======
|
|
*/
|
|
|
|
const axios = require("axios");
|
|
const { Buffer } = require("buffer");
|
|
const zlib = require("zlib");
|
|
const { getCached, setCache } = require("./cache");
|
|
|
|
// ══════════════════════════════════════════════════════════════
|
|
// MIRURO PIPE CONFIGURATION
|
|
// ══════════════════════════════════════════════════════════════
|
|
|
|
const PIPE_PATH = "/api/secure/pipe";
|
|
|
|
const MIRURO_ORIGINS = [
|
|
"https://www.miruro.ru",
|
|
"https://www.miruro.to",
|
|
"https://www.miruro.bz",
|
|
"https://www.miruro.tv",
|
|
];
|
|
|
|
const CANONICAL_ORIGIN = "https://www.miruro.to";
|
|
|
|
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",
|
|
Accept: "*/*",
|
|
"Accept-Language": "en-US,en;q=0.9",
|
|
"sec-ch-ua": '"Chromium";v="137", "Not?A_Brand";v="24"',
|
|
"sec-ch-ua-mobile": "?0",
|
|
"sec-ch-ua-platform": '"Windows"',
|
|
"sec-fetch-dest": "empty",
|
|
"sec-fetch-mode": "cors",
|
|
"sec-fetch-site": "same-origin",
|
|
Referer: CANONICAL_ORIGIN + "/",
|
|
Origin: CANONICAL_ORIGIN,
|
|
};
|
|
|
|
// ══════════════════════════════════════════════════════════════
|
|
// FALLBACK METHOD CONFIGURATION
|
|
// ══════════════════════════════════════════════════════════════
|
|
|
|
const SCRAPER_API_KEY = process.env.SCRAPER_API_KEY || "";
|
|
const FLARESOLVERR_URL = process.env.FLARESOLVERR_URL || "";
|
|
|
|
// ══════════════════════════════════════════════════════════════
|
|
// ENCODING / DECODING UTILITIES
|
|
// ══════════════════════════════════════════════════════════════
|
|
|
|
const encodePipeRequest = (payload) => {
|
|
const json = JSON.stringify(payload);
|
|
return Buffer.from(json).toString("base64url");
|
|
};
|
|
|
|
const PIPE_OBF_KEY = Buffer.from(
|
|
process.env.PIPE_OBF_KEY || "71951034f8fbcf53d89db52ceb3dc22c",
|
|
"hex"
|
|
);
|
|
|
|
const decodePipeResponse = (encodedStr, obfHeader = null) => {
|
|
try {
|
|
if (!obfHeader) return JSON.parse(encodedStr);
|
|
|
|
const padded =
|
|
encodedStr + "=".repeat((4 - (encodedStr.length % 4)) % 4);
|
|
const raw = Buffer.from(padded, "base64url");
|
|
|
|
let bytes = raw;
|
|
if (String(obfHeader) === "2") {
|
|
const xored = Buffer.alloc(raw.length);
|
|
for (let i = 0; i < raw.length; i++) {
|
|
xored[i] = raw[i] ^ PIPE_OBF_KEY[i % PIPE_OBF_KEY.length];
|
|
}
|
|
bytes = xored;
|
|
}
|
|
|
|
const decompressed = zlib.gunzipSync(bytes);
|
|
return JSON.parse(decompressed.toString("utf-8"));
|
|
} catch (e) {
|
|
throw new Error("Failed to decode pipe response: " + e.message);
|
|
}
|
|
};
|
|
|
|
// ══════════════════════════════════════════════════════════════
|
|
// METHOD 1: DIRECT REQUEST (with mirror rotation)
|
|
// ══════════════════════════════════════════════════════════════
|
|
|
|
const methodDirect = async (encodedReq) => {
|
|
const maxRetries = 4;
|
|
let lastError;
|
|
|
|
for (let attempt = 0; attempt < maxRetries; attempt++) {
|
|
const origin = MIRURO_ORIGINS[attempt % MIRURO_ORIGINS.length];
|
|
|
|
try {
|
|
const res = await axios.get(`${origin}${PIPE_PATH}?e=${encodedReq}`, {
|
|
headers: HEADERS,
|
|
timeout: 15000,
|
|
maxRedirects: 5,
|
|
});
|
|
|
|
if (res.status !== 200)
|
|
throw new Error(`Pipe request failed: ${res.status}`);
|
|
|
|
const obf = res.headers["x-obfuscated"];
|
|
return { data: decodePipeResponse(res.data, obf), method: "direct" };
|
|
} catch (e) {
|
|
lastError = e;
|
|
const status = e.response?.status;
|
|
|
|
if (status === 403 || status === 444) {
|
|
throw new Error(`Cloudflare is blocking requests to ${origin} (HTTP ${status}). Configure SCRAPER_API_KEY or FLARESOLVERR_URL for fallback.`);
|
|
}
|
|
|
|
if (
|
|
status &&
|
|
status >= 400 &&
|
|
status < 500 &&
|
|
status !== 444 &&
|
|
status !== 403
|
|
) {
|
|
throw new Error(`Pipe request failed with status ${status}`);
|
|
}
|
|
|
|
if (e.code === "ECONNABORTED" || e.code === "ETIMEDOUT") {
|
|
lastError = new Error(`Request to ${origin} timed out after 15s`);
|
|
}
|
|
|
|
if (attempt < maxRetries - 1) {
|
|
const delay = Math.pow(2, attempt) * 1000;
|
|
await new Promise((r) => setTimeout(r, delay));
|
|
}
|
|
}
|
|
}
|
|
throw lastError || new Error("Direct request failed after all retries. The streaming provider may be temporarily unavailable.");
|
|
};
|
|
|
|
// ══════════════════════════════════════════════════════════════
|
|
// METHOD 2: SCRAPERAPI PROXY
|
|
// ══════════════════════════════════════════════════════════════
|
|
|
|
const methodScraperAPI = async (encodedReq) => {
|
|
if (!SCRAPER_API_KEY) throw new Error("SCRAPER_API_KEY not configured");
|
|
|
|
const targetUrl = `${MIRURO_ORIGINS[0]}${PIPE_PATH}?e=${encodedReq}`;
|
|
const scraperUrl = `https://api.scraperapi.com/?api_key=${SCRAPER_API_KEY}&premium=true&url=${encodeURIComponent(targetUrl)}`;
|
|
|
|
const res = await axios.get(scraperUrl, {
|
|
headers: {
|
|
"User-Agent": HEADERS["User-Agent"],
|
|
},
|
|
timeout: 60000,
|
|
maxRedirects: 5,
|
|
});
|
|
|
|
if (res.status !== 200)
|
|
throw new Error(`ScraperAPI request failed: ${res.status}`);
|
|
|
|
const obf = res.headers["x-obfuscated"];
|
|
return { data: decodePipeResponse(res.data, obf), method: "scraperapi" };
|
|
};
|
|
|
|
// ══════════════════════════════════════════════════════════════
|
|
// METHOD 3: FLARESOLVERR BROWSER PROXY
|
|
// ══════════════════════════════════════════════════════════════
|
|
|
|
const methodFlareSolverr = async (encodedReq) => {
|
|
if (!FLARESOLVERR_URL) throw new Error("FLARESOLVERR_URL not configured");
|
|
|
|
const targetUrl = `${MIRURO_ORIGINS[0]}${PIPE_PATH}?e=${encodedReq}`;
|
|
|
|
const res = await axios.post(
|
|
`${FLARESOLVERR_URL.replace(/\/$/, "")}/v1`,
|
|
{
|
|
cmd: "request.get",
|
|
url: targetUrl,
|
|
maxTimeout: 60000,
|
|
},
|
|
{
|
|
headers: { "Content-Type": "application/json" },
|
|
timeout: 70000,
|
|
}
|
|
);
|
|
|
|
if (res.status !== 200)
|
|
throw new Error(`FlareSolverr request failed: ${res.status}`);
|
|
|
|
const solution = res.data?.solution;
|
|
if (!solution?.response)
|
|
throw new Error("FlareSolverr returned no response");
|
|
|
|
const body = solution.response;
|
|
const obf = solution.headers?.["x-obfuscated"] || null;
|
|
|
|
return { data: decodePipeResponse(body, obf), method: "flaresolverr" };
|
|
};
|
|
|
|
// ══════════════════════════════════════════════════════════════
|
|
// SELF-HEALING PIPE REQUEST (tries all methods in order)
|
|
// ══════════════════════════════════════════════════════════════
|
|
|
|
const METHODS = [
|
|
{ name: "direct", fn: methodDirect },
|
|
{ name: "scraperapi", fn: methodScraperAPI, requires: () => !!SCRAPER_API_KEY },
|
|
{ name: "flaresolverr", fn: methodFlareSolverr, requires: () => !!FLARESOLVERR_URL },
|
|
];
|
|
|
|
const pipeRequest = async (path, query) => {
|
|
const payload = { path, method: "GET", query, body: null };
|
|
const encodedReq = encodePipeRequest(payload);
|
|
|
|
const errors = [];
|
|
const availableMethods = METHODS.filter((m) => !m.requires || m.requires());
|
|
|
|
if (availableMethods.length === 0) {
|
|
throw new Error("No streaming methods configured. Set SCRAPER_API_KEY or FLARESOLVERR_URL in your .env file to enable fallback methods.");
|
|
}
|
|
|
|
for (const method of availableMethods) {
|
|
try {
|
|
const result = await method.fn(encodedReq);
|
|
return result.data;
|
|
} catch (e) {
|
|
errors.push({ method: method.name, error: e.message });
|
|
}
|
|
}
|
|
|
|
const isCloudflare = errors.some((e) => e.error.includes("Cloudflare") || e.error.includes("403"));
|
|
const baseMessage = isCloudflare
|
|
? "Cloudflare is blocking all streaming requests"
|
|
: "All streaming methods failed";
|
|
|
|
throw new Error(
|
|
`${baseMessage}: ${errors.map((e) => `${e.method}(${e.error})`).join(", ")}. ` +
|
|
"Configure SCRAPER_API_KEY or FLARESOLVERR_URL for additional fallback methods."
|
|
);
|
|
};
|
|
|
|
// ══════════════════════════════════════════════════════════════
|
|
// PIPE HEALTH CHECK (for /api/pipe-health)
|
|
// ══════════════════════════════════════════════════════════════
|
|
|
|
const pipeHealthCheck = async () => {
|
|
const results = {};
|
|
const testPayload = { path: "episodes", method: "GET", query: { anilistId: 20 }, body: null };
|
|
const encodedReq = encodePipeRequest(testPayload);
|
|
|
|
for (const method of METHODS) {
|
|
if (method.requires && !method.requires()) {
|
|
results[method.name] = { status: "skipped", reason: "not configured" };
|
|
continue;
|
|
}
|
|
|
|
const start = Date.now();
|
|
try {
|
|
await method.fn(encodedReq);
|
|
results[method.name] = {
|
|
status: "ok",
|
|
latency: Date.now() - start + "ms",
|
|
};
|
|
} catch (e) {
|
|
results[method.name] = {
|
|
status: "failed",
|
|
error: e.message,
|
|
latency: Date.now() - start + "ms",
|
|
};
|
|
}
|
|
}
|
|
|
|
return results;
|
|
};
|
|
|
|
// ══════════════════════════════════════════════════════════════
|
|
// PROXY & ENCODE HELPERS (unchanged)
|
|
// ══════════════════════════════════════════════════════════════
|
|
|
|
const PROXY_BASE = "/api/proxy?url=";
|
|
|
|
const proxyUrl = (url, referer) => {
|
|
if (!url || !url.startsWith("http")) return url;
|
|
try {
|
|
const ref = referer ? "&referer=" + encodeURIComponent(referer) : "";
|
|
return PROXY_BASE + encodeURIComponent(url) + ref;
|
|
} catch {
|
|
return url;
|
|
}
|
|
};
|
|
|
|
const proxyStreams = (sources) => sources;
|
|
|
|
const proxySubtitles = (sources) => {
|
|
if (!sources?.subtitles) return sources;
|
|
sources.subtitles = sources.subtitles.map((s) => {
|
|
const raw = s.url || s.file;
|
|
if (!raw || !raw.startsWith("http")) return s;
|
|
const proxied = proxyUrl(raw, "https://www.miruro.to/");
|
|
return { ...s, url: proxied, file: proxied };
|
|
});
|
|
return sources;
|
|
};
|
|
|
|
// ══════════════════════════════════════════════════════════════
|
|
// EPISODE ID TRANSLATION
|
|
// ══════════════════════════════════════════════════════════════
|
|
|
|
const 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;
|
|
}
|
|
};
|
|
|
|
const deepTranslate = (obj) => {
|
|
if (obj && typeof obj === "object") {
|
|
if (Array.isArray(obj)) {
|
|
return obj.map((item) => deepTranslate(item));
|
|
}
|
|
const clone = { ...obj };
|
|
for (const key of Object.keys(clone)) {
|
|
if (key === "id" && typeof clone[key] === "string") {
|
|
if (clone.number !== undefined) {
|
|
clone.rawPipeId = clone[key];
|
|
}
|
|
clone[key] = translateId(clone[key]);
|
|
} else if (typeof clone[key] === "object") {
|
|
clone[key] = deepTranslate(clone[key]);
|
|
}
|
|
}
|
|
return clone;
|
|
}
|
|
return obj;
|
|
};
|
|
|
|
// ══════════════════════════════════════════════════════════════
|
|
// EPISODE ID INJECTION
|
|
// ══════════════════════════════════════════════════════════════
|
|
|
|
const 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 prefix = ep.id.includes(":") ? ep.id.split(":")[0] : ep.id;
|
|
ep.id = `watch/${provName}/${anilistId}/${category}/${prefix}-${ep.number}`;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return data;
|
|
};
|
|
|
|
// ══════════════════════════════════════════════════════════════
|
|
// PIPE API FUNCTIONS
|
|
// ══════════════════════════════════════════════════════════════
|
|
|
|
const fetchRawEpisodes = async (anilistId) => {
|
|
const cacheKey = `pipe:episodes:${anilistId}`;
|
|
const cached = getCached(cacheKey);
|
|
if (cached) return cached;
|
|
|
|
const data = await pipeRequest("episodes", { anilistId });
|
|
const result = deepTranslate(data);
|
|
setCache(cacheKey, result, 300 * 1000);
|
|
return result;
|
|
};
|
|
|
|
const getEpisodes = async (anilistId) => {
|
|
const data = await fetchRawEpisodes(anilistId);
|
|
const result = injectSourceSlugs(data, anilistId);
|
|
|
|
if (!result.mappings) {
|
|
result.mappings = { anilistId };
|
|
if (result.malId) result.mappings.malId = result.malId;
|
|
if (result.kitsuId) result.mappings.kitsuId = result.kitsuId;
|
|
}
|
|
|
|
return result;
|
|
};
|
|
|
|
const getSources = async (episodeId, provider, anilistId, category = "sub") => {
|
|
const cacheKey = `pipe:sources:${episodeId}:${provider}:${category}`;
|
|
const cached = getCached(cacheKey);
|
|
if (cached) return cached;
|
|
|
|
const encId = Buffer.from(episodeId).toString("base64url");
|
|
const sources = await pipeRequest("sources", {
|
|
episodeId: encId,
|
|
provider,
|
|
category,
|
|
anilistId,
|
|
});
|
|
|
|
const result = proxySubtitles(proxyStreams(deepTranslate(sources)));
|
|
setCache(cacheKey, result, 600 * 1000);
|
|
return result;
|
|
};
|
|
|
|
const getWatchSources = async (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 rawId = ep.id || "";
|
|
let match = false;
|
|
|
|
if (rawId.includes("/")) {
|
|
const slugSuffix = rawId.split("/").pop();
|
|
match = slugSuffix === slug;
|
|
} else if (rawId.includes(":")) {
|
|
const prefix = rawId.split(":")[0];
|
|
match = `${prefix}-${ep.number}` === slug;
|
|
}
|
|
|
|
if (match) {
|
|
targetId = ep.rawPipeId ? translateId(ep.rawPipeId) : rawId;
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (!targetId)
|
|
throw new Error(
|
|
`Episode slug '${slug}' not found for provider ${provider}`
|
|
);
|
|
return getSources(targetId, provider, anilistId, category);
|
|
};
|
|
|
|
// ══════════════════════════════════════════════════════════════
|
|
// SUBTITLE & QUALITY UTILITIES
|
|
// ══════════════════════════════════════════════════════════════
|
|
|
|
const extractSubtitles = (sources) => {
|
|
const raw = sources.subtitles || sources.captions || [];
|
|
if (!Array.isArray(raw)) return [];
|
|
|
|
return raw
|
|
.map((sub) => ({
|
|
url: sub.url || sub.file || null,
|
|
label: sub.label || sub.name || sub.language || "Unknown",
|
|
language: sub.language || sub.lang || sub.label || "en",
|
|
kind: sub.kind || "subtitles",
|
|
format: sub.format || "vtt",
|
|
encoding: sub.encoding || "utf-8",
|
|
isDefault: sub.default || false,
|
|
}))
|
|
.filter((sub) => sub.url);
|
|
};
|
|
|
|
const extractSkipTimes = (sources) => {
|
|
const skipTimes = sources.skipTimes || sources.skip || null;
|
|
if (!skipTimes || typeof skipTimes !== "object") return null;
|
|
|
|
return {
|
|
intro: skipTimes.intro || skipTimes.op || null,
|
|
outro: skipTimes.outro || skipTimes.ed || null,
|
|
preview: skipTimes.preview || null,
|
|
};
|
|
};
|
|
|
|
const getBestStream = (sources, preferredQuality = "1080p") => {
|
|
const streams = (sources.streams || []).filter((s) => s.url);
|
|
if (streams.length === 0) return null;
|
|
|
|
const hlsStreams = streams.filter(
|
|
(s) =>
|
|
s.type === "hls" ||
|
|
!s.type ||
|
|
s.url?.endsWith(".m3u8") ||
|
|
s.url?.includes("m3u8")
|
|
);
|
|
|
|
const usable = hlsStreams.length > 0 ? hlsStreams : streams;
|
|
|
|
const qualityOrder = ["1080p", "720p", "480p", "360p"];
|
|
const startIdx = qualityOrder.indexOf(preferredQuality);
|
|
const ordered = startIdx >= 0 ? qualityOrder.slice(startIdx) : qualityOrder;
|
|
|
|
for (const q of ordered) {
|
|
const match = usable.find((s) => {
|
|
const quality = (s.quality || s.label || "").toLowerCase();
|
|
return quality.includes(q);
|
|
});
|
|
if (match) return match;
|
|
}
|
|
|
|
const active = usable.find((s) => s.isActive);
|
|
if (active) return active;
|
|
|
|
return usable[0];
|
|
};
|
|
|
|
// ══════════════════════════════════════════════════════════════
|
|
// DOWNLOAD & BATCH
|
|
// ══════════════════════════════════════════════════════════════
|
|
|
|
const getDownloadUrl = async (provider, anilistId, category, slug) => {
|
|
const sources = await getWatchSources(provider, anilistId, category, slug);
|
|
const subtitles = extractSubtitles(sources);
|
|
const bestStream = getBestStream(sources);
|
|
|
|
return {
|
|
download: sources.download || null,
|
|
subtitles,
|
|
bestStream,
|
|
provider,
|
|
anilistId,
|
|
category,
|
|
slug,
|
|
hasDownload: !!sources.download,
|
|
hasSubtitles: subtitles.length > 0,
|
|
};
|
|
};
|
|
|
|
const getBatchSources = async (provider, anilistId, category, slugs) => {
|
|
const results = {};
|
|
|
|
const fetchOne = async (slug) => {
|
|
try {
|
|
const sources = await getWatchSources(provider, anilistId, category, slug);
|
|
const best = getBestStream(sources);
|
|
const subtitles = extractSubtitles(sources);
|
|
results[slug] = {
|
|
success: true,
|
|
streams: sources.streams,
|
|
bestStream: best,
|
|
subtitles,
|
|
download: sources.download || null,
|
|
};
|
|
} catch (err) {
|
|
results[slug] = {
|
|
success: false,
|
|
error: err.message,
|
|
};
|
|
}
|
|
};
|
|
|
|
const batchSize = 5;
|
|
for (let i = 0; i < slugs.length; i += batchSize) {
|
|
const batch = slugs.slice(i, i + batchSize);
|
|
await Promise.all(batch.map(fetchOne));
|
|
}
|
|
|
|
return {
|
|
provider,
|
|
anilistId,
|
|
category,
|
|
total: slugs.length,
|
|
successful: Object.values(results).filter((r) => r.success).length,
|
|
failed: Object.values(results).filter((r) => !r.success).length,
|
|
episodes: results,
|
|
};
|
|
};
|
|
|
|
module.exports = {
|
|
getEpisodes,
|
|
getSources,
|
|
getWatchSources,
|
|
getDownloadUrl,
|
|
getBatchSources,
|
|
extractSubtitles,
|
|
extractSkipTimes,
|
|
getBestStream,
|
|
encodePipeRequest,
|
|
decodePipeResponse,
|
|
translateId,
|
|
deepTranslate,
|
|
injectSourceSlugs,
|
|
pipeHealthCheck,
|
|
};
|
|
|
|
// ══════════════════════════════════════════════════════════════ END: pipe.js
|