diff --git a/CHANGELOG.md b/CHANGELOG.md index 1487999..cf6862f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # 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 ### Major Feature Update — Provider capabilities, enriched metadata, skip times diff --git a/api/proxy.js b/api/proxy.js new file mode 100644 index 0000000..1b0fbc3 --- /dev/null +++ b/api/proxy.js @@ -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 }); + } +}; diff --git a/src/helpers/pipe.js b/src/helpers/pipe.js index 0622fe1..33e2758 100644 --- a/src/helpers/pipe.js +++ b/src/helpers/pipe.js @@ -66,29 +66,97 @@ const encodePipeRequest = (payload) => { // ---- FEATURE: Decode base64+gzip pipe response ---- /** - * Decodes a pipe response from base64url + gzip format into a plain object. - * The Miruro pipe returns all responses in this compressed format. + * Decodes a pipe response from base64url + XOR obfuscation + gzip 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 * @throws {Error} If decoding or decompression fails - * - * @example - * const data = decodePipeResponse(res.text); - * console.log(data.providers); // { kiwi: {...}, bee: {...} } */ const decodePipeResponse = (encodedStr) => { try { - // NOTE: Add padding if necessary (base64url may omit trailing =) const padded = encodedStr + "=".repeat((4 - (encodedStr.length % 4)) % 4); - const compressed = Buffer.from(padded, "base64url"); - const decompressed = zlib.gunzipSync(compressed); + 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]; + } + const decompressed = zlib.gunzipSync(xored); return JSON.parse(decompressed.toString("utf-8")); } catch (e) { 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 ---- /** * Decodes a base64-encoded episode ID back to plain text. @@ -209,7 +277,7 @@ const fetchRawEpisodes = async (anilistId) => { method: "GET", query: { anilistId }, body: null, - version: "0.1.0", + version: "0.2.0", }; const encodedReq = encodePipeRequest(payload); @@ -271,7 +339,7 @@ const getSources = async (episodeId, provider, anilistId, category = "sub") => { method: "GET", query: { episodeId: encId, provider, category, anilistId }, body: null, - version: "0.1.0", + version: "0.2.0", }; 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}`); - 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) ---- @@ -348,7 +417,7 @@ const extractSubtitles = (sources) => { if (!Array.isArray(raw)) return []; return raw.map((sub) => ({ - url: sub.file || sub.url || null, + url: sub.url || sub.file || null, label: sub.label || sub.name || sub.language || "Unknown", language: sub.language || sub.lang || sub.label || "en", kind: sub.kind || "subtitles", diff --git a/vercel.json b/vercel.json index 4cd23e9..a99d8ff 100644 --- a/vercel.json +++ b/vercel.json @@ -4,9 +4,17 @@ { "src": "api/index.js", "use": "@vercel/node" + }, + { + "src": "api/proxy.js", + "use": "@vercel/node" } ], "routes": [ + { + "src": "/api/proxy", + "dest": "/api/proxy.js" + }, { "src": "/api/(.*)", "dest": "/api/index.js"