mirror of
https://github.com/Shineii86/MiruroAPI.git
synced 2026-10-22 18:25:11 +00:00
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:
@@ -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
@@ -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
@@ -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
@@ -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"
|
||||||
|
|||||||
@@ -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
@@ -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;
|
||||||
|
|||||||
@@ -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: [
|
||||||
|
|||||||
Reference in New Issue
Block a user