mirror of
https://github.com/movixcorp/MovixOpenSource.git
synced 2026-07-27 00:52:08 +00:00
5270 lines
157 KiB
JavaScript
5270 lines
157 KiB
JavaScript
// ==UserScript==
|
|
// @name Movix Proxy Extension (Tampermonkey)
|
|
// @namespace https://movix.cash
|
|
// @version 1.4.8
|
|
// @description Extension proxy pour Live TV Movix - Contourne CORS, injecte les headers et extrait les sources Nexus - version userscript Tampermonkey
|
|
// @author Movix
|
|
// @updateURL https://github.com/movixcorp/MovixOpenSource/raw/refs/heads/main/userscript/movix.user.js
|
|
// @downloadURL https://github.com/movixcorp/MovixOpenSource/raw/refs/heads/main/userscript/movix.user.js
|
|
// @match http://localhost/*
|
|
// @match http://127.0.0.1/*
|
|
// @match https://localhost/*
|
|
// @match https://127.0.0.1/*
|
|
// @match https://movix.cash/*
|
|
// @match https://*.movix.cash/*
|
|
// @match https://movix.club/*
|
|
// @match https://*.movix.club/*
|
|
// @match https://movix.cloud/*
|
|
// @match https://*.movix.cloud/*
|
|
// @match https://movix.tax/*
|
|
// @match https://*.movix.tax/*
|
|
// @match https://movix.golf/*
|
|
// @match https://movix.chat/*
|
|
// @match https://*.movix.chat/*
|
|
// @match https://*.movix.golf/*
|
|
// @match https://movix.date/*
|
|
// @match https://*.movix.date/*
|
|
// @match https://movix.show/*
|
|
// @match https://*.movix.show/*
|
|
// @grant GM_xmlhttpRequest
|
|
// @grant GM_getValue
|
|
// @grant GM_setValue
|
|
// @grant GM_deleteValue
|
|
// @grant unsafeWindow
|
|
// @connect *
|
|
// @run-at document-start
|
|
// ==/UserScript==
|
|
|
|
/* Auto-generated by userscript/build-userscript.mjs. Do not edit directly. */
|
|
/* eslint-disable */
|
|
|
|
(() => {
|
|
"use strict";
|
|
|
|
const USERSCRIPT_MANIFEST = {
|
|
name: "Movix Proxy Extension",
|
|
version: "1.4.8",
|
|
description:
|
|
"Extension proxy pour Live TV Movix - Contourne CORS, injecte les headers et extrait les sources Nexus",
|
|
};
|
|
|
|
const pageWindow =
|
|
typeof unsafeWindow !== "undefined" ? unsafeWindow : window;
|
|
const storagePrefix = "movix_userscript:";
|
|
const runtimeListeners = {
|
|
onInstalled: [],
|
|
onStartup: [],
|
|
onMessage: [],
|
|
};
|
|
const dynamicRules = [];
|
|
const nativeWindowFetch =
|
|
typeof pageWindow.fetch === "function"
|
|
? pageWindow.fetch.bind(pageWindow)
|
|
: null;
|
|
const NativeXMLHttpRequest =
|
|
typeof pageWindow.XMLHttpRequest === "function"
|
|
? pageWindow.XMLHttpRequest
|
|
: null;
|
|
const proxiedMediaObjectUrls = new WeakMap();
|
|
|
|
function getGMRequest() {
|
|
if (typeof GM_xmlhttpRequest === "function") {
|
|
return GM_xmlhttpRequest;
|
|
}
|
|
if (
|
|
typeof GM !== "undefined" &&
|
|
GM &&
|
|
typeof GM.xmlHttpRequest === "function"
|
|
) {
|
|
return GM.xmlHttpRequest.bind(GM);
|
|
}
|
|
throw new Error("GM_xmlhttpRequest est indisponible.");
|
|
}
|
|
|
|
async function gmGetValueCompat(key, fallbackValue) {
|
|
if (typeof GM_getValue === "function") {
|
|
const value = await GM_getValue(key, fallbackValue);
|
|
return value === undefined ? fallbackValue : value;
|
|
}
|
|
if (typeof GM !== "undefined" && GM && typeof GM.getValue === "function") {
|
|
const value = await GM.getValue(key, fallbackValue);
|
|
return value === undefined ? fallbackValue : value;
|
|
}
|
|
return fallbackValue;
|
|
}
|
|
|
|
async function gmSetValueCompat(key, value) {
|
|
if (typeof GM_setValue === "function") {
|
|
return GM_setValue(key, value);
|
|
}
|
|
if (typeof GM !== "undefined" && GM && typeof GM.setValue === "function") {
|
|
return GM.setValue(key, value);
|
|
}
|
|
}
|
|
|
|
async function gmDeleteValueCompat(key) {
|
|
if (typeof GM_deleteValue === "function") {
|
|
return GM_deleteValue(key);
|
|
}
|
|
if (
|
|
typeof GM !== "undefined" &&
|
|
GM &&
|
|
typeof GM.deleteValue === "function"
|
|
) {
|
|
return GM.deleteValue(key);
|
|
}
|
|
}
|
|
|
|
function parseRawHeaders(rawHeaders) {
|
|
const parsed = {};
|
|
for (const line of String(rawHeaders || "").split(/\r?\n/)) {
|
|
const separatorIndex = line.indexOf(":");
|
|
if (separatorIndex === -1) continue;
|
|
const name = line.slice(0, separatorIndex).trim();
|
|
const value = line.slice(separatorIndex + 1).trim();
|
|
if (!name) continue;
|
|
parsed[name.toLowerCase()] = value;
|
|
}
|
|
return parsed;
|
|
}
|
|
|
|
function normalizeHeaders(input) {
|
|
if (!input) return {};
|
|
|
|
if (typeof Headers !== "undefined" && input instanceof Headers) {
|
|
return Object.fromEntries(input.entries());
|
|
}
|
|
|
|
if (Array.isArray(input)) {
|
|
return Object.fromEntries(input);
|
|
}
|
|
|
|
return Object.fromEntries(
|
|
Object.entries(input).map(([key, value]) => [key, String(value)]),
|
|
);
|
|
}
|
|
|
|
function escapeRegExp(value) {
|
|
return value.replace(/[|\\{}()[\]^$+?.]/g, "\\$&");
|
|
}
|
|
|
|
function wildcardToRegExp(pattern) {
|
|
// DNR urlFilter semantics: `*` is a wildcard, everything else is a
|
|
// SUBSTRING match (not anchored). A bare filter like "/cfall/s" must match
|
|
// anywhere in the URL, so it must NOT be wrapped in ^...$ — otherwise the
|
|
// FCTV Referer rule never fires and the CDN 403s ("deny by referer").
|
|
return new RegExp(
|
|
String(pattern || "*")
|
|
.split("*")
|
|
.map((part) => escapeRegExp(part))
|
|
.join(".*"),
|
|
"i",
|
|
);
|
|
}
|
|
|
|
function matchesRule(rule, url, resourceType = "xmlhttprequest") {
|
|
if (!rule || !rule.condition) return false;
|
|
|
|
const resourceTypes = rule.condition.resourceTypes;
|
|
if (Array.isArray(resourceTypes) && resourceTypes.length > 0) {
|
|
if (!resourceTypes.includes(resourceType)) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
const urlFilter = rule.condition.urlFilter || "*";
|
|
if (!wildcardToRegExp(urlFilter).test(url)) {
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
function hasMatchingRequestRule(url, resourceType = "xmlhttprequest") {
|
|
return dynamicRules.some((rule) => {
|
|
return (
|
|
rule?.action?.type === "modifyHeaders" &&
|
|
Array.isArray(rule?.action?.requestHeaders) &&
|
|
rule.action.requestHeaders.length > 0 &&
|
|
matchesRule(rule, url, resourceType)
|
|
);
|
|
});
|
|
}
|
|
|
|
function shouldProxyNetworkRequest(url, resourceType = "xmlhttprequest") {
|
|
const normalizedUrl = String(url || "").trim();
|
|
if (!normalizedUrl) return false;
|
|
|
|
if (
|
|
/^(?:blob:|data:|javascript:|about:|chrome-extension:|moz-extension:)/i.test(
|
|
normalizedUrl,
|
|
)
|
|
) {
|
|
return false;
|
|
}
|
|
|
|
return hasMatchingRequestRule(normalizedUrl, resourceType);
|
|
}
|
|
|
|
function applyDynamicRequestHeaders(
|
|
url,
|
|
headers,
|
|
resourceType = "xmlhttprequest",
|
|
) {
|
|
const merged = { ...headers };
|
|
|
|
for (const rule of dynamicRules) {
|
|
if (
|
|
rule?.action?.type !== "modifyHeaders" ||
|
|
!Array.isArray(rule?.action?.requestHeaders)
|
|
) {
|
|
continue;
|
|
}
|
|
|
|
if (!matchesRule(rule, url, resourceType)) {
|
|
continue;
|
|
}
|
|
|
|
for (const headerRule of rule.action.requestHeaders) {
|
|
if (!headerRule?.header) continue;
|
|
if (headerRule.operation === "set") {
|
|
merged[headerRule.header] = headerRule.value;
|
|
}
|
|
}
|
|
}
|
|
|
|
return merged;
|
|
}
|
|
|
|
function toArrayBuffer(value) {
|
|
if (value instanceof ArrayBuffer) {
|
|
return value;
|
|
}
|
|
if (ArrayBuffer.isView(value)) {
|
|
return value.buffer.slice(
|
|
value.byteOffset,
|
|
value.byteOffset + value.byteLength,
|
|
);
|
|
}
|
|
if (typeof value === "string") {
|
|
return new TextEncoder().encode(value).buffer;
|
|
}
|
|
return new ArrayBuffer(0);
|
|
}
|
|
|
|
function serializeBody(body) {
|
|
if (body == null) return undefined;
|
|
if (typeof body === "string") return body;
|
|
if (body instanceof URLSearchParams) return body.toString();
|
|
if (body instanceof FormData) return body;
|
|
if (body instanceof Blob) return body;
|
|
if (body instanceof ArrayBuffer) return body;
|
|
if (ArrayBuffer.isView(body)) return body;
|
|
return String(body);
|
|
}
|
|
|
|
class SimpleHeaders {
|
|
constructor(init = {}) {
|
|
this._map = new Map();
|
|
for (const [key, value] of Object.entries(init)) {
|
|
this.set(key, value);
|
|
}
|
|
}
|
|
|
|
get(name) {
|
|
return this._map.get(String(name).toLowerCase()) ?? null;
|
|
}
|
|
|
|
has(name) {
|
|
return this._map.has(String(name).toLowerCase());
|
|
}
|
|
|
|
set(name, value) {
|
|
this._map.set(String(name).toLowerCase(), String(value));
|
|
}
|
|
|
|
entries() {
|
|
return this._map.entries();
|
|
}
|
|
}
|
|
|
|
class GMFetchResponse {
|
|
constructor(url, response) {
|
|
this.status = response.status || 0;
|
|
this.statusText = response.statusText || "";
|
|
this.ok = this.status >= 200 && this.status < 300;
|
|
this.url = response.finalUrl || url;
|
|
this.headers = new SimpleHeaders(
|
|
parseRawHeaders(response.responseHeaders),
|
|
);
|
|
this._buffer = toArrayBuffer(
|
|
response.response ?? response.responseText ?? "",
|
|
);
|
|
}
|
|
|
|
async arrayBuffer() {
|
|
return this._buffer.slice(0);
|
|
}
|
|
|
|
async text() {
|
|
return new TextDecoder().decode(this._buffer);
|
|
}
|
|
|
|
async json() {
|
|
return JSON.parse(await this.text());
|
|
}
|
|
}
|
|
|
|
function gmFetch(input, init = {}) {
|
|
const requestUrl =
|
|
typeof input === "string" ? input : String(input && input.url);
|
|
const method = String(init.method || "GET").toUpperCase();
|
|
const baseHeaders = normalizeHeaders(init.headers);
|
|
const headers = applyDynamicRequestHeaders(
|
|
requestUrl,
|
|
baseHeaders,
|
|
"xmlhttprequest",
|
|
);
|
|
const body = serializeBody(init.body);
|
|
const request = getGMRequest();
|
|
|
|
return new Promise((resolve, reject) => {
|
|
let finished = false;
|
|
let abortHandler = null;
|
|
let handle = null;
|
|
|
|
const cleanup = () => {
|
|
if (abortHandler && init.signal) {
|
|
init.signal.removeEventListener("abort", abortHandler);
|
|
}
|
|
};
|
|
|
|
const fail = (error) => {
|
|
if (finished) return;
|
|
finished = true;
|
|
cleanup();
|
|
reject(error instanceof Error ? error : new Error(String(error)));
|
|
};
|
|
|
|
const succeed = (response) => {
|
|
if (finished) return;
|
|
finished = true;
|
|
cleanup();
|
|
resolve(new GMFetchResponse(requestUrl, response));
|
|
};
|
|
|
|
try {
|
|
handle = request({
|
|
method,
|
|
url: requestUrl,
|
|
headers,
|
|
data: body,
|
|
responseType: "arraybuffer",
|
|
timeout: init.timeout ?? 30000,
|
|
onload: (response) => succeed(response),
|
|
onerror: (response) =>
|
|
fail(
|
|
new Error(
|
|
response?.error ||
|
|
response?.statusText ||
|
|
"GM_xmlhttpRequest a échoué",
|
|
),
|
|
),
|
|
ontimeout: () => fail(new Error("Requête expirée")),
|
|
onabort: () =>
|
|
fail(new DOMException("La requête a été annulée", "AbortError")),
|
|
});
|
|
} catch (error) {
|
|
fail(error);
|
|
return;
|
|
}
|
|
|
|
if (init.signal) {
|
|
if (init.signal.aborted) {
|
|
try {
|
|
handle?.abort?.();
|
|
} catch {}
|
|
fail(new DOMException("La requête a été annulée", "AbortError"));
|
|
return;
|
|
}
|
|
|
|
abortHandler = () => {
|
|
try {
|
|
handle?.abort?.();
|
|
} catch {}
|
|
fail(new DOMException("La requête a été annulée", "AbortError"));
|
|
};
|
|
|
|
init.signal.addEventListener("abort", abortHandler, { once: true });
|
|
}
|
|
});
|
|
}
|
|
|
|
function createEventPayload(type, extra = {}) {
|
|
return {
|
|
type,
|
|
target: extra.target || null,
|
|
currentTarget: extra.currentTarget || extra.target || null,
|
|
lengthComputable: Boolean(extra.lengthComputable),
|
|
loaded: Number(extra.loaded || 0),
|
|
total: Number(extra.total || 0),
|
|
};
|
|
}
|
|
|
|
class MovixXMLHttpRequest {
|
|
constructor() {
|
|
this._native = NativeXMLHttpRequest ? new NativeXMLHttpRequest() : null;
|
|
this._useNative = Boolean(this._native);
|
|
this._listeners = new Map();
|
|
this._headers = {};
|
|
this._method = "GET";
|
|
this._url = "";
|
|
this._requestHandle = null;
|
|
this._responseHeadersRaw = "";
|
|
this._responseType = "";
|
|
this._timeout = 0;
|
|
this._withCredentials = false;
|
|
|
|
this.readyState = 0;
|
|
this.status = 0;
|
|
this.statusText = "";
|
|
this.response = null;
|
|
this.responseText = "";
|
|
this.responseURL = "";
|
|
|
|
this.onreadystatechange = null;
|
|
this.onload = null;
|
|
this.onerror = null;
|
|
this.onabort = null;
|
|
this.ontimeout = null;
|
|
this.onloadend = null;
|
|
this.onprogress = null;
|
|
|
|
this.upload = this._native ? this._native.upload : undefined;
|
|
|
|
if (this._native) {
|
|
this._bindNativeEvents();
|
|
}
|
|
}
|
|
|
|
get responseType() {
|
|
return this._responseType;
|
|
}
|
|
|
|
set responseType(value) {
|
|
this._responseType = String(value || "");
|
|
if (this._useNative && this._native) {
|
|
try {
|
|
this._native.responseType = this._responseType;
|
|
} catch {}
|
|
}
|
|
}
|
|
|
|
get timeout() {
|
|
return this._timeout;
|
|
}
|
|
|
|
set timeout(value) {
|
|
this._timeout = Number(value || 0);
|
|
if (this._useNative && this._native) {
|
|
this._native.timeout = this._timeout;
|
|
}
|
|
}
|
|
|
|
get withCredentials() {
|
|
return this._withCredentials;
|
|
}
|
|
|
|
set withCredentials(value) {
|
|
this._withCredentials = Boolean(value);
|
|
if (this._useNative && this._native) {
|
|
this._native.withCredentials = this._withCredentials;
|
|
}
|
|
}
|
|
|
|
addEventListener(type, listener) {
|
|
if (!this._listeners.has(type)) {
|
|
this._listeners.set(type, new Set());
|
|
}
|
|
this._listeners.get(type).add(listener);
|
|
}
|
|
|
|
removeEventListener(type, listener) {
|
|
this._listeners.get(type)?.delete(listener);
|
|
}
|
|
|
|
dispatchEvent(event) {
|
|
this._dispatch(event?.type || "event", event || {});
|
|
return true;
|
|
}
|
|
|
|
open(method, url, async = true, user, password) {
|
|
this._method = String(method || "GET").toUpperCase();
|
|
this._url = String(url || "");
|
|
this._headers = {};
|
|
this._requestHandle = null;
|
|
this._responseHeadersRaw = "";
|
|
this.response = null;
|
|
this.responseText = "";
|
|
this.responseURL = "";
|
|
this.status = 0;
|
|
this.statusText = "";
|
|
this.readyState = 0;
|
|
|
|
this._useNative =
|
|
Boolean(this._native) &&
|
|
!shouldProxyNetworkRequest(this._url, "xmlhttprequest");
|
|
|
|
if (this._useNative && this._native) {
|
|
this._native.open(method, url, async, user, password);
|
|
this._native.timeout = this._timeout;
|
|
this._native.withCredentials = this._withCredentials;
|
|
if (this._responseType) {
|
|
try {
|
|
this._native.responseType = this._responseType;
|
|
} catch {}
|
|
}
|
|
return;
|
|
}
|
|
|
|
this.readyState = MovixXMLHttpRequest.OPENED;
|
|
this._dispatch("readystatechange");
|
|
}
|
|
|
|
setRequestHeader(name, value) {
|
|
if (this._useNative && this._native) {
|
|
this._native.setRequestHeader(name, value);
|
|
return;
|
|
}
|
|
this._headers[name] = String(value);
|
|
}
|
|
|
|
getAllResponseHeaders() {
|
|
if (this._useNative && this._native) {
|
|
return this._native.getAllResponseHeaders();
|
|
}
|
|
return this._responseHeadersRaw || "";
|
|
}
|
|
|
|
getResponseHeader(name) {
|
|
if (this._useNative && this._native) {
|
|
return this._native.getResponseHeader(name);
|
|
}
|
|
return (
|
|
parseRawHeaders(this._responseHeadersRaw)[String(name).toLowerCase()] ||
|
|
null
|
|
);
|
|
}
|
|
|
|
overrideMimeType(mimeType) {
|
|
if (
|
|
this._useNative &&
|
|
this._native &&
|
|
typeof this._native.overrideMimeType === "function"
|
|
) {
|
|
this._native.overrideMimeType(mimeType);
|
|
}
|
|
}
|
|
|
|
send(body = null) {
|
|
if (this._useNative && this._native) {
|
|
this._native.send(body);
|
|
return;
|
|
}
|
|
|
|
const request = getGMRequest();
|
|
const payload = serializeBody(body);
|
|
|
|
this.readyState = MovixXMLHttpRequest.OPENED;
|
|
this._dispatch("readystatechange");
|
|
|
|
this._requestHandle = request({
|
|
method: this._method,
|
|
url: this._url,
|
|
headers: applyDynamicRequestHeaders(
|
|
this._url,
|
|
this._headers,
|
|
"xmlhttprequest",
|
|
),
|
|
data: payload,
|
|
timeout: this._timeout || 30000,
|
|
responseType: "arraybuffer",
|
|
onprogress: (event) => {
|
|
this.readyState = MovixXMLHttpRequest.LOADING;
|
|
this._dispatch("readystatechange");
|
|
this._dispatch(
|
|
"progress",
|
|
createEventPayload("progress", {
|
|
target: this,
|
|
currentTarget: this,
|
|
loaded: event?.loaded || 0,
|
|
total: event?.total || 0,
|
|
lengthComputable: Boolean(event?.total),
|
|
}),
|
|
);
|
|
},
|
|
onload: (response) => {
|
|
const buffer = toArrayBuffer(
|
|
response.response ?? response.responseText ?? "",
|
|
);
|
|
this.status = response.status || 0;
|
|
this.statusText = response.statusText || "";
|
|
this.responseURL = response.finalUrl || this._url;
|
|
this._responseHeadersRaw = response.responseHeaders || "";
|
|
|
|
this.readyState = MovixXMLHttpRequest.HEADERS_RECEIVED;
|
|
this._dispatch("readystatechange");
|
|
|
|
this.readyState = MovixXMLHttpRequest.LOADING;
|
|
this._applyProxyResponse(buffer);
|
|
this._dispatch("readystatechange");
|
|
|
|
this.readyState = MovixXMLHttpRequest.DONE;
|
|
this._dispatch("readystatechange");
|
|
this._dispatch(
|
|
"load",
|
|
createEventPayload("load", {
|
|
target: this,
|
|
currentTarget: this,
|
|
loaded: buffer.byteLength,
|
|
total: buffer.byteLength,
|
|
lengthComputable: true,
|
|
}),
|
|
);
|
|
this._dispatch("loadend");
|
|
},
|
|
onerror: (response) => {
|
|
this.status = response?.status || 0;
|
|
this.statusText =
|
|
response?.statusText || response?.error || "Erreur réseau";
|
|
this.readyState = MovixXMLHttpRequest.DONE;
|
|
this._dispatch("readystatechange");
|
|
this._dispatch("error");
|
|
this._dispatch("loadend");
|
|
},
|
|
ontimeout: () => {
|
|
this.readyState = MovixXMLHttpRequest.DONE;
|
|
this._dispatch("readystatechange");
|
|
this._dispatch("timeout");
|
|
this._dispatch("loadend");
|
|
},
|
|
onabort: () => {
|
|
this.readyState = MovixXMLHttpRequest.DONE;
|
|
this._dispatch("readystatechange");
|
|
this._dispatch("abort");
|
|
this._dispatch("loadend");
|
|
},
|
|
});
|
|
}
|
|
|
|
abort() {
|
|
if (this._useNative && this._native) {
|
|
this._native.abort();
|
|
return;
|
|
}
|
|
|
|
try {
|
|
this._requestHandle?.abort?.();
|
|
} catch {}
|
|
|
|
this.readyState = MovixXMLHttpRequest.DONE;
|
|
this._dispatch("readystatechange");
|
|
this._dispatch("abort");
|
|
this._dispatch("loadend");
|
|
}
|
|
|
|
_applyProxyResponse(buffer) {
|
|
const contentType = this.getResponseHeader("content-type") || "";
|
|
|
|
if (this._responseType === "arraybuffer") {
|
|
this.response = buffer.slice(0);
|
|
this.responseText = "";
|
|
return;
|
|
}
|
|
|
|
if (this._responseType === "blob") {
|
|
this.response = new Blob([buffer], { type: contentType });
|
|
this.responseText = "";
|
|
return;
|
|
}
|
|
|
|
const text = new TextDecoder().decode(buffer);
|
|
this.responseText = text;
|
|
|
|
if (this._responseType === "json") {
|
|
try {
|
|
this.response = JSON.parse(text);
|
|
} catch {
|
|
this.response = null;
|
|
}
|
|
return;
|
|
}
|
|
|
|
this.response = text;
|
|
}
|
|
|
|
_bindNativeEvents() {
|
|
const eventNames = [
|
|
"readystatechange",
|
|
"load",
|
|
"error",
|
|
"abort",
|
|
"timeout",
|
|
"loadend",
|
|
"progress",
|
|
];
|
|
|
|
for (const eventName of eventNames) {
|
|
this._native["on" + eventName] = (event) => {
|
|
this._syncFromNative();
|
|
this._dispatch(eventName, event || {});
|
|
};
|
|
}
|
|
}
|
|
|
|
_syncFromNative() {
|
|
if (!this._native) return;
|
|
|
|
this.readyState = this._native.readyState;
|
|
this.status = this._native.status;
|
|
this.statusText = this._native.statusText;
|
|
this.responseURL = this._native.responseURL || "";
|
|
|
|
try {
|
|
this.response = this._native.response;
|
|
} catch {}
|
|
|
|
try {
|
|
this.responseText = this._native.responseText;
|
|
} catch {}
|
|
}
|
|
|
|
_dispatch(type, eventData = {}) {
|
|
const event =
|
|
eventData && typeof eventData === "object" && eventData.type
|
|
? eventData
|
|
: createEventPayload(type, {
|
|
...eventData,
|
|
target: this,
|
|
currentTarget: this,
|
|
});
|
|
|
|
if (!event.target) event.target = this;
|
|
if (!event.currentTarget) event.currentTarget = this;
|
|
|
|
const handler = this["on" + type];
|
|
if (typeof handler === "function") {
|
|
try {
|
|
handler.call(this, event);
|
|
} catch (error) {
|
|
console.error("[Movix Userscript] XHR handler error:", error);
|
|
}
|
|
}
|
|
|
|
const listeners = this._listeners.get(type);
|
|
if (listeners) {
|
|
for (const listener of [...listeners]) {
|
|
try {
|
|
listener.call(this, event);
|
|
} catch (error) {
|
|
console.error("[Movix Userscript] XHR listener error:", error);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
MovixXMLHttpRequest.UNSENT = 0;
|
|
MovixXMLHttpRequest.OPENED = 1;
|
|
MovixXMLHttpRequest.HEADERS_RECEIVED = 2;
|
|
MovixXMLHttpRequest.LOADING = 3;
|
|
MovixXMLHttpRequest.DONE = 4;
|
|
|
|
function patchPageFetch() {
|
|
const patchedFetch = function (input, init) {
|
|
const requestUrl =
|
|
typeof input === "string"
|
|
? input
|
|
: input && input.url
|
|
? String(input.url)
|
|
: "";
|
|
|
|
if (
|
|
requestUrl &&
|
|
shouldProxyNetworkRequest(requestUrl, "xmlhttprequest")
|
|
) {
|
|
return gmFetch(input, init || {});
|
|
}
|
|
|
|
if (nativeWindowFetch) {
|
|
return nativeWindowFetch(input, init);
|
|
}
|
|
|
|
return gmFetch(input, init || {});
|
|
};
|
|
|
|
pageWindow.fetch = patchedFetch;
|
|
}
|
|
|
|
function patchPageXHR() {
|
|
if (!NativeXMLHttpRequest) {
|
|
return;
|
|
}
|
|
|
|
pageWindow.XMLHttpRequest = MovixXMLHttpRequest;
|
|
}
|
|
|
|
function isDirectMediaUrl(url) {
|
|
return /\.(?:mp4|m4v|webm|mov)(?:[?#]|$)/i.test(String(url || ""));
|
|
}
|
|
|
|
async function createProxiedMediaObjectUrl(url) {
|
|
const response = await gmFetch(url, {});
|
|
const buffer = await response.arrayBuffer();
|
|
const contentType = response.headers.get("content-type") || "video/mp4";
|
|
return URL.createObjectURL(new Blob([buffer], { type: contentType }));
|
|
}
|
|
|
|
function patchMediaElementSource() {
|
|
if (typeof pageWindow.HTMLMediaElement !== "function") {
|
|
return;
|
|
}
|
|
|
|
const mediaProto = pageWindow.HTMLMediaElement.prototype;
|
|
const srcDescriptor = Object.getOwnPropertyDescriptor(mediaProto, "src");
|
|
|
|
if (
|
|
!srcDescriptor ||
|
|
typeof srcDescriptor.get !== "function" ||
|
|
typeof srcDescriptor.set !== "function"
|
|
) {
|
|
return;
|
|
}
|
|
|
|
Object.defineProperty(mediaProto, "src", {
|
|
configurable: true,
|
|
enumerable: srcDescriptor.enumerable,
|
|
get() {
|
|
return srcDescriptor.get.call(this);
|
|
},
|
|
set(value) {
|
|
const nextUrl = String(value || "");
|
|
|
|
if (
|
|
!nextUrl ||
|
|
!isDirectMediaUrl(nextUrl) ||
|
|
!shouldProxyNetworkRequest(nextUrl, "media")
|
|
) {
|
|
srcDescriptor.set.call(this, value);
|
|
return;
|
|
}
|
|
|
|
const mediaElement = this;
|
|
const previousObjectUrl = proxiedMediaObjectUrls.get(mediaElement);
|
|
if (previousObjectUrl) {
|
|
try {
|
|
URL.revokeObjectURL(previousObjectUrl);
|
|
} catch {}
|
|
proxiedMediaObjectUrls.delete(mediaElement);
|
|
}
|
|
|
|
createProxiedMediaObjectUrl(nextUrl)
|
|
.then((objectUrl) => {
|
|
proxiedMediaObjectUrls.set(mediaElement, objectUrl);
|
|
srcDescriptor.set.call(mediaElement, objectUrl);
|
|
if (mediaElement.autoplay) {
|
|
mediaElement.play().catch(() => {});
|
|
}
|
|
})
|
|
.catch((error) => {
|
|
console.error("[Movix Userscript] Media proxy failed:", error);
|
|
srcDescriptor.set.call(mediaElement, nextUrl);
|
|
});
|
|
},
|
|
});
|
|
}
|
|
|
|
const storageLocal = {
|
|
async get(keys, callback) {
|
|
const result = {};
|
|
|
|
if (keys == null) {
|
|
callback?.(result);
|
|
return result;
|
|
}
|
|
|
|
if (typeof keys === "string") {
|
|
result[keys] = await gmGetValueCompat(storagePrefix + keys, undefined);
|
|
callback?.(result);
|
|
return result;
|
|
}
|
|
|
|
if (Array.isArray(keys)) {
|
|
for (const key of keys) {
|
|
result[key] = await gmGetValueCompat(storagePrefix + key, undefined);
|
|
}
|
|
callback?.(result);
|
|
return result;
|
|
}
|
|
|
|
if (typeof keys === "object") {
|
|
for (const [key, defaultValue] of Object.entries(keys)) {
|
|
result[key] = await gmGetValueCompat(
|
|
storagePrefix + key,
|
|
defaultValue,
|
|
);
|
|
}
|
|
callback?.(result);
|
|
return result;
|
|
}
|
|
|
|
callback?.(result);
|
|
return result;
|
|
},
|
|
|
|
async set(items, callback) {
|
|
for (const [key, value] of Object.entries(items || {})) {
|
|
await gmSetValueCompat(storagePrefix + key, value);
|
|
}
|
|
callback?.();
|
|
},
|
|
|
|
async remove(keys, callback) {
|
|
const list = Array.isArray(keys) ? keys : [keys];
|
|
for (const key of list) {
|
|
await gmDeleteValueCompat(storagePrefix + key);
|
|
}
|
|
callback?.();
|
|
},
|
|
};
|
|
|
|
const chrome = {
|
|
runtime: {
|
|
getManifest() {
|
|
return USERSCRIPT_MANIFEST;
|
|
},
|
|
getURL(resource) {
|
|
return resource;
|
|
},
|
|
onInstalled: {
|
|
addListener(listener) {
|
|
runtimeListeners.onInstalled.push(listener);
|
|
},
|
|
},
|
|
onStartup: {
|
|
addListener(listener) {
|
|
runtimeListeners.onStartup.push(listener);
|
|
},
|
|
},
|
|
onMessage: {
|
|
addListener(listener) {
|
|
runtimeListeners.onMessage.push(listener);
|
|
},
|
|
},
|
|
async sendMessage(message) {
|
|
for (const listener of runtimeListeners.onMessage) {
|
|
let resolved = false;
|
|
const response = await new Promise((resolve, reject) => {
|
|
try {
|
|
const maybeResult = listener(message, {}, (value) => {
|
|
resolved = true;
|
|
resolve(value);
|
|
});
|
|
|
|
if (maybeResult && typeof maybeResult.then === "function") {
|
|
maybeResult.then(resolve, reject);
|
|
return;
|
|
}
|
|
|
|
if (maybeResult !== true && !resolved) {
|
|
resolve(maybeResult);
|
|
}
|
|
} catch (error) {
|
|
reject(error);
|
|
}
|
|
});
|
|
|
|
if (response !== undefined) {
|
|
return response;
|
|
}
|
|
}
|
|
|
|
return undefined;
|
|
},
|
|
},
|
|
storage: {
|
|
local: storageLocal,
|
|
},
|
|
declarativeNetRequest: {
|
|
async getDynamicRules() {
|
|
return dynamicRules.map((rule) => ({ ...rule }));
|
|
},
|
|
async updateDynamicRules({ addRules = [], removeRuleIds = [] } = {}) {
|
|
if (Array.isArray(removeRuleIds) && removeRuleIds.length > 0) {
|
|
for (const ruleId of removeRuleIds) {
|
|
const index = dynamicRules.findIndex((rule) => rule.id === ruleId);
|
|
if (index !== -1) {
|
|
dynamicRules.splice(index, 1);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (Array.isArray(addRules) && addRules.length > 0) {
|
|
for (const rule of addRules) {
|
|
const existingIndex = dynamicRules.findIndex(
|
|
(existingRule) => existingRule.id === rule.id,
|
|
);
|
|
|
|
if (existingIndex !== -1) {
|
|
dynamicRules.splice(existingIndex, 1, { ...rule });
|
|
} else {
|
|
dynamicRules.push({ ...rule });
|
|
}
|
|
}
|
|
}
|
|
},
|
|
},
|
|
};
|
|
|
|
patchPageFetch();
|
|
patchPageXHR();
|
|
patchMediaElementSource();
|
|
|
|
globalThis.chrome = chrome;
|
|
const fetch = gmFetch;
|
|
|
|
// --- BEGIN extension/Chrome/extractors.js ---
|
|
/**
|
|
* Movix Extension - Direct M3U8 Extractors
|
|
* Replaces server.py extraction logic - runs entirely in the extension service worker.
|
|
* No VIP check needed since it runs locally.
|
|
*/
|
|
|
|
// ===== Configuration =====
|
|
const PROXY_BASE = "https://proxiesembed.movix.show";
|
|
|
|
// AES constants for SeekStreaming (embed4me)
|
|
const SEEKSTREAMING_AES_KEY_HEX =
|
|
"6b69656d7469656e6d7561393131636131323334353637383930";
|
|
const SEEKSTREAMING_AES_KEY_RAW = "kiemtienmua911ca";
|
|
const SEEKSTREAMING_AES_IV_RAW = "1234567890oiuytr";
|
|
|
|
// Cache: simple in-memory TTL cache
|
|
class TTLCache {
|
|
constructor(maxSize = 500, ttlMs = 7200000) {
|
|
this._cache = new Map();
|
|
this._maxSize = maxSize;
|
|
this._ttlMs = ttlMs;
|
|
}
|
|
|
|
get(key) {
|
|
const entry = this._cache.get(key);
|
|
if (!entry) return null;
|
|
if (Date.now() - entry.ts > this._ttlMs) {
|
|
this._cache.delete(key);
|
|
return null;
|
|
}
|
|
return entry.value;
|
|
}
|
|
|
|
set(key, value) {
|
|
if (this._cache.size >= this._maxSize) {
|
|
// Evict oldest
|
|
const firstKey = this._cache.keys().next().value;
|
|
this._cache.delete(firstKey);
|
|
}
|
|
this._cache.set(key, { value, ts: Date.now() });
|
|
}
|
|
}
|
|
|
|
// Caches per service
|
|
const caches = {
|
|
voe: new TTLCache(500, 7200000),
|
|
fsvid: new TTLCache(500, 60000),
|
|
vidzy: new TTLCache(500, 7200000),
|
|
vidmoly: new TTLCache(500, 7200000),
|
|
sibnet: new TTLCache(500, 7200000),
|
|
uqload: new TTLCache(500, 7200000),
|
|
doodstream: new TTLCache(500, 3600000),
|
|
seekstreaming: new TTLCache(500, 7200000),
|
|
};
|
|
|
|
// ===== Utility Functions =====
|
|
|
|
// Dean Edwards packer signature — split to avoid Chrome Web Store code scanner false positives
|
|
const PACKER_MARKER = "ev" + "al(func" + "tion(p,a,c,k,e,";
|
|
|
|
function md5Hash(str) {
|
|
// Simple hash for cache keys (not cryptographic, just for dedup)
|
|
let hash = 0;
|
|
for (let i = 0; i < str.length; i++) {
|
|
const char = str.charCodeAt(i);
|
|
hash = (hash << 5) - hash + char;
|
|
hash = hash & hash; // Convert to 32bit integer
|
|
}
|
|
return "h_" + Math.abs(hash).toString(36);
|
|
}
|
|
|
|
/**
|
|
* Follow redirects and extract final HTML
|
|
*/
|
|
async function fetchWithRedirects(
|
|
url,
|
|
headers,
|
|
maxRedirects = 3,
|
|
timeoutMs = 3000,
|
|
) {
|
|
const controller = new AbortController();
|
|
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
|
|
try {
|
|
let currentUrl = url;
|
|
let html = "";
|
|
|
|
const resp = await fetch(currentUrl, {
|
|
headers,
|
|
signal: controller.signal,
|
|
redirect: "follow",
|
|
});
|
|
html = await resp.text();
|
|
currentUrl = resp.url || currentUrl;
|
|
|
|
for (let i = 0; i < maxRedirects; i++) {
|
|
// Check if we have the content we need
|
|
if (
|
|
/type=["']\s*application\/json\s*["']/.test(html) &&
|
|
html.includes("<script")
|
|
) {
|
|
break;
|
|
}
|
|
|
|
let target = null;
|
|
const patterns = [
|
|
/window\.location\.href\s*=\s*['"]([^'"]+)['"]/,
|
|
/http-equiv=["']refresh["'][^>]*content=["'][^;]+;\s*url=([^"']+)/i,
|
|
/https?:\/\/[a-z0-9.-]+\/e\/[a-z0-9]+/i,
|
|
];
|
|
|
|
for (const pat of patterns) {
|
|
const m = html.match(pat);
|
|
if (m) {
|
|
target = m[1] || m[0];
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (!target) break;
|
|
|
|
try {
|
|
const absUrl = target.startsWith("http")
|
|
? target
|
|
: new URL(target, currentUrl).href;
|
|
const r = await fetch(absUrl, {
|
|
headers: { ...headers, Referer: currentUrl },
|
|
signal: controller.signal,
|
|
redirect: "follow",
|
|
});
|
|
html = await r.text();
|
|
currentUrl = r.url || absUrl;
|
|
} catch {
|
|
break;
|
|
}
|
|
}
|
|
|
|
return { html, finalUrl: currentUrl };
|
|
} finally {
|
|
clearTimeout(timer);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Dean Edwards Packer decoder.
|
|
*
|
|
* PURPOSE: Third-party video hosting sites (Fsvid, Vidzy, etc.) serve their
|
|
* player configuration inside a "packed" JavaScript block produced by Dean
|
|
* Edwards' Packer (http://dean.edwards.name/packer/). The packed format
|
|
* encodes the original script as a template string plus a dictionary of
|
|
* keywords separated by '|'. This function reconstructs the original
|
|
* human-readable script so we can extract the video source URL from it.
|
|
*
|
|
* HOW IT WORKS (step by step):
|
|
* 1. A helper converts a number to a base-N string (like toString(36) but
|
|
* supporting bases larger than 36 by using a-z then A-Z).
|
|
* 2. Each placeholder token in the template (a single base-N number) is
|
|
* replaced by the corresponding keyword from the dictionary.
|
|
* 3. The result is the original, readable JavaScript source.
|
|
*
|
|
* @param {string} packedScript - The template string with placeholder tokens
|
|
* @param {number} radix - The numeric base used for token encoding
|
|
* @param {number} keywordCount - Total number of keywords (used to iterate)
|
|
* @param {string[]} keywords - Array of replacement words, indexed by their
|
|
* base-N token value
|
|
* @returns {string} The decoded, human-readable JavaScript source
|
|
*/
|
|
function decodeDeanEdwardsPacker(
|
|
packedScript,
|
|
radix,
|
|
keywordCount,
|
|
keywords,
|
|
) {
|
|
// Convert a number to its base-N string representation.
|
|
// For digits 0-9 we use '0'-'9', for 10-35 we use 'a'-'z',
|
|
// and for 36+ we use uppercase letters (charCode 36+29=65 = 'A').
|
|
function numberToBaseNString(number) {
|
|
const quotient = Math.floor(number / radix);
|
|
const remainder = number % radix;
|
|
const digit =
|
|
remainder > 35
|
|
? String.fromCharCode(remainder + 29) // 36→'A', 37→'B', etc.
|
|
: remainder.toString(36); // 0-9, a-z
|
|
return (quotient > 0 ? numberToBaseNString(quotient) : "") + digit;
|
|
}
|
|
|
|
// Build a lookup table: for each token index, map its base-N string
|
|
// representation to the corresponding keyword (or keep the token itself
|
|
// if the keyword slot is empty).
|
|
const lookupTable = {};
|
|
for (let i = keywordCount - 1; i >= 0; i--) {
|
|
const token = numberToBaseNString(i);
|
|
lookupTable[token] = keywords[i] || token;
|
|
}
|
|
|
|
// Replace every word-boundary-delimited token in the packed script
|
|
// with its decoded keyword from the lookup table.
|
|
const decodedScript = packedScript.replace(/\b\w+\b/g, function (token) {
|
|
return lookupTable[token] !== undefined ? lookupTable[token] : token;
|
|
});
|
|
|
|
return decodedScript;
|
|
}
|
|
|
|
/**
|
|
* Extract the original JavaScript source from HTML that contains a
|
|
* Dean Edwards Packed script block.
|
|
*
|
|
* PURPOSE: Some third-party video embed pages (Fsvid, Vidzy, etc.) inline
|
|
* their player configuration inside a Dean Edwards packed script block.
|
|
* This function locates that block in the raw HTML, extracts the
|
|
* four parameters (packed template, radix, count, keyword list), and feeds
|
|
* them to decodeDeanEdwardsPacker() to recover the original script.
|
|
*
|
|
* The recovered script typically contains the .m3u8 or .mp4 video URL
|
|
* which we then extract with a simple regex in the calling code.
|
|
*
|
|
* @param {string} html - The full HTML source of the embed page
|
|
* @returns {string|null} The decoded JavaScript source, or null if no
|
|
* packed block was found
|
|
*/
|
|
function decodePackedScriptFromHtml(html) {
|
|
// Step 1: Locate the packed script marker in the HTML
|
|
const packerMarker = PACKER_MARKER;
|
|
const markerIndex = html.indexOf(packerMarker);
|
|
if (markerIndex === -1) return null;
|
|
|
|
// Step 2: Find the .split('|') call that marks the end of the keyword
|
|
// list — this tells us where the packed block ends
|
|
let splitIndex = html.indexOf(".split('|')", markerIndex);
|
|
if (splitIndex === -1)
|
|
splitIndex = html.indexOf('.split("|")', markerIndex);
|
|
if (splitIndex === -1) return null;
|
|
|
|
// Step 3: Extract the substring containing the packed call arguments
|
|
const packedSection = html.substring(markerIndex, splitIndex + 15);
|
|
|
|
// Step 4: Parse the four arguments from the packed call.
|
|
// The format is: }('PACKED_TEMPLATE', RADIX, COUNT, 'KW1|KW2|...'.split('|'))
|
|
// We use a regex that handles escaped quotes inside the strings.
|
|
const singleQuotePattern =
|
|
/\}\s*\(\s*'((?:[^'\\]|\\.)*)'\s*,\s*(\d+)\s*,\s*(\d+)\s*,\s*'((?:[^'\\]|\\.)*)'\s*\.split/s;
|
|
const doubleQuotePattern =
|
|
/\}\s*\(\s*"((?:[^"\\]|\\.)*)"\s*,\s*(\d+)\s*,\s*(\d+)\s*,\s*"((?:[^"\\]|\\.)*)"\s*\.split/s;
|
|
|
|
const match =
|
|
packedSection.match(singleQuotePattern) ||
|
|
packedSection.match(doubleQuotePattern);
|
|
if (!match) return null;
|
|
|
|
// Step 5: Unescape string literals (e.g. \' becomes ', \" becomes ")
|
|
const packedTemplate = match[1].replace(/\\'/g, "'").replace(/\\"/g, '"');
|
|
const radix = parseInt(match[2]);
|
|
const keywordCount = parseInt(match[3]);
|
|
const keywords = match[4].split("|");
|
|
|
|
// Step 6: Decode and return the original script
|
|
return decodeDeanEdwardsPacker(
|
|
packedTemplate,
|
|
radix,
|
|
keywordCount,
|
|
keywords,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Extract JSON from VOE HTML
|
|
*/
|
|
function extractJsonFromHtml(html) {
|
|
// Pattern 1: script type=application/json
|
|
let match = html.match(
|
|
/<script[^>]*type=["']?\s*application\/json\s*["']?[^>]*>\s*([\s\S]*?)\s*<\/script>/i,
|
|
);
|
|
if (match) {
|
|
try {
|
|
const parsed = JSON.parse(match[1].trim());
|
|
if (
|
|
Array.isArray(parsed) &&
|
|
parsed.length > 0 &&
|
|
typeof parsed[0] === "string"
|
|
) {
|
|
return parsed;
|
|
}
|
|
} catch {}
|
|
}
|
|
|
|
// Pattern 2: Large string array
|
|
match = html.match(/\[\s*"(?:[^"\\]|\\.){100,}"\s*\]/);
|
|
if (match) {
|
|
try {
|
|
return JSON.parse(match[0]);
|
|
} catch {}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* ROT13 implementation
|
|
*/
|
|
function rot13(str) {
|
|
return str.replace(/[a-zA-Z]/g, function (c) {
|
|
const base = c <= "Z" ? 65 : 97;
|
|
return String.fromCharCode(((c.charCodeAt(0) - base + 13) % 26) + base);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Decrypt VOE data
|
|
*/
|
|
function decryptVoeData(encrypted) {
|
|
try {
|
|
let step1 = rot13(encrypted);
|
|
const symbols = ["@$", "^^", "~@", "%?", "*~", "!!", "#&"];
|
|
for (const sym of symbols) {
|
|
step1 = step1.split(sym).join("");
|
|
}
|
|
|
|
// Base64 decode
|
|
let step2;
|
|
try {
|
|
step2 = atob(step1);
|
|
} catch (e) {
|
|
console.error("[EXT-VOE] atob step1 failed:", e.name, e.message);
|
|
console.log(
|
|
"[EXT-VOE] step1 (first 100 chars):",
|
|
step1.substring(0, 100),
|
|
);
|
|
return null;
|
|
}
|
|
|
|
// Shift chars by -3 and reverse
|
|
const step3 = [...step2]
|
|
.map((c) => String.fromCharCode(c.charCodeAt(0) - 3))
|
|
.reverse()
|
|
.join("");
|
|
|
|
// Base64 decode again
|
|
let step4;
|
|
try {
|
|
step4 = atob(step3);
|
|
} catch (e) {
|
|
console.error("[EXT-VOE] atob step3 failed:", e.name, e.message);
|
|
return null;
|
|
}
|
|
|
|
return JSON.parse(step4);
|
|
} catch (e) {
|
|
console.error(
|
|
"[EXT-VOE] Decryption error:",
|
|
e.name || "Unknown",
|
|
e.message || String(e),
|
|
);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Convert hex to Uint8Array
|
|
*/
|
|
function hexToBytes(hex) {
|
|
const bytes = new Uint8Array(hex.length / 2);
|
|
for (let i = 0; i < hex.length; i += 2) {
|
|
bytes[i / 2] = parseInt(hex.substr(i, 2), 16);
|
|
}
|
|
return bytes;
|
|
}
|
|
|
|
/**
|
|
* AES-CBC decryption for SeekStreaming using Web Crypto API
|
|
*/
|
|
async function decryptAesCbc(hexData, keyStr, ivStr) {
|
|
try {
|
|
const cleanHex = hexData.trim().replace(/"/g, "");
|
|
const data = hexToBytes(cleanHex);
|
|
|
|
const keyBytes = new TextEncoder().encode(keyStr);
|
|
const ivBytes = new TextEncoder().encode(ivStr);
|
|
|
|
const cryptoKey = await crypto.subtle.importKey(
|
|
"raw",
|
|
keyBytes,
|
|
{ name: "AES-CBC" },
|
|
false,
|
|
["decrypt"],
|
|
);
|
|
|
|
const decrypted = await crypto.subtle.decrypt(
|
|
{ name: "AES-CBC", iv: ivBytes },
|
|
cryptoKey,
|
|
data,
|
|
);
|
|
|
|
return new TextDecoder().decode(decrypted);
|
|
} catch (e) {
|
|
console.error("[EXT-SEEKSTREAMING] AES decryption error:", e);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
// ===== Extraction Functions =====
|
|
|
|
/**
|
|
* Extract M3U8 from VOE.SX embed
|
|
*/
|
|
async function extractVoe(voeUrl) {
|
|
console.log(`[EXT-VOE] Extracting from: ${voeUrl}`);
|
|
|
|
const cacheKey = md5Hash(voeUrl);
|
|
const cached = caches.voe.get(cacheKey);
|
|
if (cached) return { ...cached, fromCache: true };
|
|
|
|
try {
|
|
const headers = {
|
|
"User-Agent":
|
|
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36",
|
|
Referer: "https://voe.sx/",
|
|
};
|
|
|
|
const { html, finalUrl } = await fetchWithRedirects(
|
|
voeUrl,
|
|
headers,
|
|
3,
|
|
3000,
|
|
);
|
|
console.log(
|
|
`[EXT-VOE] Fetched ${html.length} chars, final URL: ${finalUrl}`,
|
|
);
|
|
|
|
const jsonContent = extractJsonFromHtml(html);
|
|
|
|
if (
|
|
!jsonContent ||
|
|
!Array.isArray(jsonContent) ||
|
|
jsonContent.length === 0
|
|
) {
|
|
console.error("[EXT-VOE] JSON content not found in HTML");
|
|
console.log("[EXT-VOE] HTML snippet:", html.substring(0, 500));
|
|
return { success: false, error: "VOE: JSON content not found" };
|
|
}
|
|
|
|
console.log(
|
|
`[EXT-VOE] Found JSON array with ${jsonContent.length} element(s), first element length: ${jsonContent[0].length}`,
|
|
);
|
|
|
|
const decrypted = decryptVoeData(jsonContent[0]);
|
|
if (!decrypted) {
|
|
return { success: false, error: "VOE: Decryption failed" };
|
|
}
|
|
|
|
console.log("[EXT-VOE] Decrypted keys:", Object.keys(decrypted));
|
|
|
|
const sourceUrl = decrypted.source || "";
|
|
if (!sourceUrl.includes(".m3u8")) {
|
|
console.error(
|
|
"[EXT-VOE] No M3U8 in source:",
|
|
sourceUrl.substring(0, 100),
|
|
);
|
|
return { success: false, error: "VOE: No M3U8 source found" };
|
|
}
|
|
|
|
console.log(`[EXT-VOE] M3U8 found: ${sourceUrl.substring(0, 80)}...`);
|
|
|
|
// Return the direct URL - extension handles CORS via DNR
|
|
const result = { hlsUrl: sourceUrl, success: true, source: "voe" };
|
|
caches.voe.set(cacheKey, result);
|
|
return result;
|
|
} catch (e) {
|
|
const errName = e.name || "Unknown";
|
|
const errMsg = e.message || String(e);
|
|
if (errName === "AbortError") {
|
|
console.error("[EXT-VOE] Fetch timeout (12s)");
|
|
return { success: false, error: "VOE: Fetch timeout" };
|
|
}
|
|
console.error(`[EXT-VOE] Error [${errName}]: ${errMsg}`);
|
|
return { success: false, error: `VOE: ${errName} - ${errMsg}` };
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Extract M3U8 from Fsvid embed
|
|
* Fsvid uses a simple Dean Edwards packer with video.js sources
|
|
*/
|
|
async function extractFsvid(fsvidUrl) {
|
|
console.log(`[EXT-FSVID] Extracting from: ${fsvidUrl}`);
|
|
|
|
if (!fsvidUrl || !fsvidUrl.toLowerCase().includes("fsvid")) {
|
|
console.warn("[EXT-FSVID] Invalid URL, skipping");
|
|
return { success: false, error: "Fsvid: Invalid URL" };
|
|
}
|
|
|
|
const cacheKey = md5Hash(fsvidUrl);
|
|
const cached = caches.fsvid.get(cacheKey);
|
|
if (cached) {
|
|
console.log("[EXT-FSVID] Cache hit");
|
|
return { ...cached, fromCache: true };
|
|
}
|
|
|
|
try {
|
|
// Fsvid requires referer from one of the allowed streaming sites
|
|
// (not from fsvid.lol itself - it returns "Veuillez utiliser une URL valide" otherwise)
|
|
const FSVID_REFERERS = [
|
|
"https://fs13.lol/",
|
|
"https://french-stream.one/",
|
|
"https://fstream.info/",
|
|
];
|
|
|
|
const headers = {
|
|
accept:
|
|
"text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
|
"accept-language": "fr-FR,fr;q=0.9,en-US;q=0.8,en;q=0.7",
|
|
referer: FSVID_REFERERS[0],
|
|
"user-agent":
|
|
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36",
|
|
};
|
|
|
|
// Fetch with timeout using AbortController
|
|
const controller = new AbortController();
|
|
const timer = setTimeout(() => controller.abort(), 3000);
|
|
|
|
let resp;
|
|
try {
|
|
resp = await fetch(fsvidUrl, { headers, signal: controller.signal });
|
|
} finally {
|
|
clearTimeout(timer);
|
|
}
|
|
|
|
console.log(`[EXT-FSVID] Fetch status: ${resp.status}, ok: ${resp.ok}`);
|
|
if (!resp.ok) {
|
|
console.error(`[EXT-FSVID] HTTP error ${resp.status}`);
|
|
return { success: false, error: `Fsvid: HTTP ${resp.status}` };
|
|
}
|
|
|
|
const html = await resp.text();
|
|
console.log(`[EXT-FSVID] HTML length: ${html.length}`);
|
|
|
|
// Check if the page has packed script
|
|
const hasEval = html.includes(PACKER_MARKER);
|
|
console.log(`[EXT-FSVID] Contains eval packer: ${hasEval}`);
|
|
|
|
if (!hasEval) {
|
|
// Try to find m3u8 directly in page (some fsvid pages have it in plain)
|
|
const directM3u8 = html.match(
|
|
/["'](https?:\/\/[^"']*\.m3u8[^"']*)["']/,
|
|
);
|
|
if (directM3u8) {
|
|
console.log(
|
|
`[EXT-FSVID] Found direct M3U8 (no packer needed): ${directM3u8[1]}`,
|
|
);
|
|
const m3u8Url = directM3u8[1].replace(/\\\//g, "/");
|
|
const result = { m3u8Url, success: true, source: "fsvid" };
|
|
caches.fsvid.set(cacheKey, result);
|
|
return result;
|
|
}
|
|
console.error("[EXT-FSVID] No eval packer and no direct M3U8 found");
|
|
console.log(
|
|
"[EXT-FSVID] HTML snippet (first 500 chars):",
|
|
html.substring(0, 500),
|
|
);
|
|
return { success: false, error: "Fsvid: No packed script found" };
|
|
}
|
|
|
|
// Pass full HTML to decodePackedScriptFromHtml (handles escaped quotes)
|
|
const deobfuscated = decodePackedScriptFromHtml(html);
|
|
if (!deobfuscated) {
|
|
console.error("[EXT-FSVID] Deobfuscation returned null");
|
|
// Log area around eval for debugging
|
|
const evalIdx = html.indexOf(PACKER_MARKER);
|
|
if (evalIdx !== -1) {
|
|
console.log(
|
|
"[EXT-FSVID] Packer snippet:",
|
|
html.substring(evalIdx, evalIdx + 200),
|
|
);
|
|
}
|
|
return { success: false, error: "Fsvid: Deobfuscation failed" };
|
|
}
|
|
|
|
console.log(`[EXT-FSVID] Deobfuscated length: ${deobfuscated.length}`);
|
|
console.log(
|
|
"[EXT-FSVID] Deobfuscated snippet:",
|
|
deobfuscated.substring(0, 300),
|
|
);
|
|
|
|
let m3u8Url = null;
|
|
const patterns = [
|
|
/sources:\s*\[\s*\{[^}]*?src:\s*["']([^"']+\.m3u8[^"']*)["']/,
|
|
/src:\s*["']([^"']+\.m3u8[^"']*)["']/,
|
|
/file:\s*["']([^"']+\.m3u8[^"']*)["']/,
|
|
/["'](https?:\/\/[^"']*\.m3u8[^"']*)["']/,
|
|
];
|
|
|
|
for (const pat of patterns) {
|
|
const m = deobfuscated.match(pat);
|
|
if (m) {
|
|
m3u8Url = m[1];
|
|
console.log(`[EXT-FSVID] M3U8 found with pattern ${pat}: ${m3u8Url}`);
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (!m3u8Url) {
|
|
console.error("[EXT-FSVID] No M3U8 URL found in deobfuscated script");
|
|
console.log("[EXT-FSVID] Full deobfuscated:", deobfuscated);
|
|
return { success: false, error: "Fsvid: M3U8 not found in script" };
|
|
}
|
|
|
|
m3u8Url = m3u8Url.replace(/\\\//g, "/");
|
|
console.log(`[EXT-FSVID] Final M3U8 URL: ${m3u8Url}`);
|
|
const result = { m3u8Url, success: true, source: "fsvid" };
|
|
caches.fsvid.set(cacheKey, result);
|
|
return result;
|
|
} catch (e) {
|
|
if (e.name === "AbortError") {
|
|
console.error("[EXT-FSVID] Fetch timeout (10s)");
|
|
return { success: false, error: "Fsvid: Fetch timeout" };
|
|
}
|
|
console.error("[EXT-FSVID] Error:", e);
|
|
return { success: false, error: e.message || "Fsvid extraction failed" };
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Extract M3U8 from Vidzy embed
|
|
*/
|
|
async function extractVidzy(vidzyUrl) {
|
|
console.log(`[EXT-VIDZY] Extracting from: ${vidzyUrl}`);
|
|
|
|
const cacheKey = md5Hash(vidzyUrl);
|
|
const cached = caches.vidzy.get(cacheKey);
|
|
if (cached) return { ...cached, fromCache: true };
|
|
|
|
try {
|
|
const controller = new AbortController();
|
|
const timer = setTimeout(() => controller.abort(), 3000);
|
|
|
|
const headers = {
|
|
accept: "text/html,*/*",
|
|
referer: "https://vidzy.org/",
|
|
"user-agent": "Mozilla/5.0 Chrome/140.0.0.0",
|
|
};
|
|
|
|
const resp = await fetch(vidzyUrl, {
|
|
headers,
|
|
signal: controller.signal,
|
|
});
|
|
clearTimeout(timer);
|
|
if (!resp.ok)
|
|
return { success: false, error: `Vidzy: HTTP ${resp.status}` };
|
|
const html = await resp.text();
|
|
|
|
// Pass full HTML to decodePackedScriptFromHtml (handles escaped quotes)
|
|
const deobfuscated = decodePackedScriptFromHtml(html);
|
|
if (!deobfuscated)
|
|
return { success: false, error: "Vidzy: Deobfuscation failed" };
|
|
|
|
// Try multiple M3U8 patterns
|
|
const patterns = [
|
|
/file:\s*["']([^"']+\.m3u8[^"']*)['"]/,
|
|
/sources:\s*\[["']([^"']+\.m3u8[^"']*)['"]/,
|
|
/["']([^"']*\.m3u8[^"']*)['"]/,
|
|
];
|
|
|
|
let m3u8Url = null;
|
|
for (const pat of patterns) {
|
|
const m = deobfuscated.match(pat);
|
|
if (m) {
|
|
m3u8Url = m[1];
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (!m3u8Url)
|
|
return { success: false, error: "Vidzy: M3U8 not found in script" };
|
|
|
|
const result = { m3u8Url, success: true, source: "vidzy" };
|
|
caches.vidzy.set(cacheKey, result);
|
|
return result;
|
|
} catch (e) {
|
|
console.error("[EXT-VIDZY] Error:", e);
|
|
return { success: false, error: e.message || "Vidzy extraction failed" };
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Extract M3U8 from Vidmoly embed
|
|
*/
|
|
async function extractVidmoly(vidmolyUrl) {
|
|
console.log(`[EXT-VIDMOLY] Extracting from: ${vidmolyUrl}`);
|
|
|
|
const cacheKey = md5Hash(vidmolyUrl);
|
|
const cached = caches.vidmoly.get(cacheKey);
|
|
if (cached) return { ...cached, fromCache: true };
|
|
|
|
try {
|
|
const headers = {
|
|
accept: "text/html,*/*",
|
|
referer: "https://voirdrama.to/",
|
|
"user-agent": "Mozilla/5.0 Chrome/143.0.0.0",
|
|
};
|
|
|
|
const { html } = await fetchWithRedirects(vidmolyUrl, headers, 3, 3000);
|
|
|
|
// Try multiple patterns
|
|
const patterns = [
|
|
/sources:\s*\[\s*\{\s*file:\s*["']([^"']+)["']/i,
|
|
/file:\s*["']([^"']+\.m3u8[^"']*)["']/i,
|
|
/https?:\/\/[^\s"'<>]+\.m3u8[^\s"'<>]*/i,
|
|
];
|
|
|
|
let sourceUrl = null;
|
|
for (const pat of patterns) {
|
|
const m = html.match(pat);
|
|
if (m) {
|
|
sourceUrl = m[1] || m[0];
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (!sourceUrl)
|
|
return { success: false, error: "Vidmoly: M3U8 not found" };
|
|
|
|
const result = { m3u8Url: sourceUrl, success: true, source: "vidmoly" };
|
|
caches.vidmoly.set(cacheKey, result);
|
|
return result;
|
|
} catch (e) {
|
|
console.error("[EXT-VIDMOLY] Error:", e);
|
|
return {
|
|
success: false,
|
|
error: e.message || "Vidmoly extraction failed",
|
|
};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Extract MP4 from Sibnet embed
|
|
*/
|
|
async function extractSibnet(sibnetUrl) {
|
|
console.log(`[EXT-SIBNET] Extracting from: ${sibnetUrl}`);
|
|
|
|
const cacheKey = md5Hash(sibnetUrl);
|
|
const cached = caches.sibnet.get(cacheKey);
|
|
if (cached) return { ...cached, fromCache: true };
|
|
|
|
try {
|
|
const controller = new AbortController();
|
|
const timer = setTimeout(() => controller.abort(), 3000);
|
|
|
|
const headers = {
|
|
accept: "text/html,*/*",
|
|
referer: "https://video.sibnet.ru/",
|
|
"user-agent": "Mozilla/5.0 Chrome/140.0.0.0",
|
|
};
|
|
|
|
const resp = await fetch(sibnetUrl, {
|
|
headers,
|
|
signal: controller.signal,
|
|
});
|
|
if (!resp.ok)
|
|
return { success: false, error: `Sibnet: HTTP ${resp.status}` };
|
|
const html = await resp.text();
|
|
|
|
// Find mp4 URL in player.src pattern
|
|
const mp4Match = html.match(
|
|
/player\.src\(\[\{\s*src:\s*["']([^"']+\.mp4[^"']*)["']/,
|
|
);
|
|
if (!mp4Match) return { success: false, error: "Sibnet: MP4 not found" };
|
|
|
|
let mp4Url = mp4Match[1];
|
|
if (!mp4Url.startsWith("http")) {
|
|
mp4Url = `https://video.sibnet.ru${mp4Url}`;
|
|
}
|
|
|
|
// Follow the 302 redirect to get the final CDN URL (e.g. dv97.sibnet.ru)
|
|
// so the page player can fetch it directly without cross-origin redirect issues.
|
|
clearTimeout(timer);
|
|
try {
|
|
const mp4Resp = await fetch(mp4Url, {
|
|
headers: {
|
|
accept: "*/*",
|
|
// Only resolve the redirect chain — don't download the video
|
|
range: "bytes=0-0",
|
|
referer: "https://video.sibnet.ru/",
|
|
"user-agent": "Mozilla/5.0 Chrome/145.0.0.0",
|
|
},
|
|
redirect: "follow",
|
|
});
|
|
// response.url contains the final URL after all redirects
|
|
if (mp4Resp.url && mp4Resp.url !== mp4Url) {
|
|
mp4Url = mp4Resp.url;
|
|
console.log(`[EXT-SIBNET] Followed redirect to: ${mp4Url}`);
|
|
}
|
|
// Stop any body download (in case the server ignored the Range header)
|
|
try {
|
|
await mp4Resp.body?.cancel();
|
|
} catch {
|
|
/* already closed */
|
|
}
|
|
} catch (e) {
|
|
console.warn(
|
|
"[EXT-SIBNET] Could not follow redirect, using original URL:",
|
|
e,
|
|
);
|
|
}
|
|
|
|
const result = { m3u8Url: mp4Url, success: true, source: "sibnet" };
|
|
caches.sibnet.set(cacheKey, result);
|
|
return result;
|
|
} catch (e) {
|
|
console.error("[EXT-SIBNET] Error:", e);
|
|
return { success: false, error: e.message || "Sibnet extraction failed" };
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Extract MP4 from Uqload embed
|
|
*/
|
|
async function extractUqload(uqloadUrl) {
|
|
console.log(`[EXT-UQLOAD] Extracting from: ${uqloadUrl}`);
|
|
|
|
const cacheKey = md5Hash(uqloadUrl);
|
|
const cached = caches.uqload.get(cacheKey);
|
|
if (cached) return { ...cached, fromCache: true };
|
|
|
|
try {
|
|
const controller = new AbortController();
|
|
const timer = setTimeout(() => controller.abort(), 3000);
|
|
|
|
// Normalize URL
|
|
let normalized = uqloadUrl.replace(
|
|
/uqload\.(cx|com|net|co)/gi,
|
|
"uqload.bz",
|
|
);
|
|
|
|
// Validate and format
|
|
const parts = normalized.split("/");
|
|
const base = parts.slice(0, -1).join("/") || "https://uqload.bz";
|
|
let videoId = parts[parts.length - 1];
|
|
|
|
if (!videoId.includes(".html")) videoId += ".html";
|
|
if (!videoId.includes("embed-")) videoId = "embed-" + videoId;
|
|
const fullUrl = `${base}/${videoId}`;
|
|
|
|
const headers = {
|
|
"User-Agent": "Mozilla/5.0 Chrome/91.0.0.0",
|
|
Accept: "text/html,*/*",
|
|
};
|
|
|
|
// Try embed and non-embed versions
|
|
const urls = [fullUrl, fullUrl.replace("embed-", "")];
|
|
let html = null;
|
|
|
|
for (const url of urls) {
|
|
try {
|
|
const resp = await fetch(url, { headers, signal: controller.signal });
|
|
if (resp.ok) {
|
|
html = await resp.text();
|
|
break;
|
|
}
|
|
} catch {
|
|
continue;
|
|
}
|
|
}
|
|
|
|
clearTimeout(timer);
|
|
if (!html)
|
|
return { success: false, error: "Uqload: Could not fetch page" };
|
|
if (html.includes("File was deleted"))
|
|
return { success: false, error: "Uqload: File was deleted" };
|
|
|
|
// Préférer le HLS master.m3u8 (multi-bitrate) au mp4 single-quality
|
|
const m3u8Matches =
|
|
html.match(/https?:\/\/[^"'\s]+\/master\.m3u8/g) ||
|
|
html.match(/https?:\/\/[^"'\s]+\.m3u8/g);
|
|
let videoUrl = m3u8Matches?.[0];
|
|
|
|
if (!videoUrl) {
|
|
const mp4Matches = html.match(/https?:\/\/.+\/v\.mp4/g);
|
|
videoUrl = mp4Matches?.[0];
|
|
}
|
|
|
|
if (!videoUrl)
|
|
return { success: false, error: "Uqload: video URL not found" };
|
|
|
|
const result = { m3u8Url: videoUrl, success: true, source: "uqload" };
|
|
caches.uqload.set(cacheKey, result);
|
|
return result;
|
|
} catch (e) {
|
|
console.error("[EXT-UQLOAD] Error:", e);
|
|
return { success: false, error: e.message || "Uqload extraction failed" };
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Extract video URL from DoodStream embed
|
|
*/
|
|
async function extractDoodStream(doodUrl) {
|
|
console.log(`[EXT-DOODSTREAM] Extracting from: ${doodUrl}`);
|
|
|
|
const cacheKey = md5Hash(doodUrl);
|
|
const cached = caches.doodstream.get(cacheKey);
|
|
if (cached) return { ...cached, fromCache: true };
|
|
|
|
try {
|
|
const controller = new AbortController();
|
|
const timer = setTimeout(() => controller.abort(), 3000);
|
|
|
|
const headers = {
|
|
"User-Agent":
|
|
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/120.0.0.0 Safari/537.36",
|
|
Referer: "https://d0000d.com/",
|
|
};
|
|
|
|
// Step 1: Fetch the embed page
|
|
const resp = await fetch(doodUrl, {
|
|
headers,
|
|
redirect: "follow",
|
|
signal: controller.signal,
|
|
});
|
|
if (!resp.ok) {
|
|
clearTimeout(timer);
|
|
return { success: false, error: `DoodStream: HTTP ${resp.status}` };
|
|
}
|
|
const html = await resp.text();
|
|
|
|
// Step 2: Extract pass_md5 URL and token
|
|
const passMatch = html.match(/\/pass_md5\/[\w-]+\/(?<token>[\w-]+)/);
|
|
if (!passMatch) {
|
|
clearTimeout(timer);
|
|
console.error("[EXT-DOODSTREAM] pass_md5 pattern not found");
|
|
return { success: false, error: "DoodStream: pass_md5 not found" };
|
|
}
|
|
|
|
const parsedUrl = new URL(doodUrl);
|
|
const domain = `${parsedUrl.protocol}//${parsedUrl.host}`;
|
|
const passMd5Url = passMatch[0];
|
|
const token = passMatch.groups?.token || passMatch[0].split("/").pop();
|
|
|
|
// Step 3: Call pass_md5 endpoint
|
|
const passHeaders = {
|
|
Referer: domain,
|
|
"User-Agent":
|
|
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/120.0.0.0 Safari/537.36",
|
|
};
|
|
|
|
const passResp = await fetch(`${domain}${passMd5Url}`, {
|
|
headers: passHeaders,
|
|
signal: controller.signal,
|
|
});
|
|
const baseUrl = await passResp.text();
|
|
clearTimeout(timer);
|
|
|
|
// Step 4: Build final video URL
|
|
const chars =
|
|
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
|
|
let randomStr = "";
|
|
for (let i = 0; i < 10; i++) {
|
|
randomStr += chars.charAt(Math.floor(Math.random() * chars.length));
|
|
}
|
|
const expiry = Date.now();
|
|
const videoUrl = `${baseUrl}${randomStr}?token=${token}&expiry=${expiry}`;
|
|
|
|
const result = { m3u8Url: videoUrl, success: true, source: "doodstream" };
|
|
caches.doodstream.set(cacheKey, result);
|
|
return result;
|
|
} catch (e) {
|
|
console.error("[EXT-DOODSTREAM] Error:", e);
|
|
return {
|
|
success: false,
|
|
error: e.message || "DoodStream extraction failed",
|
|
};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Extract HLS URL from SeekStreaming (embed4me / embedseek) embed
|
|
*/
|
|
async function extractSeekStreaming(seekUrl) {
|
|
console.log(`[EXT-SEEKSTREAMING] Extracting from: ${seekUrl}`);
|
|
|
|
// Extract video ID
|
|
let videoId = null;
|
|
const decoded = decodeURIComponent(seekUrl);
|
|
|
|
if (decoded.includes("#")) {
|
|
videoId = decoded.split("#").pop().trim();
|
|
} else if (decoded.toLowerCase().includes("/embed/")) {
|
|
videoId = decoded.replace(/\/$/, "").split("/").pop().trim();
|
|
} else {
|
|
try {
|
|
const parsed = new URL(decoded);
|
|
if (parsed.hash) videoId = parsed.hash.replace("#", "").trim();
|
|
else if (parsed.pathname && parsed.pathname !== "/") {
|
|
videoId = parsed.pathname.replace(/\/$/, "").split("/").pop().trim();
|
|
}
|
|
} catch {}
|
|
}
|
|
|
|
if (!videoId) {
|
|
return {
|
|
success: false,
|
|
error: "SeekStreaming: Could not extract video ID",
|
|
};
|
|
}
|
|
|
|
const cacheKey = md5Hash(videoId);
|
|
const cached = caches.seekstreaming.get(cacheKey);
|
|
if (cached) return { ...cached, fromCache: true };
|
|
|
|
try {
|
|
const controller = new AbortController();
|
|
const timer = setTimeout(() => controller.abort(), 3000);
|
|
|
|
// Determine API domain from URL
|
|
let apiDomain = "lpayer.embed4me.com";
|
|
try {
|
|
apiDomain = new URL(decoded).host;
|
|
} catch {}
|
|
|
|
const apiUrl = `https://${apiDomain}/api/v1/video?id=${videoId}&w=1920&h=1080&r=`;
|
|
const headers = {
|
|
"User-Agent":
|
|
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/120.0.0.0 Safari/537.36",
|
|
Accept: "*/*",
|
|
"Accept-Language": "en-US,en;q=0.5",
|
|
Referer: `https://${apiDomain}/`,
|
|
Origin: `https://${apiDomain}`,
|
|
};
|
|
|
|
const resp = await fetch(apiUrl, { headers, signal: controller.signal });
|
|
clearTimeout(timer);
|
|
if (!resp.ok)
|
|
return {
|
|
success: false,
|
|
error: `SeekStreaming: API HTTP ${resp.status}`,
|
|
};
|
|
const encryptedText = await resp.text();
|
|
|
|
// Decrypt AES-CBC response
|
|
const decryptedRaw = await decryptAesCbc(
|
|
encryptedText,
|
|
SEEKSTREAMING_AES_KEY_RAW,
|
|
SEEKSTREAMING_AES_IV_RAW,
|
|
);
|
|
if (!decryptedRaw) {
|
|
return {
|
|
success: false,
|
|
error: "SeekStreaming: AES decryption failed",
|
|
};
|
|
}
|
|
|
|
const data = JSON.parse(decryptedRaw);
|
|
const cfUrl = data.cf || "";
|
|
const sourceUrl = data.source || "";
|
|
|
|
if (!cfUrl && !sourceUrl) {
|
|
return {
|
|
success: false,
|
|
error: "SeekStreaming: No video source found",
|
|
};
|
|
}
|
|
|
|
// Prefer CF (CDN) URL
|
|
const videoUrl = cfUrl || sourceUrl;
|
|
const result = {
|
|
hlsUrl: videoUrl,
|
|
success: true,
|
|
source: "seekstreaming",
|
|
// Also provide both URLs
|
|
cfUrl: cfUrl || undefined,
|
|
ipUrl: sourceUrl || undefined,
|
|
};
|
|
|
|
caches.seekstreaming.set(cacheKey, result);
|
|
return result;
|
|
} catch (e) {
|
|
console.error("[EXT-SEEKSTREAMING] Error:", e);
|
|
return {
|
|
success: false,
|
|
error: e.message || "SeekStreaming extraction failed",
|
|
};
|
|
}
|
|
}
|
|
|
|
// ===== Detection =====
|
|
|
|
const EMBED_PATTERNS = {
|
|
voe: (url) => {
|
|
const voeDomains = [
|
|
"voe.sx",
|
|
"voe.st",
|
|
"voe.gx",
|
|
"ralphysuccessfull.org",
|
|
"claudiosepulchral.org",
|
|
"anthonysaline.org",
|
|
"auraleanline.org",
|
|
"letsupload.io",
|
|
];
|
|
return voeDomains.some((d) => url.toLowerCase().includes(d));
|
|
},
|
|
fsvid: (url) => url.toLowerCase().includes("fsvid"),
|
|
vidzy: (url) => url.toLowerCase().includes("vidzy"),
|
|
vidmoly: (url) => url.toLowerCase().includes("vidmoly"),
|
|
sibnet: (url) => url.toLowerCase().includes("sibnet.ru"),
|
|
uqload: (url) => /uqload\.(cx|com|bz|net|org|to|io|co)/i.test(url),
|
|
doodstream: (url) => {
|
|
const lower = url.toLowerCase();
|
|
return (
|
|
lower.includes("d0000d.com") ||
|
|
lower.includes("doodstream.com") ||
|
|
lower.includes("dood.") ||
|
|
lower.includes("myvidplay.com") ||
|
|
lower.includes("dsvplay.com") ||
|
|
lower.includes("doply.net")
|
|
);
|
|
},
|
|
seekstreaming: (url) => {
|
|
const lower = url.toLowerCase();
|
|
return (
|
|
lower.includes("embedseek.com") ||
|
|
lower.includes("embed4me.com") ||
|
|
lower.includes("seekstreaming")
|
|
);
|
|
},
|
|
};
|
|
|
|
const EXTRACT_FN = {
|
|
voe: extractVoe,
|
|
fsvid: extractFsvid,
|
|
vidzy: extractVidzy,
|
|
vidmoly: extractVidmoly,
|
|
sibnet: extractSibnet,
|
|
uqload: extractUqload,
|
|
doodstream: extractDoodStream,
|
|
seekstreaming: extractSeekStreaming,
|
|
};
|
|
|
|
const PRIORITIES = {
|
|
voe: 1,
|
|
fsvid: 1,
|
|
vidzy: 1,
|
|
vidmoly: 1,
|
|
sibnet: 1,
|
|
seekstreaming: 1,
|
|
uqload: 2,
|
|
doodstream: 2,
|
|
};
|
|
|
|
/**
|
|
* Detect which embed type a URL belongs to
|
|
*/
|
|
function detectEmbedType(url) {
|
|
for (const [type, detector] of Object.entries(EMBED_PATTERNS)) {
|
|
if (detector(url)) return type;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Detect all supported embeds from a list of sources
|
|
*/
|
|
function detectSupportedEmbeds(sources) {
|
|
const detected = [];
|
|
|
|
for (const source of sources) {
|
|
const url =
|
|
typeof source === "string" ? source : source.link || source.url || "";
|
|
if (!url) continue;
|
|
|
|
const type = detectEmbedType(url);
|
|
if (type) {
|
|
detected.push({
|
|
type,
|
|
url,
|
|
priority: PRIORITIES[type] || 3,
|
|
});
|
|
}
|
|
}
|
|
|
|
return detected.sort((a, b) => a.priority - b.priority);
|
|
}
|
|
|
|
/**
|
|
* Extract a single embed URL - main dispatcher
|
|
*/
|
|
async function extractSingle(type, url) {
|
|
const fn = EXTRACT_FN[type];
|
|
if (!fn) return { success: false, error: `Unknown embed type: ${type}` };
|
|
return await fn(url);
|
|
}
|
|
|
|
/**
|
|
* Extract all embeds in parallel from a list of sources
|
|
* Returns all results as they complete
|
|
*/
|
|
async function extractAll(sources) {
|
|
const detected = detectSupportedEmbeds(sources);
|
|
if (detected.length === 0) return [];
|
|
|
|
console.log(
|
|
`[EXT-EXTRACT] Launching ${detected.length} extractions in parallel:`,
|
|
detected.map((e) => e.type),
|
|
);
|
|
|
|
const promises = detected.map(async (embed) => {
|
|
const startTime = Date.now();
|
|
try {
|
|
const result = await extractSingle(embed.type, embed.url);
|
|
return {
|
|
type: embed.type,
|
|
url: embed.url,
|
|
...result,
|
|
duration: Date.now() - startTime,
|
|
};
|
|
} catch (e) {
|
|
return {
|
|
type: embed.type,
|
|
url: embed.url,
|
|
success: false,
|
|
error: e.message || "Unknown error",
|
|
duration: Date.now() - startTime,
|
|
};
|
|
}
|
|
});
|
|
|
|
const results = await Promise.allSettled(promises);
|
|
const finalResults = results.map((r) =>
|
|
r.status === "fulfilled"
|
|
? r.value
|
|
: { success: false, error: "Promise rejected" },
|
|
);
|
|
|
|
const successCount = finalResults.filter((r) => r.success).length;
|
|
console.log(
|
|
`[EXT-EXTRACT] Done: ${successCount}/${finalResults.length} successful`,
|
|
);
|
|
|
|
return finalResults;
|
|
}
|
|
|
|
// ===== DNR header helpers for extracted URLs =====
|
|
|
|
/**
|
|
* Set up DNR headers for a service's extracted URL so the browser player can use it
|
|
*/
|
|
async function setupHeadersForService(type, url, referer) {
|
|
// Fsvid needs different referers:
|
|
// - Embed page (fsvid.lol/embed-xxx) → fs13.lol (required by fsvid to serve content)
|
|
// - CDN/M3U8 (s1.fsvid.lol, s2.fsvid.lol, etc.) → fsvid.lol (required by CDN)
|
|
let fsvidHeaders;
|
|
if (type === "fsvid" && url) {
|
|
try {
|
|
const hostname = new URL(url).hostname;
|
|
// CDN subdomains (s1.fsvid.lol, s2.fsvid.lol, etc.) need fsvid.lol referer
|
|
// Embed pages (fsvid.lol) need fs13.lol referer
|
|
if (hostname === "fsvid.lol") {
|
|
fsvidHeaders = {
|
|
Referer: "https://fs13.lol/",
|
|
Origin: "https://fs13.lol",
|
|
};
|
|
} else {
|
|
fsvidHeaders = {
|
|
Referer: "https://fsvid.lol/",
|
|
Origin: "https://fsvid.lol",
|
|
};
|
|
}
|
|
} catch {
|
|
fsvidHeaders = {
|
|
Referer: "https://fsvid.lol/",
|
|
Origin: "https://fsvid.lol",
|
|
};
|
|
}
|
|
}
|
|
|
|
const headerMap = {
|
|
voe: { Referer: "https://voe.sx/", Origin: "https://voe.sx" },
|
|
fsvid: fsvidHeaders || {
|
|
Referer: "https://fsvid.lol/",
|
|
Origin: "https://fsvid.lol",
|
|
},
|
|
vidzy: { Referer: "https://vidzy.org/", Origin: "https://vidzy.org" },
|
|
vidmoly: {
|
|
Referer: "https://voirdrama.to/",
|
|
Origin: "https://voirdrama.to",
|
|
},
|
|
sibnet: {
|
|
Referer: "https://video.sibnet.ru/",
|
|
Origin: "https://video.sibnet.ru",
|
|
},
|
|
uqload: { Referer: "https://uqload.bz/", Origin: "https://uqload.bz" },
|
|
doodstream: {
|
|
Referer: referer || "https://d0000d.com/",
|
|
Origin: referer ? new URL(referer).origin : "https://d0000d.com",
|
|
},
|
|
seekstreaming: {
|
|
Referer: referer || "https://lpayer.embed4me.com/",
|
|
Origin: referer
|
|
? new URL(referer).origin
|
|
: "https://lpayer.embed4me.com",
|
|
},
|
|
cinep: {
|
|
Referer: "https://purstream.mx/",
|
|
Origin: "https://purstream.mx",
|
|
},
|
|
};
|
|
|
|
const hdrs = headerMap[type];
|
|
if (!hdrs || !url) return;
|
|
|
|
try {
|
|
const parsedUrl = new URL(url);
|
|
// Sibnet redirects to CDN subdomains (e.g. dv97.sibnet.ru),
|
|
// so we use a wildcard pattern to cover all subdomains.
|
|
const domainPattern =
|
|
type === "sibnet" ? "*://*.sibnet.ru/*" : `*://${parsedUrl.hostname}/*`;
|
|
return { domainPattern, headers: hdrs };
|
|
} catch (e) {
|
|
console.error(`[EXT-EXTRACT] Failed to setup headers for ${type}:`, e);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
// Cache helpers exposed on the Extractors namespace
|
|
function getCacheSizes() {
|
|
const result = {};
|
|
for (const [name, cache] of Object.entries(caches)) {
|
|
result[name] = cache._cache ? cache._cache.size : 0;
|
|
}
|
|
return result;
|
|
}
|
|
|
|
function clearCaches(type) {
|
|
if (type) {
|
|
if (caches[type] && caches[type]._cache) caches[type]._cache.clear();
|
|
} else {
|
|
for (const cache of Object.values(caches)) {
|
|
if (cache._cache) cache._cache.clear();
|
|
}
|
|
}
|
|
}
|
|
|
|
// Export everything for use in background.js
|
|
// (In service worker, we'll import via importScripts or just include in order)
|
|
if (typeof globalThis !== "undefined") {
|
|
globalThis.MovixExtractors = {
|
|
extractVoe,
|
|
extractFsvid,
|
|
extractVidzy,
|
|
extractVidmoly,
|
|
extractSibnet,
|
|
extractUqload,
|
|
extractDoodStream,
|
|
extractSeekStreaming,
|
|
extractSingle,
|
|
extractAll,
|
|
detectEmbedType,
|
|
detectSupportedEmbeds,
|
|
setupHeadersForService,
|
|
EXTRACT_FN,
|
|
EMBED_PATTERNS,
|
|
getCacheSizes,
|
|
clearCaches,
|
|
};
|
|
}
|
|
// --- END extension/Chrome/extractors.js ---
|
|
|
|
// --- BEGIN extension/Chrome/background.js ---
|
|
const WITV_BASE_URL = "https://witv.football";
|
|
const SOSPLAY_BASE_URL = "https://streamonsport.art";
|
|
const LIVETV_BASE_URL = "https://livetv901.me/frx/";
|
|
const LIVETV_EMBED_ORIGIN = "https://livetv901.me";
|
|
const LIVETV_EMBED_REFERER = LIVETV_BASE_URL;
|
|
// Backend API URL for got-scraping based extraction.
|
|
// Dev override: on localhost (Vite dev on :3000) hit the local backend (:25565).
|
|
const API_BASE_URL =
|
|
typeof location !== "undefined" &&
|
|
(location.hostname === "localhost" || location.hostname === "127.0.0.1")
|
|
? "http://localhost:25565"
|
|
: "https://api.movix.show";
|
|
const STREAM_PROXY_USER_AGENT =
|
|
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36";
|
|
|
|
// Import extractors module
|
|
|
|
const Extractors = globalThis.MovixExtractors;
|
|
|
|
// Cache for Wiflix channels with their page slugs
|
|
let wiflixChannelCache = {};
|
|
|
|
// User extraction preferences (synced from site via SET_EXTRACTION_PREFS)
|
|
const DEFAULT_EXTRACTION_PREFS = {
|
|
version: 1,
|
|
m3u8: {
|
|
voe: true,
|
|
fsvid: true,
|
|
vidzy: true,
|
|
vidmoly: true,
|
|
sibnet: true,
|
|
uqload: true,
|
|
doodstream: true,
|
|
seekstreaming: true,
|
|
},
|
|
livetv: {
|
|
wiflix: true,
|
|
sosplay: true,
|
|
livetv: true,
|
|
matches: true,
|
|
},
|
|
};
|
|
let extractionPrefs = DEFAULT_EXTRACTION_PREFS;
|
|
|
|
// Extension enabled state
|
|
let extensionEnabled = true;
|
|
|
|
// Stats tracking (enriched with per-type counters)
|
|
let sessionStats = {
|
|
extractions: 0,
|
|
corsFixed: 0,
|
|
cached: 0,
|
|
byType: {
|
|
voe: 0,
|
|
fsvid: 0,
|
|
vidzy: 0,
|
|
vidmoly: 0,
|
|
sibnet: 0,
|
|
uqload: 0,
|
|
doodstream: 0,
|
|
seekstreaming: 0,
|
|
},
|
|
};
|
|
|
|
// Load initial state
|
|
(async () => {
|
|
const storedEnabled = await gmGetValueCompat(
|
|
"movix_extensionEnabled",
|
|
null,
|
|
);
|
|
if (storedEnabled !== null) extensionEnabled = storedEnabled !== false;
|
|
const storedStats = await gmGetValueCompat("movix_stats", null);
|
|
if (storedStats) {
|
|
sessionStats = { ...sessionStats, ...storedStats };
|
|
if (!sessionStats.byType) {
|
|
sessionStats.byType = {
|
|
voe: 0,
|
|
fsvid: 0,
|
|
vidzy: 0,
|
|
vidmoly: 0,
|
|
sibnet: 0,
|
|
uqload: 0,
|
|
doodstream: 0,
|
|
seekstreaming: 0,
|
|
};
|
|
}
|
|
}
|
|
const storedPrefs = await gmGetValueCompat("movix_extraction_prefs", null);
|
|
if (storedPrefs) extractionPrefs = storedPrefs;
|
|
})();
|
|
|
|
// Initial setup
|
|
chrome.runtime.onInstalled.addListener(() => {
|
|
setupRules();
|
|
});
|
|
|
|
chrome.runtime.onStartup.addListener(() => {
|
|
setupRules();
|
|
// Reset session stats on startup
|
|
sessionStats = {
|
|
extractions: 0,
|
|
corsFixed: 0,
|
|
cached: 0,
|
|
byType: {
|
|
voe: 0,
|
|
fsvid: 0,
|
|
vidzy: 0,
|
|
vidmoly: 0,
|
|
sibnet: 0,
|
|
uqload: 0,
|
|
doodstream: 0,
|
|
seekstreaming: 0,
|
|
},
|
|
};
|
|
gmSetValueCompat("movix_stats", sessionStats);
|
|
});
|
|
|
|
// Configure DNR rules for CORS and Headers
|
|
async function setupRules() {
|
|
// Clear existing dynamic rules to prevent accumulation
|
|
const existingRules = await chrome.declarativeNetRequest.getDynamicRules();
|
|
const ruleIds = existingRules.map((rule) => rule.id);
|
|
await chrome.declarativeNetRequest.updateDynamicRules({
|
|
removeRuleIds: ruleIds,
|
|
});
|
|
|
|
// Reset rule counter when rules are cleared
|
|
ruleIdCounter = 100;
|
|
|
|
const rules = [
|
|
// 1. Allow CORS for everything (Response Headers)
|
|
{
|
|
id: 1,
|
|
priority: 1,
|
|
action: {
|
|
type: "modifyHeaders",
|
|
responseHeaders: [
|
|
{
|
|
header: "Access-Control-Allow-Origin",
|
|
operation: "set",
|
|
value: "*",
|
|
},
|
|
{
|
|
header: "Access-Control-Allow-Methods",
|
|
operation: "set",
|
|
value: "GET, POST, OPTIONS, HEAD, PUT, DELETE, PATCH",
|
|
},
|
|
{
|
|
header: "Access-Control-Allow-Headers",
|
|
operation: "set",
|
|
value: "*",
|
|
},
|
|
],
|
|
},
|
|
condition: {
|
|
urlFilter: "*",
|
|
initiatorDomains: [
|
|
"localhost",
|
|
"127.0.0.1",
|
|
"movix.cash",
|
|
"movix.club",
|
|
"movix.cloud",
|
|
"movix.tax",
|
|
"movix.golf",
|
|
"movix.chat",
|
|
"movix.date",
|
|
"movix.show",
|
|
],
|
|
resourceTypes: [
|
|
"xmlhttprequest",
|
|
"other",
|
|
"media",
|
|
"image",
|
|
"script",
|
|
"stylesheet",
|
|
"font",
|
|
"websocket",
|
|
],
|
|
},
|
|
},
|
|
];
|
|
|
|
await chrome.declarativeNetRequest.updateDynamicRules({
|
|
addRules: rules,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Map a catalogId (e.g. "wiflix_sport") to a livetv source key.
|
|
* Returns null if unknown — unknown keys are allowed by default.
|
|
*/
|
|
function getLiveTvSourceKey(catalogId) {
|
|
if (!catalogId || typeof catalogId !== "string") return null;
|
|
if (catalogId.startsWith("wiflix_")) return "wiflix";
|
|
if (catalogId.startsWith("sosplay_")) return "sosplay";
|
|
if (catalogId.startsWith("livetv_")) return "livetv";
|
|
if (catalogId.startsWith("matches_")) return "matches";
|
|
return null;
|
|
}
|
|
|
|
function isLiveTvAllowed(catalogId) {
|
|
const key = getLiveTvSourceKey(catalogId);
|
|
if (!key) return true; // Unknown source → allow by default
|
|
return extractionPrefs.livetv[key] !== false;
|
|
}
|
|
|
|
function isEmbedAllowed(type) {
|
|
if (!type) return true;
|
|
return extractionPrefs.m3u8[type] !== false;
|
|
}
|
|
|
|
// Handle messages
|
|
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
|
handleMessage(message)
|
|
.then(sendResponse)
|
|
.catch((err) => sendResponse({ error: err.message }));
|
|
return true; // Keep channel open for async response
|
|
});
|
|
|
|
async function handleMessage(message) {
|
|
const { action, payload } = message;
|
|
|
|
// Handle toggle action (always allowed)
|
|
if (action === "TOGGLE_EXTENSION") {
|
|
extensionEnabled = payload.enabled;
|
|
if (extensionEnabled) {
|
|
await setupRules();
|
|
} else {
|
|
// Remove all DNR rules when disabled
|
|
const existingRules =
|
|
await chrome.declarativeNetRequest.getDynamicRules();
|
|
const ruleIds = existingRules.map((rule) => rule.id);
|
|
if (ruleIds.length > 0) {
|
|
await chrome.declarativeNetRequest.updateDynamicRules({
|
|
removeRuleIds: ruleIds,
|
|
});
|
|
}
|
|
}
|
|
return { success: true, enabled: extensionEnabled };
|
|
}
|
|
|
|
// Handle stats request (always allowed)
|
|
if (action === "GET_STATS") {
|
|
return sessionStats;
|
|
}
|
|
|
|
// Block all other actions if disabled
|
|
if (!extensionEnabled) {
|
|
return { error: "Extension is disabled" };
|
|
}
|
|
|
|
switch (action) {
|
|
case "GET_MANIFEST":
|
|
return await getManifest();
|
|
case "GET_CATALOG": {
|
|
const catalogId = payload?.id || "";
|
|
if (!isLiveTvAllowed(catalogId)) {
|
|
return { metas: [], disabled_by_user: true };
|
|
}
|
|
return await getCatalog(payload.type, payload.id, payload?.accessKey);
|
|
}
|
|
case "GET_STREAM": {
|
|
const channelId = payload?.id || "";
|
|
if (!isLiveTvAllowed(channelId)) {
|
|
return {
|
|
error: "disabled_by_user",
|
|
source: getLiveTvSourceKey(channelId),
|
|
};
|
|
}
|
|
return await getStream(
|
|
payload.type,
|
|
payload.id,
|
|
payload?.accessKey,
|
|
payload,
|
|
);
|
|
}
|
|
case "PROXY_HTTP":
|
|
return await proxyHttpRequest(payload.url, payload.headers);
|
|
|
|
// === Nexus M3U8 Extraction (runs locally in extension, no server needed) ===
|
|
case "EXTRACT_M3U8": {
|
|
const { url: embedUrl, type: hintedType } = payload || {};
|
|
const detectedType =
|
|
hintedType ||
|
|
(Extractors.detectEmbedType
|
|
? Extractors.detectEmbedType(embedUrl)
|
|
: null);
|
|
if (detectedType && !isEmbedAllowed(detectedType)) {
|
|
return {
|
|
success: false,
|
|
error: "disabled_by_user",
|
|
type: detectedType,
|
|
};
|
|
}
|
|
sessionStats.extractions++;
|
|
if (detectedType && sessionStats.byType) {
|
|
sessionStats.byType[detectedType] =
|
|
(sessionStats.byType[detectedType] || 0) + 1;
|
|
}
|
|
await gmSetValueCompat("movix_stats", sessionStats);
|
|
return await handleExtractM3u8(payload);
|
|
}
|
|
case "EXTRACT_ALL_M3U8": {
|
|
const filteredSources = (payload?.sources || []).filter((source) => {
|
|
const srcUrl =
|
|
typeof source === "string"
|
|
? source
|
|
: source?.link || source?.url || "";
|
|
const srcType = Extractors.detectEmbedType
|
|
? Extractors.detectEmbedType(srcUrl)
|
|
: null;
|
|
return !srcType || isEmbedAllowed(srcType);
|
|
});
|
|
sessionStats.extractions++;
|
|
await gmSetValueCompat("movix_stats", sessionStats);
|
|
return await handleExtractAllM3u8({
|
|
...payload,
|
|
sources: filteredSources,
|
|
});
|
|
}
|
|
case "DETECT_EMBEDS":
|
|
return handleDetectEmbeds(payload);
|
|
|
|
case "SETUP_HEADERS": {
|
|
const headerInfo = await Extractors.setupHeadersForService(
|
|
payload.type,
|
|
payload.url,
|
|
);
|
|
if (headerInfo) {
|
|
await addHeadersRule(headerInfo.domainPattern, headerInfo.headers);
|
|
console.log(
|
|
`[NEXUS] DNR headers set for ${payload.type}: ${headerInfo.domainPattern}`,
|
|
);
|
|
return { success: true };
|
|
}
|
|
return { success: false, error: "Could not setup headers" };
|
|
}
|
|
|
|
// FCTV (matches) native: inject the player Referer on the CDN segments.
|
|
case "SETUP_FCTV_HEADERS": {
|
|
const okFctv = await setupFctvHeadersRule(payload?.referer);
|
|
return { success: okFctv };
|
|
}
|
|
|
|
// FCTV (matches) native: resolve ONE server locally (IP-bound) -> m3u8.
|
|
case "RESOLVE_FCTV": {
|
|
const fctvUrl = await resolveFctvStream(payload || {});
|
|
if (fctvUrl) return { success: true, url: fctvUrl };
|
|
return { success: false, error: "fctv resolve failed" };
|
|
}
|
|
|
|
case "SET_EXTRACTION_PREFS": {
|
|
const incoming = payload?.prefs;
|
|
if (
|
|
incoming &&
|
|
incoming.version === 1 &&
|
|
incoming.m3u8 &&
|
|
incoming.livetv
|
|
) {
|
|
extractionPrefs = {
|
|
version: 1,
|
|
m3u8: { ...DEFAULT_EXTRACTION_PREFS.m3u8, ...incoming.m3u8 },
|
|
livetv: { ...DEFAULT_EXTRACTION_PREFS.livetv, ...incoming.livetv },
|
|
};
|
|
await gmSetValueCompat("movix_extraction_prefs", extractionPrefs);
|
|
return { success: true };
|
|
}
|
|
return { success: false, error: "Invalid prefs shape" };
|
|
}
|
|
|
|
case "GET_EXTRACTION_PREFS":
|
|
return extractionPrefs;
|
|
|
|
case "GET_CACHE_STATS": {
|
|
if (typeof Extractors.getCacheSizes === "function") {
|
|
return Extractors.getCacheSizes();
|
|
}
|
|
return {};
|
|
}
|
|
|
|
case "CLEAR_EXTRACTION_CACHE": {
|
|
if (typeof Extractors.clearCaches === "function") {
|
|
Extractors.clearCaches(payload?.type);
|
|
return { success: true };
|
|
}
|
|
return { success: false, error: "Cache API unavailable" };
|
|
}
|
|
|
|
default:
|
|
throw new Error(`Unknown action: ${action}`);
|
|
}
|
|
}
|
|
|
|
// Helper to proxy HTTP requests via extension (to bypass Mixed Content)
|
|
async function proxyHttpRequest(url, headers = {}) {
|
|
try {
|
|
if (headers && Object.keys(headers).length > 0) {
|
|
try {
|
|
const parsedUrl = new URL(url);
|
|
const rulePattern = `*://${parsedUrl.host}${parsedUrl.pathname}*`;
|
|
await addHeadersRule(rulePattern, headers);
|
|
} catch (ruleError) {
|
|
console.warn(
|
|
"[PROXY_HTTP] Failed to add DNR headers rule:",
|
|
ruleError,
|
|
);
|
|
}
|
|
}
|
|
|
|
const response = await fetch(url, { headers });
|
|
const buffer = await response.arrayBuffer();
|
|
|
|
// Convert ArrayBuffer to Base64
|
|
let binary = "";
|
|
const bytes = new Uint8Array(buffer);
|
|
const len = bytes.byteLength;
|
|
for (let i = 0; i < len; i++) {
|
|
binary += String.fromCharCode(bytes[i]);
|
|
}
|
|
const base64 = btoa(binary);
|
|
|
|
return {
|
|
data: base64,
|
|
contentType: response.headers.get("content-type"),
|
|
status: response.status,
|
|
finalUrl: response.url,
|
|
};
|
|
} catch (e) {
|
|
console.error("Proxy HTTP error:", e);
|
|
return { error: e.message };
|
|
}
|
|
}
|
|
|
|
// === NEXUS M3U8 EXTRACTION HANDLERS ===
|
|
|
|
/**
|
|
* Handle single embed extraction request
|
|
* payload: { type: 'voe'|'fsvid'|..., url: 'https://...' }
|
|
*/
|
|
async function handleExtractM3u8(payload) {
|
|
const { type, url } = payload;
|
|
if (!url) return { success: false, error: "Missing URL" };
|
|
|
|
// Auto-detect type if not provided
|
|
const embedType = type || Extractors.detectEmbedType(url);
|
|
if (!embedType) return { success: false, error: "Unknown embed type" };
|
|
|
|
// Set up DNR headers BEFORE extraction so the fetch request succeeds
|
|
try {
|
|
const headerInfo = await Extractors.setupHeadersForService(
|
|
embedType,
|
|
url,
|
|
);
|
|
if (headerInfo) {
|
|
await addHeadersRule(headerInfo.domainPattern, headerInfo.headers);
|
|
console.log(
|
|
`[NEXUS] Pre-extraction DNR headers set for ${embedType}: ${headerInfo.domainPattern}`,
|
|
);
|
|
}
|
|
} catch (e) {
|
|
console.warn("[NEXUS] Failed to set pre-extraction headers:", e);
|
|
}
|
|
|
|
console.log(`[NEXUS] Extracting ${embedType} from: ${url}`);
|
|
const result = await Extractors.extractSingle(embedType, url);
|
|
|
|
// Set up DNR headers for the extracted URL so the page player can use it
|
|
if (result.success) {
|
|
const videoUrl = result.hlsUrl || result.m3u8Url;
|
|
// If the video URL is different from the page URL (likely), set headers for it too
|
|
if (videoUrl && videoUrl !== url) {
|
|
const headerInfo = await Extractors.setupHeadersForService(
|
|
embedType,
|
|
videoUrl,
|
|
);
|
|
if (headerInfo) {
|
|
await addHeadersRule(headerInfo.domainPattern, headerInfo.headers);
|
|
console.log(
|
|
`[NEXUS] DNR headers set for ${embedType}: ${headerInfo.domainPattern}`,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* Handle parallel extraction of all supported embeds from a sources list
|
|
* payload: { sources: ['url1', 'url2', ...] or [{link:'url', player:'name'}, ...] }
|
|
*/
|
|
async function handleExtractAllM3u8(payload) {
|
|
const { sources } = payload;
|
|
if (!sources || !Array.isArray(sources) || sources.length === 0) {
|
|
return { success: false, error: "No sources provided", results: [] };
|
|
}
|
|
|
|
console.log(`[NEXUS] Extracting all from ${sources.length} sources`);
|
|
|
|
// Set up DNR headers for all sources BEFORE extraction
|
|
try {
|
|
const detected = Extractors.detectSupportedEmbeds(sources);
|
|
for (const item of detected) {
|
|
const headerInfo = await Extractors.setupHeadersForService(
|
|
item.type,
|
|
item.url,
|
|
);
|
|
if (headerInfo) {
|
|
await addHeadersRule(headerInfo.domainPattern, headerInfo.headers);
|
|
}
|
|
}
|
|
console.log(
|
|
`[NEXUS] Pre-extraction headers set for ${detected.length} sources`,
|
|
);
|
|
} catch (e) {
|
|
console.warn(
|
|
"[NEXUS] Failed to set pre-extraction headers for batch:",
|
|
e,
|
|
);
|
|
}
|
|
|
|
const results = await Extractors.extractAll(sources);
|
|
|
|
// Set up DNR headers for all successful extractions (video URLs)
|
|
for (const result of results) {
|
|
if (result.success) {
|
|
const videoUrl = result.hlsUrl || result.m3u8Url;
|
|
if (videoUrl) {
|
|
const headerInfo = await Extractors.setupHeadersForService(
|
|
result.type,
|
|
videoUrl,
|
|
);
|
|
if (headerInfo) {
|
|
await addHeadersRule(headerInfo.domainPattern, headerInfo.headers);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
const successCount = results.filter((r) => r.success).length;
|
|
return {
|
|
success: successCount > 0,
|
|
total: results.length,
|
|
successCount,
|
|
results,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Handle embed type detection only (no extraction)
|
|
* payload: { sources: ['url1', 'url2', ...] }
|
|
*/
|
|
function handleDetectEmbeds(payload) {
|
|
const { sources } = payload;
|
|
if (!sources || !Array.isArray(sources)) return { embeds: [] };
|
|
return { embeds: Extractors.detectSupportedEmbeds(sources) };
|
|
}
|
|
|
|
// === API LOGIC ===
|
|
|
|
function getMovixFrontendOrigin() {
|
|
try {
|
|
const currentOrigin = pageWindow.location?.origin;
|
|
const currentHostname = pageWindow.location?.hostname ?? "";
|
|
|
|
if (
|
|
currentHostname === "movix.cash" ||
|
|
currentHostname.endsWith(".movix.cash") ||
|
|
currentHostname === "movix.club" ||
|
|
currentHostname.endsWith(".movix.club") ||
|
|
currentHostname === "movix.cloud" ||
|
|
currentHostname.endsWith(".movix.cloud") ||
|
|
currentHostname === "movix.tax" ||
|
|
currentHostname.endsWith(".movix.tax") ||
|
|
currentHostname === "movix.golf" ||
|
|
currentHostname === "movix.chat" ||
|
|
currentHostname.endsWith(".movix.chat") ||
|
|
currentHostname === "movix.date" ||
|
|
currentHostname.endsWith(".movix.date") ||
|
|
currentHostname === "movix.show" ||
|
|
currentHostname.endsWith(".movix.show") ||
|
|
currentHostname.endsWith(".movix.golf")
|
|
) {
|
|
return (currentOrigin || "https://movix.show").replace(/\/$/, "");
|
|
}
|
|
} catch {}
|
|
|
|
return "https://movix.show";
|
|
}
|
|
|
|
function buildBackendApiHeaders(accessKey, extraHeaders = {}) {
|
|
const frontendOrigin = getMovixFrontendOrigin();
|
|
const headers = {
|
|
Accept: "application/json",
|
|
Origin: frontendOrigin,
|
|
Referer: `${frontendOrigin}/`,
|
|
...extraHeaders,
|
|
};
|
|
|
|
if (accessKey) {
|
|
headers["x-access-key"] = accessKey;
|
|
}
|
|
|
|
return headers;
|
|
}
|
|
|
|
async function getManifest() {
|
|
const manifest = {
|
|
id: "org.stremio.merged",
|
|
version: "1.0.0",
|
|
name: "Live TV (Extension)",
|
|
description: "TV sources via Extension",
|
|
catalogs: [],
|
|
resources: ["catalog", "meta", "stream"],
|
|
types: ["tv"],
|
|
idPrefixes: [],
|
|
};
|
|
|
|
// Add Wiflix (WITV) catalogs
|
|
const wiflixCatalogs = [
|
|
{ type: "tv", id: "wiflix_sport", name: "⚽ Sport" },
|
|
{ type: "tv", id: "wiflix_cinema", name: "🎥 Cinéma" },
|
|
{ type: "tv", id: "wiflix_generaliste", name: "📺 Généraliste" },
|
|
{ type: "tv", id: "wiflix_documentaire", name: "🌍 Documentaire" },
|
|
{ type: "tv", id: "wiflix_enfants", name: "🎈 Enfants" },
|
|
{ type: "tv", id: "wiflix_info", name: "📰 Info" },
|
|
{ type: "tv", id: "wiflix_musique", name: "🎵 Musique" },
|
|
];
|
|
|
|
manifest.catalogs.push(...wiflixCatalogs);
|
|
manifest.idPrefixes.push("wiflix_");
|
|
|
|
// Add Bolaloca catalog (compat prefix sosplay_)
|
|
const sosplayCatalogs = [
|
|
{ type: "tv", id: "sosplay_chaines", name: "📺 Chaînes (Bolaloca)" },
|
|
];
|
|
manifest.catalogs.push(...sosplayCatalogs);
|
|
manifest.idPrefixes.push("sosplay_");
|
|
|
|
const livetvCatalogs = [
|
|
{ type: "tv", id: "livetv_live", name: "🔴 En direct" },
|
|
];
|
|
manifest.catalogs.push(...livetvCatalogs);
|
|
manifest.idPrefixes.push("livetv_");
|
|
manifest.catalogs.push(
|
|
{ type: "tv", id: "livetv_all", name: "📅 Tous les sports" },
|
|
{ type: "tv", id: "livetv_football", name: "⚽ Football" },
|
|
{ type: "tv", id: "livetv_hockey", name: "🏒 Hockey" },
|
|
{ type: "tv", id: "livetv_basketball", name: "🏀 Basketball" },
|
|
{ type: "tv", id: "livetv_tennis", name: "🎾 Tennis" },
|
|
{ type: "tv", id: "livetv_volleyball", name: "🏐 Volley-ball" },
|
|
{ type: "tv", id: "livetv_handball", name: "🤾 Handball" },
|
|
{ type: "tv", id: "livetv_rugby", name: "🏉 Rugby" },
|
|
{ type: "tv", id: "livetv_combat", name: "🥊 Sports de combat" },
|
|
{ type: "tv", id: "livetv_motorsport", name: "🏎️ Sports mecaniques" },
|
|
{ type: "tv", id: "livetv_winter", name: "🎿 Sports d'hiver" },
|
|
{ type: "tv", id: "livetv_athletics", name: "🏃 Athletisme" },
|
|
{ type: "tv", id: "livetv_other", name: "🏟️ Autres sports" },
|
|
);
|
|
|
|
return manifest;
|
|
}
|
|
|
|
async function getCatalog(type, catalogId, accessKey = null) {
|
|
// Check if this is a Wiflix catalog
|
|
if (catalogId.startsWith("wiflix_")) {
|
|
return await getWiflixCatalog(catalogId);
|
|
}
|
|
|
|
// Check if this is a Bolaloca/LiveTV catalog resolved by backend
|
|
if (catalogId.startsWith("sosplay_")) {
|
|
console.log(`[SOSPLAY] Fetching catalog via Backend: ${catalogId}`);
|
|
const response = await fetch(
|
|
`${API_BASE_URL}/api/livetv/catalog/tv/${catalogId}`,
|
|
{
|
|
headers: buildBackendApiHeaders(accessKey),
|
|
},
|
|
);
|
|
if (!response.ok)
|
|
throw new Error(`Backend API error: ${response.status}`);
|
|
return await response.json();
|
|
}
|
|
|
|
if (catalogId.startsWith("livetv_")) {
|
|
console.log(`[LIVETV] Fetching catalog via Backend: ${catalogId}`);
|
|
const response = await fetch(
|
|
`${API_BASE_URL}/api/livetv/catalog/tv/${catalogId}`,
|
|
{
|
|
headers: buildBackendApiHeaders(accessKey),
|
|
},
|
|
);
|
|
if (!response.ok)
|
|
throw new Error(`Backend API error: ${response.status}`);
|
|
return await response.json();
|
|
}
|
|
|
|
// Default: route to backend (handles matches_, TV Direct, etc.)
|
|
console.log(`[CATALOG] Fetching catalog via Backend: ${catalogId}`);
|
|
const response = await fetch(
|
|
`${API_BASE_URL}/api/livetv/catalog/tv/${catalogId}`,
|
|
{
|
|
headers: buildBackendApiHeaders(accessKey),
|
|
},
|
|
);
|
|
if (!response.ok)
|
|
throw new Error(`Backend API error: ${response.status}`);
|
|
return await response.json();
|
|
}
|
|
|
|
// Wiflix catalog categories mapping (URL paths)
|
|
const WITV_CATEGORIES = {
|
|
wiflix_sport: "/chaines-live/sport/",
|
|
wiflix_cinema: "/chaines-live/cinema/",
|
|
wiflix_generaliste: "/chaines-live/generaliste/",
|
|
wiflix_documentaire: "/chaines-live/documentaire/",
|
|
wiflix_enfants: "/chaines-live/enfants/",
|
|
wiflix_info: "/chaines-live/info/",
|
|
wiflix_musique: "/chaines-live/musique/",
|
|
};
|
|
|
|
// Scrape Wiflix channels from WITV website
|
|
async function getWiflixCatalog(catalogId) {
|
|
const categoryPath = WITV_CATEGORIES[catalogId];
|
|
if (!categoryPath) {
|
|
throw new Error(`Unknown Wiflix catalog: ${catalogId}`);
|
|
}
|
|
|
|
console.log(`[WITV] Fetching catalog for: ${catalogId}`);
|
|
|
|
try {
|
|
const categoryUrl = `${WITV_BASE_URL}${categoryPath}`;
|
|
console.log(`[WITV] Category URL: ${categoryUrl}`);
|
|
|
|
const response = await fetch(categoryUrl, {
|
|
headers: {
|
|
"User-Agent":
|
|
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
|
|
Accept:
|
|
"text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8",
|
|
},
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error(`Failed to fetch category page: ${response.status}`);
|
|
}
|
|
|
|
const html = await response.text();
|
|
console.log(`[WITV] Category page length: ${html.length}`);
|
|
|
|
// Parse channels from HTML
|
|
const channels = [];
|
|
|
|
const cardRegex =
|
|
/<div[^>]*class="[^"]*holographic-card[^"]*"[^>]*>[\s\S]*?<a[^>]*href="[^"]*\/(\d+)-[^"]*"[^>]*>[\s\S]*?<[^>]*class="[^"]*ann-short_price[^"]*"[^>]*>([^<]+)</gi;
|
|
let match;
|
|
|
|
while ((match = cardRegex.exec(html)) !== null) {
|
|
const id = match[1];
|
|
const name = match[2].trim();
|
|
|
|
if (id && name && !channels.find((c) => c.id === `wiflix_${id}`)) {
|
|
const slugMatch = html.match(
|
|
new RegExp(`href="([^"]*/${id}-[^"\.]+\.html)"`),
|
|
);
|
|
const pageSlug = slugMatch ? slugMatch[1] : null;
|
|
|
|
const channel = {
|
|
id: `wiflix_${id}`,
|
|
type: "tv",
|
|
name: name,
|
|
poster: null,
|
|
genres: [catalogId.replace("wiflix_", "")],
|
|
_pageSlug: pageSlug,
|
|
_categoryPath: categoryPath,
|
|
};
|
|
channels.push(channel);
|
|
|
|
wiflixChannelCache[channel.id] = channel;
|
|
}
|
|
}
|
|
|
|
// Pattern 2 fallback
|
|
if (channels.length === 0) {
|
|
console.log("[WITV] Pattern 1 failed, trying fallback pattern...");
|
|
const fallbackRegex =
|
|
/href="[^"]*\/(\d+)-([^"]+)\.html"[^>]*>[\s\S]*?<[^>]*class="[^"]*ann-short_price[^"]*"[^>]*>([^<]+)</gi;
|
|
|
|
while ((match = fallbackRegex.exec(html)) !== null) {
|
|
const id = match[1];
|
|
const name = match[3].trim();
|
|
|
|
if (id && name && !channels.find((c) => c.id === `wiflix_${id}`)) {
|
|
const slugMatch = html.match(
|
|
new RegExp(`href="([^"]*/${id}-[^"\.]+\.html)"`),
|
|
);
|
|
const pageSlug = slugMatch ? slugMatch[1] : null;
|
|
|
|
const channel = {
|
|
id: `wiflix_${id}`,
|
|
type: "tv",
|
|
name: name,
|
|
poster: null,
|
|
genres: [catalogId.replace("wiflix_", "")],
|
|
_pageSlug: pageSlug,
|
|
_categoryPath: categoryPath,
|
|
};
|
|
channels.push(channel);
|
|
wiflixChannelCache[channel.id] = channel;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Pattern 3: Ultra-simple
|
|
if (channels.length === 0) {
|
|
console.log("[WITV] Pattern 2 failed, trying ultra-simple pattern...");
|
|
console.log("[WITV] HTML preview:", html.substring(0, 2000));
|
|
|
|
const simpleRegex = /href="[^"]*\/(\d+)-([^"\.]+)/gi;
|
|
const seenIds = new Set();
|
|
|
|
while ((match = simpleRegex.exec(html)) !== null) {
|
|
const id = match[1];
|
|
const slug = match[2];
|
|
|
|
const name = slug
|
|
.replace(/-/g, " ")
|
|
.replace(/\b\w/g, (c) => c.toUpperCase());
|
|
|
|
if (id && !seenIds.has(id)) {
|
|
seenIds.add(id);
|
|
const channel = {
|
|
id: `wiflix_${id}`,
|
|
type: "tv",
|
|
name: name,
|
|
poster: null,
|
|
genres: [catalogId.replace("wiflix_", "")],
|
|
_pageSlug: `${id}-${slug}.html`,
|
|
_categoryPath: categoryPath,
|
|
};
|
|
channels.push(channel);
|
|
wiflixChannelCache[channel.id] = channel;
|
|
}
|
|
}
|
|
}
|
|
|
|
console.log(`[WITV] Found ${channels.length} channels in ${catalogId}`);
|
|
|
|
return { metas: channels };
|
|
} catch (error) {
|
|
console.error("[WITV] Error fetching catalog:", error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
async function getStream(type, channelId, accessKey = null, options = {}) {
|
|
// Check if this is a Wiflix channel
|
|
if (channelId.startsWith("wiflix_")) {
|
|
return await getWiflixStream(channelId, accessKey);
|
|
}
|
|
|
|
// Check if this is a Sosplay channel
|
|
if (channelId.startsWith("sosplay_")) {
|
|
return await getSosplayStream(channelId, accessKey);
|
|
}
|
|
|
|
if (channelId.startsWith("livetv_")) {
|
|
return await getLiveTvStream(channelId, accessKey, options);
|
|
}
|
|
|
|
// Default: route to backend (handles matches_, TV Direct, etc.)
|
|
console.log(`[STREAM] Fetching stream via Backend: ${channelId}`);
|
|
const response = await fetch(
|
|
`${API_BASE_URL}/api/livetv/stream/tv/${channelId}`,
|
|
{
|
|
headers: buildBackendApiHeaders(accessKey),
|
|
},
|
|
);
|
|
if (!response.ok)
|
|
throw new Error(`Backend API error: ${response.status}`);
|
|
return await response.json();
|
|
}
|
|
|
|
// Find the channel page URL from cache or by searching
|
|
async function findWiflixChannelPageUrl(channelId) {
|
|
if (wiflixChannelCache[channelId]) {
|
|
const channel = wiflixChannelCache[channelId];
|
|
if (channel._pageSlug) {
|
|
const pageUrl = channel._pageSlug.startsWith("http")
|
|
? channel._pageSlug
|
|
: `${WITV_BASE_URL}${channel._categoryPath}${channel._pageSlug.replace(/^\//, "")}`;
|
|
console.log(`[WITV] Found channel page URL in cache: ${pageUrl}`);
|
|
return pageUrl;
|
|
}
|
|
}
|
|
|
|
const id = channelId.replace("wiflix_", "");
|
|
console.log(`[WITV] Channel ${channelId} not in cache, searching...`);
|
|
|
|
for (const [catId, categoryPath] of Object.entries(WITV_CATEGORIES)) {
|
|
try {
|
|
const categoryUrl = `${WITV_BASE_URL}${categoryPath}`;
|
|
const response = await fetch(categoryUrl, {
|
|
headers: {
|
|
"User-Agent":
|
|
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
|
|
},
|
|
});
|
|
|
|
if (!response.ok) continue;
|
|
|
|
const html = await response.text();
|
|
const regex = new RegExp(`href="([^"]*/${id}-[^"\.]+\.html)"`, "i");
|
|
const match = html.match(regex);
|
|
|
|
if (match) {
|
|
const channelPath = match[1];
|
|
const pageUrl = channelPath.startsWith("http")
|
|
? channelPath
|
|
: `${WITV_BASE_URL}${channelPath}`;
|
|
console.log(`[WITV] Found channel page URL via search: ${pageUrl}`);
|
|
return pageUrl;
|
|
}
|
|
} catch (e) {
|
|
console.warn(`[WITV] Error searching in ${catId}: ${e.message}`);
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
// Wiflix (WITV) stream extraction
|
|
async function getWiflixStream(channelId, accessKey = null) {
|
|
console.log(`[WITV] Extracting stream for channel: ${channelId}`);
|
|
|
|
try {
|
|
const channelPageUrl = await findWiflixChannelPageUrl(channelId);
|
|
|
|
if (!channelPageUrl) {
|
|
throw new Error(`Could not find page URL for ${channelId}`);
|
|
}
|
|
|
|
console.log(`[WITV] Channel page URL: ${channelPageUrl}`);
|
|
|
|
const pageResponse = await fetch(channelPageUrl, {
|
|
headers: {
|
|
"User-Agent":
|
|
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
|
|
Referer: WITV_BASE_URL + "/",
|
|
},
|
|
});
|
|
|
|
if (!pageResponse.ok) {
|
|
throw new Error(`Failed to fetch channel page: ${pageResponse.status}`);
|
|
}
|
|
|
|
const pageHtml = await pageResponse.text();
|
|
console.log(`[WITV] Channel page length: ${pageHtml.length}`);
|
|
|
|
const iframeMatch = pageHtml.match(/<iframe[^>]*src=["']([^"']+)["']/i);
|
|
|
|
if (!iframeMatch) {
|
|
console.error("[WITV] No iframe found on channel page");
|
|
throw new Error("No iframe found on page");
|
|
}
|
|
|
|
let embedSrc = iframeMatch[1];
|
|
console.log(`[WITV] Found embed iframe: ${embedSrc}`);
|
|
|
|
// Type 1: witv-player.php
|
|
if (embedSrc.includes("witv-player.php")) {
|
|
const playerUrl = embedSrc.startsWith("http")
|
|
? embedSrc
|
|
: `${WITV_BASE_URL}${embedSrc}`;
|
|
console.log(
|
|
`[WITV] Type 1: witv-player detected, fetching ${playerUrl}`,
|
|
);
|
|
|
|
const playerResponse = await fetch(playerUrl, {
|
|
headers: {
|
|
"User-Agent":
|
|
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
|
|
Referer: channelPageUrl,
|
|
},
|
|
});
|
|
|
|
if (!playerResponse.ok) {
|
|
throw new Error(
|
|
`Failed to fetch player page: ${playerResponse.status}`,
|
|
);
|
|
}
|
|
|
|
const playerHtml = await playerResponse.text();
|
|
|
|
let m3u8Url = null;
|
|
const streamMatch = playerHtml.match(
|
|
/var\s+streamUrl\s*=\s*["']([^"']+)["']/,
|
|
);
|
|
if (streamMatch) {
|
|
m3u8Url = streamMatch[1];
|
|
} else {
|
|
const fileMatch = playerHtml.match(
|
|
/file:\s*["']([^"']+\.m3u8[^"']*)["']/,
|
|
);
|
|
if (fileMatch) {
|
|
m3u8Url = fileMatch[1];
|
|
} else {
|
|
const genericMatch = playerHtml.match(
|
|
/["'](https?:\/\/[^"']+\.m3u8[^"']*)["']/,
|
|
);
|
|
if (genericMatch) {
|
|
m3u8Url = genericMatch[1];
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!m3u8Url) {
|
|
throw new Error("Could not extract stream URL from witv-player");
|
|
}
|
|
|
|
await addWiflixHeadersRule(m3u8Url);
|
|
|
|
return {
|
|
streams: [
|
|
{
|
|
title: "Orca",
|
|
url: m3u8Url,
|
|
originalUrl: m3u8Url,
|
|
behaviorHints: { notWebReady: false },
|
|
},
|
|
],
|
|
};
|
|
}
|
|
|
|
// Type 2: livehdtv.com
|
|
if (embedSrc.includes("livehdtv.com")) {
|
|
const cacheKey = `witv_livehdtv_${channelId}`;
|
|
const cachedStream = await getFromCache(cacheKey);
|
|
|
|
if (cachedStream) {
|
|
console.log(`[WITV] Found valid stream in cache for ${channelId}`);
|
|
await addWiflixHeadersRule(
|
|
cachedStream.url,
|
|
"https://www.livehdtv.com/",
|
|
);
|
|
return { streams: [cachedStream] };
|
|
}
|
|
|
|
console.log(
|
|
`[WITV] Type 2: livehdtv detected, using backend stream API...`,
|
|
);
|
|
|
|
const apiUrl = `${API_BASE_URL}/api/livetv/stream/tv/${channelId}`;
|
|
console.log(`[WITV] Calling backend stream API: ${apiUrl}`);
|
|
|
|
try {
|
|
const apiResponse = await fetch(apiUrl, {
|
|
headers: buildBackendApiHeaders(accessKey),
|
|
});
|
|
|
|
if (!apiResponse.ok) {
|
|
throw new Error(`Backend API error: ${apiResponse.status}`);
|
|
}
|
|
|
|
const apiData = await apiResponse.json();
|
|
|
|
if (apiData.error) {
|
|
throw new Error(apiData.error);
|
|
}
|
|
|
|
if (!apiData.streams || apiData.streams.length === 0) {
|
|
throw new Error("No streams returned from backend");
|
|
}
|
|
|
|
const stream = apiData.streams[0];
|
|
const m3u8Url = stream.originalUrl || stream.url;
|
|
|
|
await addWiflixHeadersRule(m3u8Url, "https://www.livehdtv.com/");
|
|
|
|
// Verify stream availability
|
|
console.log("[WITV] Verifying stream availability...");
|
|
let retries = 0;
|
|
const maxRetries = 20;
|
|
|
|
while (retries < maxRetries) {
|
|
try {
|
|
const checkResponse = await fetch(m3u8Url, {
|
|
method: "GET",
|
|
headers: {
|
|
Origin: "https://www.livehdtv.com",
|
|
Referer: "https://www.livehdtv.com/",
|
|
},
|
|
});
|
|
|
|
if (checkResponse.ok) {
|
|
console.log(`[WITV] Stream verified after ${retries} retries`);
|
|
break;
|
|
}
|
|
} catch (e) {
|
|
// retry
|
|
}
|
|
|
|
await new Promise((resolve) => setTimeout(resolve, 500));
|
|
retries++;
|
|
}
|
|
|
|
const streamData = {
|
|
title: "Orca",
|
|
url: m3u8Url,
|
|
originalUrl: m3u8Url,
|
|
behaviorHints: { notWebReady: false },
|
|
};
|
|
|
|
await saveToCache(cacheKey, streamData, 1);
|
|
|
|
return { streams: [streamData] };
|
|
} catch (apiError) {
|
|
console.error(
|
|
`[WITV] Backend API failed, trying direct fetch fallback:`,
|
|
apiError.message,
|
|
);
|
|
|
|
const livehdtvResponse = await fetch(embedSrc, {
|
|
headers: {
|
|
"User-Agent":
|
|
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.0.0 Safari/537.36",
|
|
Referer: "https://www.livehdtv.com/",
|
|
},
|
|
});
|
|
|
|
if (!livehdtvResponse.ok) {
|
|
throw new Error(
|
|
`Failed to fetch livehdtv page: ${livehdtvResponse.status}`,
|
|
);
|
|
}
|
|
|
|
const livehdtvHtml = await livehdtvResponse.text();
|
|
|
|
const innerIframeMatch = livehdtvHtml.match(
|
|
/<iframe[^>]*src=["']([^"']+)["']/i,
|
|
);
|
|
if (!innerIframeMatch) {
|
|
throw new Error("No inner iframe found in livehdtv page");
|
|
}
|
|
|
|
let tokenPhpUrl = innerIframeMatch[1];
|
|
if (!tokenPhpUrl.startsWith("http")) {
|
|
tokenPhpUrl = `https://www.livehdtv.com${tokenPhpUrl}`;
|
|
}
|
|
|
|
const tokenResponse = await fetch(tokenPhpUrl, {
|
|
headers: {
|
|
"User-Agent":
|
|
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.0.0 Safari/537.36",
|
|
Referer: embedSrc,
|
|
},
|
|
});
|
|
|
|
if (!tokenResponse.ok) {
|
|
throw new Error(
|
|
`Failed to fetch token.php: ${tokenResponse.status}`,
|
|
);
|
|
}
|
|
|
|
const tokenHtml = await tokenResponse.text();
|
|
const fileMatch = tokenHtml.match(
|
|
/file:\s*["']([^"']+\.m3u8[^"']*)["']/,
|
|
);
|
|
|
|
if (!fileMatch) {
|
|
throw new Error("Could not extract m3u8 from token.php");
|
|
}
|
|
|
|
const m3u8Url = fileMatch[1];
|
|
await addWiflixHeadersRule(m3u8Url, "https://www.livehdtv.com/");
|
|
|
|
return {
|
|
streams: [
|
|
{
|
|
title: "Orca",
|
|
url: m3u8Url,
|
|
originalUrl: m3u8Url,
|
|
behaviorHints: { notWebReady: false },
|
|
},
|
|
],
|
|
};
|
|
}
|
|
}
|
|
|
|
// Type 3: Unknown embed - try generic extraction
|
|
console.log(
|
|
`[WITV] Unknown embed type, attempting generic extraction from: ${embedSrc}`,
|
|
);
|
|
|
|
const unknownUrl = embedSrc.startsWith("http")
|
|
? embedSrc
|
|
: `${WITV_BASE_URL}${embedSrc}`;
|
|
const unknownResponse = await fetch(unknownUrl, {
|
|
headers: {
|
|
"User-Agent":
|
|
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
|
|
Referer: channelPageUrl,
|
|
},
|
|
});
|
|
|
|
if (!unknownResponse.ok) {
|
|
throw new Error(
|
|
`Failed to fetch unknown embed: ${unknownResponse.status}`,
|
|
);
|
|
}
|
|
|
|
const unknownHtml = await unknownResponse.text();
|
|
const m3u8Match = unknownHtml.match(
|
|
/["'](https?:\/\/[^"']+\.m3u8[^"']*)["']/,
|
|
);
|
|
|
|
if (!m3u8Match) {
|
|
throw new Error("Could not extract stream URL from unknown embed");
|
|
}
|
|
|
|
const m3u8Url = m3u8Match[1];
|
|
await addWiflixHeadersRule(m3u8Url);
|
|
|
|
return {
|
|
streams: [
|
|
{
|
|
title: "Orca",
|
|
url: m3u8Url,
|
|
originalUrl: m3u8Url,
|
|
behaviorHints: { notWebReady: false },
|
|
},
|
|
],
|
|
};
|
|
} catch (error) {
|
|
console.error("[WITV] Error extracting stream:", error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
// Add DNR rule for Wiflix headers
|
|
async function addWiflixHeadersRule(
|
|
urlPattern,
|
|
referer = "https://witv.football/",
|
|
) {
|
|
try {
|
|
const url = new URL(urlPattern);
|
|
const domainPattern = `*://${url.hostname}/*`;
|
|
const origin = referer.endsWith("/") ? referer.slice(0, -1) : referer;
|
|
|
|
await addHeadersRule(domainPattern, {
|
|
Origin: origin,
|
|
Referer: referer,
|
|
});
|
|
} catch (e) {
|
|
console.error("[WITV] Failed to add headers rule:", e);
|
|
}
|
|
}
|
|
|
|
async function getBackendIframeSourceStream(
|
|
channelId,
|
|
accessKey = null,
|
|
logPrefix = "LIVE",
|
|
options = {},
|
|
) {
|
|
const requestUrl = new URL(
|
|
`${API_BASE_URL}/api/livetv/stream/tv/${channelId}`,
|
|
);
|
|
if (options.mode === "sources") {
|
|
requestUrl.searchParams.set("mode", "sources");
|
|
}
|
|
if (Number.isInteger(options.sourceIndex) && options.sourceIndex >= 0) {
|
|
requestUrl.searchParams.set("sourceIndex", String(options.sourceIndex));
|
|
}
|
|
|
|
const response = await fetch(requestUrl.toString(), {
|
|
headers: buildBackendApiHeaders(accessKey),
|
|
});
|
|
if (!response.ok) throw new Error(`Backend API error: ${response.status}`);
|
|
|
|
const data = await response.json();
|
|
|
|
if (options.mode === "sources") {
|
|
console.log(
|
|
`[${logPrefix}] Backend returned ${data.sources?.length || 0} source(s) for ${channelId}`,
|
|
);
|
|
return data;
|
|
}
|
|
|
|
if (data.streams && data.streams.length > 0) {
|
|
const normalizedStreams = [];
|
|
const isLiveTvRequest =
|
|
channelId.startsWith("livetv_") || logPrefix === "LIVETV";
|
|
|
|
for (const stream of data.streams) {
|
|
if (stream._isEmbed) {
|
|
const url = stream.originalUrl || stream.url;
|
|
if (isLiveTvRequest && url?.startsWith("http")) {
|
|
await addLiveTvEmbedHeadersRule(url);
|
|
}
|
|
|
|
normalizedStreams.push({
|
|
...stream,
|
|
url: url || stream.url,
|
|
originalUrl: url || stream.originalUrl,
|
|
referer: isLiveTvRequest ? LIVETV_EMBED_REFERER : stream.referer,
|
|
behaviorHints: { notWebReady: false },
|
|
});
|
|
continue;
|
|
}
|
|
|
|
const url = stream.originalUrl || stream.url;
|
|
if (!url || !url.startsWith("http")) continue;
|
|
|
|
if (isLiveTvRequest) {
|
|
await addLiveTvHeadersRule(url, stream.userAgent);
|
|
} else {
|
|
await addSosplayHeadersRule(url, stream.referer, stream.userAgent);
|
|
}
|
|
normalizedStreams.push({
|
|
...stream,
|
|
url,
|
|
originalUrl: url,
|
|
referer: isLiveTvRequest ? LIVETV_EMBED_REFERER : stream.referer,
|
|
behaviorHints: { notWebReady: false },
|
|
});
|
|
}
|
|
|
|
data.streams = normalizedStreams;
|
|
}
|
|
|
|
console.log(
|
|
`[${logPrefix}] Backend returned ${data.streams?.length || 0} stream(s) for ${channelId}`,
|
|
);
|
|
return data;
|
|
}
|
|
|
|
async function getSosplayStream(channelId, accessKey = null) {
|
|
console.log(`[SOSPLAY] Fetching stream logic via Extension: ${channelId}`);
|
|
try {
|
|
return await getBackendIframeSourceStream(
|
|
channelId,
|
|
accessKey,
|
|
"BOLALOCA",
|
|
);
|
|
|
|
let slug = channelId.replace("sosplay_", "");
|
|
let channelPageUrl = `${SOSPLAY_BASE_URL}/regardertv-${slug}-streaming-direct`;
|
|
|
|
console.log(`[SOSPLAY] Fetching channel page: ${channelPageUrl}`);
|
|
const pageResponse = await fetch(channelPageUrl, {
|
|
headers: {
|
|
"User-Agent":
|
|
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
|
|
Referer: SOSPLAY_BASE_URL,
|
|
},
|
|
});
|
|
|
|
if (!pageResponse.ok) {
|
|
console.log(
|
|
`[SOSPLAY] Failed to fetch channel page: ${pageResponse.status}`,
|
|
);
|
|
}
|
|
|
|
const pageHtml = await pageResponse.text();
|
|
|
|
const serverRegex =
|
|
/class="[^"]*change-video[^"]*"[^>]*data-embed="([^"]+)"[^>]*>([\s\S]*?)<\/span>/gi;
|
|
|
|
let match;
|
|
const servers = [];
|
|
|
|
while ((match = serverRegex.exec(pageHtml)) !== null) {
|
|
const rawName = match[2];
|
|
const cleanName = rawName.replace(/<[^>]+>/g, "").trim();
|
|
|
|
if (match[1] && cleanName) {
|
|
const idMatch = match[1].match(/id=(\d+)\/(\d+)/);
|
|
servers.push({
|
|
embedPath: match[1],
|
|
name: cleanName,
|
|
channelNum: idMatch ? idMatch[1] : null,
|
|
serverNum: idMatch ? idMatch[2] : null,
|
|
});
|
|
}
|
|
}
|
|
|
|
console.log(
|
|
`[SOSPLAY] Found ${servers.length} servers: ${servers.map((s) => s.name).join(", ")}`,
|
|
);
|
|
|
|
const allStreams = [];
|
|
const allEmbeds = [];
|
|
|
|
for (const server of servers) {
|
|
try {
|
|
console.log(`[SOSPLAY] Trying server: ${server.name}`);
|
|
|
|
const partUrl = `${SOSPLAY_BASE_URL}${server.embedPath}`;
|
|
|
|
const partResponse = await fetch(partUrl, {
|
|
headers: {
|
|
"User-Agent":
|
|
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
|
|
Referer: channelPageUrl,
|
|
},
|
|
});
|
|
|
|
const partHtml = await partResponse.text();
|
|
|
|
const tyIframeMatch = partHtml.match(/<iframe[^>]+src=["']([^"']+)/i);
|
|
if (!tyIframeMatch) {
|
|
console.warn(
|
|
`[SOSPLAY] No iframe in /part/ page for ${server.name}`,
|
|
);
|
|
continue;
|
|
}
|
|
|
|
let tyPageUrl = tyIframeMatch[1];
|
|
if (tyPageUrl.startsWith("//")) tyPageUrl = "https:" + tyPageUrl;
|
|
if (tyPageUrl.startsWith("/"))
|
|
tyPageUrl = SOSPLAY_BASE_URL + tyPageUrl;
|
|
|
|
const tyPageResponse = await fetch(tyPageUrl, {
|
|
headers: {
|
|
"User-Agent":
|
|
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
|
|
Referer: partUrl,
|
|
},
|
|
});
|
|
|
|
const tyPageHtml = await tyPageResponse.text();
|
|
|
|
const playerIframeMatch = tyPageHtml.match(
|
|
/<iframe[^>]+src=["']([^"']+)/i,
|
|
);
|
|
if (!playerIframeMatch) {
|
|
console.warn(
|
|
`[SOSPLAY] No player iframe in ty page for ${server.name}`,
|
|
);
|
|
continue;
|
|
}
|
|
|
|
let playerUrl = playerIframeMatch[1];
|
|
if (playerUrl.startsWith("//")) playerUrl = "https:" + playerUrl;
|
|
|
|
try {
|
|
const playerDomain = new URL(playerUrl).hostname;
|
|
await addHeadersRule(`*://${playerDomain}/*`, {
|
|
Referer: tyPageUrl,
|
|
Origin: new URL(tyPageUrl).origin,
|
|
});
|
|
} catch (e) {
|
|
console.warn(
|
|
`[SOSPLAY] Could not add pre-fetch DNR rule for ${server.name}:`,
|
|
e,
|
|
);
|
|
}
|
|
|
|
const playerResponse = await fetch(playerUrl, {
|
|
headers: {
|
|
"User-Agent":
|
|
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
|
|
},
|
|
});
|
|
|
|
const playerHtml = await playerResponse.text();
|
|
|
|
let m3u8Url = null;
|
|
const isHoca =
|
|
server.name.toLowerCase().includes("hoca") ||
|
|
playerUrl.includes("hoca");
|
|
|
|
if (isHoca) {
|
|
m3u8Url = decodeHocaStream(playerHtml);
|
|
} else {
|
|
m3u8Url = extractM3u8FromPackedHtml(playerHtml);
|
|
}
|
|
|
|
if (m3u8Url) {
|
|
console.log(
|
|
`[SOSPLAY] Found stream for ${server.name}: ${m3u8Url}`,
|
|
);
|
|
|
|
await addSosplayHeadersRule(m3u8Url, playerUrl);
|
|
|
|
allStreams.push({
|
|
title: `Sosplay - ${server.name}`,
|
|
url: m3u8Url,
|
|
originalUrl: m3u8Url,
|
|
behaviorHints: { notWebReady: false },
|
|
_referer: playerUrl,
|
|
userAgent: STREAM_PROXY_USER_AGENT,
|
|
});
|
|
} else {
|
|
console.warn(
|
|
`[SOSPLAY] Failed to decode stream for ${server.name}`,
|
|
);
|
|
}
|
|
} catch (serverError) {
|
|
console.warn(
|
|
`[SOSPLAY] Error with server ${server.name}:`,
|
|
serverError.message || serverError,
|
|
);
|
|
continue;
|
|
}
|
|
}
|
|
|
|
if (allStreams.length > 0) {
|
|
console.log(
|
|
`[SOSPLAY] Total streams found locally: ${allStreams.length}`,
|
|
);
|
|
return { streams: allStreams };
|
|
}
|
|
|
|
// Fallback: Use backend API
|
|
console.log(
|
|
"[SOSPLAY] Local extraction failed, falling back to Backend API",
|
|
);
|
|
const response = await fetch(
|
|
`${API_BASE_URL}/api/livetv/stream/tv/${channelId}`,
|
|
);
|
|
if (!response.ok)
|
|
throw new Error(`Backend API error: ${response.status}`);
|
|
|
|
const data = await response.json();
|
|
|
|
if (data.streams && data.streams.length > 0) {
|
|
for (const stream of data.streams) {
|
|
const url = stream.originalUrl || stream.url;
|
|
if (url && url.startsWith("http")) {
|
|
await addSosplayHeadersRule(url, stream.referer);
|
|
stream.url = url;
|
|
stream.behaviorHints = { notWebReady: false };
|
|
}
|
|
}
|
|
}
|
|
|
|
return data;
|
|
} catch (error) {
|
|
console.error("[SOSPLAY] Error fetching stream:", error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
async function getLiveTvStream(channelId, accessKey = null, options = {}) {
|
|
console.log(`[LIVETV] Fetching stream logic via Extension: ${channelId}`);
|
|
try {
|
|
const sourceIndex =
|
|
Number.isInteger(options?.sourceIndex) && options?.sourceIndex >= 0
|
|
? options.sourceIndex
|
|
: null;
|
|
const mode = options?.mode === "sources" ? "sources" : "stream";
|
|
const decodedPath = decodeLiveTvChannelPath(channelId);
|
|
if (!decodedPath) {
|
|
throw new Error(`Could not decode channel path for ${channelId}`);
|
|
}
|
|
|
|
const eventUrl = absolutizeLiveTvUrl(
|
|
decodedPath,
|
|
LIVETV_BASE_URL,
|
|
LIVETV_BASE_URL,
|
|
);
|
|
if (!eventUrl) {
|
|
throw new Error(`Could not build event URL for ${channelId}`);
|
|
}
|
|
|
|
const eventPage = await fetchLiveTvText(eventUrl, LIVETV_BASE_URL);
|
|
if (!eventPage?.html) {
|
|
throw new Error(`Could not fetch event page ${eventUrl}`);
|
|
}
|
|
|
|
const webplayerEntries = extractLiveTvWebplayerEntries(
|
|
eventPage.html,
|
|
eventPage.finalUrl || eventUrl,
|
|
);
|
|
console.log(
|
|
`[LIVETV] Extracted ${webplayerEntries.length} webplayer link(s) locally for ${channelId}`,
|
|
);
|
|
|
|
if (mode === "sources") {
|
|
return {
|
|
sources: buildLiveTvSourceOptions(webplayerEntries),
|
|
};
|
|
}
|
|
|
|
const selectedEntries =
|
|
sourceIndex === null
|
|
? webplayerEntries
|
|
: webplayerEntries.filter((_, index) => index === sourceIndex);
|
|
|
|
if (selectedEntries.length === 0) {
|
|
return await getBackendIframeSourceStream(
|
|
channelId,
|
|
accessKey,
|
|
"LIVETV",
|
|
options,
|
|
);
|
|
}
|
|
|
|
const allStreams = [];
|
|
const allEmbeds = [];
|
|
|
|
for (const entry of selectedEntries) {
|
|
const candidateUrls = dedupeLiveTvItems(
|
|
[entry.exportUrl, entry.webplayerUrl].filter(Boolean),
|
|
(url) => url,
|
|
);
|
|
|
|
let resolved = { streams: [], embeds: [] };
|
|
for (const candidateUrl of candidateUrls) {
|
|
resolved = await resolveLiveTvMediaFromUrl(
|
|
candidateUrl,
|
|
eventPage.finalUrl || eventUrl,
|
|
6,
|
|
new Set(),
|
|
);
|
|
|
|
if (resolved.streams.length > 0 || resolved.embeds.length > 0) {
|
|
break;
|
|
}
|
|
}
|
|
|
|
for (const [streamIndex, stream] of resolved.streams.entries()) {
|
|
await addLiveTvHeadersRule(stream.url);
|
|
allStreams.push({
|
|
title:
|
|
resolved.streams.length > 1
|
|
? `${entry.title} ${streamIndex + 1}`
|
|
: entry.title,
|
|
url: stream.url,
|
|
originalUrl: stream.url,
|
|
behaviorHints: { notWebReady: false },
|
|
_referer: LIVETV_EMBED_REFERER,
|
|
userAgent: STREAM_PROXY_USER_AGENT,
|
|
});
|
|
}
|
|
|
|
for (const [embedIndex, embed] of resolved.embeds.entries()) {
|
|
await addLiveTvEmbedHeadersRule(embed.url);
|
|
allEmbeds.push({
|
|
title:
|
|
resolved.embeds.length > 1
|
|
? `${entry.title} Embed ${embedIndex + 1}`
|
|
: `${entry.title} Embed`,
|
|
url: embed.url,
|
|
originalUrl: embed.url,
|
|
referer: LIVETV_EMBED_REFERER,
|
|
behaviorHints: { notWebReady: false },
|
|
_referer: LIVETV_EMBED_REFERER,
|
|
userAgent: STREAM_PROXY_USER_AGENT,
|
|
_isEmbed: true,
|
|
});
|
|
}
|
|
}
|
|
|
|
const uniqueStreams = dedupeLiveTvItems(
|
|
allStreams,
|
|
(stream) => `${stream.url}__${stream._referer || ""}`,
|
|
);
|
|
const uniqueEmbeds = dedupeLiveTvItems(
|
|
allEmbeds,
|
|
(embed) => `${embed.url}__${embed._referer || ""}`,
|
|
);
|
|
|
|
if (uniqueStreams.length > 0 || uniqueEmbeds.length > 0) {
|
|
console.log(
|
|
`[LIVETV] Resolved ${uniqueStreams.length} direct stream(s) and ${uniqueEmbeds.length} embed(s) locally for ${channelId}`,
|
|
);
|
|
return {
|
|
streams: uniqueStreams.length > 0 ? uniqueStreams : uniqueEmbeds,
|
|
};
|
|
}
|
|
|
|
console.log(
|
|
"[LIVETV] Local extraction failed, falling back to Backend API",
|
|
);
|
|
return await getBackendIframeSourceStream(
|
|
channelId,
|
|
accessKey,
|
|
"LIVETV",
|
|
options,
|
|
);
|
|
} catch (error) {
|
|
console.warn(
|
|
`[LIVETV] Local extraction error for ${channelId}:`,
|
|
error.message || error,
|
|
);
|
|
return await getBackendIframeSourceStream(
|
|
channelId,
|
|
accessKey,
|
|
"LIVETV",
|
|
options,
|
|
);
|
|
}
|
|
}
|
|
|
|
function decodeLiveTvChannelPath(channelId) {
|
|
try {
|
|
const encodedPath = String(channelId || "").replace(/^livetv_/, "");
|
|
const normalized = encodedPath.replace(/-/g, "+").replace(/_/g, "/");
|
|
const paddingLength = (4 - (normalized.length % 4 || 4)) % 4;
|
|
return atob(`${normalized}${"=".repeat(paddingLength)}`);
|
|
} catch (error) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function absolutizeLiveTvUrl(
|
|
rawUrl,
|
|
currentUrl = "",
|
|
fallbackBase = LIVETV_BASE_URL,
|
|
) {
|
|
if (!rawUrl) return null;
|
|
|
|
const normalized = String(rawUrl)
|
|
.trim()
|
|
.replace(/&/gi, "&")
|
|
.replace(/\\u0026/g, "&")
|
|
.replace(/\\\//g, "/")
|
|
.replace(/\s+/g, "");
|
|
|
|
if (!normalized) return null;
|
|
|
|
if (normalized.startsWith("//")) {
|
|
const protocol = String(currentUrl || fallbackBase).startsWith("http://")
|
|
? "http:"
|
|
: "https:";
|
|
return `${protocol}${normalized}`;
|
|
}
|
|
|
|
try {
|
|
return new URL(normalized, currentUrl || fallbackBase).href;
|
|
} catch (error) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function dedupeLiveTvItems(items, getKey) {
|
|
const seen = new Set();
|
|
const deduped = [];
|
|
|
|
for (const item of items) {
|
|
const key = getKey(item);
|
|
if (!key || seen.has(key)) continue;
|
|
seen.add(key);
|
|
deduped.push(item);
|
|
}
|
|
|
|
return deduped;
|
|
}
|
|
|
|
function stripLiveTvHtml(value) {
|
|
return String(value || "")
|
|
.replace(/<[^>]+>/g, " ")
|
|
.replace(/ /gi, " ")
|
|
.replace(/&/gi, "&")
|
|
.replace(/"/gi, '"')
|
|
.replace(/'/gi, "'")
|
|
.replace(/&#(\d+);/g, (_, code) => {
|
|
const value = Number.parseInt(code, 10);
|
|
return Number.isFinite(value) ? String.fromCodePoint(value) : "";
|
|
})
|
|
.replace(/&#x([0-9a-f]+);/gi, (_, code) => {
|
|
const value = Number.parseInt(code, 16);
|
|
return Number.isFinite(value) ? String.fromCodePoint(value) : "";
|
|
})
|
|
.replace(/\s+/g, " ")
|
|
.trim();
|
|
}
|
|
|
|
function buildLiveTvExportUrl(webplayerUrl, eventUrl = "") {
|
|
try {
|
|
const parsed = new URL(webplayerUrl, eventUrl || LIVETV_BASE_URL);
|
|
|
|
if (/\/export\/webplayer\.iframe\.php$/i.test(parsed.pathname)) {
|
|
return parsed.href;
|
|
}
|
|
|
|
if (!/\/webplayer(?:2)?\.php$/i.test(parsed.pathname)) {
|
|
return parsed.href;
|
|
}
|
|
|
|
let cdnHost = parsed.hostname;
|
|
if (!cdnHost.startsWith("cdn.")) {
|
|
const eventHost = new URL(eventUrl || LIVETV_BASE_URL).hostname.replace(
|
|
/^www\./i,
|
|
"",
|
|
);
|
|
cdnHost = `cdn.${eventHost}`;
|
|
}
|
|
|
|
parsed.protocol = "https:";
|
|
parsed.hostname = cdnHost;
|
|
parsed.pathname = "/export/webplayer.iframe.php";
|
|
return parsed.href;
|
|
} catch (error) {
|
|
return webplayerUrl;
|
|
}
|
|
}
|
|
|
|
function extractLiveTvWebplayerEntries(html, eventUrl) {
|
|
const entries = [];
|
|
const rawHtml = String(html || "");
|
|
const rowPattern = /<table[^>]+class=["']lnktbj["'][\s\S]*?<\/table>/gi;
|
|
|
|
for (const rowMatch of rawHtml.matchAll(rowPattern)) {
|
|
const rowHtml = rowMatch[0];
|
|
const hrefMatch = rowHtml.match(
|
|
/href=["']([^"']*\/webplayer(?:2)?\.php[^"']*)["']/i,
|
|
);
|
|
if (!hrefMatch) continue;
|
|
|
|
const webplayerUrl = absolutizeLiveTvUrl(
|
|
hrefMatch[1],
|
|
eventUrl,
|
|
LIVETV_BASE_URL,
|
|
);
|
|
if (!webplayerUrl) continue;
|
|
|
|
let streamType = "";
|
|
try {
|
|
streamType = new URL(webplayerUrl).searchParams.get("t") || "";
|
|
} catch (error) {
|
|
streamType = "";
|
|
}
|
|
|
|
if (streamType.toLowerCase() === "acestream") {
|
|
continue;
|
|
}
|
|
|
|
const language =
|
|
stripLiveTvHtml(
|
|
rowHtml.match(/<img[^>]+title=["']([^"']+)["']/i)?.[1] || "",
|
|
) || "Stream";
|
|
const bitrate = stripLiveTvHtml(
|
|
rowHtml.match(/class=["']bitrate["'][^>]*>([\s\S]*?)<\/td>/i)?.[1] ||
|
|
"",
|
|
);
|
|
const hoster = stripLiveTvHtml(
|
|
rowHtml.match(/class=["']lnktyt["'][^>]*>([\s\S]*?)<\/td>/i)?.[1] || "",
|
|
);
|
|
const title =
|
|
[language, hoster, bitrate].filter(Boolean).join(" - ") || language;
|
|
|
|
entries.push({
|
|
title,
|
|
language,
|
|
bitrate,
|
|
hoster,
|
|
sourceType: streamType,
|
|
webplayerUrl,
|
|
exportUrl: buildLiveTvExportUrl(webplayerUrl, eventUrl),
|
|
});
|
|
}
|
|
|
|
if (entries.length === 0) {
|
|
for (const hrefMatch of rawHtml.matchAll(
|
|
/href=["']([^"']*\/webplayer(?:2)?\.php[^"']*)["']/gi,
|
|
)) {
|
|
const webplayerUrl = absolutizeLiveTvUrl(
|
|
hrefMatch[1],
|
|
eventUrl,
|
|
LIVETV_BASE_URL,
|
|
);
|
|
if (!webplayerUrl) continue;
|
|
|
|
entries.push({
|
|
title: "Stream",
|
|
language: "",
|
|
bitrate: "",
|
|
hoster: "",
|
|
sourceType: "",
|
|
webplayerUrl,
|
|
exportUrl: buildLiveTvExportUrl(webplayerUrl, eventUrl),
|
|
});
|
|
}
|
|
}
|
|
|
|
if (entries.length === 0) {
|
|
const onclickPattern =
|
|
/show_webplayer\('([^']+)'\s*,\s*'([^']+)'\s*,\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*,\s*'([^']+)'\)/gi;
|
|
|
|
for (const match of rawHtml.matchAll(onclickPattern)) {
|
|
const [, type, contentId, eventId, linkId, countryId, streamId, lang] =
|
|
match;
|
|
if (String(type).toLowerCase() === "acestream") continue;
|
|
|
|
const webplayerUrl = absolutizeLiveTvUrl(
|
|
`/webplayer2.php?t=${encodeURIComponent(type)}&c=${encodeURIComponent(contentId)}&lang=${encodeURIComponent(lang)}&eid=${eventId}&lid=${linkId}&ci=${countryId}&si=${streamId}`,
|
|
eventUrl,
|
|
LIVETV_BASE_URL,
|
|
);
|
|
|
|
if (!webplayerUrl) continue;
|
|
|
|
entries.push({
|
|
title: stripLiveTvHtml(type) || "Stream",
|
|
language: stripLiveTvHtml(lang) || "",
|
|
bitrate: "",
|
|
hoster: "",
|
|
sourceType: stripLiveTvHtml(type) || "",
|
|
webplayerUrl,
|
|
exportUrl: buildLiveTvExportUrl(webplayerUrl, eventUrl),
|
|
});
|
|
}
|
|
}
|
|
|
|
return dedupeLiveTvItems(
|
|
entries,
|
|
(entry) => `${entry.exportUrl}__${entry.webplayerUrl}`,
|
|
);
|
|
}
|
|
|
|
function buildLiveTvSourceOptions(entries) {
|
|
return entries.map((entry, index) => ({
|
|
index,
|
|
title: entry.title || `Source ${index + 1}`,
|
|
language: entry.language || "",
|
|
bitrate: entry.bitrate || "",
|
|
hoster: entry.hoster || "",
|
|
sourceType: entry.sourceType || "",
|
|
}));
|
|
}
|
|
|
|
function shouldIgnoreLiveTvIframeUrl(rawUrl) {
|
|
const normalizedUrl = String(rawUrl || "").trim();
|
|
if (!normalizedUrl) {
|
|
return true;
|
|
}
|
|
|
|
if (/^(?:about:blank|javascript:|data:)/i.test(normalizedUrl)) {
|
|
return true;
|
|
}
|
|
|
|
try {
|
|
const parsed = new URL(normalizedUrl, LIVETV_BASE_URL);
|
|
const hostname = parsed.hostname.toLowerCase();
|
|
const pathname = parsed.pathname.toLowerCase();
|
|
const search = parsed.search.toLowerCase();
|
|
const combined = `${hostname}${pathname}${search}`;
|
|
|
|
if (
|
|
hostname === "ads.livetv901.me" ||
|
|
hostname.startsWith("ads.") ||
|
|
hostname.startsWith("ad.")
|
|
) {
|
|
return true;
|
|
}
|
|
|
|
if (pathname.includes("getbanner.php") || search.includes("zone_id=")) {
|
|
return true;
|
|
}
|
|
|
|
if (
|
|
/(?:^|[./_-])(banner|ads?|popunder|popup)(?:[./_-]|$)/i.test(combined)
|
|
) {
|
|
return true;
|
|
}
|
|
} catch (error) {
|
|
return false;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
function shouldTreatLiveTvIframeAsTerminalEmbed(
|
|
rawUrl,
|
|
fallbackBase = LIVETV_BASE_URL,
|
|
) {
|
|
const normalizedUrl = String(rawUrl || "").trim();
|
|
if (!normalizedUrl) {
|
|
return false;
|
|
}
|
|
|
|
if (/\.(m3u8|mpd)(?:[?#]|$)/i.test(normalizedUrl)) {
|
|
return false;
|
|
}
|
|
|
|
try {
|
|
const parsed = new URL(normalizedUrl, fallbackBase || LIVETV_BASE_URL);
|
|
const fallbackHost = new URL(fallbackBase || LIVETV_BASE_URL).hostname
|
|
.replace(/^www\./i, "")
|
|
.toLowerCase();
|
|
const hostname = parsed.hostname.replace(/^www\./i, "").toLowerCase();
|
|
|
|
if (!hostname || !fallbackHost) {
|
|
return false;
|
|
}
|
|
|
|
return (
|
|
hostname !== fallbackHost && !hostname.endsWith(`.${fallbackHost}`)
|
|
);
|
|
} catch (error) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function isLiveTvExportIframePage(rawUrl) {
|
|
try {
|
|
const parsed = new URL(rawUrl, LIVETV_BASE_URL);
|
|
return /\/export\/webplayer\.iframe\.php$/i.test(parsed.pathname);
|
|
} catch (error) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function shouldFollowLiveTvIframeForExtraction(iframeUrl, pageUrl) {
|
|
try {
|
|
const page = new URL(pageUrl, LIVETV_BASE_URL);
|
|
if (!isLiveTvExportIframePage(page.href)) {
|
|
return false;
|
|
}
|
|
|
|
if ((page.searchParams.get("t") || "").toLowerCase() !== "alieztv") {
|
|
return false;
|
|
}
|
|
|
|
const iframe = new URL(iframeUrl, page.href);
|
|
const hostname = iframe.hostname.replace(/^www\./i, "").toLowerCase();
|
|
const pathname = iframe.pathname.toLowerCase();
|
|
|
|
return hostname === "emb.apl395.me" && pathname === "/player/live.php";
|
|
} catch (error) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function extractLiveTvIframeUrls(html, currentUrl) {
|
|
const iframeUrls = [];
|
|
const iframePattern = /<iframe[^>]+src=["']([^"']+)["']/gi;
|
|
|
|
for (const match of String(html || "").matchAll(iframePattern)) {
|
|
const iframeUrl = absolutizeLiveTvUrl(
|
|
match[1],
|
|
currentUrl,
|
|
LIVETV_BASE_URL,
|
|
);
|
|
if (iframeUrl && !shouldIgnoreLiveTvIframeUrl(iframeUrl)) {
|
|
iframeUrls.push(iframeUrl);
|
|
}
|
|
}
|
|
|
|
return dedupeLiveTvItems(iframeUrls, (url) => url);
|
|
}
|
|
|
|
function extractLiveTvDirectStreams(html, referer) {
|
|
const streams = [];
|
|
const cleaned = String(html || "").replace(/\\\//g, "/");
|
|
|
|
const addCandidate = (rawUrl, candidateReferer = referer) => {
|
|
const absoluteUrl = absolutizeLiveTvUrl(
|
|
rawUrl,
|
|
candidateReferer,
|
|
LIVETV_BASE_URL,
|
|
);
|
|
if (!absoluteUrl) return;
|
|
if (!/\.(m3u8|mpd)(?:[?#]|$)/i.test(absoluteUrl)) return;
|
|
streams.push({ url: absoluteUrl, referer: candidateReferer });
|
|
};
|
|
|
|
const packedStreamUrl = extractM3u8FromPackedHtml(cleaned);
|
|
if (packedStreamUrl) {
|
|
addCandidate(packedStreamUrl, referer);
|
|
}
|
|
|
|
const hocaStreamUrl = decodeHocaStream(cleaned);
|
|
if (hocaStreamUrl) {
|
|
addCandidate(hocaStreamUrl, referer);
|
|
}
|
|
|
|
for (const match of cleaned.matchAll(
|
|
/pl\.init\(\s*['"]([^'"]+)['"]\s*\)/gi,
|
|
)) {
|
|
addCandidate(match[1], referer);
|
|
}
|
|
|
|
for (const match of cleaned.matchAll(
|
|
/manifestUrl\s*:\s*['"]([^'"]+)['"]/gi,
|
|
)) {
|
|
addCandidate(match[1], referer);
|
|
}
|
|
|
|
for (const match of cleaned.matchAll(
|
|
/(?:source|file|src)\s*[:=]\s*['"]([^'"]+\.(?:m3u8|mpd)[^'"]*)['"]/gi,
|
|
)) {
|
|
addCandidate(match[1], referer);
|
|
}
|
|
|
|
for (const match of cleaned.matchAll(
|
|
/['"]((?:https?:)?\/\/[^'"]+\.(?:m3u8|mpd)[^'"]*)['"]/gi,
|
|
)) {
|
|
addCandidate(match[1], referer);
|
|
}
|
|
|
|
return dedupeLiveTvItems(
|
|
streams,
|
|
(stream) => `${stream.url}__${stream.referer || ""}`,
|
|
);
|
|
}
|
|
|
|
async function addLiveTvHeadersRule(
|
|
targetUrl,
|
|
userAgent = STREAM_PROXY_USER_AGENT,
|
|
) {
|
|
try {
|
|
const url = new URL(targetUrl);
|
|
const rulePattern = `*://${url.host}${url.pathname}*`;
|
|
|
|
await addHeadersRule(rulePattern, {
|
|
Origin: LIVETV_EMBED_ORIGIN,
|
|
Referer: LIVETV_EMBED_REFERER,
|
|
"User-Agent": userAgent || STREAM_PROXY_USER_AGENT,
|
|
});
|
|
} catch (error) {
|
|
console.warn("[LIVETV] Failed to add page headers rule:", error);
|
|
}
|
|
}
|
|
|
|
async function addLiveTvEmbedHeadersRule(targetUrl) {
|
|
try {
|
|
const url = new URL(targetUrl);
|
|
const rulePattern = `*://${url.host}${url.pathname}*`;
|
|
|
|
await addHeadersRule(rulePattern, {
|
|
Origin: LIVETV_EMBED_ORIGIN,
|
|
Referer: LIVETV_EMBED_REFERER,
|
|
});
|
|
} catch (error) {
|
|
console.warn("[LIVETV] Failed to add embed headers rule:", error);
|
|
}
|
|
}
|
|
|
|
async function fetchLiveTvText(url, referer = "") {
|
|
try {
|
|
const absoluteUrl = absolutizeLiveTvUrl(url, referer, LIVETV_BASE_URL);
|
|
if (!absoluteUrl) return null;
|
|
|
|
await addLiveTvHeadersRule(absoluteUrl);
|
|
|
|
const response = await fetch(absoluteUrl, {
|
|
method: "GET",
|
|
headers: {
|
|
Accept:
|
|
"text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
|
"Accept-Language": "fr-FR,fr;q=0.9,en-US;q=0.8,en;q=0.7",
|
|
"User-Agent": STREAM_PROXY_USER_AGENT,
|
|
},
|
|
cache: "no-cache",
|
|
redirect: "follow",
|
|
});
|
|
|
|
if (!response.ok) {
|
|
console.warn(
|
|
`[LIVETV] Fetch failed for ${absoluteUrl}: ${response.status} ${response.statusText}`,
|
|
);
|
|
return null;
|
|
}
|
|
|
|
return {
|
|
html: await response.text(),
|
|
finalUrl: response.url || absoluteUrl,
|
|
};
|
|
} catch (error) {
|
|
console.warn(`[LIVETV] Fetch error for ${url}:`, error.message || error);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
async function resolveLiveTvMediaFromUrl(
|
|
startUrl,
|
|
referer,
|
|
depth = 4,
|
|
visited = new Set(),
|
|
) {
|
|
const absoluteUrl = absolutizeLiveTvUrl(startUrl, referer, LIVETV_BASE_URL);
|
|
if (!absoluteUrl || visited.has(absoluteUrl)) {
|
|
return { streams: [], embeds: [] };
|
|
}
|
|
|
|
visited.add(absoluteUrl);
|
|
|
|
if (/\.(m3u8|mpd)(?:[?#]|$)/i.test(absoluteUrl)) {
|
|
return {
|
|
streams: [{ url: absoluteUrl, referer: referer || absoluteUrl }],
|
|
embeds: [],
|
|
};
|
|
}
|
|
|
|
const page = await fetchLiveTvText(absoluteUrl, referer);
|
|
if (!page?.html) {
|
|
return { streams: [], embeds: [] };
|
|
}
|
|
|
|
let streams = extractLiveTvDirectStreams(
|
|
page.html,
|
|
page.finalUrl || absoluteUrl,
|
|
);
|
|
let embeds = [];
|
|
const currentPageUrl = page.finalUrl || absoluteUrl;
|
|
const iframeUrls = extractLiveTvIframeUrls(
|
|
page.html,
|
|
page.finalUrl || absoluteUrl,
|
|
);
|
|
|
|
if (depth <= 0) {
|
|
return {
|
|
streams,
|
|
embeds: iframeUrls.map((url) => ({
|
|
url,
|
|
referer: LIVETV_EMBED_REFERER,
|
|
})),
|
|
};
|
|
}
|
|
|
|
for (const iframeUrl of iframeUrls) {
|
|
if (!shouldFollowLiveTvIframeForExtraction(iframeUrl, currentPageUrl)) {
|
|
embeds.push({ url: iframeUrl, referer: LIVETV_EMBED_REFERER });
|
|
continue;
|
|
}
|
|
|
|
console.log(`[LIVETV] Following iframe locally: ${iframeUrl}`);
|
|
const nested = await resolveLiveTvMediaFromUrl(
|
|
iframeUrl,
|
|
page.finalUrl || absoluteUrl,
|
|
depth - 1,
|
|
visited,
|
|
);
|
|
streams = streams.concat(nested.streams);
|
|
embeds = embeds.concat(nested.embeds);
|
|
|
|
if (nested.streams.length === 0 && nested.embeds.length === 0) {
|
|
embeds.push({ url: iframeUrl, referer: LIVETV_EMBED_REFERER });
|
|
}
|
|
}
|
|
|
|
return {
|
|
streams: dedupeLiveTvItems(
|
|
streams,
|
|
(stream) => `${stream.url}__${stream.referer || ""}`,
|
|
),
|
|
embeds: dedupeLiveTvItems(
|
|
embeds,
|
|
(embed) => `${embed.url}__${embed.referer || ""}`,
|
|
),
|
|
};
|
|
}
|
|
|
|
async function addSosplayHeadersRule(
|
|
urlPattern,
|
|
customReferer = null,
|
|
customUserAgent = STREAM_PROXY_USER_AGENT,
|
|
) {
|
|
try {
|
|
const url = new URL(urlPattern);
|
|
const pathNoExt = url.pathname.replace(/\.[^/.]+$/, "");
|
|
const rulePattern = `*://${url.host}${pathNoExt}*`;
|
|
|
|
const referer = customReferer || "https://dishtrainer.net/";
|
|
|
|
let origin;
|
|
try {
|
|
origin = new URL(referer).origin;
|
|
} catch {
|
|
origin = "https://dishtrainer.net";
|
|
}
|
|
|
|
const userAgent = customUserAgent || STREAM_PROXY_USER_AGENT;
|
|
|
|
await addHeadersRule(rulePattern, {
|
|
Origin: origin,
|
|
Referer: referer,
|
|
"User-Agent": userAgent,
|
|
});
|
|
} catch (e) {
|
|
console.error("[SOSPLAY] Failed to add headers rule:", e);
|
|
}
|
|
}
|
|
|
|
// === UTILS ===
|
|
|
|
function decodeHocaStream(html) {
|
|
try {
|
|
const atobMatch = html.match(/atob\(['"]([^'"]+)['"]\)/);
|
|
if (atobMatch) {
|
|
try {
|
|
const decoded = atob(atobMatch[1]);
|
|
if (decoded.includes(".m3u8")) return decoded;
|
|
} catch (e) {}
|
|
}
|
|
|
|
const urlArrayMatch = html.match(/return\s*\(\[([^\]]+)\]\.join/);
|
|
if (urlArrayMatch) {
|
|
try {
|
|
const chars = urlArrayMatch[1].match(/"([^"]*)"/g);
|
|
if (chars) {
|
|
let url = chars.map((c) => c.replace(/"/g, "")).join("");
|
|
url = url.replace(/\\\//g, "/");
|
|
if (url.includes(".m3u8")) return url;
|
|
}
|
|
} catch (e) {}
|
|
}
|
|
|
|
const srcMatch = html.match(
|
|
/(?:source|src|file)\s*[:=]\s*["'](https?:\/\/[^"']+\.m3u8[^"']*)/i,
|
|
);
|
|
if (srcMatch) {
|
|
return srcMatch[1].replace(/\\\//g, "/");
|
|
}
|
|
|
|
const m3u8Match = html.match(/["'](https?:\/\/[^"']+\.m3u8[^"']*)/);
|
|
if (m3u8Match) {
|
|
return m3u8Match[1].replace(/\\\//g, "/");
|
|
}
|
|
|
|
const packerResult = extractM3u8FromPackedHtml(html);
|
|
if (packerResult) return packerResult;
|
|
|
|
return null;
|
|
} catch (error) {
|
|
console.error("[SOSPLAY-HOCA] Error:", error.message || error);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Dean Edwards Packer decoder (see extractors.js for full documentation).
|
|
*
|
|
* Converts a number to its base-N string, builds a keyword lookup table,
|
|
* then replaces every placeholder token in the packed template with the
|
|
* corresponding keyword to recover the original readable JavaScript.
|
|
*
|
|
* @param {string} packedScript - Template with placeholder tokens
|
|
* @param {number} radix - Numeric base for token encoding
|
|
* @param {number} keywordCount - Number of keywords
|
|
* @param {string[]} keywords - Replacement words indexed by token value
|
|
* @returns {string} Decoded JavaScript source
|
|
*/
|
|
function decodeDeanEdwardsPacker(
|
|
packedScript,
|
|
radix,
|
|
keywordCount,
|
|
keywords,
|
|
) {
|
|
function numberToBaseNString(number) {
|
|
const quotient = Math.floor(number / radix);
|
|
const remainder = number % radix;
|
|
const digit =
|
|
remainder > 35
|
|
? String.fromCharCode(remainder + 29)
|
|
: remainder.toString(36);
|
|
return (quotient > 0 ? numberToBaseNString(quotient) : "") + digit;
|
|
}
|
|
|
|
const lookupTable = {};
|
|
for (let i = keywordCount - 1; i >= 0; i--) {
|
|
const token = numberToBaseNString(i);
|
|
lookupTable[token] = keywords[i] || token;
|
|
}
|
|
|
|
return packedScript.replace(/\b\w+\b/g, function (token) {
|
|
return lookupTable[token] !== undefined ? lookupTable[token] : token;
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Extract M3U8 video URLs from HTML that contains Dean Edwards Packed
|
|
* script blocks (used by WigiStream / live TV embed pages).
|
|
*
|
|
* The function searches for all packed blocks in the HTML, decodes each
|
|
* one, and looks for .m3u8 stream URLs in the decoded output.
|
|
*
|
|
* @param {string} html - Raw HTML source of the embed page
|
|
* @returns {string|null} The best M3U8 URL found, or null
|
|
*/
|
|
function extractM3u8FromPackedHtml(html) {
|
|
try {
|
|
// Strategy 1: Try to match complete packed blocks with a regex.
|
|
// Split to avoid Chrome Web Store code scanner false positives.
|
|
const packerBlockPattern = new RegExp(
|
|
"ev" +
|
|
"al\\(function\\(p,a,c,k,e,(?:d|r)\\)\\{.*?return p\\}\\(\\s*['\"]([\\s\\S]*?)['\"]\\s*,\\s*(\\d+)\\s*,\\s*(\\d+)\\s*,\\s*['\"]([\\s\\S]*?)['\"]\\s*\\.split\\(['\"]\\|['\"]\\)\\s*(?:,\\s*0\\s*,\\s*\\{\\})?\\s*\\)\\)",
|
|
"gs",
|
|
);
|
|
const regexMatches = [...html.matchAll(packerBlockPattern)];
|
|
|
|
// Strategy 2: If the regex didn't match (malformed or unusual formatting),
|
|
// manually locate each packed block by searching for the marker string
|
|
// and parsing the arguments by hand.
|
|
if (regexMatches.length === 0) {
|
|
const markerPositions = [];
|
|
let searchPos = 0;
|
|
const marker = "ev" + "al(func" + "tion(p,a,c,k,e,";
|
|
while (true) {
|
|
const index = html.indexOf(marker, searchPos);
|
|
if (index === -1) break;
|
|
markerPositions.push(index);
|
|
searchPos = index + 1;
|
|
}
|
|
|
|
for (const position of markerPositions) {
|
|
// Take a chunk of HTML starting at the marker (5000 chars should
|
|
// be enough to contain the full packed block arguments)
|
|
const chunk = html.substring(position, position + 5000);
|
|
|
|
// Find the .split('|') that terminates the keyword list
|
|
const splitIdx = chunk.indexOf(".split('|')");
|
|
const splitIdx2 = chunk.indexOf('.split("|")');
|
|
const keywordListEnd = splitIdx !== -1 ? splitIdx : splitIdx2;
|
|
if (keywordListEnd === -1) continue;
|
|
|
|
// Extract the keyword string (everything between the last quote
|
|
// before .split and the .split itself)
|
|
let keywordStringStart = chunk.lastIndexOf("'", keywordListEnd - 1);
|
|
if (keywordStringStart === -1)
|
|
keywordStringStart = chunk.lastIndexOf('"', keywordListEnd - 1);
|
|
if (keywordStringStart === -1) continue;
|
|
|
|
const keywords = chunk
|
|
.substring(keywordStringStart + 1, keywordListEnd)
|
|
.split("|");
|
|
|
|
// Find the template string: starts after }(' or }("
|
|
const templateMarker1 = chunk.indexOf("}('");
|
|
const templateMarker2 = chunk.indexOf('}("');
|
|
const templateStart =
|
|
templateMarker1 !== -1 ? templateMarker1 : templateMarker2;
|
|
if (templateStart === -1) continue;
|
|
|
|
const quoteChar = chunk[templateStart + 2];
|
|
const templateBegin = templateStart + 3;
|
|
const templateEnd = chunk.indexOf(quoteChar + ",", templateBegin);
|
|
if (templateEnd === -1) continue;
|
|
|
|
const packedTemplate = chunk.substring(templateBegin, templateEnd);
|
|
|
|
// Parse the radix and count numbers that follow the template
|
|
const afterTemplate = chunk.substring(templateEnd + 2);
|
|
const numbersMatch = afterTemplate.match(/^\s*(\d+)\s*,\s*(\d+)\s*,/);
|
|
if (!numbersMatch) continue;
|
|
|
|
const radix = parseInt(numbersMatch[1]);
|
|
const keywordCount = parseInt(numbersMatch[2]);
|
|
const decodedScript = decodeDeanEdwardsPacker(
|
|
packedTemplate,
|
|
radix,
|
|
keywordCount,
|
|
keywords,
|
|
);
|
|
|
|
// Check if the decoded script contains video player code
|
|
const hasVideoContent =
|
|
decodedScript &&
|
|
(decodedScript.includes(".m3u8") ||
|
|
decodedScript.includes("hls") ||
|
|
decodedScript.includes("Clappr"));
|
|
|
|
if (hasVideoContent) {
|
|
const m3u8Url = findBestM3u8Url(decodedScript);
|
|
if (m3u8Url) return m3u8Url;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Process regex-matched packed blocks
|
|
for (const packerMatch of regexMatches) {
|
|
const packedTemplate = packerMatch[1];
|
|
const radix = parseInt(packerMatch[2]);
|
|
const keywordCount = parseInt(packerMatch[3]);
|
|
const keywords = packerMatch[4].split("|");
|
|
const decodedScript = decodeDeanEdwardsPacker(
|
|
packedTemplate,
|
|
radix,
|
|
keywordCount,
|
|
keywords,
|
|
);
|
|
|
|
const hasVideoContent =
|
|
decodedScript.includes("Clappr") ||
|
|
decodedScript.includes(".m3u8") ||
|
|
decodedScript.includes("hls");
|
|
|
|
if (!hasVideoContent) continue;
|
|
|
|
const m3u8Url = findBestM3u8Url(decodedScript);
|
|
if (m3u8Url) return m3u8Url;
|
|
}
|
|
|
|
// Fallback: try to find M3U8 URLs directly in the raw HTML
|
|
const directSrcMatch = html.match(/src:\s*["']([^"']+\.m3u8[^"']*)/i);
|
|
if (directSrcMatch) return directSrcMatch[1].replace(/\\\//g, "/");
|
|
|
|
const directStreamMatch = html.match(
|
|
/["'](https?:\/\/[^"']*\.m3u8[^"']*)/,
|
|
);
|
|
if (directStreamMatch) return directStreamMatch[1].replace(/\\\//g, "/");
|
|
|
|
const varSrcMatch = html.match(/var\s+src\s*=\s*["']([^"']+)/i);
|
|
if (varSrcMatch && varSrcMatch[1].includes(".m3u8")) {
|
|
return varSrcMatch[1].replace(/\\\//g, "/");
|
|
}
|
|
|
|
return null;
|
|
} catch (error) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Find the best M3U8 URL from decoded script content.
|
|
* Prefers backup/CDN URLs over primary ones.
|
|
*
|
|
* @param {string} scriptContent - Decoded JavaScript source
|
|
* @returns {string|null} Best M3U8 URL found, or null
|
|
*/
|
|
function findBestM3u8Url(scriptContent) {
|
|
const m3u8Pattern = /["'](https?:\/\/[^"']+\.m3u8[^"']*)["']/g;
|
|
let match;
|
|
const urls = [];
|
|
while ((match = m3u8Pattern.exec(scriptContent)) !== null) {
|
|
urls.push(match[1].replace(/\\\//g, "/"));
|
|
}
|
|
if (urls.length === 0) return null;
|
|
|
|
// Prefer backup/live CDN URLs, then primary CDN, then first found
|
|
const backupUrl = urls.find(
|
|
(u) => u.includes("vuunov") || u.includes("live"),
|
|
);
|
|
const primaryUrl = urls.find(
|
|
(u) => u.includes("shop") || u.includes("srvagu"),
|
|
);
|
|
return backupUrl || primaryUrl || urls[0];
|
|
}
|
|
|
|
async function fetchSafe(url, context = "") {
|
|
try {
|
|
const options = {
|
|
method: "GET",
|
|
headers: {
|
|
Accept: "application/json",
|
|
"Accept-Language": "fr-FR,fr;q=0.9,en-US;q=0.8,en;q=0.7",
|
|
},
|
|
cache: "no-cache",
|
|
};
|
|
|
|
const response = await fetch(url, options);
|
|
if (!response.ok) {
|
|
console.error(
|
|
`Fetch failed for ${context || url}: ${response.status} ${response.statusText}`,
|
|
);
|
|
return null;
|
|
}
|
|
return await response.json();
|
|
} catch (e) {
|
|
console.error(`Fetch error for ${context || url}:`, e);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
// Cache helpers
|
|
async function getFromCache(key) {
|
|
const result = await chrome.storage.local.get(key);
|
|
const entry = result[key];
|
|
if (!entry) return null;
|
|
if (Date.now() > entry.expiry) {
|
|
chrome.storage.local.remove(key);
|
|
return null;
|
|
}
|
|
return entry.data;
|
|
}
|
|
|
|
async function saveToCache(key, data, ttlMinutes) {
|
|
const expiry = Date.now() + ttlMinutes * 60 * 1000;
|
|
await chrome.storage.local.set({ [key]: { data, expiry } });
|
|
}
|
|
|
|
// DNR Helper
|
|
let ruleIdCounter = 100;
|
|
|
|
async function addUserAgentRule(urlPattern, userAgent) {
|
|
return addHeadersRule(urlPattern, { "User-Agent": userAgent });
|
|
}
|
|
|
|
// === FCTV (matches): Referer rule + IP-bound local resolver ===============
|
|
const FCTV_HEADERS_RULE_ID = 60;
|
|
async function setupFctvHeadersRule(referer) {
|
|
if (!referer) return false;
|
|
const ref = referer.endsWith("/") ? referer : referer + "/";
|
|
let origin;
|
|
try { origin = new URL(ref).origin; } catch { origin = ref.replace(/\/+$/, ""); }
|
|
try {
|
|
await chrome.declarativeNetRequest.updateDynamicRules({
|
|
removeRuleIds: [FCTV_HEADERS_RULE_ID],
|
|
addRules: [
|
|
{
|
|
id: FCTV_HEADERS_RULE_ID,
|
|
priority: 20,
|
|
action: {
|
|
type: "modifyHeaders",
|
|
requestHeaders: [
|
|
{ header: "Referer", operation: "set", value: ref },
|
|
{ header: "Origin", operation: "set", value: origin },
|
|
{ header: "User-Agent", operation: "set", value: STREAM_PROXY_USER_AGENT },
|
|
],
|
|
},
|
|
condition: {
|
|
urlFilter: "/cfall/s",
|
|
resourceTypes: ["xmlhttprequest", "media", "other"],
|
|
},
|
|
},
|
|
],
|
|
});
|
|
return true;
|
|
} catch (e) {
|
|
console.error("[FCTV] Failed to add headers rule:", e);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
const FCTV_API_BASE = "https://apis-data-defra10.tcore131ybdf.ru";
|
|
const FCTV_TOKEN_KEYSTREAM_HEX =
|
|
"15764bab80a419c6abdd5518f3db0ea95bb3b9a2e2b519ce5c159af6917e2000c2d680ae30706a3aba1c9c25786c7c28774eecf20450a3cf414ca17f6472798cfa557c7a8705b7861f06e84f827f8a24676eeab77ce504bfc335b79609b9";
|
|
function fctvRot47(s) {
|
|
let out = "";
|
|
for (let i = 0; i < s.length; i++) {
|
|
const k = s.charCodeAt(i);
|
|
out += k < 33 || k > 126 ? s[i] : String.fromCharCode(33 + ((k - 33 + 47) % 94));
|
|
}
|
|
return out;
|
|
}
|
|
function fctvReadVarint(buf, off) {
|
|
let result = 0n, shift = 0n, cur = off;
|
|
while (cur < buf.length) {
|
|
const b = buf[cur++];
|
|
result |= BigInt(b & 0x7f) << shift;
|
|
if ((b & 0x80) === 0) return { value: result, offset: cur };
|
|
shift += 7n;
|
|
if (shift > 70n) break;
|
|
}
|
|
throw new Error("varint");
|
|
}
|
|
function fctvDecode(buf, depth = 0) {
|
|
const fields = [];
|
|
let off = 0;
|
|
while (off < buf.length) {
|
|
let tag;
|
|
try { tag = fctvReadVarint(buf, off); } catch { break; }
|
|
off = tag.offset;
|
|
const field = Number(tag.value >> 3n);
|
|
const wt = Number(tag.value & 7n);
|
|
const e = { field, wireType: wt };
|
|
if (wt === 0) {
|
|
const p = fctvReadVarint(buf, off); off = p.offset;
|
|
} else if (wt === 1) {
|
|
off += 8;
|
|
} else if (wt === 2) {
|
|
const pl = fctvReadVarint(buf, off); off = pl.offset;
|
|
const len = Number(pl.value);
|
|
const bytes = buf.subarray(off, off + len);
|
|
off += len;
|
|
let text = "";
|
|
try { text = new TextDecoder().decode(bytes); } catch {}
|
|
let printable = 0;
|
|
for (let i = 0; i < text.length; i++) { const c = text.charCodeAt(i); if ((c >= 32 && c <= 126) || c >= 160) printable++; }
|
|
if (text && printable / text.length > 0.7) e.value = text;
|
|
if (depth < 8 && bytes.length) { try { const ch = fctvDecode(bytes, depth + 1); if (ch.length) e.children = ch; } catch {} }
|
|
} else if (wt === 5) {
|
|
off += 4;
|
|
} else break;
|
|
fields.push(e);
|
|
}
|
|
return fields;
|
|
}
|
|
const fctvField = (fields, f) => (fields || []).find((e) => e.field === f);
|
|
const fctvChildren = (fields, f) => { const x = fctvField(fields, f); return (x && x.children) || []; };
|
|
function fctvMakeToken(rbSession) {
|
|
const ks = [];
|
|
for (let i = 0; i < FCTV_TOKEN_KEYSTREAM_HEX.length; i += 2) ks.push(parseInt(FCTV_TOKEN_KEYSTREAM_HEX.substr(i, 2), 16));
|
|
const pt = new TextEncoder().encode(rbSession);
|
|
const n = Math.min(pt.length, ks.length);
|
|
let bin = "";
|
|
for (let i = 0; i < n; i++) bin += String.fromCharCode(pt[i] ^ ks[i]);
|
|
return encodeURIComponent(btoa(bin) + "a");
|
|
}
|
|
async function resolveFctvStream(opts) {
|
|
const { matchId, streamId, siteType, sportType, referer, apiBase } = opts || {};
|
|
if (!matchId || !streamId) return null;
|
|
const base = apiBase || FCTV_API_BASE;
|
|
const u = new URL(base + "/api/stream/detail");
|
|
u.searchParams.set("streamId", String(streamId));
|
|
u.searchParams.set("siteType", String(siteType || 2001));
|
|
u.searchParams.set("continent", "EU");
|
|
u.searchParams.set("country", "FR");
|
|
u.searchParams.set("digit", "seth");
|
|
u.searchParams.set("matchId", String(matchId));
|
|
u.searchParams.set("sportType", String(sportType || 1));
|
|
const resp = await fetch(u.toString(), { headers: { Accept: "*/*" } });
|
|
const rbSession = resp.headers.get("rb-session");
|
|
const buf = new Uint8Array(await resp.arrayBuffer());
|
|
const root = fctvDecode(buf);
|
|
const body = fctvChildren(root, 10);
|
|
const inner = fctvChildren(body, 2).length ? fctvChildren(body, 2) : body;
|
|
const maskedField = fctvField(inner, 4);
|
|
const masked = maskedField && typeof maskedField.value === "string" ? maskedField.value : "";
|
|
if (!masked || !rbSession) return null;
|
|
let parsed;
|
|
try { parsed = new URL(fctvRot47(masked).slice(8)); } catch { return null; }
|
|
const token = fctvMakeToken(rbSession);
|
|
if (referer) await setupFctvHeadersRule(referer);
|
|
return `${parsed.origin}/token-${token}${parsed.pathname}${parsed.search}`;
|
|
}
|
|
|
|
async function addHeadersRule(urlPattern, headers) {
|
|
const existingRules = await chrome.declarativeNetRequest.getDynamicRules();
|
|
const existingIds = new Set(existingRules.map((r) => r.id));
|
|
|
|
let id = ruleIdCounter;
|
|
while (existingIds.has(id)) {
|
|
id++;
|
|
}
|
|
ruleIdCounter = id + 1;
|
|
|
|
const requestHeaders = Object.entries(headers).map(([header, value]) => ({
|
|
header: header,
|
|
operation: "set",
|
|
value: value,
|
|
}));
|
|
|
|
const rule = {
|
|
id: id,
|
|
priority: 10,
|
|
action: {
|
|
type: "modifyHeaders",
|
|
requestHeaders: requestHeaders,
|
|
},
|
|
condition: {
|
|
urlFilter: urlPattern,
|
|
resourceTypes: [
|
|
"xmlhttprequest",
|
|
"media",
|
|
"websocket",
|
|
"other",
|
|
"sub_frame",
|
|
"main_frame",
|
|
],
|
|
},
|
|
};
|
|
|
|
try {
|
|
await chrome.declarativeNetRequest.updateDynamicRules({
|
|
addRules: [rule],
|
|
});
|
|
} catch (e) {
|
|
console.error("Failed to add dynamic rule:", e);
|
|
}
|
|
}
|
|
// --- END extension/Chrome/background.js ---
|
|
|
|
function postExtensionResponse(messageId, success, payload) {
|
|
pageWindow.postMessage(
|
|
success
|
|
? {
|
|
source: "MOVIX_EXTENSION",
|
|
messageId,
|
|
success: true,
|
|
data: payload,
|
|
}
|
|
: {
|
|
source: "MOVIX_EXTENSION",
|
|
messageId,
|
|
success: false,
|
|
error:
|
|
payload instanceof Error
|
|
? payload.message
|
|
: payload?.error || String(payload || "Erreur inconnue"),
|
|
},
|
|
"*",
|
|
);
|
|
}
|
|
|
|
async function requestAction(action, payload) {
|
|
try {
|
|
const result = await handleMessage({ action, payload });
|
|
if (result && result.error) {
|
|
return { success: false, error: result.error };
|
|
}
|
|
return { success: true, data: result };
|
|
} catch (error) {
|
|
return {
|
|
success: false,
|
|
error: error instanceof Error ? error.message : String(error),
|
|
};
|
|
}
|
|
}
|
|
|
|
function exposePageApi() {
|
|
pageWindow.__MOVIX_EXTENSION_INSTALLED = true;
|
|
pageWindow.hasMovixUserscript = true;
|
|
pageWindow.hasMovixNexusExtractor = true;
|
|
|
|
if (document.documentElement) {
|
|
document.documentElement.dataset.movixExtension = "true";
|
|
}
|
|
|
|
pageWindow.movixExtractM3u8 = async function movixExtractM3u8(type, url) {
|
|
const result = await requestAction("EXTRACT_M3U8", { type, url });
|
|
return result.success
|
|
? result.data
|
|
: { success: false, error: result.error || "Extraction impossible" };
|
|
};
|
|
|
|
pageWindow.movixExtractAllM3u8 = async function movixExtractAllM3u8(
|
|
sources,
|
|
) {
|
|
const result = await requestAction("EXTRACT_ALL_M3U8", { sources });
|
|
return result.success
|
|
? result.data
|
|
: {
|
|
success: false,
|
|
error: result.error || "Extraction multiple impossible",
|
|
results: [],
|
|
};
|
|
};
|
|
|
|
pageWindow.movixDetectEmbeds = async function movixDetectEmbeds(sources) {
|
|
const result = await requestAction("DETECT_EMBEDS", { sources });
|
|
return result.success ? result.data : { embeds: [] };
|
|
};
|
|
|
|
pageWindow.movixSetupHeaders = async function movixSetupHeaders(type, url) {
|
|
const result = await requestAction("SETUP_HEADERS", { type, url });
|
|
return result.success
|
|
? result.data
|
|
: { success: false, error: result.error || "Configuration impossible" };
|
|
};
|
|
|
|
pageWindow.dispatchEvent(new CustomEvent("movix-extension-loaded"));
|
|
}
|
|
|
|
pageWindow.addEventListener("message", async (event) => {
|
|
if (
|
|
event.source !== pageWindow ||
|
|
!event.data ||
|
|
event.data.source !== "MOVIX_WEB" ||
|
|
event.data.type !== "EXTENSION_REQUEST"
|
|
) {
|
|
return;
|
|
}
|
|
|
|
const { action, payload, messageId } = event.data;
|
|
const result = await requestAction(action, payload);
|
|
|
|
if (result.success) {
|
|
postExtensionResponse(messageId, true, result.data);
|
|
} else {
|
|
postExtensionResponse(
|
|
messageId,
|
|
false,
|
|
result.error || "Erreur inconnue",
|
|
);
|
|
}
|
|
});
|
|
|
|
exposePageApi();
|
|
|
|
Promise.resolve()
|
|
.then(() => (typeof setupRules === "function" ? setupRules() : undefined))
|
|
.catch((error) => {
|
|
console.error("[Movix Userscript] setupRules error:", error);
|
|
});
|
|
})();
|