Initial commit
This commit is contained in:
commit
6e86adb0e6
23 files changed
+6141
No files matched your search
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* Extractor Logic
|
||||
* This file handles parsing HTML/JSON to find video streams.
|
||||
*/
|
||||
|
||||
import { fetchText, HEADERS } from './http.js';
|
||||
import cheerio from 'cheerio-without-node-native';
|
||||
|
||||
export async function extractStreams(tmdbId, mediaType, season, episode) {
|
||||
// 1. Construct the search or video URL
|
||||
// const url = `https://example.com/watch/${tmdbId}`;
|
||||
|
||||
// 2. Fetch content
|
||||
// const html = await fetchText(url);
|
||||
|
||||
// 3. Parse with Cheerio
|
||||
// const $ = cheerio.load(html);
|
||||
// const videoUrl = $('video source').attr('src');
|
||||
|
||||
// 4. Return streams
|
||||
// if (videoUrl) {
|
||||
// return [{
|
||||
// name: "MyTemplateProvider",
|
||||
// title: "1080p Stream",
|
||||
// url: videoUrl,
|
||||
// quality: "1080p",
|
||||
// headers: HEADERS
|
||||
// }];
|
||||
// }
|
||||
|
||||
return [];
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* HTTP Utilities
|
||||
* Use this file for network requests and headers.
|
||||
*/
|
||||
|
||||
export const HEADERS = {
|
||||
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/120.0.0.0 Safari/537.36",
|
||||
// Add other common headers like 'Referer' if needed
|
||||
};
|
||||
|
||||
/**
|
||||
* Fetch text content from a URL
|
||||
* @param {string} url
|
||||
* @param {object} options
|
||||
*/
|
||||
export async function fetchText(url, options = {}) {
|
||||
console.log(`[Template] Fetching: ${url}`);
|
||||
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
...HEADERS,
|
||||
...options.headers
|
||||
},
|
||||
...options
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error ${response.status} for ${url}`);
|
||||
}
|
||||
|
||||
return await response.text();
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch JSON content from a URL
|
||||
* @param {string} url
|
||||
* @param {object} options
|
||||
*/
|
||||
export async function fetchJson(url, options = {}) {
|
||||
const raw = await fetchText(url, options);
|
||||
return JSON.parse(raw);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* Template Provider
|
||||
* Main entry point.
|
||||
*/
|
||||
|
||||
import { extractStreams } from './extractor.js';
|
||||
|
||||
/**
|
||||
* Main function called by Nuvio
|
||||
* @param {string} tmdbId - TMDB ID of the media
|
||||
* @param {string} mediaType - 'movie' or 'tv'
|
||||
* @param {number} season - Season number (for TV)
|
||||
* @param {number} episode - Episode number (for TV)
|
||||
*/
|
||||
async function getStreams(tmdbId, mediaType, season, episode) {
|
||||
try {
|
||||
console.log(`[Template] Request: ${mediaType} ${tmdbId}`);
|
||||
|
||||
// Call your extraction logic
|
||||
const streams = await extractStreams(tmdbId, mediaType, season, episode);
|
||||
|
||||
return streams;
|
||||
} catch (error) {
|
||||
console.error(`[Template] Error: ${error.message}`);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { getStreams };
|
||||
@@ -0,0 +1,281 @@
|
||||
/** Pure-JavaScript replacement for Flixer's img_data WebAssembly module. */
|
||||
|
||||
import { encodeUtf8, hmacSha256, sha256 } from './http.js';
|
||||
|
||||
const CANVAS_FINGERPRINT = 'iVBORw0KGgoAAAANSUhEUgAAAMgAAAAyCAYAAAAZUZThAAAQAE';
|
||||
|
||||
function concatBytes(...values) {
|
||||
let length = 0;
|
||||
for (const value of values) length += value.length;
|
||||
const result = new Uint8Array(length);
|
||||
let offset = 0;
|
||||
for (const value of values) {
|
||||
result.set(value, offset);
|
||||
offset += value.length;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function toHex(bytes) {
|
||||
let result = '';
|
||||
for (const value of bytes) result += value.toString(16).padStart(2, '0');
|
||||
return result;
|
||||
}
|
||||
|
||||
function decodeBase64(value) {
|
||||
const binary = atob(value);
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
for (let index = 0; index < binary.length; index++) bytes[index] = binary.charCodeAt(index);
|
||||
return bytes;
|
||||
}
|
||||
|
||||
function decodeUtf8(bytes) {
|
||||
let result = '';
|
||||
for (let index = 0; index < bytes.length;) {
|
||||
const first = bytes[index++];
|
||||
let codePoint;
|
||||
if (first < 0x80) codePoint = first;
|
||||
else if ((first & 0xE0) === 0xC0) codePoint = ((first & 0x1F) << 6) | (bytes[index++] & 0x3F);
|
||||
else if ((first & 0xF0) === 0xE0) {
|
||||
codePoint = ((first & 0x0F) << 12) | ((bytes[index++] & 0x3F) << 6) | (bytes[index++] & 0x3F);
|
||||
} else {
|
||||
codePoint = ((first & 7) << 18) | ((bytes[index++] & 0x3F) << 12) |
|
||||
((bytes[index++] & 0x3F) << 6) | (bytes[index++] & 0x3F);
|
||||
}
|
||||
if (codePoint <= 0xFFFF) result += String.fromCharCode(codePoint);
|
||||
else {
|
||||
codePoint -= 0x10000;
|
||||
result += String.fromCharCode(0xD800 | (codePoint >>> 10), 0xDC00 | (codePoint & 0x3FF));
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function multiplyByte(first, second) {
|
||||
let result = 0;
|
||||
for (let index = 0; index < 8; index++) {
|
||||
if (second & 1) result ^= first;
|
||||
first = ((first << 1) ^ ((first & 0x80) ? 0x11B : 0)) & 255;
|
||||
second >>>= 1;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function inverseByte(value) {
|
||||
if (!value) return 0;
|
||||
let result = 1;
|
||||
let base = value;
|
||||
let power = 254;
|
||||
while (power) {
|
||||
if (power & 1) result = multiplyByte(result, base);
|
||||
base = multiplyByte(base, base);
|
||||
power >>>= 1;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function rotateByte(value, amount) {
|
||||
return ((value << amount) | (value >>> (8 - amount))) & 255;
|
||||
}
|
||||
|
||||
const AES_SBOX = (() => {
|
||||
const result = new Uint8Array(256);
|
||||
for (let index = 0; index < 256; index++) {
|
||||
const value = inverseByte(index);
|
||||
result[index] = value ^ rotateByte(value, 1) ^ rotateByte(value, 2) ^
|
||||
rotateByte(value, 3) ^ rotateByte(value, 4) ^ 0x63;
|
||||
}
|
||||
return result;
|
||||
})();
|
||||
|
||||
function expandAes256Key(key) {
|
||||
if (key.length !== 32) throw new Error('Invalid AES-256 key');
|
||||
const words = new Uint32Array(60);
|
||||
for (let index = 0; index < 8; index++) {
|
||||
const offset = index * 4;
|
||||
words[index] = ((key[offset] << 24) | (key[offset + 1] << 16) |
|
||||
(key[offset + 2] << 8) | key[offset + 3]) >>> 0;
|
||||
}
|
||||
|
||||
let roundConstant = 1;
|
||||
for (let index = 8; index < words.length; index++) {
|
||||
let temporary = words[index - 1];
|
||||
if (index % 8 === 0) {
|
||||
temporary = ((temporary << 8) | (temporary >>> 24)) >>> 0;
|
||||
temporary = ((AES_SBOX[temporary >>> 24] << 24) | (AES_SBOX[(temporary >>> 16) & 255] << 16) |
|
||||
(AES_SBOX[(temporary >>> 8) & 255] << 8) | AES_SBOX[temporary & 255]) >>> 0;
|
||||
temporary ^= roundConstant << 24;
|
||||
roundConstant = ((roundConstant << 1) ^ ((roundConstant & 0x80) ? 0x11B : 0)) & 255;
|
||||
} else if (index % 8 === 4) {
|
||||
temporary = ((AES_SBOX[temporary >>> 24] << 24) | (AES_SBOX[(temporary >>> 16) & 255] << 16) |
|
||||
(AES_SBOX[(temporary >>> 8) & 255] << 8) | AES_SBOX[temporary & 255]) >>> 0;
|
||||
}
|
||||
words[index] = (words[index - 8] ^ temporary) >>> 0;
|
||||
}
|
||||
return words;
|
||||
}
|
||||
|
||||
function addRoundKey(state, words, round) {
|
||||
for (let column = 0; column < 4; column++) {
|
||||
const word = words[round * 4 + column];
|
||||
state[column * 4] ^= word >>> 24;
|
||||
state[column * 4 + 1] ^= (word >>> 16) & 255;
|
||||
state[column * 4 + 2] ^= (word >>> 8) & 255;
|
||||
state[column * 4 + 3] ^= word & 255;
|
||||
}
|
||||
}
|
||||
|
||||
function encryptAesBlock(input, words) {
|
||||
const state = new Uint8Array(input);
|
||||
const shifted = new Uint8Array(16);
|
||||
addRoundKey(state, words, 0);
|
||||
|
||||
for (let round = 1; round <= 14; round++) {
|
||||
for (let index = 0; index < 16; index++) state[index] = AES_SBOX[state[index]];
|
||||
for (let row = 0; row < 4; row++) {
|
||||
for (let column = 0; column < 4; column++) {
|
||||
shifted[row + column * 4] = state[row + ((column + row) & 3) * 4];
|
||||
}
|
||||
}
|
||||
state.set(shifted);
|
||||
|
||||
if (round !== 14) {
|
||||
for (let column = 0; column < 4; column++) {
|
||||
const offset = column * 4;
|
||||
const first = state[offset];
|
||||
const second = state[offset + 1];
|
||||
const third = state[offset + 2];
|
||||
const fourth = state[offset + 3];
|
||||
const combined = first ^ second ^ third ^ fourth;
|
||||
state[offset] ^= combined ^ multiplyByte(first ^ second, 2);
|
||||
state[offset + 1] ^= combined ^ multiplyByte(second ^ third, 2);
|
||||
state[offset + 2] ^= combined ^ multiplyByte(third ^ fourth, 2);
|
||||
state[offset + 3] ^= combined ^ multiplyByte(fourth ^ first, 2);
|
||||
}
|
||||
}
|
||||
addRoundKey(state, words, round);
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
function bytesToWords(bytes) {
|
||||
const result = new Uint32Array(4);
|
||||
for (let index = 0; index < 4; index++) {
|
||||
const offset = index * 4;
|
||||
result[index] = ((bytes[offset] << 24) | (bytes[offset + 1] << 16) |
|
||||
(bytes[offset + 2] << 8) | bytes[offset + 3]) >>> 0;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function wordsToBytes(words) {
|
||||
const result = new Uint8Array(16);
|
||||
for (let index = 0; index < 4; index++) {
|
||||
result[index * 4] = words[index] >>> 24;
|
||||
result[index * 4 + 1] = (words[index] >>> 16) & 255;
|
||||
result[index * 4 + 2] = (words[index] >>> 8) & 255;
|
||||
result[index * 4 + 3] = words[index] & 255;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function multiplyGhash(first, second) {
|
||||
const result = new Uint32Array(4);
|
||||
const value = new Uint32Array(second);
|
||||
for (let bit = 0; bit < 128; bit++) {
|
||||
if ((first[bit >>> 5] >>> (31 - (bit & 31))) & 1) {
|
||||
for (let index = 0; index < 4; index++) result[index] ^= value[index];
|
||||
}
|
||||
const lowBit = value[3] & 1;
|
||||
value[3] = ((value[3] >>> 1) | ((value[2] & 1) << 31)) >>> 0;
|
||||
value[2] = ((value[2] >>> 1) | ((value[1] & 1) << 31)) >>> 0;
|
||||
value[1] = ((value[1] >>> 1) | ((value[0] & 1) << 31)) >>> 0;
|
||||
value[0] >>>= 1;
|
||||
if (lowBit) value[0] = (value[0] ^ 0xE1000000) >>> 0;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function calculateGhash(ciphertext, hashKey) {
|
||||
let state = new Uint32Array(4);
|
||||
for (let offset = 0; offset < ciphertext.length; offset += 16) {
|
||||
const block = new Uint8Array(16);
|
||||
block.set(ciphertext.subarray(offset, Math.min(offset + 16, ciphertext.length)));
|
||||
const words = bytesToWords(block);
|
||||
for (let index = 0; index < 4; index++) state[index] ^= words[index];
|
||||
state = multiplyGhash(state, hashKey);
|
||||
}
|
||||
|
||||
const lengthBlock = new Uint8Array(16);
|
||||
const bitLength = ciphertext.length * 8;
|
||||
const view = new DataView(lengthBlock.buffer);
|
||||
view.setUint32(12, bitLength >>> 0);
|
||||
view.setUint32(8, Math.floor(bitLength / 0x100000000));
|
||||
const lengths = bytesToWords(lengthBlock);
|
||||
for (let index = 0; index < 4; index++) state[index] ^= lengths[index];
|
||||
return multiplyGhash(state, hashKey);
|
||||
}
|
||||
|
||||
function incrementCounter(counter) {
|
||||
for (let index = 15; index >= 12; index--) {
|
||||
counter[index] = (counter[index] + 1) & 255;
|
||||
if (counter[index]) break;
|
||||
}
|
||||
}
|
||||
|
||||
function decryptAesGcm(payload, key) {
|
||||
if (payload.length < 28) throw new Error('Invalid encrypted payload');
|
||||
const nonce = payload.subarray(0, 12);
|
||||
const ciphertext = payload.subarray(12, payload.length - 16);
|
||||
const expectedTag = payload.subarray(payload.length - 16);
|
||||
const words = expandAes256Key(key);
|
||||
const zero = new Uint8Array(16);
|
||||
const hashKey = bytesToWords(encryptAesBlock(zero, words));
|
||||
const initialCounter = new Uint8Array(16);
|
||||
initialCounter.set(nonce);
|
||||
initialCounter[15] = 1;
|
||||
|
||||
const authentication = wordsToBytes(calculateGhash(ciphertext, hashKey));
|
||||
const encryptedCounter = encryptAesBlock(initialCounter, words);
|
||||
let mismatch = 0;
|
||||
for (let index = 0; index < 16; index++) mismatch |= (authentication[index] ^ encryptedCounter[index]) ^ expectedTag[index];
|
||||
if (mismatch) throw new Error('Invalid encrypted payload tag');
|
||||
|
||||
const output = new Uint8Array(ciphertext.length);
|
||||
const counter = new Uint8Array(initialCounter);
|
||||
for (let offset = 0; offset < ciphertext.length; offset += 16) {
|
||||
incrementCounter(counter);
|
||||
const stream = encryptAesBlock(counter, words);
|
||||
const length = Math.min(16, ciphertext.length - offset);
|
||||
for (let index = 0; index < length; index++) output[offset + index] = ciphertext[offset + index] ^ stream[index];
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
function derivePayloadKey(apiKey, now) {
|
||||
const hour = Math.floor(now / 3600000);
|
||||
const dynamic = new Uint8Array(16);
|
||||
for (let index = 0; index < dynamic.length; index++) {
|
||||
dynamic[index] = (index + 1) ^ (Math.floor(hour / (2 ** (index & 7))) & 255);
|
||||
}
|
||||
|
||||
let derived = concatBytes(encodeUtf8(apiKey), dynamic);
|
||||
for (let iteration = 0; iteration < 600; iteration++) derived = sha256(derived);
|
||||
const timeBucket = Math.floor(now / 300000);
|
||||
return hmacSha256(derived, `${timeBucket}${apiKey}`);
|
||||
}
|
||||
|
||||
export function initDecoder() {
|
||||
return true;
|
||||
}
|
||||
|
||||
export function get_img_key() {
|
||||
const timestamp = Math.floor(Date.now() / 1000);
|
||||
const fingerprint = `1365x768:24:Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36:Linux x86_64:en-US:-180:${timestamp}:${CANVAS_FINGERPRINT}`;
|
||||
return toHex(sha256(encodeUtf8(toHex(sha256(encodeUtf8(fingerprint))))));
|
||||
}
|
||||
|
||||
export function process_img_data(value, apiKey) {
|
||||
const key = derivePayloadKey(apiKey, Date.now());
|
||||
return decodeUtf8(decryptAesGcm(decodeBase64(value), key));
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
/** Resolve a TMDB title through Flixer's signed source API. */
|
||||
|
||||
import { get_img_key, initDecoder, process_img_data } from './decoder.js';
|
||||
import { API_URL, createSecureHeaders, fetchText, HEADERS, SITE_URL } from './http.js';
|
||||
|
||||
const NATO_ORDER = [
|
||||
'alpha', 'bravo', 'charlie', 'delta', 'echo', 'foxtrot', 'golf', 'hotel', 'india',
|
||||
'juliet', 'kilo', 'lima', 'mike', 'november', 'oscar', 'papa', 'quebec', 'romeo',
|
||||
'sierra', 'tango', 'uniform', 'victor', 'whiskey', 'xray', 'yankee', 'zulu'
|
||||
];
|
||||
|
||||
const SERVER_LABELS = {
|
||||
bravo: 'Balder', delta: 'Dionysus', echo: 'Eros', golf: 'Gaia', hotel: 'Hades',
|
||||
india: 'Isis', juliet: 'Juno', kilo: 'Kronos', lima: 'Loki'
|
||||
};
|
||||
|
||||
const TMDB_ID_CACHE = {};
|
||||
|
||||
async function resolveTmdbId(id, mediaType) {
|
||||
if (/^\d+$/.test(id)) return id;
|
||||
if (!/^tt\d+$/i.test(id)) return null;
|
||||
|
||||
const cacheKey = `${mediaType}:${id.toLowerCase()}`;
|
||||
if (TMDB_ID_CACHE[cacheKey]) return TMDB_ID_CACHE[cacheKey];
|
||||
|
||||
const catalogType = mediaType === 'movie' ? 'movie' : 'series';
|
||||
try {
|
||||
const raw = await fetchText(`https://v3-cinemeta.strem.io/meta/${catalogType}/${encodeURIComponent(id)}.json`, {
|
||||
headers: { 'Accept': 'application/json' }
|
||||
});
|
||||
const payload = JSON.parse(raw);
|
||||
const resolved = payload && payload.meta && payload.meta.moviedb_id;
|
||||
if (!resolved || !/^\d+$/.test(String(resolved))) return null;
|
||||
|
||||
TMDB_ID_CACHE[cacheKey] = String(resolved);
|
||||
console.log(`[Flixer] Resolved ${id} to TMDB ${resolved}`);
|
||||
return TMDB_ID_CACHE[cacheKey];
|
||||
} catch (error) {
|
||||
console.warn(`[Flixer] Could not resolve IMDb ID ${id}: ${error && error.message ? error.message : String(error)}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function buildApiPath(tmdbId, mediaType, season, episode) {
|
||||
if (mediaType === 'movie') return `/api/tmdb/movie/${encodeURIComponent(tmdbId)}/images`;
|
||||
return `/api/tmdb/tv/${encodeURIComponent(tmdbId)}/season/${season}/episode/${episode}/images`;
|
||||
}
|
||||
|
||||
async function decryptPayload(encrypted, apiKey) {
|
||||
const clearText = await process_img_data(encrypted, apiKey);
|
||||
return JSON.parse(clearText);
|
||||
}
|
||||
|
||||
function getServers(payload) {
|
||||
const values = [];
|
||||
if (payload && Array.isArray(payload.sources)) {
|
||||
for (const source of payload.sources) {
|
||||
const server = source && String(source.server || '').toLowerCase();
|
||||
if (server && !values.includes(server)) values.push(server);
|
||||
}
|
||||
}
|
||||
if (payload && payload.servers && typeof payload.servers === 'object') {
|
||||
for (const server of Object.keys(payload.servers)) {
|
||||
const normalized = server.toLowerCase();
|
||||
if (!values.includes(normalized)) values.push(normalized);
|
||||
}
|
||||
}
|
||||
return NATO_ORDER.filter(server => values.includes(server)).concat(values.filter(server => !NATO_ORDER.includes(server)));
|
||||
}
|
||||
|
||||
function findUrl(payload, server) {
|
||||
if (payload && Array.isArray(payload.sources)) {
|
||||
const exact = payload.sources.find(source => source && String(source.server || '').toLowerCase() === server);
|
||||
const candidate = exact || payload.sources.find(source => source && source.url);
|
||||
return candidate && typeof candidate.url === 'string' ? candidate.url.trim() : '';
|
||||
}
|
||||
if (payload && payload.sources && typeof payload.sources === 'object') {
|
||||
const value = payload.sources.file || payload.sources.url;
|
||||
return typeof value === 'string' ? value.trim() : '';
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function getQuality(url) {
|
||||
const match = String(url).match(/(?:^|[^0-9])(2160|1440|1080|720|480|360)p?(?:[^0-9]|$)/i);
|
||||
return match ? `${match[1]}p` : 'Auto';
|
||||
}
|
||||
|
||||
async function fetchServerPayload(apiUrl, apiPath, apiKey, server) {
|
||||
const secureHeaders = await createSecureHeaders(apiKey, apiPath);
|
||||
return await fetchText(apiUrl, {
|
||||
headers: {
|
||||
...secureHeaders,
|
||||
'X-Only-Sources': '1',
|
||||
'X-Server': server
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export async function extractStreams(tmdbId, mediaType, season, episode) {
|
||||
const id = String(tmdbId || '').trim();
|
||||
if (mediaType !== 'movie' && mediaType !== 'tv') return [];
|
||||
if (mediaType === 'tv' && (!Number.isInteger(Number(season)) || !Number.isInteger(Number(episode)) || Number(season) < 1 || Number(episode) < 1)) return [];
|
||||
|
||||
const resolvedId = await resolveTmdbId(id, mediaType);
|
||||
if (!resolvedId) return [];
|
||||
|
||||
initDecoder();
|
||||
const apiKey = get_img_key();
|
||||
const apiPath = buildApiPath(resolvedId, mediaType, Number(season), Number(episode));
|
||||
const apiUrl = `${API_URL}${apiPath}`;
|
||||
const secureHeaders = await createSecureHeaders(apiKey, apiPath);
|
||||
const serverPayload = await fetchText(apiUrl, {
|
||||
headers: { ...secureHeaders, bW90aGFmYWth: '1' }
|
||||
});
|
||||
const servers = getServers(await decryptPayload(serverPayload, apiKey));
|
||||
// Stagger requests as the website does, but allow slow/dead mirrors to run
|
||||
// concurrently so they do not add their timeout to every other mirror.
|
||||
const encryptedResponses = await Promise.all(servers.map(async server => {
|
||||
try {
|
||||
return { server, encrypted: await fetchServerPayload(apiUrl, apiPath, apiKey, server) };
|
||||
} catch (error) {
|
||||
console.warn(`[Flixer] ${server} request failed: ${error && error.message ? error.message : String(error)}`);
|
||||
return { server, encrypted: '' };
|
||||
}
|
||||
}));
|
||||
|
||||
const streams = [];
|
||||
const seenUrls = {};
|
||||
for (const response of encryptedResponses) {
|
||||
if (!response.encrypted) continue;
|
||||
|
||||
try {
|
||||
const url = findUrl(await decryptPayload(response.encrypted, apiKey), response.server);
|
||||
if (!url || seenUrls[url]) continue;
|
||||
seenUrls[url] = true;
|
||||
const label = SERVER_LABELS[response.server] || response.server.charAt(0).toUpperCase() + response.server.slice(1);
|
||||
const quality = getQuality(url);
|
||||
streams.push({
|
||||
name: `Flixer · ${label}`,
|
||||
title: `${label} · ${quality}`,
|
||||
url,
|
||||
quality,
|
||||
headers: {
|
||||
'Referer': `${SITE_URL}/`,
|
||||
'Origin': SITE_URL,
|
||||
'User-Agent': HEADERS['User-Agent']
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error && error.message ? error.message : String(error);
|
||||
if (!/^E5[678]$/.test(message)) {
|
||||
console.warn(`[Flixer] ${response.server} decode failed: ${message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return streams;
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
/** HTTP and request-signing helpers for Flixer's source API. */
|
||||
|
||||
export const SITE_URL = 'https://flixer.gd';
|
||||
export const API_URL = 'https://plsdontscrapemelove.flixer.gd';
|
||||
|
||||
export const HEADERS = {
|
||||
'Accept': 'text/plain',
|
||||
'Origin': SITE_URL,
|
||||
'Referer': `${SITE_URL}/`,
|
||||
'User-Agent': 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36'
|
||||
};
|
||||
|
||||
const BASE64_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
|
||||
const SHA256_CONSTANTS = [
|
||||
0x428A2F98, 0x71374491, 0xB5C0FBCF, 0xE9B5DBA5, 0x3956C25B, 0x59F111F1, 0x923F82A4, 0xAB1C5ED5,
|
||||
0xD807AA98, 0x12835B01, 0x243185BE, 0x550C7DC3, 0x72BE5D74, 0x80DEB1FE, 0x9BDC06A7, 0xC19BF174,
|
||||
0xE49B69C1, 0xEFBE4786, 0x0FC19DC6, 0x240CA1CC, 0x2DE92C6F, 0x4A7484AA, 0x5CB0A9DC, 0x76F988DA,
|
||||
0x983E5152, 0xA831C66D, 0xB00327C8, 0xBF597FC7, 0xC6E00BF3, 0xD5A79147, 0x06CA6351, 0x14292967,
|
||||
0x27B70A85, 0x2E1B2138, 0x4D2C6DFC, 0x53380D13, 0x650A7354, 0x766A0ABB, 0x81C2C92E, 0x92722C85,
|
||||
0xA2BFE8A1, 0xA81A664B, 0xC24B8B70, 0xC76C51A3, 0xD192E819, 0xD6990624, 0xF40E3585, 0x106AA070,
|
||||
0x19A4C116, 0x1E376C08, 0x2748774C, 0x34B0BCB5, 0x391C0CB3, 0x4ED8AA4A, 0x5B9CCA4F, 0x682E6FF3,
|
||||
0x748F82EE, 0x78A5636F, 0x84C87814, 0x8CC70208, 0x90BEFFFA, 0xA4506CEB, 0xBEF9A3F7, 0xC67178F2
|
||||
];
|
||||
|
||||
export function encodeUtf8(value) {
|
||||
const bytes = [];
|
||||
for (let index = 0; index < value.length; index++) {
|
||||
let codePoint = value.charCodeAt(index);
|
||||
if (codePoint >= 0xD800 && codePoint <= 0xDBFF && index + 1 < value.length) {
|
||||
const trailing = value.charCodeAt(index + 1);
|
||||
if (trailing >= 0xDC00 && trailing <= 0xDFFF) {
|
||||
codePoint = 0x10000 + ((codePoint - 0xD800) << 10) + trailing - 0xDC00;
|
||||
index++;
|
||||
}
|
||||
}
|
||||
|
||||
if (codePoint < 0x80) bytes.push(codePoint);
|
||||
else if (codePoint < 0x800) bytes.push(0xC0 | (codePoint >> 6), 0x80 | (codePoint & 63));
|
||||
else if (codePoint < 0x10000) bytes.push(0xE0 | (codePoint >> 12), 0x80 | ((codePoint >> 6) & 63), 0x80 | (codePoint & 63));
|
||||
else bytes.push(0xF0 | (codePoint >> 18), 0x80 | ((codePoint >> 12) & 63), 0x80 | ((codePoint >> 6) & 63), 0x80 | (codePoint & 63));
|
||||
}
|
||||
return new Uint8Array(bytes);
|
||||
}
|
||||
|
||||
function rotateRight(value, amount) {
|
||||
return (value >>> amount) | (value << (32 - amount));
|
||||
}
|
||||
|
||||
export function sha256(input) {
|
||||
const bitLength = input.length * 8;
|
||||
const paddedLength = Math.ceil((input.length + 9) / 64) * 64;
|
||||
const bytes = new Uint8Array(paddedLength);
|
||||
bytes.set(input);
|
||||
bytes[input.length] = 0x80;
|
||||
|
||||
const high = Math.floor(bitLength / 0x100000000);
|
||||
const low = bitLength >>> 0;
|
||||
const end = paddedLength - 8;
|
||||
bytes[end] = (high >>> 24) & 255;
|
||||
bytes[end + 1] = (high >>> 16) & 255;
|
||||
bytes[end + 2] = (high >>> 8) & 255;
|
||||
bytes[end + 3] = high & 255;
|
||||
bytes[end + 4] = (low >>> 24) & 255;
|
||||
bytes[end + 5] = (low >>> 16) & 255;
|
||||
bytes[end + 6] = (low >>> 8) & 255;
|
||||
bytes[end + 7] = low & 255;
|
||||
|
||||
const hash = [0x6A09E667, 0xBB67AE85, 0x3C6EF372, 0xA54FF53A, 0x510E527F, 0x9B05688C, 0x1F83D9AB, 0x5BE0CD19];
|
||||
const words = new Uint32Array(64);
|
||||
|
||||
for (let offset = 0; offset < bytes.length; offset += 64) {
|
||||
for (let index = 0; index < 16; index++) {
|
||||
const position = offset + index * 4;
|
||||
words[index] = ((bytes[position] << 24) | (bytes[position + 1] << 16) |
|
||||
(bytes[position + 2] << 8) | bytes[position + 3]) >>> 0;
|
||||
}
|
||||
for (let index = 16; index < 64; index++) {
|
||||
const first = rotateRight(words[index - 15], 7) ^ rotateRight(words[index - 15], 18) ^ (words[index - 15] >>> 3);
|
||||
const second = rotateRight(words[index - 2], 17) ^ rotateRight(words[index - 2], 19) ^ (words[index - 2] >>> 10);
|
||||
words[index] = (words[index - 16] + first + words[index - 7] + second) >>> 0;
|
||||
}
|
||||
|
||||
let [a, b, c, d, e, f, g, h] = hash;
|
||||
for (let index = 0; index < 64; index++) {
|
||||
const sum1 = rotateRight(e, 6) ^ rotateRight(e, 11) ^ rotateRight(e, 25);
|
||||
const choice = (e & f) ^ (~e & g);
|
||||
const temporary1 = (h + sum1 + choice + SHA256_CONSTANTS[index] + words[index]) >>> 0;
|
||||
const sum0 = rotateRight(a, 2) ^ rotateRight(a, 13) ^ rotateRight(a, 22);
|
||||
const majority = (a & b) ^ (a & c) ^ (b & c);
|
||||
const temporary2 = (sum0 + majority) >>> 0;
|
||||
h = g; g = f; f = e; e = (d + temporary1) >>> 0;
|
||||
d = c; c = b; b = a; a = (temporary1 + temporary2) >>> 0;
|
||||
}
|
||||
|
||||
hash[0] = (hash[0] + a) >>> 0;
|
||||
hash[1] = (hash[1] + b) >>> 0;
|
||||
hash[2] = (hash[2] + c) >>> 0;
|
||||
hash[3] = (hash[3] + d) >>> 0;
|
||||
hash[4] = (hash[4] + e) >>> 0;
|
||||
hash[5] = (hash[5] + f) >>> 0;
|
||||
hash[6] = (hash[6] + g) >>> 0;
|
||||
hash[7] = (hash[7] + h) >>> 0;
|
||||
}
|
||||
|
||||
const output = new Uint8Array(32);
|
||||
for (let index = 0; index < hash.length; index++) {
|
||||
output[index * 4] = (hash[index] >>> 24) & 255;
|
||||
output[index * 4 + 1] = (hash[index] >>> 16) & 255;
|
||||
output[index * 4 + 2] = (hash[index] >>> 8) & 255;
|
||||
output[index * 4 + 3] = hash[index] & 255;
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
export function hmacSha256(key, message) {
|
||||
let keyBytes = key instanceof Uint8Array ? key : encodeUtf8(String(key));
|
||||
if (keyBytes.length > 64) keyBytes = sha256(keyBytes);
|
||||
|
||||
const messageBytes = message instanceof Uint8Array ? message : encodeUtf8(String(message));
|
||||
const inner = new Uint8Array(64 + messageBytes.length);
|
||||
const outer = new Uint8Array(96);
|
||||
for (let index = 0; index < 64; index++) {
|
||||
const value = index < keyBytes.length ? keyBytes[index] : 0;
|
||||
inner[index] = value ^ 0x36;
|
||||
outer[index] = value ^ 0x5C;
|
||||
}
|
||||
inner.set(messageBytes, 64);
|
||||
outer.set(sha256(inner), 64);
|
||||
return sha256(outer);
|
||||
}
|
||||
|
||||
function encodeBase64(bytes) {
|
||||
let output = '';
|
||||
for (let index = 0; index < bytes.length; index += 3) {
|
||||
const secondExists = index + 1 < bytes.length;
|
||||
const thirdExists = index + 2 < bytes.length;
|
||||
const value = (bytes[index] << 16) | ((secondExists ? bytes[index + 1] : 0) << 8) |
|
||||
(thirdExists ? bytes[index + 2] : 0);
|
||||
output += BASE64_ALPHABET[(value >>> 18) & 63];
|
||||
output += BASE64_ALPHABET[(value >>> 12) & 63];
|
||||
output += secondExists ? BASE64_ALPHABET[(value >>> 6) & 63] : '=';
|
||||
output += thirdExists ? BASE64_ALPHABET[value & 63] : '=';
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
function createNonce() {
|
||||
const bytes = new Uint8Array(16);
|
||||
for (let index = 0; index < bytes.length; index++) bytes[index] = Math.floor(Math.random() * 256);
|
||||
return encodeBase64(bytes).replace(/[/+=]/g, '').slice(0, 22);
|
||||
}
|
||||
|
||||
let serverTimeOffset = 0;
|
||||
let serverTimeFetchedAt = 0;
|
||||
|
||||
async function getServerTimestamp() {
|
||||
const now = Date.now();
|
||||
if (serverTimeFetchedAt && now - serverTimeFetchedAt < 300000) {
|
||||
return Math.floor((now + serverTimeOffset) / 1000);
|
||||
}
|
||||
|
||||
try {
|
||||
const startedAt = Date.now();
|
||||
const response = await fetch(`${API_URL}/api/time?t=${startedAt}`, {
|
||||
headers: { ...HEADERS, 'Cache-Control': 'no-cache' }
|
||||
});
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
const payload = await response.json();
|
||||
if (!payload || typeof payload.timestamp !== 'number') throw new Error('invalid response');
|
||||
const finishedAt = Date.now();
|
||||
serverTimeOffset = payload.timestamp * 1000 + (finishedAt - startedAt) / 2 - finishedAt;
|
||||
serverTimeFetchedAt = finishedAt;
|
||||
return Math.floor((finishedAt + serverTimeOffset) / 1000);
|
||||
} catch (_) {
|
||||
return Math.floor(Date.now() / 1000);
|
||||
}
|
||||
}
|
||||
|
||||
export async function createSecureHeaders(apiKey, path) {
|
||||
const timestamp = await getServerTimestamp();
|
||||
const nonce = createNonce();
|
||||
const message = `${apiKey}:${timestamp}:${nonce}:${path}`;
|
||||
|
||||
return {
|
||||
'X-Api-Key': apiKey,
|
||||
'X-Request-Timestamp': String(timestamp),
|
||||
'X-Request-Nonce': nonce,
|
||||
'X-Request-Signature': encodeBase64(hmacSha256(apiKey, message)),
|
||||
'X-Client-Fingerprint': 'zerlgt',
|
||||
'X-Fingerprint-Lite': 'e9136c41504646444'
|
||||
};
|
||||
}
|
||||
|
||||
export async function fetchText(url, options = {}) {
|
||||
const response = await fetch(url, {
|
||||
...options,
|
||||
headers: { ...HEADERS, ...(options.headers || {}) }
|
||||
});
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status} for ${url}`);
|
||||
return await response.text();
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
/** Flixer provider entry point. */
|
||||
|
||||
import { extractStreams } from './extractor.js';
|
||||
|
||||
async function getStreams(tmdbId, mediaType, season, episode) {
|
||||
try {
|
||||
console.log(`[Flixer] Request: ${mediaType} ${tmdbId}`);
|
||||
const streams = await extractStreams(tmdbId, mediaType, season, episode);
|
||||
console.log(`[Flixer] Found ${streams.length} stream(s)`);
|
||||
return streams;
|
||||
} catch (error) {
|
||||
console.error(`[Flixer] Error: ${error.message}`);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { getStreams };
|
||||
@@ -0,0 +1,379 @@
|
||||
/**
|
||||
* Resolve TMDB media to AniList, then retrieve Miruro episode sources.
|
||||
*/
|
||||
|
||||
import { BASE_URL, fetchJson, HEADERS, secureGet } from './http.js';
|
||||
|
||||
const ANIMAP_URL = 'https://animap.id/api/map';
|
||||
const ANILIST_URL = 'https://graphql.anilist.co';
|
||||
|
||||
// Miruro's visible native-player providers and their preferred subtitle mode.
|
||||
const PROVIDERS = [
|
||||
{ id: 'bonk', label: 'Bonk', subCategory: 'ssub' },
|
||||
{ id: 'kiwi', label: 'Kiwi', subCategory: 'sub' },
|
||||
{ id: 'hop', label: 'Hop', subCategory: 'ssub' },
|
||||
{ id: 'ally', label: 'Ally', subCategory: 'sub' },
|
||||
{ id: 'pewe', label: 'Pewe', subCategory: 'sub' },
|
||||
{ id: 'bee', label: 'Bee', subCategory: 'ssub' },
|
||||
{ id: 'moo', label: 'Moo', subCategory: 'sub' }
|
||||
];
|
||||
|
||||
function asNumber(value) {
|
||||
const number = Number(value);
|
||||
return Number.isFinite(number) ? number : null;
|
||||
}
|
||||
|
||||
function parseIdentifier(value) {
|
||||
const id = String(value || '').trim();
|
||||
|
||||
if (/^tt\d+$/i.test(id)) {
|
||||
return { service: 'imdb', value: id.toLowerCase() };
|
||||
}
|
||||
|
||||
if (/^\d+$/.test(id)) {
|
||||
return { service: 'tmdb', value: id };
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function recordMatchesIdentifier(record, identifier, mediaType) {
|
||||
const expectedType = mediaType === 'movie' ? 'movie' : 'tv';
|
||||
|
||||
if (identifier.service === 'imdb') {
|
||||
const imdbMatches = (record.imdb_id || []).some((id) => (
|
||||
String(id).toLowerCase() === identifier.value
|
||||
));
|
||||
const typeMatches = (record.tmdb_id || []).length === 0 || (record.tmdb_id || []).some((mapping) => (
|
||||
mapping.type === expectedType
|
||||
));
|
||||
|
||||
return imdbMatches && typeMatches;
|
||||
}
|
||||
|
||||
return (record.tmdb_id || []).some((mapping) => (
|
||||
String(mapping.id) === identifier.value && mapping.type === expectedType
|
||||
));
|
||||
}
|
||||
|
||||
function getAniListIds(records, identifier, mediaType) {
|
||||
const ids = [];
|
||||
|
||||
for (const record of Array.isArray(records) ? records : []) {
|
||||
if (!recordMatchesIdentifier(record, identifier, mediaType)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const id of record.anilist_id || []) {
|
||||
const numericId = asNumber(id);
|
||||
|
||||
if (numericId && !ids.includes(numericId)) {
|
||||
ids.push(numericId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return ids;
|
||||
}
|
||||
|
||||
function getLocalEpisode(mappings, mediaType, season, episode) {
|
||||
if (!mappings) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (mediaType === 'movie') {
|
||||
return 1;
|
||||
}
|
||||
|
||||
const requestedSeason = asNumber(season) || 1;
|
||||
const requestedEpisode = asNumber(episode);
|
||||
|
||||
if (!requestedEpisode || requestedEpisode < 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const mappedSeason = mappings.tmdbSeason || mappings.defaultTvdbSeason;
|
||||
|
||||
if (mappedSeason === 'absolute') {
|
||||
// Absolute mappings cannot reliably translate later TMDB seasons without
|
||||
// season lengths. Season one still maps directly.
|
||||
return requestedSeason === 1 ? requestedEpisode : null;
|
||||
}
|
||||
|
||||
if (mappedSeason != null && String(mappedSeason) !== String(requestedSeason)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const offset = asNumber(mappings.tmdbOffset) || asNumber(mappings.episodeOffset) || 0;
|
||||
const episodeCount = asNumber(mappings.episodes);
|
||||
|
||||
if (requestedEpisode <= offset || (episodeCount && requestedEpisode > offset + episodeCount)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return requestedEpisode - offset;
|
||||
}
|
||||
|
||||
async function getAniListSequel(anilistId) {
|
||||
const response = await fetchJson(ANILIST_URL, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
query: `
|
||||
query ($id: Int!) {
|
||||
Media(id: $id, type: ANIME) {
|
||||
relations {
|
||||
edges {
|
||||
relationType
|
||||
node {
|
||||
id
|
||||
type
|
||||
format
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
variables: { id: anilistId }
|
||||
})
|
||||
});
|
||||
const edges = response && response.data && response.data.Media && response.data.Media.relations
|
||||
? response.data.Media.relations.edges
|
||||
: [];
|
||||
const sequel = (edges || []).find((edge) => (
|
||||
edge.relationType === 'SEQUEL' &&
|
||||
edge.node &&
|
||||
edge.node.type === 'ANIME' &&
|
||||
['TV', 'TV_SHORT', 'ONA'].includes(edge.node.format)
|
||||
));
|
||||
|
||||
return sequel ? asNumber(sequel.node.id) : null;
|
||||
}
|
||||
|
||||
async function getSeasonAniListId(anilistId, season) {
|
||||
const requestedSeason = asNumber(season) || 1;
|
||||
let currentId = anilistId;
|
||||
|
||||
for (let currentSeason = 1; currentSeason < requestedSeason; currentSeason++) {
|
||||
const sequelId = await getAniListSequel(currentId);
|
||||
|
||||
if (!sequelId || sequelId === currentId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
currentId = sequelId;
|
||||
}
|
||||
|
||||
return currentId;
|
||||
}
|
||||
|
||||
function getSequelEpisode(payload, mediaType, season, episode) {
|
||||
// A sequel has its own AniList episode list, so its first episode is local
|
||||
// episode 1 even when the caller identifies it as TMDB/IMDb season 2+. Keep
|
||||
// an explicit TMDB season mapping when Miruro provides one, then fall back
|
||||
// to numbering relative to the selected AniList entry.
|
||||
return getLocalEpisode(payload.mappings, mediaType, season, episode) ||
|
||||
getLocalEpisode(payload.mappings, mediaType, 1, episode);
|
||||
}
|
||||
|
||||
async function resolveEpisodePayload(mediaId, mediaType, season, episode) {
|
||||
const identifier = parseIdentifier(mediaId);
|
||||
|
||||
if (!identifier) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let mappingRecords;
|
||||
|
||||
try {
|
||||
mappingRecords = await fetchJson(
|
||||
`${ANIMAP_URL}/${identifier.service}/${encodeURIComponent(identifier.value)}`,
|
||||
{ headers: { 'Accept': 'application/json' } }
|
||||
);
|
||||
} catch (error) {
|
||||
console.warn(`[Miruro] No anime mapping for ${mediaId}: ${error.message}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const aniListIds = getAniListIds(mappingRecords, identifier, mediaType);
|
||||
|
||||
if (aniListIds.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// A single mapping can be validated using the episode response itself.
|
||||
if (aniListIds.length === 1) {
|
||||
const anilistId = aniListIds[0];
|
||||
|
||||
if (mediaType === 'tv' && (asNumber(season) || 1) > 1) {
|
||||
try {
|
||||
const seasonAniListId = await getSeasonAniListId(anilistId, season);
|
||||
|
||||
if (seasonAniListId && seasonAniListId !== anilistId) {
|
||||
const payload = await secureGet('episodes', { anilistId: seasonAniListId });
|
||||
const localEpisode = getSequelEpisode(payload, mediaType, season, episode);
|
||||
|
||||
if (localEpisode) {
|
||||
return { anilistId: seasonAniListId, localEpisode, payload };
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// AniList is a fallback for mappings that only identify season
|
||||
// one. If it is unavailable, retain Miruro's normal mapping.
|
||||
console.warn(`[Miruro] Season ${season} mapping fallback failed: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
const payload = await secureGet('episodes', { anilistId });
|
||||
const localEpisode = getLocalEpisode(payload.mappings, mediaType, season, episode);
|
||||
|
||||
return localEpisode ? { anilistId, localEpisode, payload } : null;
|
||||
}
|
||||
|
||||
// Franchises can map multiple AniList cours to one TMDB show. Miruro's info
|
||||
// metadata identifies both the TMDB season and the cour's episode offset.
|
||||
for (const anilistId of aniListIds) {
|
||||
try {
|
||||
const info = await secureGet(`info/${anilistId}`);
|
||||
const localEpisode = getLocalEpisode(info.mappings, mediaType, season, episode);
|
||||
|
||||
if (localEpisode) {
|
||||
const payload = await secureGet('episodes', { anilistId });
|
||||
return { anilistId, localEpisode, payload };
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(`[Miruro] Skipping AniList ${anilistId}: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function findEpisode(providerPayload, variant, episodeNumber) {
|
||||
const episodes = providerPayload && providerPayload.episodes
|
||||
? providerPayload.episodes[variant]
|
||||
: null;
|
||||
|
||||
if (!Array.isArray(episodes)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return episodes.find((item) => Number(item.number) === Number(episodeNumber)) || null;
|
||||
}
|
||||
|
||||
function getOrigin(url) {
|
||||
const match = typeof url === 'string' ? url.match(/^(https?:\/\/[^/]+)/i) : null;
|
||||
return match ? match[1] : BASE_URL;
|
||||
}
|
||||
|
||||
function getQuality(stream) {
|
||||
const value = String(stream.quality || stream.server || 'HD');
|
||||
const resolution = value.match(/(2160|1440|1080|720|480|360)p?/i);
|
||||
|
||||
if (resolution) {
|
||||
return `${resolution[1]}p`;
|
||||
}
|
||||
|
||||
return /4k/i.test(value) ? '4K' : 'HD';
|
||||
}
|
||||
|
||||
function isPlayableStream(stream) {
|
||||
const type = String(stream && stream.type || '').toLowerCase();
|
||||
return !!(stream && stream.url) && (
|
||||
type === 'hls' ||
|
||||
type === 'm3u8' ||
|
||||
type === 'mp4' ||
|
||||
type === 'application/x-mpegurl'
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeStreams(sourcePayload, provider, variant) {
|
||||
const subtitleCount = Array.isArray(sourcePayload.subtitles) ? sourcePayload.subtitles.length : 0;
|
||||
const language = variant === 'dub' ? 'DUB' : 'SUB';
|
||||
|
||||
return (sourcePayload.streams || []).filter(isPlayableStream).map((stream) => {
|
||||
const referer = stream.referer || BASE_URL;
|
||||
const server = stream.server || getQuality(stream);
|
||||
const subtitleLabel = subtitleCount > 0 && variant !== 'dub' ? ' · CC' : '';
|
||||
|
||||
return {
|
||||
// Nuvio's source picker prominently displays `name`, so include the
|
||||
// Miruro server and audio variant here instead of only in `title`.
|
||||
name: `Miruro · ${provider.label} · ${language}`,
|
||||
title: `[${language}] ${provider.label} · ${server}${subtitleLabel}`,
|
||||
url: stream.url,
|
||||
quality: getQuality(stream),
|
||||
headers: {
|
||||
'Referer': referer,
|
||||
'Origin': getOrigin(referer),
|
||||
'User-Agent': HEADERS['User-Agent']
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async function getProviderStreams(provider, providerPayload, anilistId, episodeNumber) {
|
||||
const requests = [];
|
||||
const subEpisode = findEpisode(providerPayload, 'sub', episodeNumber);
|
||||
const dubEpisode = findEpisode(providerPayload, 'dub', episodeNumber);
|
||||
|
||||
if (subEpisode) {
|
||||
requests.push({ episode: subEpisode, variant: 'sub', category: provider.subCategory });
|
||||
}
|
||||
|
||||
if (dubEpisode) {
|
||||
requests.push({ episode: dubEpisode, variant: 'dub', category: 'dub' });
|
||||
}
|
||||
|
||||
const results = await Promise.all(requests.map(async (request) => {
|
||||
try {
|
||||
const sources = await secureGet('sources', {
|
||||
episodeId: request.episode.id,
|
||||
provider: provider.id,
|
||||
category: request.category,
|
||||
anilistId
|
||||
});
|
||||
|
||||
return normalizeStreams(sources, provider, request.variant);
|
||||
} catch (error) {
|
||||
console.warn(`[Miruro] ${provider.label} ${request.variant} failed: ${error.message}`);
|
||||
return [];
|
||||
}
|
||||
}));
|
||||
|
||||
return results.flat();
|
||||
}
|
||||
|
||||
export async function extractStreams(tmdbId, mediaType, season, episode) {
|
||||
if (!tmdbId || (mediaType !== 'movie' && mediaType !== 'tv')) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const resolved = await resolveEpisodePayload(tmdbId, mediaType, season, episode);
|
||||
|
||||
if (!resolved || !resolved.payload || !resolved.payload.providers) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const providerResults = await Promise.all(PROVIDERS.map((provider) => {
|
||||
const payload = resolved.payload.providers[provider.id];
|
||||
return payload
|
||||
? getProviderStreams(provider, payload, resolved.anilistId, resolved.localEpisode)
|
||||
: [];
|
||||
}));
|
||||
const streams = providerResults.flat();
|
||||
const seen = new Set();
|
||||
|
||||
return streams.filter((stream) => {
|
||||
if (seen.has(stream.url)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
seen.add(stream.url);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
/**
|
||||
* HTTP helpers for Miruro's public read API.
|
||||
*/
|
||||
|
||||
import { gunzipSync, strFromU8 } from 'fflate';
|
||||
|
||||
export const BASE_URL = 'https://www.miruro.to';
|
||||
|
||||
export const HEADERS = {
|
||||
'Accept': '*/*',
|
||||
'Referer': `${BASE_URL}/`,
|
||||
'User-Agent': 'Mozilla/5.0 (Linux; Android 15) AppleWebKit/537.36 Chrome/140.0.0.0 Mobile Safari/537.36',
|
||||
'Sec-Fetch-Dest': 'empty',
|
||||
'Sec-Fetch-Mode': 'cors',
|
||||
'Sec-Fetch-Site': 'same-origin'
|
||||
};
|
||||
|
||||
const PIPE_OBFUSCATION_KEY = hexToBytes('71951034f8fbcf53d89db52ceb3dc22c');
|
||||
const BASE64_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
|
||||
|
||||
function hexToBytes(value) {
|
||||
const bytes = new Uint8Array(value.length / 2);
|
||||
|
||||
for (let index = 0; index < bytes.length; index++) {
|
||||
bytes[index] = parseInt(value.slice(index * 2, index * 2 + 2), 16);
|
||||
}
|
||||
|
||||
return bytes;
|
||||
}
|
||||
|
||||
function encodeBase64Url(value) {
|
||||
let result = '';
|
||||
|
||||
for (let index = 0; index < value.length; index += 3) {
|
||||
const first = value.charCodeAt(index);
|
||||
const hasSecond = index + 1 < value.length;
|
||||
const hasThird = index + 2 < value.length;
|
||||
const second = hasSecond ? value.charCodeAt(index + 1) : 0;
|
||||
const third = hasThird ? value.charCodeAt(index + 2) : 0;
|
||||
const chunk = (first << 16) | (second << 8) | third;
|
||||
|
||||
result += BASE64_ALPHABET[(chunk >> 18) & 63];
|
||||
result += BASE64_ALPHABET[(chunk >> 12) & 63];
|
||||
result += hasSecond ? BASE64_ALPHABET[(chunk >> 6) & 63] : '=';
|
||||
result += hasThird ? BASE64_ALPHABET[chunk & 63] : '=';
|
||||
}
|
||||
|
||||
return result.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '');
|
||||
}
|
||||
|
||||
function decodeBase64Url(value) {
|
||||
const normalized = value.replace(/-/g, '+').replace(/_/g, '/').replace(/\s/g, '');
|
||||
const outputLength = Math.floor(normalized.length * 6 / 8);
|
||||
const output = new Uint8Array(outputLength);
|
||||
let buffer = 0;
|
||||
let bits = 0;
|
||||
let outputIndex = 0;
|
||||
|
||||
for (let index = 0; index < normalized.length; index++) {
|
||||
const code = BASE64_ALPHABET.indexOf(normalized[index]);
|
||||
|
||||
if (code < 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
buffer = (buffer << 6) | code;
|
||||
bits += 6;
|
||||
|
||||
if (bits >= 8) {
|
||||
bits -= 8;
|
||||
output[outputIndex++] = (buffer >> bits) & 255;
|
||||
}
|
||||
}
|
||||
|
||||
return outputIndex === output.length ? output : output.slice(0, outputIndex);
|
||||
}
|
||||
|
||||
function decodeSecurePayload(raw, obfuscationVersion) {
|
||||
if (!obfuscationVersion) {
|
||||
return JSON.parse(raw);
|
||||
}
|
||||
|
||||
const bytes = decodeBase64Url(raw);
|
||||
|
||||
if (obfuscationVersion === '2') {
|
||||
for (let index = 0; index < bytes.length; index++) {
|
||||
bytes[index] ^= PIPE_OBFUSCATION_KEY[index % PIPE_OBFUSCATION_KEY.length];
|
||||
}
|
||||
}
|
||||
|
||||
return JSON.parse(strFromU8(gunzipSync(bytes)));
|
||||
}
|
||||
|
||||
function mergeHeaders(headers) {
|
||||
return {
|
||||
...HEADERS,
|
||||
...(headers || {})
|
||||
};
|
||||
}
|
||||
|
||||
/** Fetch text and throw a useful error for non-success responses. */
|
||||
export async function fetchText(url, options = {}) {
|
||||
const response = await fetch(url, {
|
||||
...options,
|
||||
headers: mergeHeaders(options.headers)
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status} for ${url}`);
|
||||
}
|
||||
|
||||
return await response.text();
|
||||
}
|
||||
|
||||
/** Fetch a regular JSON endpoint. */
|
||||
export async function fetchJson(url, options = {}) {
|
||||
const raw = await fetchText(url, options);
|
||||
return JSON.parse(raw);
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform the compact, read-only request used by Miruro's secure API. Miruro
|
||||
* returns base64url-encoded gzip JSON, optionally XOR-obfuscated.
|
||||
*/
|
||||
export async function secureGet(path, query = {}) {
|
||||
const envelope = encodeBase64Url(JSON.stringify({
|
||||
path,
|
||||
method: 'GET',
|
||||
query,
|
||||
body: null
|
||||
}));
|
||||
const url = `${BASE_URL}/api/secure/pipe?e=${envelope}`;
|
||||
let response;
|
||||
|
||||
for (let attempt = 0; attempt < 2; attempt++) {
|
||||
response = await fetch(url, { headers: HEADERS });
|
||||
|
||||
if (response.ok || (response.status !== 403 && response.status !== 429 && response.status < 500)) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!response || !response.ok) {
|
||||
throw new Error(`Miruro API returned HTTP ${response ? response.status : 'unknown'}`);
|
||||
}
|
||||
|
||||
const raw = await response.text();
|
||||
const version = response.headers && response.headers.get
|
||||
? response.headers.get('x-obfuscated')
|
||||
: null;
|
||||
|
||||
return decodeSecurePayload(raw.trim(), version);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* Miruro Provider
|
||||
* Main entry point.
|
||||
*/
|
||||
|
||||
import { extractStreams } from './extractor.js';
|
||||
|
||||
/**
|
||||
* Main function called by Nuvio
|
||||
* @param {string} tmdbId - TMDB numeric ID or IMDb tt-prefixed ID
|
||||
* @param {string} mediaType - 'movie' or 'tv'
|
||||
* @param {number} season - Season number (for TV)
|
||||
* @param {number} episode - Episode number (for TV)
|
||||
*/
|
||||
async function getStreams(tmdbId, mediaType, season, episode) {
|
||||
try {
|
||||
const episodeLabel = mediaType === 'tv' ? ` S${season}E${episode}` : '';
|
||||
console.log(`[Miruro] Request: ${mediaType} ${tmdbId}${episodeLabel}`);
|
||||
|
||||
const streams = await extractStreams(tmdbId, mediaType, season, episode);
|
||||
console.log(`[Miruro] Found ${streams.length} stream(s)`);
|
||||
return streams;
|
||||
} catch (error) {
|
||||
console.error(`[Miruro] Error: ${error.message}`);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { getStreams };
|
||||
Reference in new issue
Block a user