Files
MiruroAPI/src/helpers/pipe.js
T
Shinei Nouzen a5fbace9c1 fix: resolve Issue #3 — API stability, error handling & new endpoints
- 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
2026-08-25 06:45:38 +05:30

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