mirror of
https://github.com/movixcorp/MovixOpenSource.git
synced 2026-07-28 01:52:22 +00:00
2625 lines
77 KiB
JavaScript
2625 lines
77 KiB
JavaScript
// Firefox-compatible background script for Movix Extension
|
|
// In Firefox MV3, background scripts run in an event page context.
|
|
// extractors.js is loaded before this file via manifest "background.scripts".
|
|
|
|
const browserAPI = typeof browser !== "undefined" ? browser : chrome;
|
|
|
|
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: when the requesting page is localhost (Vite dev on :3000),
|
|
// talk to the local backend (:25565) instead of prod. Set per-message from
|
|
// the sender origin (see maybeUseLocalApi in the onMessage listener below).
|
|
const PROD_API_BASE_URL = "https://api.movix.show";
|
|
const LOCAL_API_BASE_URL = "http://localhost:25565";
|
|
let API_BASE_URL = PROD_API_BASE_URL;
|
|
|
|
function maybeUseLocalApi(sender) {
|
|
try {
|
|
const u =
|
|
sender && (sender.url || (sender.tab && sender.tab.url) || sender.origin);
|
|
if (!u) return;
|
|
const host = new URL(u).hostname;
|
|
API_BASE_URL =
|
|
host === "localhost" || host === "127.0.0.1"
|
|
? LOCAL_API_BASE_URL
|
|
: PROD_API_BASE_URL;
|
|
} catch (e) {}
|
|
}
|
|
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";
|
|
|
|
// Access extractors loaded via manifest background.scripts
|
|
const Extractors = globalThis.MovixExtractors;
|
|
|
|
// Cache for Wiflix channels with their page slugs
|
|
let wiflixChannelCache = {};
|
|
|
|
// Extension enabled state
|
|
let extensionEnabled = true;
|
|
|
|
// 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;
|
|
|
|
// 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
|
|
browserAPI.storage.local.get(["extensionEnabled", "stats", "extractionPrefs"], (result) => {
|
|
extensionEnabled = result.extensionEnabled !== false;
|
|
if (result.stats) {
|
|
sessionStats = { ...sessionStats, ...result.stats };
|
|
// Guarantee byType subobject even for stats saved before this migration
|
|
if (!sessionStats.byType) {
|
|
sessionStats.byType = { voe: 0, fsvid: 0, vidzy: 0, vidmoly: 0, sibnet: 0, uqload: 0, doodstream: 0, seekstreaming: 0 };
|
|
}
|
|
}
|
|
if (result.extractionPrefs) extractionPrefs = result.extractionPrefs;
|
|
});
|
|
|
|
// Initial setup
|
|
browserAPI.runtime.onInstalled.addListener(() => {
|
|
setupRules();
|
|
});
|
|
|
|
browserAPI.runtime.onStartup.addListener(() => {
|
|
setupRules();
|
|
// Reset session stats on startup (keep byType shape to avoid silent miss-counts)
|
|
sessionStats = {
|
|
extractions: 0,
|
|
corsFixed: 0,
|
|
cached: 0,
|
|
byType: { voe: 0, fsvid: 0, vidzy: 0, vidmoly: 0, sibnet: 0, uqload: 0, doodstream: 0, seekstreaming: 0 },
|
|
};
|
|
browserAPI.storage.local.set({ stats: sessionStats });
|
|
});
|
|
|
|
// Configure DNR rules for CORS and Headers
|
|
async function setupRules() {
|
|
// Clear existing dynamic rules to prevent accumulation
|
|
const existingRules =
|
|
await browserAPI.declarativeNetRequest.getDynamicRules();
|
|
const ruleIds = existingRules.map((rule) => rule.id);
|
|
await browserAPI.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.cloud",
|
|
"movix.tax",
|
|
"movix.club",
|
|
"movix.chat",
|
|
"movix.golf",
|
|
"movix.date",
|
|
"movix.show",
|
|
],
|
|
resourceTypes: [
|
|
"xmlhttprequest",
|
|
"other",
|
|
"media",
|
|
"image",
|
|
"script",
|
|
"stylesheet",
|
|
"font",
|
|
"websocket",
|
|
],
|
|
},
|
|
},
|
|
];
|
|
|
|
await browserAPI.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
|
|
browserAPI.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
|
maybeUseLocalApi(sender);
|
|
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 browserAPI.declarativeNetRequest.getDynamicRules();
|
|
const ruleIds = existingRules.map((rule) => rule.id);
|
|
if (ruleIds.length > 0) {
|
|
await browserAPI.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;
|
|
}
|
|
browserAPI.storage.local.set({ 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++;
|
|
browserAPI.storage.local.set({ 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 so
|
|
// free users play natively without the server proxy.
|
|
case "SETUP_FCTV_HEADERS": {
|
|
const okFctv = await setupFctvHeadersRule(payload?.referer);
|
|
return { success: okFctv };
|
|
}
|
|
|
|
// FCTV (matches) native: resolve ONE server locally (IP-bound) -> m3u8 url.
|
|
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 browserAPI.storage.local.set({ 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 buildBackendApiHeaders(accessKey, extraHeaders = {}) {
|
|
const headers = {
|
|
Accept: "application/json",
|
|
Origin: "https://movix.show",
|
|
Referer: "https://movix.show/",
|
|
...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 = decodeWigiStream(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 = decodeWigiStream(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 = decodeWigiStream(html);
|
|
if (packerResult) return packerResult;
|
|
|
|
return null;
|
|
} catch (error) {
|
|
console.error("[SOSPLAY-HOCA] Error:", error.message || error);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function unpackPacker(p, a, c, k, e, d) {
|
|
e = function (c) {
|
|
return (
|
|
(c < a ? "" : e(parseInt(c / a))) +
|
|
((c = c % a) > 35 ? String.fromCharCode(c + 29) : c.toString(36))
|
|
);
|
|
};
|
|
|
|
if (!"".replace(/^/, String)) {
|
|
while (c--) {
|
|
d[e(c)] = k[c] || e(c);
|
|
}
|
|
k = [
|
|
function (e) {
|
|
return d[e];
|
|
},
|
|
];
|
|
e = function () {
|
|
return "\\w+";
|
|
};
|
|
c = 1;
|
|
}
|
|
|
|
while (c--) {
|
|
if (k[c]) {
|
|
p = p.replace(new RegExp("\\b" + e(c) + "\\b", "g"), k[c]);
|
|
}
|
|
}
|
|
return p;
|
|
}
|
|
|
|
function decodeWigiStream(html) {
|
|
try {
|
|
const packerRegex =
|
|
/eval\(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 packerMatches = [...html.matchAll(packerRegex)];
|
|
|
|
if (packerMatches.length === 0) {
|
|
const evalPositions = [];
|
|
let searchPos = 0;
|
|
while (true) {
|
|
const idx = html.indexOf("eval(function(p,a,c,k,e,", searchPos);
|
|
if (idx === -1) break;
|
|
evalPositions.push(idx);
|
|
searchPos = idx + 1;
|
|
}
|
|
|
|
for (const pos of evalPositions) {
|
|
const chunk = html.substring(pos, pos + 5000);
|
|
|
|
const splitIdx = chunk.indexOf(".split('|')");
|
|
const splitIdx2 = chunk.indexOf('.split("|")');
|
|
const actualSplitIdx = splitIdx !== -1 ? splitIdx : splitIdx2;
|
|
|
|
if (actualSplitIdx !== -1) {
|
|
let kwEnd = actualSplitIdx;
|
|
let kwStart = chunk.lastIndexOf("'", kwEnd - 1);
|
|
if (kwStart === -1) kwStart = chunk.lastIndexOf('"', kwEnd - 1);
|
|
|
|
if (kwStart !== -1) {
|
|
const keywords = chunk.substring(kwStart + 1, kwEnd).split("|");
|
|
|
|
const payloadStartMarker = chunk.indexOf("}('");
|
|
const payloadStartMarker2 = chunk.indexOf('}("');
|
|
const pStart =
|
|
payloadStartMarker !== -1
|
|
? payloadStartMarker
|
|
: payloadStartMarker2;
|
|
|
|
if (pStart !== -1) {
|
|
const quoteChar = chunk[pStart + 2];
|
|
const payloadStart = pStart + 3;
|
|
const payloadEnd = chunk.indexOf(quoteChar + ",", payloadStart);
|
|
|
|
if (payloadEnd !== -1) {
|
|
const payload = chunk.substring(payloadStart, payloadEnd);
|
|
const afterPayload = chunk.substring(payloadEnd + 2);
|
|
const numMatch = afterPayload.match(
|
|
/^\s*(\d+)\s*,\s*(\d+)\s*,/,
|
|
);
|
|
|
|
if (numMatch) {
|
|
const radix = parseInt(numMatch[1]);
|
|
const count = parseInt(numMatch[2]);
|
|
const d = {};
|
|
const decodedScript = unpackPacker(
|
|
payload,
|
|
radix,
|
|
count,
|
|
keywords,
|
|
null,
|
|
d,
|
|
);
|
|
|
|
if (
|
|
decodedScript &&
|
|
(decodedScript.includes(".m3u8") ||
|
|
decodedScript.includes("hls") ||
|
|
decodedScript.includes("Clappr"))
|
|
) {
|
|
const urlRegex = /["'](https?:\/\/[^"']+\.m3u8[^"']*)["']/g;
|
|
let urlMatch;
|
|
const urls = [];
|
|
while ((urlMatch = urlRegex.exec(decodedScript)) !== null) {
|
|
urls.push(urlMatch[1].replace(/\\/ / g, "/"));
|
|
}
|
|
if (urls.length > 0) {
|
|
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];
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
for (let i = 0; i < packerMatches.length; i++) {
|
|
const packerMatch = packerMatches[i];
|
|
const payload = packerMatch[1];
|
|
const radix = parseInt(packerMatch[2]);
|
|
const count = parseInt(packerMatch[3]);
|
|
const keywords = packerMatch[4].split("|");
|
|
const d = {};
|
|
const decodedScript = unpackPacker(
|
|
payload,
|
|
radix,
|
|
count,
|
|
keywords,
|
|
null,
|
|
d,
|
|
);
|
|
|
|
if (
|
|
!decodedScript.includes("Clappr") &&
|
|
!decodedScript.includes(".m3u8") &&
|
|
!decodedScript.includes("hls")
|
|
) {
|
|
continue;
|
|
}
|
|
|
|
const urlRegex = /["'](https?:\/\/[^"']+\.m3u8[^"']*)["']/g;
|
|
let urlMatch;
|
|
const urls = [];
|
|
|
|
while ((urlMatch = urlRegex.exec(decodedScript)) !== null) {
|
|
urls.push(urlMatch[1].replace(/\\\//g, "/"));
|
|
}
|
|
|
|
if (urls.length > 0) {
|
|
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];
|
|
}
|
|
}
|
|
|
|
const srcMatch = html.match(/src:\s*["']([^"']+\.m3u8[^"']*)/i);
|
|
if (srcMatch) return srcMatch[1].replace(/\\\//g, "/");
|
|
|
|
const streamMatch = html.match(/["'](https?:\/\/[^"']*\.m3u8[^"']*)/);
|
|
if (streamMatch) return streamMatch[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;
|
|
}
|
|
}
|
|
|
|
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 browserAPI.storage.local.get(key);
|
|
const entry = result[key];
|
|
if (!entry) return null;
|
|
if (Date.now() > entry.expiry) {
|
|
browserAPI.storage.local.remove(key);
|
|
return null;
|
|
}
|
|
return entry.data;
|
|
}
|
|
|
|
async function saveToCache(key, data, ttlMinutes) {
|
|
const expiry = Date.now() + ttlMinutes * 60 * 1000;
|
|
await browserAPI.storage.local.set({ [key]: { data, expiry } });
|
|
}
|
|
|
|
// DNR Helper
|
|
let ruleIdCounter = 100;
|
|
|
|
// FCTV (matches) native streams: rotating segment CDN hosts are Referer-gated
|
|
// to the current player origin; they share a `/cfall/s.../v3b/` path, so one
|
|
// rule injects the player Referer for every host + cdnSmartLink redirect.
|
|
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 browserAPI.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;
|
|
}
|
|
}
|
|
|
|
async function addUserAgentRule(urlPattern, userAgent) {
|
|
return addHeadersRule(urlPattern, { "User-Agent": userAgent });
|
|
}
|
|
|
|
// === FCTV (matches) local resolver ==========================================
|
|
// IP-locked stream: the token must be minted from the SAME IP that fetches the
|
|
// segments, so free users resolve it here in the browser.
|
|
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 browserAPI.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 browserAPI.declarativeNetRequest.updateDynamicRules({
|
|
addRules: [rule],
|
|
});
|
|
} catch (e) {
|
|
console.error("Failed to add dynamic rule:", e);
|
|
}
|
|
}
|