From 92154d7ea2920002b152c1d8ba576287d791d7f4 Mon Sep 17 00:00:00 2001 From: Shinei Nouzen <157171073+Shineii86@users.noreply.github.com> Date: Thu, 2 Jul 2026 06:57:58 +0530 Subject: [PATCH] =?UTF-8?q?fix(v2.2.0):=20migrate=20to=20new=20Miruro=20pi?= =?UTF-8?q?pe=20protocol=20=E2=80=94=20restore=20streaming=20endpoints?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- CHANGELOG.md | 31 ++++++++++ api/proxy.js | 6 +- package.json | 4 +- public/openapi.json | 2 +- server.js | 2 +- src/helpers/pipe.js | 128 ++++++++++++++++++++++++++++++---------- src/routes/apiRoutes.js | 4 +- 7 files changed, 138 insertions(+), 39 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6f2974b..a245d2b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,36 @@ # 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 ### Security Hardening, Bug Fixes, Code Quality Improvements diff --git a/api/proxy.js b/api/proxy.js index 2281cc3..f99ba82 100644 --- a/api/proxy.js +++ b/api/proxy.js @@ -209,7 +209,7 @@ module.exports = async function handler(req, res) { const decoded = decodePruUrl(url); const isPru = !!decoded; 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 fetchHeaders; @@ -219,8 +219,8 @@ module.exports = async function handler(req, res) { fetchUrl = encodeForPru(targetUrl, targetReferer); 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", - Referer: "https://www.miruro.tv/", - Origin: "https://www.miruro.tv", + Referer: "https://www.miruro.to/", + Origin: "https://www.miruro.to", }; } else { fetchUrl = targetUrl; diff --git a/package.json b/package.json index da35101..18dc9d4 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "miruro-api", - "version": "2.1.4", - "description": "RESTful API for miruro.tv - Anime streaming data powered by AniList GraphQL and Miruro streaming providers", + "version": "2.2.0", + "description": "RESTful API for Miruro - Anime streaming data powered by AniList GraphQL and Miruro streaming providers", "main": "server.js", "scripts": { "start": "node server.js", diff --git a/public/openapi.json b/public/openapi.json index 324f165..d15b9d4 100644 --- a/public/openapi.json +++ b/public/openapi.json @@ -3,7 +3,7 @@ "info": { "title": "MiruroAPI", "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": { "name": "Shineii86", "url": "https://github.com/Shineii86/MiruroAPI" diff --git a/server.js b/server.js index 63521ec..b1a4a3d 100644 --- a/server.js +++ b/server.js @@ -301,7 +301,7 @@ app.use((req, res) => { if (!process.env.VERCEL) { 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}`); }); } diff --git a/src/helpers/pipe.js b/src/helpers/pipe.js index d2669ff..d4ede66 100644 --- a/src/helpers/pipe.js +++ b/src/helpers/pipe.js @@ -27,23 +27,57 @@ const { getCached, setCache } = require("./cache"); // ══════════════════════════════════════════════════════════════ /** - * Miruro's secure pipe endpoint URL. - * All streaming requests go through this base64+gzip encoded tunnel. + * Miruro's secure pipe endpoint path (same-origin relative on every mirror). + * The pipe tunnel uses base64+gzip (and optionally XOR) encoded request/response. * * @type {string} */ -const MIRURO_PIPE_URL = "https://www.miruro.tv/api/secure/pipe"; +const PIPE_PATH = "/api/secure/pipe"; /** - * Request headers mimicking a browser. - * Referer is required for the pipe endpoint to accept requests. + * Ordered list of Miruro mirror origins that host the secure pipe endpoint. + * 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} */ 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/", - Origin: "https://www.miruro.tv", + "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, }; // ══════════════════════════════════════════════════════════════ @@ -66,10 +100,11 @@ const encodePipeRequest = (payload) => { 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. - * The Miruro pipe returns all responses XORed with PIPE_OBF_KEY then gzipped. + * XOR key applied to pipe responses when the server returns the + * `x-obfuscated: 2` header. Loaded from PIPE_OBF_KEY with hardcoded fallback. + * Key length: 16 bytes (32 hex chars). * * @type {Buffer} */ @@ -78,32 +113,54 @@ const PIPE_OBF_KEY = Buffer.from( "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 * @throws {Error} If decoding or decompression fails */ -const decodePipeResponse = (encodedStr) => { +const decodePipeResponse = (encodedStr, obfHeader = null) => { 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"); - // NOTE: XOR decode with obfuscation key before gzip decompression - 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]; + + // NOTE: Scheme "2" XORs the base64-decoded bytes with PIPE_OBF_KEY before gunzip + 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(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); } }; -// ---- 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. - * The pipe endpoint intermittently rate-limits (444/connection reset) from datacenter IPs. - * Retries up to 3 times with increasing delays (1s, 2s, 4s). + * Makes a pipe API request, rotating across Miruro mirrors on block/reset + * and retrying with exponential backoff. + * + * 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 {object} query - Query parameters for the pipe @@ -111,28 +168,39 @@ const decodePipeResponse = (encodedStr) => { * @returns {Promise} Decoded pipe response * @throws {Error} If all retries fail */ -const pipeRequest = async (path, query, maxRetries = 3) => { - const payload = { path, method: "GET", query, body: null, version: "0.2.0" }; +const pipeRequest = async (path, query, maxRetries = 4) => { + const payload = { path, method: "GET", query, body: null }; const encodedReq = encodePipeRequest(payload); let lastError; 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 { - const res = await axios.get(`${MIRURO_PIPE_URL}?e=${encodedReq}`, { + const res = await axios.get(`${origin}${PIPE_PATH}?e=${encodedReq}`, { headers: HEADERS, timeout: 20000, maxRedirects: 5, }); 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) { lastError = e; 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}`); } + // NOTE: Exponential backoff: 1s, 2s, 4s if (attempt < maxRetries - 1) { const delay = Math.pow(2, attempt) * 1000; @@ -195,7 +263,7 @@ const proxySubtitles = (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.tv/"); + const proxied = proxyUrl(raw, "https://www.miruro.to/"); return { ...s, url: proxied, file: proxied }; }); return sources; diff --git a/src/routes/apiRoutes.js b/src/routes/apiRoutes.js index 4ffa837..3a63d88 100644 --- a/src/routes/apiRoutes.js +++ b/src/routes/apiRoutes.js @@ -1123,7 +1123,7 @@ const createApiRoutes = (jsonResponse, jsonError, startTime) => { success: true, results: { status: "healthy", - version: "2.1.4", + version: "2.2.0", uptime: `${hours}h ${minutes}m ${seconds}s`, uptimeSeconds: uptime, timestamp: new Date().toISOString(), @@ -1178,7 +1178,7 @@ const createApiRoutes = (jsonResponse, jsonError, startTime) => { info: { title: "MiruroAPI", 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" }, }, servers: [