v2.1.3: Pipe resilience — retry logic, caching, optimized streaming

- Added pipeRequest() with retry + exponential backoff (3 attempts)
- Cached episodes (5min) and sources (10min) responses
- Confirmed kiwi/bonk CDNs serve real content, bee/ally serve decoys
- Updated version to 2.1.3 across package.json, health, OpenAPI
This commit is contained in:
Sʜɪɴᴇɪ Nᴏᴜᴢᴇɴ
2026-06-25 08:53:02 +05:30
parent 398db2b4f0
commit e35d934190
4 changed files with 94 additions and 36 deletions
+24
View File
@@ -1,5 +1,29 @@
# Changelog # Changelog
## v2.1.3
### Pipe Resilience — Retry logic, caching, optimized streaming
#### Pipe Request Improvements
- Added `pipeRequest()` helper with retry logic and exponential backoff (3 attempts: 1s, 2s, 4s delays)
- Retries on 444 (Cloudflare rate limit) and connection reset errors
- Skips retry on non-retryable 4xx errors (except 444)
- Increased pipe request timeout from 15s to 20s
#### Caching
- `fetchRawEpisodes` now caches episode data for 5 minutes (was uncached)
- `getSources` now caches streaming sources for 10 minutes (was uncached)
- Subsequent calls for the same episode are instant (no pipe requests)
#### Streaming Pipeline
- Confirmed kiwi provider CDN (owocdn.top) serves real video content with CORS `*`
- Confirmed bonk provider CDN (vibeplayer.site) returns streams
- bee/ally CDNs serve decoy PNGs to server IPs (confirmed)
- Stream URLs returned as raw CDN URLs (browser loads directly)
- Subtitles proxied through `/api/proxy` for CORS bypass
#### Version Bump
- MiruroAPI v2.1.3 (package.json, health endpoint, OpenAPI spec)
## v2.1.2 ## v2.1.2
### Streaming Architecture — pru proxy + raw CDN URLs ### Streaming Architecture — pru proxy + raw CDN URLs
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "miruro-api", "name": "miruro-api",
"version": "2.1.0", "version": "2.1.3",
"description": "RESTful API for miruro.tv - Anime streaming data powered by AniList GraphQL and Miruro streaming providers", "description": "RESTful API for miruro.tv - Anime streaming data powered by AniList GraphQL and Miruro streaming providers",
"main": "server.js", "main": "server.js",
"scripts": { "scripts": {
+67 -33
View File
@@ -20,6 +20,7 @@
const axios = require("axios"); const axios = require("axios");
const { Buffer } = require("buffer"); const { Buffer } = require("buffer");
const zlib = require("zlib"); const zlib = require("zlib");
const { getCached, setCache } = require("./cache");
// ══════════════════════════════════════════════════════════════ // ══════════════════════════════════════════════════════════════
// MIRURO PIPE CONFIGURATION // MIRURO PIPE CONFIGURATION
@@ -95,6 +96,50 @@ const decodePipeResponse = (encodedStr) => {
} }
}; };
// ---- FEATURE: Pipe request with retry and 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).
*
* @param {string} path - Pipe path ("episodes" or "sources")
* @param {object} query - Query parameters for the pipe
* @param {number} [maxRetries=3] - Maximum number of retries
* @returns {Promise<object>} 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 encodedReq = encodePipeRequest(payload);
let lastError;
for (let attempt = 0; attempt < maxRetries; attempt++) {
try {
const res = await axios.get(`${MIRURO_PIPE_URL}?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);
} 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) {
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;
await new Promise((r) => setTimeout(r, delay));
}
}
}
throw lastError || new Error("Pipe request failed after all retries");
};
// ---- FEATURE: Subtitle proxy for CORS-safe browser access ---- // ---- FEATURE: Subtitle proxy for CORS-safe browser access ----
/** /**
* CDN streams (mt.nekostream.site) have CORS * — browsers load them directly. * CDN streams (mt.nekostream.site) have CORS * — browsers load them directly.
@@ -261,30 +306,21 @@ const injectSourceSlugs = (data, anilistId) => {
// ---- FEATURE: Fetch raw decoded episode data from Miruro pipe ---- // ---- FEATURE: Fetch raw decoded episode data from Miruro pipe ----
/** /**
* Internal helper to fetch and decode raw episode data from the pipe. * Internal helper to fetch and decode raw episode data from the pipe.
* Sends a base64-encoded request and decodes the gzipped response. * Uses pipeRequest with retry logic and caches results for 5 minutes.
* *
* @param {number} anilistId - The AniList anime ID * @param {number} anilistId - The AniList anime ID
* @returns {Promise<object>} Decoded episode data with providers * @returns {Promise<object>} Decoded episode data with providers
* @throws {Error} If the pipe request fails * @throws {Error} If the pipe request fails
*/ */
const fetchRawEpisodes = async (anilistId) => { const fetchRawEpisodes = async (anilistId) => {
const payload = { const cacheKey = `pipe:episodes:${anilistId}`;
path: "episodes", const cached = getCached(cacheKey);
method: "GET", if (cached) return cached;
query: { anilistId },
body: null,
version: "0.2.0",
};
const encodedReq = encodePipeRequest(payload); const data = await pipeRequest("episodes", { anilistId });
const res = await axios.get(`${MIRURO_PIPE_URL}?e=${encodedReq}`, { const result = deepTranslate(data);
headers: HEADERS, setCache(cacheKey, result, 300 * 1000);
timeout: 15000, return result;
});
if (res.status !== 200) throw new Error(`Pipe request failed: ${res.status}`);
const data = decodePipeResponse(res.text || res.data);
return deepTranslate(data);
}; };
// ---- FEATURE: Get episodes with slug-based IDs ---- // ---- FEATURE: Get episodes with slug-based IDs ----
@@ -316,7 +352,8 @@ const getEpisodes = async (anilistId) => {
// ---- FEATURE: Get streaming sources (detailed endpoint) ---- // ---- FEATURE: Get streaming sources (detailed endpoint) ----
/** /**
* Fetches M3U8 streaming sources for a specific episode. * Fetches M3U8 streaming sources for a specific episode.
* This is the detailed/manual endpoint — use /watch for simpler access. * Uses pipeRequest with retry logic and caches results for 10 minutes.
* Stream URLs are ephemeral but CDNs serve the same content for hours.
* *
* @param {string} episodeId - The raw episode ID from the pipe * @param {string} episodeId - The raw episode ID from the pipe
* @param {string} provider - Provider name (e.g., "kiwi", "bee", "bonk") * @param {string} provider - Provider name (e.g., "kiwi", "bee", "bonk")
@@ -329,24 +366,21 @@ const getEpisodes = async (anilistId) => {
* console.log(sources.streams[0].url); // "https://.../master.m3u8" * console.log(sources.streams[0].url); // "https://.../master.m3u8"
*/ */
const getSources = async (episodeId, provider, anilistId, category = "sub") => { const getSources = async (episodeId, provider, anilistId, category = "sub") => {
const encId = Buffer.from(episodeId).toString("base64url"); const cacheKey = `pipe:sources:${episodeId}:${provider}:${category}`;
const payload = { const cached = getCached(cacheKey);
path: "sources", if (cached) return cached;
method: "GET",
query: { episodeId: encId, provider, category, anilistId },
body: null,
version: "0.2.0",
};
const encodedReq = encodePipeRequest(payload); const encId = Buffer.from(episodeId).toString("base64url");
const res = await axios.get(`${MIRURO_PIPE_URL}?e=${encodedReq}`, { const sources = await pipeRequest("sources", {
headers: HEADERS, episodeId: encId,
timeout: 15000, provider,
category,
anilistId,
}); });
if (res.status !== 200) throw new Error(`Pipe sources request failed: ${res.status}`); const result = proxySubtitles(proxyStreams(deepTranslate(sources)));
const sources = deepTranslate(decodePipeResponse(res.text || res.data)); setCache(cacheKey, result, 600 * 1000);
return proxySubtitles(proxyStreams(sources)); return result;
}; };
// ---- FEATURE: Get streaming sources (simple slug-based endpoint) ---- // ---- FEATURE: Get streaming sources (simple slug-based endpoint) ----
+2 -2
View File
@@ -1102,7 +1102,7 @@ const createApiRoutes = (jsonResponse, jsonError, startTime) => {
success: true, success: true,
results: { results: {
status: "healthy", status: "healthy",
version: "2.1.0", version: "2.1.3",
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(),
@@ -1157,7 +1157,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.0", version: "2.1.3",
contact: { name: "Shineii86", url: "https://github.com/Shineii86/MiruroAPI" }, contact: { name: "Shineii86", url: "https://github.com/Shineii86/MiruroAPI" },
}, },
servers: [ servers: [