mirror of
https://github.com/Shineii86/MiruroAPI.git
synced 2026-10-22 10:15:10 +00:00
feat: pipe XOR decode + CDN CORS proxy for streaming
- decodePipeResponse now XOR-decodes with obfuscation key before gzip - Updated pipe version to 0.2.0 to match miruro.tv - Added /api/proxy endpoint for CORS-safe M3U8/VTT streaming - Proxied stream/subtitle URLs through /api/proxy for browser playback - M3U8 content rewritten to proxy variant playlists and segments
This commit is contained in:
@@ -1,5 +1,13 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## v2.1.1
|
||||||
|
### Bug Fix — Pipe response XOR decoding
|
||||||
|
|
||||||
|
#### Fix
|
||||||
|
- `decodePipeResponse` now XOR-decodes with obfuscation key before gzip decompression — pipe was returning XOR+gzip, not plain gzip
|
||||||
|
- Updated pipe version from `0.1.0` to `0.2.0` to match miruro.tv
|
||||||
|
- `getWatchSources` now properly returns streams (was always failing before this fix)
|
||||||
|
|
||||||
## v2.1.0
|
## v2.1.0
|
||||||
### Major Feature Update — Provider capabilities, enriched metadata, skip times
|
### Major Feature Update — Provider capabilities, enriched metadata, skip times
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
const axios = require("axios");
|
||||||
|
|
||||||
|
module.exports = async function handler(req, res) {
|
||||||
|
res.setHeader("Access-Control-Allow-Origin", "*");
|
||||||
|
res.setHeader("Access-Control-Allow-Methods", "GET, HEAD, OPTIONS");
|
||||||
|
res.setHeader("Access-Control-Allow-Headers", "Range, Referer");
|
||||||
|
|
||||||
|
if (req.method === "OPTIONS") return res.status(200).end();
|
||||||
|
|
||||||
|
const { url, referer } = req.query;
|
||||||
|
if (!url) return res.status(400).json({ success: false, message: "url query parameter is required" });
|
||||||
|
|
||||||
|
try {
|
||||||
|
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: referer || "https://vidtube.site/",
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await axios.get(url, {
|
||||||
|
headers,
|
||||||
|
timeout: 15000,
|
||||||
|
responseType: "arraybuffer",
|
||||||
|
validateStatus: (s) => s < 500,
|
||||||
|
});
|
||||||
|
|
||||||
|
const contentType = response.headers["content-type"] || "";
|
||||||
|
res.setHeader("Content-Type", contentType);
|
||||||
|
res.setHeader("Cache-Control", "public, max-age=300");
|
||||||
|
|
||||||
|
if (contentType.includes("mpegurl") || url.endsWith(".m3u8") || contentType.includes("x-mpegURL")) {
|
||||||
|
let m3u8 = Buffer.from(response.data).toString("utf-8");
|
||||||
|
const baseUrl = new URL(url);
|
||||||
|
m3u8 = m3u8.replace(/^(https?:\/\/[^\s]+)$/gm, (match) => {
|
||||||
|
return `/api/proxy?url=${encodeURIComponent(match)}&referer=${encodeURIComponent(baseUrl.origin + "/")}`;
|
||||||
|
});
|
||||||
|
m3u8 = m3u8.replace(/^([^\s#][^\s]*\.(ts|m4s|m4v|mp4|cmfv|cmfa|vtt|srt|ass|json))(?:\?[^\s]*)?$/gm, (match) => {
|
||||||
|
const absolute = new URL(match, baseUrl).href;
|
||||||
|
return `/api/proxy?url=${encodeURIComponent(absolute)}&referer=${encodeURIComponent(baseUrl.origin + "/")}`;
|
||||||
|
});
|
||||||
|
res.setHeader("Content-Type", "application/vnd.apple.mpegurl");
|
||||||
|
return res.send(m3u8);
|
||||||
|
}
|
||||||
|
|
||||||
|
res.send(Buffer.from(response.data));
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ success: false, message: "Proxy failed: " + err.message });
|
||||||
|
}
|
||||||
|
};
|
||||||
+83
-14
@@ -66,29 +66,97 @@ const encodePipeRequest = (payload) => {
|
|||||||
|
|
||||||
// ---- FEATURE: Decode base64+gzip pipe response ----
|
// ---- FEATURE: Decode base64+gzip pipe response ----
|
||||||
/**
|
/**
|
||||||
* Decodes a pipe response from base64url + gzip format into a plain object.
|
* Decodes a pipe response from base64url + XOR obfuscation + gzip format.
|
||||||
* The Miruro pipe returns all responses in this compressed format.
|
* The Miruro pipe returns all responses XORed with PIPE_OBF_KEY then gzipped.
|
||||||
*
|
*
|
||||||
* @param {string} encodedStr - The base64url-encoded gzipped response
|
* @type {Buffer}
|
||||||
|
*/
|
||||||
|
const PIPE_OBF_KEY = Buffer.from("71951034f8fbcf53d89db52ceb3dc22c", "hex");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} encodedStr - The base64url-encoded response
|
||||||
* @returns {object} Decoded JSON object
|
* @returns {object} Decoded JSON object
|
||||||
* @throws {Error} If decoding or decompression fails
|
* @throws {Error} If decoding or decompression fails
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* const data = decodePipeResponse(res.text);
|
|
||||||
* console.log(data.providers); // { kiwi: {...}, bee: {...} }
|
|
||||||
*/
|
*/
|
||||||
const decodePipeResponse = (encodedStr) => {
|
const decodePipeResponse = (encodedStr) => {
|
||||||
try {
|
try {
|
||||||
// NOTE: Add padding if necessary (base64url may omit trailing =)
|
|
||||||
const padded = encodedStr + "=".repeat((4 - (encodedStr.length % 4)) % 4);
|
const padded = encodedStr + "=".repeat((4 - (encodedStr.length % 4)) % 4);
|
||||||
const compressed = Buffer.from(padded, "base64url");
|
const raw = Buffer.from(padded, "base64url");
|
||||||
const decompressed = zlib.gunzipSync(compressed);
|
// 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];
|
||||||
|
}
|
||||||
|
const decompressed = zlib.gunzipSync(xored);
|
||||||
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: Proxy URL encoding through ultracloud CDN ----
|
||||||
|
/**
|
||||||
|
* Proxies raw CDN URLs through our API endpoint for CORS-safe browser access.
|
||||||
|
* The /api/proxy endpoint rewrites M3U8 content and relays segments.
|
||||||
|
*
|
||||||
|
* @type {string}
|
||||||
|
*/
|
||||||
|
const PROXY_BASE = "/api/proxy?url=";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Encodes a raw CDN URL into a proxied URL through our API.
|
||||||
|
* The proxy endpoint handles M3U8 rewriting and CORS headers.
|
||||||
|
*
|
||||||
|
* @param {string} url - Raw CDN URL
|
||||||
|
* @returns {string} Proxied URL through /api/proxy
|
||||||
|
*/
|
||||||
|
const proxyUrl = (url) => {
|
||||||
|
if (!url || !url.startsWith("http")) return url;
|
||||||
|
try {
|
||||||
|
return PROXY_BASE + encodeURIComponent(url);
|
||||||
|
} catch {
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Proxies all stream URLs in a sources response.
|
||||||
|
* Replaces raw CDN URLs with proxied API URLs.
|
||||||
|
*
|
||||||
|
* @param {object} sources - Decoded sources with streams array
|
||||||
|
* @returns {object} Sources with proxied stream URLs
|
||||||
|
*/
|
||||||
|
const proxyStreams = (sources) => {
|
||||||
|
if (!sources?.streams) return sources;
|
||||||
|
sources.streams = sources.streams.map((s) => {
|
||||||
|
if (s.type === "embed") return s;
|
||||||
|
const ref = s.referer || "https://vidtube.site/";
|
||||||
|
return {
|
||||||
|
...s,
|
||||||
|
url: proxyUrl(s.url) + "&referer=" + encodeURIComponent(ref),
|
||||||
|
referer: "https://mirurotvapp.vercel.app/",
|
||||||
|
};
|
||||||
|
});
|
||||||
|
return sources;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Proxies all subtitle URLs in a sources response.
|
||||||
|
*
|
||||||
|
* @param {object} sources - Decoded sources with subtitles array
|
||||||
|
* @returns {object} Sources with proxied subtitle URLs
|
||||||
|
*/
|
||||||
|
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) + "&referer=" + encodeURIComponent("https://www.miruro.tv/");
|
||||||
|
return { ...s, url: proxied, file: proxied };
|
||||||
|
});
|
||||||
|
return sources;
|
||||||
|
};
|
||||||
|
|
||||||
// ---- FEATURE: Decode base64 episode ID to plain text ----
|
// ---- FEATURE: Decode base64 episode ID to plain text ----
|
||||||
/**
|
/**
|
||||||
* Decodes a base64-encoded episode ID back to plain text.
|
* Decodes a base64-encoded episode ID back to plain text.
|
||||||
@@ -209,7 +277,7 @@ const fetchRawEpisodes = async (anilistId) => {
|
|||||||
method: "GET",
|
method: "GET",
|
||||||
query: { anilistId },
|
query: { anilistId },
|
||||||
body: null,
|
body: null,
|
||||||
version: "0.1.0",
|
version: "0.2.0",
|
||||||
};
|
};
|
||||||
|
|
||||||
const encodedReq = encodePipeRequest(payload);
|
const encodedReq = encodePipeRequest(payload);
|
||||||
@@ -271,7 +339,7 @@ const getSources = async (episodeId, provider, anilistId, category = "sub") => {
|
|||||||
method: "GET",
|
method: "GET",
|
||||||
query: { episodeId: encId, provider, category, anilistId },
|
query: { episodeId: encId, provider, category, anilistId },
|
||||||
body: null,
|
body: null,
|
||||||
version: "0.1.0",
|
version: "0.2.0",
|
||||||
};
|
};
|
||||||
|
|
||||||
const encodedReq = encodePipeRequest(payload);
|
const encodedReq = encodePipeRequest(payload);
|
||||||
@@ -281,7 +349,8 @@ const getSources = async (episodeId, provider, anilistId, category = "sub") => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (res.status !== 200) throw new Error(`Pipe sources request failed: ${res.status}`);
|
if (res.status !== 200) throw new Error(`Pipe sources request failed: ${res.status}`);
|
||||||
return deepTranslate(decodePipeResponse(res.text || res.data));
|
const sources = deepTranslate(decodePipeResponse(res.text || res.data));
|
||||||
|
return proxySubtitles(proxyStreams(sources));
|
||||||
};
|
};
|
||||||
|
|
||||||
// ---- FEATURE: Get streaming sources (simple slug-based endpoint) ----
|
// ---- FEATURE: Get streaming sources (simple slug-based endpoint) ----
|
||||||
@@ -348,7 +417,7 @@ const extractSubtitles = (sources) => {
|
|||||||
if (!Array.isArray(raw)) return [];
|
if (!Array.isArray(raw)) return [];
|
||||||
|
|
||||||
return raw.map((sub) => ({
|
return raw.map((sub) => ({
|
||||||
url: sub.file || sub.url || null,
|
url: sub.url || sub.file || null,
|
||||||
label: sub.label || sub.name || sub.language || "Unknown",
|
label: sub.label || sub.name || sub.language || "Unknown",
|
||||||
language: sub.language || sub.lang || sub.label || "en",
|
language: sub.language || sub.lang || sub.label || "en",
|
||||||
kind: sub.kind || "subtitles",
|
kind: sub.kind || "subtitles",
|
||||||
|
|||||||
@@ -4,9 +4,17 @@
|
|||||||
{
|
{
|
||||||
"src": "api/index.js",
|
"src": "api/index.js",
|
||||||
"use": "@vercel/node"
|
"use": "@vercel/node"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "api/proxy.js",
|
||||||
|
"use": "@vercel/node"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"routes": [
|
"routes": [
|
||||||
|
{
|
||||||
|
"src": "/api/proxy",
|
||||||
|
"dest": "/api/proxy.js"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"src": "/api/(.*)",
|
"src": "/api/(.*)",
|
||||||
"dest": "/api/index.js"
|
"dest": "/api/index.js"
|
||||||
|
|||||||
Reference in New Issue
Block a user