fix(v2.2.0): migrate to new Miruro pipe protocol — restore streaming endpoints

The streaming endpoints (/episodes, /sources, /stream, /watch, /download)
returned 500 because the upstream Miruro pipe changed and www.miruro.tv is
now hard-blocked by Cloudflare.

Reverse-engineered the current Miruro frontend contract:
- Request is plain base64url of the JSON payload (no XOR).
- Response obfuscation is now signalled by the x-obfuscated header:
  absent = plain JSON, 1 = base64+gzip, 2 = base64+XOR(PIPE_OBF_KEY)+gzip.

pipe.js now:
- rotates across mirrors (miruro.ru > .to > .bz > .tv), one per retry;
- decodes based on the x-obfuscated header instead of always XOR+gunzip;
- sends full browser sec-fetch/sec-ch-ua headers to pass Cloudflare checks;
- raises maxRetries to 4 and rotates on 403/444.

proxy.js default referer/Origin -> https://www.miruro.to (miruro.tv is dead).

AniList-backed endpoints were already healthy and are unaffected.
Version bumped to 2.2.0 across package.json, server.js, /api/health,
/api/openapi, public/openapi.json. CHANGELOG updated.
This commit is contained in:
Shinei Nouzen
2026-07-02 06:57:58 +05:30
parent 5a8879f2ba
commit 92154d7ea2
7 changed files with 138 additions and 39 deletions
+31
View File
@@ -1,5 +1,36 @@
# Changelog # Changelog
## v2.2.0
### Critical Fix — Miruro Pipe Protocol Migration (Streaming Endpoints Restored)
#### Diagnostic Summary
- **Full diagnostic sweep** of all live `/api/*` endpoints against `mirurotvapi.vercel.app`
- **AniList-backed endpoints** (`/search`, `/trending`, `/info`, `/popular`, `/top`, …) — confirmed **all 200/healthy**
- **Streaming endpoints** (`/episodes`, `/sources`, `/stream`, `/watch`, `/download`) — **all 500**
- Deep-scanned the Miruro frontend (`miruro.to/.bz/.ru`) bundles + `env2.js` to reverse-engineer the new pipe contract
#### Root Cause (streaming failure)
1. **Dead data source** — `pipe.js` targeted `https://www.miruro.tv/api/secure/pipe`, which is now hard-blocked by Cloudflare (returns 403 "Sorry, you have been blocked" page).
2. **Stale decode protocol** — the old `decode()` always did `base64url → XOR(PIPE_OBF_KEY) → gunzip`. Miruro's pipe now **signals the scheme via a response header**:
- `x-obfuscated` absent → plain JSON
- `x-obfuscated: 1` → `base64url(gzip(json))` (**no XOR**)
- `x-obfuscated: 2` → `base64url(XOR(gzip(json)))` with `PIPE_OBF_KEY`
- The hard-coded always-XOR decode corrupted the new `:1` payloads.
#### Fixes
- **`src/helpers/pipe.js`**
- New **mirror rotation** list: `miruro.ru` → `miruro.to` → `miruro.bz` → `miruro.tv`, iterating one mirror per retry.
- **New header-driven `decodePipeResponse(text, obfHeader)`** supporting plain / scheme-1 / scheme-2.
- **Browser-accurate request headers** (UA + `Accept`, `Accept-Language`, full `sec-ch-ua` / `sec-fetch-*` family) to pass Cloudflare bot fingerprinting rather than just UA+Referer.
- Request payload reverted to **plain base64url of the JSON** (no XOR on the request) matching the current client.
- Subtitle-proxy referer updated to canonical `https://www.miruro.to/`.
- Default `maxRetries` bumped 3 → 4 so each mirror gets an attempt; 403/444 now rotate instead of failing.
- **`api/proxy.js`** — default CDN referer/Origin switched from the dead `miruro.tv` to the canonical `miruro.to`.
- **Version bump** to `2.2.0` across `package.json`, `server.js` banner, `/api/health`, `/api/openapi`, and `public/openapi.json`.
#### Notes
- Streaming endpoints depend on Miruro's upstream pipe, which sits behind Cloudflare bot protection and may intermittently rate-limit datacenter egress IPs. The new rotation + browser headers + retry logic maximizes success; AniList endpoints are unaffected and remain fully healthy.
## v2.1.4 ## v2.1.4
### Security Hardening, Bug Fixes, Code Quality Improvements ### Security Hardening, Bug Fixes, Code Quality Improvements
+3 -3
View File
@@ -209,7 +209,7 @@ module.exports = async function handler(req, res) {
const decoded = decodePruUrl(url); const decoded = decodePruUrl(url);
const isPru = !!decoded; const isPru = !!decoded;
const targetUrl = decoded ? decoded.url : url; const targetUrl = decoded ? decoded.url : url;
const targetReferer = decoded ? decoded.referer : referer || "https://www.miruro.tv/"; const targetReferer = decoded ? decoded.referer : referer || "https://www.miruro.to/";
let fetchUrl; let fetchUrl;
let fetchHeaders; let fetchHeaders;
@@ -219,8 +219,8 @@ module.exports = async function handler(req, res) {
fetchUrl = encodeForPru(targetUrl, targetReferer); fetchUrl = encodeForPru(targetUrl, targetReferer);
fetchHeaders = { fetchHeaders = {
"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", "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/", Referer: "https://www.miruro.to/",
Origin: "https://www.miruro.tv", Origin: "https://www.miruro.to",
}; };
} else { } else {
fetchUrl = targetUrl; fetchUrl = targetUrl;
+2 -2
View File
@@ -1,7 +1,7 @@
{ {
"name": "miruro-api", "name": "miruro-api",
"version": "2.1.4", "version": "2.2.0",
"description": "RESTful API for miruro.tv - Anime streaming data powered by AniList GraphQL and Miruro streaming providers", "description": "RESTful API for Miruro - Anime streaming data powered by AniList GraphQL and Miruro streaming providers",
"main": "server.js", "main": "server.js",
"scripts": { "scripts": {
"start": "node server.js", "start": "node server.js",
+1 -1
View File
@@ -3,7 +3,7 @@
"info": { "info": {
"title": "MiruroAPI", "title": "MiruroAPI",
"description": "Free RESTful API for anime streaming data. Powered by AniList GraphQL and Miruro streaming providers.", "description": "Free RESTful API for anime streaming data. Powered by AniList GraphQL and Miruro streaming providers.",
"version": "2.1.4", "version": "2.2.0",
"contact": { "contact": {
"name": "Shineii86", "name": "Shineii86",
"url": "https://github.com/Shineii86/MiruroAPI" "url": "https://github.com/Shineii86/MiruroAPI"
+1 -1
View File
@@ -301,7 +301,7 @@ app.use((req, res) => {
if (!process.env.VERCEL) { if (!process.env.VERCEL) {
app.listen(PORT, () => { app.listen(PORT, () => {
console.log(`[${new Date().toISOString()}] MiruroAPI v2.1.4 listening at ${PORT}`); console.log(`[${new Date().toISOString()}] MiruroAPI v2.2.0 listening at ${PORT}`);
}); });
} }
+98 -30
View File
@@ -27,23 +27,57 @@ const { getCached, setCache } = require("./cache");
// ══════════════════════════════════════════════════════════════ // ══════════════════════════════════════════════════════════════
/** /**
* Miruro's secure pipe endpoint URL. * Miruro's secure pipe endpoint path (same-origin relative on every mirror).
* All streaming requests go through this base64+gzip encoded tunnel. * The pipe tunnel uses base64+gzip (and optionally XOR) encoded request/response.
* *
* @type {string} * @type {string}
*/ */
const MIRURO_PIPE_URL = "https://www.miruro.tv/api/secure/pipe"; const PIPE_PATH = "/api/secure/pipe";
/** /**
* Request headers mimicking a browser. * Ordered list of Miruro mirror origins that host the secure pipe endpoint.
* Referer is required for the pipe endpoint to accept requests. * Miruro rotates domains frequently and sits behind Cloudflare bot protection,
* so we try them in order and rotate to the next on any block/reset.
* `.ru` currently serves the pipe with the lightest Cloudflare friction;
* `.to` is the canonical origin referenced in CORS headers.
*
* @type {string[]}
*/
const MIRURO_ORIGINS = [
"https://www.miruro.ru",
"https://www.miruro.to",
"https://www.miruro.bz",
"https://www.miruro.tv",
];
/**
* Canonical origin used for the Referer/Origin headers (matches the
* `access-control-allow-origin` the pipe returns).
*
* @type {string}
*/
const CANONICAL_ORIGIN = "https://www.miruro.to";
/**
* Request headers mimicking a real browser. The pipe endpoint sits behind
* Cloudflare, which inspects UA + sec-fetch + Accept headers, so these must
* look like a genuine same-origin browser request.
* *
* @type {object} * @type {object}
*/ */
const HEADERS = { 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", "User-Agent":
Referer: "https://www.miruro.tv/", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36",
Origin: "https://www.miruro.tv", 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,
}; };
// ══════════════════════════════════════════════════════════════ // ══════════════════════════════════════════════════════════════
@@ -66,10 +100,11 @@ const encodePipeRequest = (payload) => {
return Buffer.from(json).toString("base64url"); return Buffer.from(json).toString("base64url");
}; };
// ---- FEATURE: Decode base64+gzip pipe response ---- // ---- FEATURE: XOR obfuscation key for pipe responses (x-obfuscated: 2) ----
/** /**
* Decodes a pipe response from base64url + XOR obfuscation + gzip format. * XOR key applied to pipe responses when the server returns the
* The Miruro pipe returns all responses XORed with PIPE_OBF_KEY then gzipped. * `x-obfuscated: 2` header. Loaded from PIPE_OBF_KEY with hardcoded fallback.
* Key length: 16 bytes (32 hex chars).
* *
* @type {Buffer} * @type {Buffer}
*/ */
@@ -78,32 +113,54 @@ const PIPE_OBF_KEY = Buffer.from(
"hex" "hex"
); );
// ---- FEATURE: Decode pipe response (header-driven obfuscation scheme) ----
/** /**
* @param {string} encodedStr - The base64url-encoded response * Decodes a pipe response based on the `x-obfuscated` response header.
*
* Obfuscation schemes (matches the Miruro frontend's secure client):
* - no header / falsy → body is plain JSON text
* - "1" → body is base64url( gzip( json ) ) (no XOR)
* - "2" → body is base64url( XOR( gzip( json ) ) ) with PIPE_OBF_KEY
*
* @param {string} encodedStr - The raw response body text
* @param {string|null} [obfHeader] - Value of the `x-obfuscated` response header
* @returns {object} Decoded JSON object * @returns {object} Decoded JSON object
* @throws {Error} If decoding or decompression fails * @throws {Error} If decoding or decompression fails
*/ */
const decodePipeResponse = (encodedStr) => { const decodePipeResponse = (encodedStr, obfHeader = null) => {
try { try {
const padded = encodedStr + "=".repeat((4 - (encodedStr.length % 4)) % 4); // NOTE: No obfuscation header → the body is plain JSON
if (!obfHeader) return JSON.parse(encodedStr);
const padded =
encodedStr + "=".repeat((4 - (encodedStr.length % 4)) % 4);
const raw = Buffer.from(padded, "base64url"); const raw = Buffer.from(padded, "base64url");
// NOTE: XOR decode with obfuscation key before gzip decompression
const xored = Buffer.alloc(raw.length); // NOTE: Scheme "2" XORs the base64-decoded bytes with PIPE_OBF_KEY before gunzip
for (let i = 0; i < raw.length; i++) { let bytes = raw;
xored[i] = raw[i] ^ PIPE_OBF_KEY[i % PIPE_OBF_KEY.length]; 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(xored);
const decompressed = zlib.gunzipSync(bytes);
return JSON.parse(decompressed.toString("utf-8")); return JSON.parse(decompressed.toString("utf-8"));
} catch (e) { } catch (e) {
throw new Error("Failed to decode pipe response: " + e.message); throw new Error("Failed to decode pipe response: " + e.message);
} }
}; };
// ---- FEATURE: Pipe request with retry and exponential backoff ---- // ---- FEATURE: Pipe request with mirror rotation + exponential backoff ----
/** /**
* Makes a pipe API request with retry logic and exponential backoff. * Makes a pipe API request, rotating across Miruro mirrors on block/reset
* The pipe endpoint intermittently rate-limits (444/connection reset) from datacenter IPs. * and retrying with exponential backoff.
* Retries up to 3 times with increasing delays (1s, 2s, 4s). *
* Miruro's pipe sits behind Cloudflare bot protection which intermittently
* returns 403/444 or resets connections from datacenter IPs, so each retry
* targets the next mirror origin and honors a short backoff.
* *
* @param {string} path - Pipe path ("episodes" or "sources") * @param {string} path - Pipe path ("episodes" or "sources")
* @param {object} query - Query parameters for the pipe * @param {object} query - Query parameters for the pipe
@@ -111,28 +168,39 @@ const decodePipeResponse = (encodedStr) => {
* @returns {Promise<object>} Decoded pipe response * @returns {Promise<object>} Decoded pipe response
* @throws {Error} If all retries fail * @throws {Error} If all retries fail
*/ */
const pipeRequest = async (path, query, maxRetries = 3) => { const pipeRequest = async (path, query, maxRetries = 4) => {
const payload = { path, method: "GET", query, body: null, version: "0.2.0" }; const payload = { path, method: "GET", query, body: null };
const encodedReq = encodePipeRequest(payload); const encodedReq = encodePipeRequest(payload);
let lastError; let lastError;
for (let attempt = 0; attempt < maxRetries; attempt++) { for (let attempt = 0; attempt < maxRetries; attempt++) {
// NOTE: Rotate mirror on every attempt to spread Cloudflare pressure
const origin = MIRURO_ORIGINS[attempt % MIRURO_ORIGINS.length];
try { try {
const res = await axios.get(`${MIRURO_PIPE_URL}?e=${encodedReq}`, { const res = await axios.get(`${origin}${PIPE_PATH}?e=${encodedReq}`, {
headers: HEADERS, headers: HEADERS,
timeout: 20000, timeout: 20000,
maxRedirects: 5, maxRedirects: 5,
}); });
if (res.status !== 200) throw new Error(`Pipe request failed: ${res.status}`); if (res.status !== 200) throw new Error(`Pipe request failed: ${res.status}`);
return decodePipeResponse(res.text || res.data);
// NOTE: Decode using the server's x-obfuscated scheme header
const obf = res.headers["x-obfuscated"];
return decodePipeResponse(res.data, obf);
} catch (e) { } catch (e) {
lastError = e; lastError = e;
const status = e.response?.status; const status = e.response?.status;
// NOTE: Don't retry on non-retryable errors (4xx except 444)
if (status && status >= 400 && status < 500 && status !== 444) { // NOTE: Rotate mirror on Cloudflare blocks / resets (403, 444, conn reset).
// 4xx (except 444) are not retryable on the same mirror, but rotating
// to another mirror may still succeed, so we only hard-fail on 4xx
// when we've exhausted all mirrors.
if (status && status >= 400 && status < 500 && status !== 444 && status !== 403) {
throw new Error(`Pipe request failed with status ${status}`); throw new Error(`Pipe request failed with status ${status}`);
} }
// NOTE: Exponential backoff: 1s, 2s, 4s // NOTE: Exponential backoff: 1s, 2s, 4s
if (attempt < maxRetries - 1) { if (attempt < maxRetries - 1) {
const delay = Math.pow(2, attempt) * 1000; const delay = Math.pow(2, attempt) * 1000;
@@ -195,7 +263,7 @@ const proxySubtitles = (sources) => {
sources.subtitles = sources.subtitles.map((s) => { sources.subtitles = sources.subtitles.map((s) => {
const raw = s.url || s.file; const raw = s.url || s.file;
if (!raw || !raw.startsWith("http")) return s; if (!raw || !raw.startsWith("http")) return s;
const proxied = proxyUrl(raw, "https://www.miruro.tv/"); const proxied = proxyUrl(raw, "https://www.miruro.to/");
return { ...s, url: proxied, file: proxied }; return { ...s, url: proxied, file: proxied };
}); });
return sources; return sources;
+2 -2
View File
@@ -1123,7 +1123,7 @@ const createApiRoutes = (jsonResponse, jsonError, startTime) => {
success: true, success: true,
results: { results: {
status: "healthy", status: "healthy",
version: "2.1.4", version: "2.2.0",
uptime: `${hours}h ${minutes}m ${seconds}s`, uptime: `${hours}h ${minutes}m ${seconds}s`,
uptimeSeconds: uptime, uptimeSeconds: uptime,
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
@@ -1178,7 +1178,7 @@ const createApiRoutes = (jsonResponse, jsonError, startTime) => {
info: { info: {
title: "MiruroAPI", title: "MiruroAPI",
description: "Free REST API for anime streaming data — AniList GraphQL + Miruro streaming providers", description: "Free REST API for anime streaming data — AniList GraphQL + Miruro streaming providers",
version: "2.1.4", version: "2.2.0",
contact: { name: "Shineii86", url: "https://github.com/Shineii86/MiruroAPI" }, contact: { name: "Shineii86", url: "https://github.com/Shineii86/MiruroAPI" },
}, },
servers: [ servers: [