From 156f9b16a51bb6d7ec6d34fcd3718198627cc5c0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=CA=9C=C9=AA=C9=B4=E1=B4=87=C9=AA=20N=E1=B4=8F=E1=B4=9C?= =?UTF-8?q?=E1=B4=A2=E1=B4=87=C9=B4?= <157171073+Shineii86@users.noreply.github.com> Date: Thu, 25 Jun 2026 10:55:48 +0530 Subject: [PATCH] style: Add full doc comments to proxy.js, server.js, test.js, api/index.js MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Box-style header with project/author/license - Section headers (═══ SECTION ═══) - Function-level JSDoc (purpose, params, returns, examples) - Inline notes for non-obvious logic - Feature markers (---- FEATURE: XYZ ----) - Footer on every module --- api/index.js | 17 +++++ api/proxy.js | 210 ++++++++++++++++++++++++++++++++++++++++++++------- server.js | 28 +++++++ test.js | 77 ++++++++++++++++--- 4 files changed, 292 insertions(+), 40 deletions(-) diff --git a/api/index.js b/api/index.js index 97d3b5f..6bbb943 100644 --- a/api/index.js +++ b/api/index.js @@ -1 +1,18 @@ +/* + * ======= • ======= • ======= • ======= • =======• ======= + * MiruroAPI — api/index.js + * Repository: https://github.com/Shineii86/MiruroAPI + * + * @description + * Vercel serverless function entry point. + * Single line that re-exports the Express app from server.js. + * Vercel maps all /api/* routes to this file. + * + * @author Shinei Nouzen + * @license MIT + * ======= • ======= • ======= • ======= • =======• ======= + */ + module.exports = require("../server"); + +// ══════════════════════════════════════════════════════════════ END: api/index.js diff --git a/api/proxy.js b/api/proxy.js index 685d47c..2281cc3 100644 --- a/api/proxy.js +++ b/api/proxy.js @@ -1,44 +1,198 @@ +/* + * ======= • ======= • ======= • ======= • =======• ======= + * MiruroAPI — proxy.js + * Repository: https://github.com/Shineii86/MiruroAPI + * + * @description + * CDN proxy endpoint that fetches and rewrites M3U8 playlists + * and media segments from third-party CDNs. Handles both + * pru.ultracloud.cc encoded URLs (XOR + base64url) and direct + * CDN URLs. Rewrites M3U8 internal URLs to route through the + * proxy for CORS-safe browser playback. + * + * @exports + * handler — Vercel serverless function (GET /api/proxy) + * + * @author Shinei Nouzen + * @license MIT + * ======= • ======= • ======= • ======= • =======• ======= + */ + const axios = require("axios"); +// ══════════════════════════════════════════════════════════════ +// PROXY CONFIGURATION +// ══════════════════════════════════════════════════════════════ + +// ---- FEATURE: PRU XOR encryption key (16-byte hex) ---- +/** + * XOR key used to encode/decode pru.ultracloud.cc URLs. + * Loaded from environment variable PRU_PROXY_KEY with hardcoded fallback. + * Key length: 16 bytes (32 hex chars). + * + * @type {Buffer} + */ const PROXY_KEY = Buffer.from( process.env.PRU_PROXY_KEY || "a54d389c18527d9fd3e7f0643e27edbe", "hex" ); + +/** + * Base URL for the PRU CDN proxy service. + * All proxied M3U8 URLs are rewritten to route through this domain. + * + * @type {string} + */ const PRU_BASE = "https://pru.ultracloud.cc/"; -function base64urlXor(str) { +// ══════════════════════════════════════════════════════════════ +// ENCODING / DECODING UTILITIES +// ══════════════════════════════════════════════════════════════ + +// ---- FEATURE: Base64url XOR encode for PRU URLs ---- +/** + * Encodes a raw URL into the PRU format: base64url(XOR(url, key)). + * Used when constructing proxied URLs for the PRU CDN. + * + * @param {string} str - The raw URL or referer string to encode + * @returns {string} Base64url-encoded XOR'd string + * + * @example + * const encoded = base64urlXor("https://example.com/video.m3u8"); + */ +const base64urlXor = (str) => { const buf = Buffer.from(str, "utf-8"); const xored = Buffer.alloc(buf.length); for (let i = 0; i < buf.length; i++) { xored[i] = buf[i] ^ PROXY_KEY[i % PROXY_KEY.length]; } return xored.toString("base64url"); -} +}; -function decodePruUrl(pruUrl) { +// ---- FEATURE: Decode PRU URL back to raw URL + referer ---- +/** + * Decodes a pru.ultracloud.cc URL back to its original URL and referer. + * PRU format: {base64url(XOR(url))}~{base64url(XOR(referer))}/pl.m3u8 + * + * @param {string} pruUrl - The full PRU URL to decode + * @returns {{ url: string, referer: string } | null} Decoded URL and referer, or null if invalid + * + * @example + * const decoded = decodePruUrl("https://pru.ultracloud.cc/abc~def/pl.m3u8"); + * // { url: "https://...", referer: "https://..." } + */ +const decodePruUrl = (pruUrl) => { try { - const path = new URL(pruUrl).pathname.replace(/^\//, "").replace(/\/pl\.m3u8$/, "").replace(/\/seg\.jpg$/, ""); + const path = new URL(pruUrl).pathname + .replace(/^\//, "") + .replace(/\/pl\.m3u8$/, "") + .replace(/\/seg\.jpg$/, ""); const tildeIdx = path.indexOf("~"); if (tildeIdx === -1) return null; + + // NOTE: Split at ~ separator — left is encoded URL, right is encoded referer 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 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"); + 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) { +// ---- FEATURE: Encode raw URL + referer for PRU proxy ---- +/** + * Encodes a raw URL and referer into a full PRU proxy URL. + * Output format: {PRU_BASE}{urlEnc}~{refEnc}/pl.m3u8 + * + * @param {string} rawUrl - The original media URL + * @param {string} referer - The referer to use when fetching + * @returns {string} Full PRU proxy URL + * + * @example + * const pruUrl = encodeForPru("https://cdn.example.com/v.m3u8", "https://miruro.tv/"); + */ +const encodeForPru = (rawUrl, referer) => { const urlEnc = base64urlXor(rawUrl); const refEnc = base64urlXor(referer || new URL(rawUrl).origin + "/"); return `${PRU_BASE}${urlEnc}~${refEnc}/pl.m3u8`; -} +}; +// ══════════════════════════════════════════════════════════════ +// M3U8 REWRITING +// ══════════════════════════════════════════════════════════════ + +// ---- FEATURE: Rewrite M3U8 internal URLs to route through proxy ---- +/** + * Rewrites M3U8 playlist content to proxy all internal URLs. + * Handles both absolute (https://...) and relative URLs. + * Skips URLs that are already proxied through pru.ultracloud.cc. + * + * @param {string} m3u8 - Raw M3U8 playlist content + * @param {string} targetReferer - Referer to pass to proxy for each URL + * @returns {string} Rewritten M3U8 content with proxied URLs + */ +const rewriteM3U8 = (m3u8, targetReferer) => { + // NOTE: Rewrite absolute URLs (lines starting with http) + m3u8 = m3u8.replace(/^(https?:\/\/[^\s]+)$/gm, (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)}`; + }); + + // NOTE: Rewrite relative URLs (lines with file extensions, no # or < prefix) + m3u8 = m3u8.replace( + /^([^\s#<][^\s]*\.(m3u8|ts|m4s|m4v|mp4|cmfv|cmfa|vtt|srt|ass|json|aac|mp3))(?:\?[^\s]*)?$/gm, + (match) => { + try { + const absolute = new URL(match, new URL(targetReferer)).href; + return `/api/proxy?url=${encodeURIComponent(absolute)}&referer=${encodeURIComponent(targetReferer)}`; + } catch { + return match; + } + } + ); + + return m3u8; +}; + +// ══════════════════════════════════════════════════════════════ +// PROXY HANDLER +// ══════════════════════════════════════════════════════════════ + +// ---- FEATURE: Vercel serverless proxy handler ---- +/** + * Main proxy handler for Vercel serverless function. + * Fetches content from a target URL and returns it to the client. + * For M3U8 playlists, rewrites internal URLs to route through the proxy. + * For other content (segments, subtitles), streams raw bytes. + * + * @param {import('http').IncomingMessage} req - Express request + * @param {import('http').ServerResponse} res - Express response + * @returns {Promise} + * + * @query {string} url - The target URL to proxy (required) + * @query {string} referer - Optional referer header for the target + * + * @example + * GET /api/proxy?url=https://example.com/video.m3u8&referer=https://miruro.tv/ + */ module.exports = async function handler(req, res) { + // NOTE: CORS headers for browser requests res.setHeader("Access-Control-Allow-Origin", "*"); res.setHeader("Access-Control-Allow-Methods", "GET, HEAD, OPTIONS"); res.setHeader("Access-Control-Allow-Headers", "Range, Referer"); @@ -46,18 +200,22 @@ module.exports = async function handler(req, res) { 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" }); + if (!url) { + return res.status(400).json({ success: false, message: "url query parameter is required" }); + } try { + // NOTE: Detect PRU-encoded URLs vs direct CDN URLs const decoded = decodePruUrl(url); const isPru = !!decoded; 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.tv/"; let fetchUrl; let fetchHeaders; if (isPru) { + // NOTE: Re-encode for PRU proxy with proper headers 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", @@ -72,6 +230,7 @@ module.exports = async function handler(req, res) { }; } + // NOTE: Fetch with arraybuffer to handle binary content (segments, fonts) const response = await axios.get(fetchUrl, { headers: fetchHeaders, timeout: 15000, @@ -82,30 +241,21 @@ module.exports = async function handler(req, res) { const contentType = response.headers["content-type"] || ""; res.setHeader("Cache-Control", "public, max-age=300"); - if (isPru && (contentType.includes("mpegurl") || targetUrl.endsWith(".m3u8") || contentType.includes("x-mpegURL"))) { + // NOTE: M3U8 playlists need URL rewriting — other content is streamed raw + if ( + isPru && + (contentType.includes("mpegurl") || + targetUrl.endsWith(".m3u8") || + contentType.includes("x-mpegURL")) + ) { let m3u8 = Buffer.from(response.data).toString("utf-8"); - - m3u8 = m3u8.replace(/^(https?:\/\/[^\s]+)$/gm, (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)}`; - }); - - m3u8 = m3u8.replace(/^([^\s#<][^\s]*\.(m3u8|ts|m4s|m4v|mp4|cmfv|cmfa|vtt|srt|ass|json|aac|mp3))(?:\?[^\s]*)?$/gm, (match) => { - try { - const absolute = new URL(match, new URL(targetUrl)).href; - return `/api/proxy?url=${encodeURIComponent(absolute)}&referer=${encodeURIComponent(targetReferer)}`; - } catch { - return match; - } - }); + m3u8 = rewriteM3U8(m3u8, targetReferer); res.setHeader("Content-Type", "application/vnd.apple.mpegurl"); return res.send(m3u8); } + // NOTE: Stream raw binary content (video segments, subtitles, thumbnails) res.setHeader("Content-Type", contentType); if (response.headers["content-length"]) { res.setHeader("Content-Length", response.headers["content-length"]); @@ -115,3 +265,5 @@ module.exports = async function handler(req, res) { res.status(500).json({ success: false, message: "Proxy failed: " + err.message }); } }; + +// ══════════════════════════════════════════════════════════════ END: proxy.js diff --git a/server.js b/server.js index b4e672d..63521ec 100644 --- a/server.js +++ b/server.js @@ -183,9 +183,37 @@ app.get("/docs", (req, res) => { // RESPONSE HELPERS // ══════════════════════════════════════════════════════════════ +// ---- FEATURE: Response helper — wrap data in standard success format ---- +/** + * Wraps data in the standard API success response format. + * All successful responses follow: { success: true, results: data } + * + * @param {import('express').Response} res - Express response object + * @param {any} data - The data to wrap + * @param {number} [status=200] - HTTP status code + * @returns {import('express').Response} Express response with JSON body + * + * @example + * jsonResponse(res, { anime: [...] }); + * // => { "success": true, "results": { "anime": [...] } } + */ const jsonResponse = (res, data, status = 200) => res.status(status).json({ success: true, results: data }); +// ---- FEATURE: Error helper — wrap message in standard error format ---- +/** + * Wraps an error message in the standard API error response format. + * All error responses follow: { success: false, message: "..." } + * + * @param {import('express').Response} res - Express response object + * @param {string} [message="Internal server error"] - Error description + * @param {number} [status=500] - HTTP status code + * @returns {import('express').Response} Express response with JSON error body + * + * @example + * jsonError(res, "Anime not found", 404); + * // => { "success": false, "message": "Anime not found" } + */ const jsonError = (res, message = "Internal server error", status = 500) => res.status(status).json({ success: false, message }); diff --git a/test.js b/test.js index dbde380..36711dd 100644 --- a/test.js +++ b/test.js @@ -4,30 +4,45 @@ * Repository: https://github.com/Shineii86/MiruroAPI * * @description - * Integration test suite for MiruroAPI v2.1.0 endpoints. - * Tests all 36 endpoints for correct response format. + * Integration test suite for MiruroAPI v2.1.4 endpoints. + * Tests all 44 endpoints for correct response format. + * Runs sequentially to avoid rate limiting. * * @author Shinei Nouzen * @license MIT * ======= • ======= • ======= • ======= • =======• ======= */ +// ══════════════════════════════════════════════════════════════ +// TEST CONFIGURATION +// ══════════════════════════════════════════════════════════════ + +/** + * Base URL for all API requests. + * Defaults to production; override with API_URL env var. + * + * @type {string} + */ const BASE = process.env.API_URL || "https://mirurotvapi.vercel.app/api"; +// ══════════════════════════════════════════════════════════════ +// TEST DEFINITIONS +// ══════════════════════════════════════════════════════════════ + const tests = [ - // System + // ---- FEATURE: System endpoints ---- { name: "Health", url: "/health" }, { name: "Stats", url: "/stats" }, { name: "OpenAPI", url: "/openapi" }, { name: "Providers", url: "/providers" }, - // Search & Discovery + // ---- FEATURE: Search & discovery endpoints ---- { name: "Search", url: "/search?query=naruto" }, { name: "Suggestions", url: "/suggestions?query=naruto" }, { name: "Filter", url: "/filter?genre=Action&per_page=1" }, { name: "Multi-Search", url: "/multi-search?q=naruto&genre=Action" }, - // Collections + // ---- FEATURE: Collection endpoints ---- { name: "Trending", url: "/trending?per_page=1" }, { name: "Trending Daily", url: "/trending/daily" }, { name: "Trending Weekly", url: "/trending/weekly" }, @@ -40,11 +55,11 @@ const tests = [ { name: "Random", url: "/random" }, { name: "Upcoming Countdown", url: "/upcoming/countdown" }, - // Seasonal & Studio + // ---- FEATURE: Seasonal & studio endpoints ---- { name: "Seasonal", url: "/season/2025/spring" }, { name: "Studio", url: "/studio/MAPPA" }, - // Genres & Tags + // ---- FEATURE: Metadata endpoints ---- { name: "Genres", url: "/genres" }, { name: "Tags", url: "/tags" }, { name: "Genre", url: "/genre/Action" }, @@ -52,35 +67,57 @@ const tests = [ { name: "Status", url: "/status/RELEASING" }, { name: "Format", url: "/format/MOVIE" }, - // Character & Staff + // ---- FEATURE: Character & staff endpoints ---- { name: "Character", url: "/character/1" }, { name: "Staff", url: "/staff/1" }, { name: "Characters Search", url: "/characters?query=kaneki" }, { name: "Staff Search", url: "/staff?query=oda" }, - // Anime Details + // ---- FEATURE: Anime detail endpoints ---- { name: "Info", url: "/info/20" }, { name: "Anime Characters", url: "/anime/20/characters" }, { name: "Relations", url: "/anime/20/relations" }, { name: "Recommendations", url: "/anime/20/recommendations" }, - // Streaming + // ---- FEATURE: Streaming endpoints ---- { name: "Episodes", url: "/episodes/20" }, { name: "Batch Episodes", url: "/episodes/20/batch?provider=kiwi&category=sub&slugs=animepahe-1,animepahe-2" }, { name: "Stream", url: "/stream?provider=kiwi&anilistId=20&category=sub&slug=animepahe-1" }, { name: "Download", url: "/download?provider=kiwi&anilistId=20&category=sub&slug=animepahe-1" }, { name: "Watch", url: "/watch/kiwi/20/sub/animepahe-1" }, - // Utility + // ---- FEATURE: Utility endpoints ---- { name: "Compare", url: "/compare/20/21" }, { name: "Genre Stats", url: "/stats/genre" }, { name: "Calendar", url: "/calendar" }, { name: "Timeline", url: "/timeline/20" }, ]; +// ══════════════════════════════════════════════════════════════ +// TEST RUNNER +// ══════════════════════════════════════════════════════════════ + +/** + * Global pass/fail counters for test results. + * + * @type {{ passed: number, failed: number }} + */ let passed = 0; let failed = 0; +// ---- FEATURE: Single test executor ---- +/** + * Runs a single API test against the specified endpoint. + * Validates HTTP 200 status and standard response format. + * + * @param {{ name: string, url: string }} test - Test definition object + * @param {string} test.name - Human-readable test name for output + * @param {string} test.url - Relative URL path (appended to BASE) + * @returns {Promise} Logs result to console, increments counters + * + * @example + * await runTest({ name: "Health", url: "/health" }); + */ async function runTest(test) { try { const res = await fetch(`${BASE}${test.url}`); @@ -103,9 +140,21 @@ async function runTest(test) { } } +// ---- FEATURE: Full test suite runner ---- +/** + * Runs all test definitions sequentially to avoid rate limiting. + * Prints summary and exits with code 1 if any tests failed. + * + * @returns {Promise} Exits process with 0 (pass) or 1 (fail) + * + * @example + * node test.js # Run against production + * API_URL=http://localhost:3000/api node test.js # Run against local + */ async function runAll() { console.log(`\n🧪 MiruroAPI v2.1.4 — Running ${tests.length} tests...\n`); + // NOTE: Sequential execution — parallel requests would hit rate limiter for (const test of tests) { await runTest(test); } @@ -114,4 +163,10 @@ async function runAll() { process.exit(failed > 0 ? 1 : 0); } +// ══════════════════════════════════════════════════════════════ +// ENTRY POINT +// ══════════════════════════════════════════════════════════════ + runAll(); + +// ══════════════════════════════════════════════════════════════ END: test.js