mirror of
https://github.com/Shineii86/MiruroAPI.git
synced 2026-10-22 18:25:11 +00:00
feat: return raw CDN URLs for streams (browser loads directly via CORS *); keep subtitle proxy for third-party CDNs
This commit is contained in:
+69
-20
@@ -3,13 +3,36 @@ const axios = require("axios");
|
|||||||
const PROXY_KEY = Buffer.from("a54d389c18527d9fd3e7f0643e27edbe", "hex");
|
const PROXY_KEY = Buffer.from("a54d389c18527d9fd3e7f0643e27edbe", "hex");
|
||||||
const PRU_BASE = "https://pru.ultracloud.cc/";
|
const PRU_BASE = "https://pru.ultracloud.cc/";
|
||||||
|
|
||||||
function encodeForPru(rawUrl) {
|
function base64urlXor(str) {
|
||||||
const urlBytes = Buffer.from(rawUrl, "utf-8");
|
const buf = Buffer.from(str, "utf-8");
|
||||||
const xored = Buffer.alloc(urlBytes.length);
|
const xored = Buffer.alloc(buf.length);
|
||||||
for (let i = 0; i < urlBytes.length; i++) {
|
for (let i = 0; i < buf.length; i++) {
|
||||||
xored[i] = urlBytes[i] ^ PROXY_KEY[i % PROXY_KEY.length];
|
xored[i] = buf[i] ^ PROXY_KEY[i % PROXY_KEY.length];
|
||||||
}
|
}
|
||||||
return PRU_BASE + xored.toString("base64url");
|
return xored.toString("base64url");
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodePruUrl(pruUrl) {
|
||||||
|
try {
|
||||||
|
const path = new URL(pruUrl).pathname.replace(/^\//, "").replace(/\/pl\.m3u8$/, "").replace(/\/seg\.jpg$/, "");
|
||||||
|
const tildeIdx = path.indexOf("~");
|
||||||
|
if (tildeIdx === -1) return null;
|
||||||
|
const urlPart = path.substring(0, tildeIdx);
|
||||||
|
const refPart = path.substring(tildeIdx + 1);
|
||||||
|
const urlBuf = Buffer.from(urlPart, "base64url");
|
||||||
|
const url = Buffer.from(Array.from(urlBuf).map((b, i) => b ^ PROXY_KEY[i % PROXY_KEY.length])).toString("utf-8");
|
||||||
|
const refBuf = Buffer.from(refPart, "base64url");
|
||||||
|
const referer = Buffer.from(Array.from(refBuf).map((b, i) => b ^ PROXY_KEY[i % PROXY_KEY.length])).toString("utf-8");
|
||||||
|
return { url, referer };
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function encodeForPru(rawUrl, referer) {
|
||||||
|
const urlEnc = base64urlXor(rawUrl);
|
||||||
|
const refEnc = base64urlXor(referer || new URL(rawUrl).origin + "/");
|
||||||
|
return `${PRU_BASE}${urlEnc}~${refEnc}/pl.m3u8`;
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = async function handler(req, res) {
|
module.exports = async function handler(req, res) {
|
||||||
@@ -23,13 +46,31 @@ module.exports = async function handler(req, res) {
|
|||||||
if (!url) return res.status(400).json({ success: false, message: "url query parameter is required" });
|
if (!url) return res.status(400).json({ success: false, message: "url query parameter is required" });
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const headers = {
|
const decoded = decodePruUrl(url);
|
||||||
"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",
|
const isPru = !!decoded;
|
||||||
Referer: referer || "https://vidtube.site/",
|
const targetUrl = decoded ? decoded.url : url;
|
||||||
};
|
const targetReferer = decoded ? decoded.referer : (referer || "https://www.miruro.tv/");
|
||||||
|
|
||||||
const response = await axios.get(url, {
|
let fetchUrl;
|
||||||
headers,
|
let fetchHeaders;
|
||||||
|
|
||||||
|
if (isPru) {
|
||||||
|
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",
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
fetchUrl = targetUrl;
|
||||||
|
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: targetReferer,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await axios.get(fetchUrl, {
|
||||||
|
headers: fetchHeaders,
|
||||||
timeout: 15000,
|
timeout: 15000,
|
||||||
responseType: "arraybuffer",
|
responseType: "arraybuffer",
|
||||||
validateStatus: (s) => s < 500,
|
validateStatus: (s) => s < 500,
|
||||||
@@ -38,26 +79,34 @@ module.exports = async function handler(req, res) {
|
|||||||
const contentType = response.headers["content-type"] || "";
|
const contentType = response.headers["content-type"] || "";
|
||||||
res.setHeader("Cache-Control", "public, max-age=300");
|
res.setHeader("Cache-Control", "public, max-age=300");
|
||||||
|
|
||||||
if (contentType.includes("mpegurl") || url.endsWith(".m3u8") || contentType.includes("x-mpegURL")) {
|
if (isPru && (contentType.includes("mpegurl") || targetUrl.endsWith(".m3u8") || contentType.includes("x-mpegURL"))) {
|
||||||
let m3u8 = Buffer.from(response.data).toString("utf-8");
|
let m3u8 = Buffer.from(response.data).toString("utf-8");
|
||||||
const baseUrl = new URL(url);
|
|
||||||
|
|
||||||
// Rewrite all URLs to go through pru.ultracloud.cc proxy
|
|
||||||
m3u8 = m3u8.replace(/^(https?:\/\/[^\s]+)$/gm, (match) => {
|
m3u8 = m3u8.replace(/^(https?:\/\/[^\s]+)$/gm, (match) => {
|
||||||
return encodeForPru(match);
|
if (match.includes("pru.ultracloud.cc")) {
|
||||||
|
const dec = decodePruUrl(match);
|
||||||
|
if (dec) return `/api/proxy?url=${encodeURIComponent(dec.url)}&referer=${encodeURIComponent(dec.referer)}`;
|
||||||
|
}
|
||||||
|
return `/api/proxy?url=${encodeURIComponent(match)}&referer=${encodeURIComponent(targetReferer)}`;
|
||||||
});
|
});
|
||||||
// Rewrite relative URLs
|
|
||||||
m3u8 = m3u8.replace(/^([^\s#<][^\s]*\.(m3u8|ts|m4s|m4v|mp4|cmfv|cmfa|vtt|srt|ass|json|aac|mp3))(?:\?[^\s]*)?$/gm, (match) => {
|
m3u8 = m3u8.replace(/^([^\s#<][^\s]*\.(m3u8|ts|m4s|m4v|mp4|cmfv|cmfa|vtt|srt|ass|json|aac|mp3))(?:\?[^\s]*)?$/gm, (match) => {
|
||||||
const absolute = new URL(match, baseUrl).href;
|
try {
|
||||||
return encodeForPru(absolute);
|
const absolute = new URL(match, new URL(targetUrl)).href;
|
||||||
|
return `/api/proxy?url=${encodeURIComponent(absolute)}&referer=${encodeURIComponent(targetReferer)}`;
|
||||||
|
} catch {
|
||||||
|
return match;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
res.setHeader("Content-Type", "application/vnd.apple.mpegurl");
|
res.setHeader("Content-Type", "application/vnd.apple.mpegurl");
|
||||||
return res.send(m3u8);
|
return res.send(m3u8);
|
||||||
}
|
}
|
||||||
|
|
||||||
// For segments/subtitles, pass through directly (they have CORS)
|
|
||||||
res.setHeader("Content-Type", contentType);
|
res.setHeader("Content-Type", contentType);
|
||||||
|
if (response.headers["content-length"]) {
|
||||||
|
res.setHeader("Content-Length", response.headers["content-length"]);
|
||||||
|
}
|
||||||
res.send(Buffer.from(response.data));
|
res.send(Buffer.from(response.data));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
res.status(500).json({ success: false, message: "Proxy failed: " + err.message });
|
res.status(500).json({ success: false, message: "Proxy failed: " + err.message });
|
||||||
|
|||||||
+19
-24
@@ -94,54 +94,49 @@ const decodePipeResponse = (encodedStr) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// ---- FEATURE: Proxy URL encoding through ultracloud CDN ----
|
// ---- FEATURE: Subtitle proxy for CORS-safe browser access ----
|
||||||
/**
|
/**
|
||||||
* Proxies raw CDN URLs through our API endpoint for CORS-safe browser access.
|
* CDN streams (mt.nekostream.site) have CORS * — browsers load them directly.
|
||||||
* The /api/proxy endpoint rewrites M3U8 content and relays segments.
|
* Subtitles use third-party CDNs that may block cross-origin — proxy those.
|
||||||
*
|
*
|
||||||
* @type {string}
|
* @type {string}
|
||||||
*/
|
*/
|
||||||
const PROXY_BASE = "/api/proxy?url=";
|
const PROXY_BASE = "/api/proxy?url=";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Encodes a raw CDN URL into a proxied URL through our API.
|
* Proxies a URL through /api/proxy for CORS bypass.
|
||||||
* The proxy endpoint handles M3U8 rewriting and CORS headers.
|
|
||||||
*
|
*
|
||||||
* @param {string} url - Raw CDN URL
|
* @param {string} url - Raw URL
|
||||||
* @returns {string} Proxied URL through /api/proxy
|
* @param {string} [referer] - Optional referer to pass
|
||||||
|
* @returns {string} Proxied URL
|
||||||
*/
|
*/
|
||||||
const proxyUrl = (url) => {
|
const proxyUrl = (url, referer) => {
|
||||||
if (!url || !url.startsWith("http")) return url;
|
if (!url || !url.startsWith("http")) return url;
|
||||||
try {
|
try {
|
||||||
return PROXY_BASE + encodeURIComponent(url);
|
const ref = referer ? "&referer=" + encodeURIComponent(referer) : "";
|
||||||
|
return PROXY_BASE + encodeURIComponent(url) + ref;
|
||||||
} catch {
|
} catch {
|
||||||
return url;
|
return url;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Proxies all stream URLs in a sources response.
|
* Streams are returned as-is (raw CDN URLs).
|
||||||
* Replaces raw CDN URLs with proxied API URLs.
|
* The browser loads them directly — CDN has CORS * and serves real content to residential IPs.
|
||||||
|
* Server-side requests get decoy PNG images, so proxying through /api/proxy defeats the purpose.
|
||||||
*
|
*
|
||||||
* @param {object} sources - Decoded sources with streams array
|
* @param {object} sources - Decoded sources with streams array
|
||||||
* @returns {object} Sources with proxied stream URLs
|
* @returns {object} Sources with raw stream URLs
|
||||||
*/
|
*/
|
||||||
const proxyStreams = (sources) => {
|
const proxyStreams = (sources) => {
|
||||||
if (!sources?.streams) return sources;
|
// NOTE: Intentionally return raw CDN URLs — the CDN serves real video to browsers
|
||||||
sources.streams = sources.streams.map((s) => {
|
// but decoy PNG images to server-side requests. The browser's residential IP is required.
|
||||||
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;
|
return sources;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Proxies all subtitle URLs in a sources response.
|
* Proxies subtitle URLs through /api/proxy for CORS bypass.
|
||||||
|
* Subtitles come from third-party CDNs that may not have CORS headers.
|
||||||
*
|
*
|
||||||
* @param {object} sources - Decoded sources with subtitles array
|
* @param {object} sources - Decoded sources with subtitles array
|
||||||
* @returns {object} Sources with proxied subtitle URLs
|
* @returns {object} Sources with proxied subtitle URLs
|
||||||
@@ -151,7 +146,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) + "&referer=" + encodeURIComponent("https://www.miruro.tv/");
|
const proxied = proxyUrl(raw, "https://www.miruro.tv/");
|
||||||
return { ...s, url: proxied, file: proxied };
|
return { ...s, url: proxied, file: proxied };
|
||||||
});
|
});
|
||||||
return sources;
|
return sources;
|
||||||
|
|||||||
Reference in New Issue
Block a user