// 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.fun"; 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; // BEGIN KISSKH FALLBACK const KISSKH_BROWSER_API = browserAPI; const KISSKH_SESSION_RULE_ID = 59; const KISSKH_MAX_REDIRECTS = 5; const KISSKH_REQUEST_TIMEOUT_MS = 10000; const KISSKH_MAX_MEDIA_URL_LENGTH = 8192; const KISSKH_MAX_HEADER_VALUE_LENGTH = 2048; const KISSKH_MAX_SUBTITLE_BYTES = 2097152; const KISSKH_MAX_EXCHANGE_BYTES = 32768; const KISSKH_MAX_EXCHANGE_LIFETIME_MS = 120000; const KISSKH_ALLOWED_HEADER_ORIGIN = "https://kisskh.nl"; let kisskhSessionRuleQueue = Promise.resolve(); function kisskhFailure(code) { return { success: false, code }; } function kisskhHasExactKeys(value, keys) { return ( value !== null && typeof value === "object" && !Array.isArray(value) && JSON.stringify(Object.keys(value).sort()) === JSON.stringify([...keys].sort()) ); } function kisskhValidateSubtitleUrl(value) { if (typeof value !== "string" || value.length === 0 || value.length > KISSKH_MAX_MEDIA_URL_LENGTH || /[\r\n\0]/.test(value)) return null; try { const parsed = new URL(value); if ( !["http:", "https:"].includes(parsed.protocol) || parsed.username || parsed.password || parsed.hash ) { return null; } return parsed.toString(); } catch { return null; } } function kisskhValidateMediaUrl(value) { if (typeof value !== "string" || value.length === 0 || value.length > KISSKH_MAX_MEDIA_URL_LENGTH || /[\r\n\0]/.test(value)) return null; try { const parsed = new URL(value); if ( !["http:", "https:"].includes(parsed.protocol) || parsed.username || parsed.password || parsed.hash ) { return null; } return parsed.toString(); } catch { return null; } } function kisskhValidateHeaderValue(name, value) { if ( typeof value !== "string" || value.length === 0 || value.length > KISSKH_MAX_HEADER_VALUE_LENGTH || /[\r\n\0]/.test(value) ) { return null; } try { const parsed = new URL(value); if ( parsed.protocol !== "https:" || parsed.username || parsed.password || (parsed.port && parsed.port !== "443") || parsed.hash || parsed.origin !== KISSKH_ALLOWED_HEADER_ORIGIN ) { return null; } if (name === "Origin" && (parsed.pathname !== "/" || parsed.search)) return null; return name === "Origin" ? parsed.origin : parsed.toString(); } catch { return null; } } function kisskhValidateExchange(value) { if (!kisskhHasExactKeys(value, ["url", "expiresAt", "requiredHeaders"])) return null; const url = kisskhValidateMediaUrl(value.url); if ( !url || !Number.isSafeInteger(value.expiresAt) || value.expiresAt <= Date.now() || value.expiresAt > Date.now() + KISSKH_MAX_EXCHANGE_LIFETIME_MS || value.requiredHeaders === null || typeof value.requiredHeaders !== "object" || Array.isArray(value.requiredHeaders) ) { return null; } const headers = {}; for (const [name, rawValue] of Object.entries(value.requiredHeaders)) { if (name !== "Referer" && name !== "Origin") return null; const headerValue = kisskhValidateHeaderValue(name, rawValue); if (!headerValue) return null; headers[name] = headerValue; } return { url, expiresAt: value.expiresAt, requiredHeaders: headers }; } async function kisskhReadBoundedBody(response, maxBytes) { const declared = response.headers.get("content-length"); if (declared !== null) { const length = Number(declared); if (!Number.isSafeInteger(length) || length < 0 || length > maxBytes) { return { error: "response_too_large" }; } } if (!response.body || typeof response.body.getReader !== "function") { return { error: "unsupported_transport" }; } const reader = response.body.getReader(); let timeoutId; const readResult = (async () => { const chunks = []; let length = 0; try { while (true) { const { done, value } = await reader.read(); if (done) break; const chunk = value instanceof Uint8Array ? value : new Uint8Array(value); length += chunk.byteLength; if (length > maxBytes) { await reader.cancel(); return { error: "response_too_large" }; } chunks.push(chunk); } } catch { return { error: "upstream_unavailable" }; } const bytes = new Uint8Array(length); let offset = 0; for (const chunk of chunks) { bytes.set(chunk, offset); offset += chunk.byteLength; } return { bytes }; })(); const timeoutResult = new Promise(resolve => { timeoutId = setTimeout(() => resolve({ error: "timeout", timedOut: true }), KISSKH_REQUEST_TIMEOUT_MS); }); const result = await Promise.race([readResult, timeoutResult]); clearTimeout(timeoutId); if (result.timedOut) { try { await reader.cancel(); } catch {} return { error: "timeout" }; } return result; } function kisskhBytesToBase64(bytes) { let binary = ""; for (let offset = 0; offset < bytes.length; offset += 32768) { binary += String.fromCharCode(...bytes.subarray(offset, offset + 32768)); } return btoa(binary); } async function kisskhFetch(url, init) { const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), KISSKH_REQUEST_TIMEOUT_MS); try { return await fetch(url, { ...init, signal: controller.signal }); } finally { clearTimeout(timeout); } } async function kisskhFetchSubtitle(sourceUrl) { let currentUrl = sourceUrl; for (let redirects = 0; redirects <= KISSKH_MAX_REDIRECTS; redirects += 1) { let response; try { response = await kisskhFetch(currentUrl, { method: "GET", redirect: "manual", credentials: "omit", cache: "no-store", headers: { Accept: "text/*", "Cache-Control": "no-store" }, }); } catch (error) { return kisskhFailure(error?.name === "AbortError" ? "timeout" : "upstream_unavailable"); } if (response.status >= 300 && response.status < 400) { if (redirects === KISSKH_MAX_REDIRECTS) return kisskhFailure("upstream_unavailable"); const location = response.headers.get("location"); let redirected = null; try { redirected = location ? kisskhValidateSubtitleUrl(new URL(location, currentUrl).toString()) : null; } catch {} if (!redirected) return kisskhFailure("invalid_request"); currentUrl = redirected; continue; } if (response.status === 429) return kisskhFailure("provider_rate_limited"); if (!response.ok) return kisskhFailure("upstream_unavailable"); const contentType = response.headers.get("content-type") || ""; if (!/^text\/[a-z0-9!#$&^_.+-]+(?:\s*;|$)/i.test(contentType)) { return kisskhFailure("invalid_content_type"); } const body = await kisskhReadBoundedBody(response, KISSKH_MAX_SUBTITLE_BYTES); if (body.error) return kisskhFailure(body.error); return { success: true, kind: "subtitle", status: response.status, contentType, bodyBase64: kisskhBytesToBase64(body.bytes), }; } return kisskhFailure("upstream_unavailable"); } function kisskhAlarmName(expiresAt) { return `${KISSKH_SESSION_RULE_ID}:${expiresAt}`; } function kisskhParseAlarm(alarm) { const match = new RegExp(`^${KISSKH_SESSION_RULE_ID}:(\\d{13})$`).exec(alarm?.name || ""); if (!match) return null; const expiresAt = Number(match[1]); return Number.isSafeInteger(expiresAt) && alarm.scheduledTime === expiresAt ? expiresAt : null; } function kisskhHasSessionTransport() { return Boolean( KISSKH_BROWSER_API?.declarativeNetRequest?.getSessionRules && KISSKH_BROWSER_API?.declarativeNetRequest?.updateSessionRules && KISSKH_BROWSER_API?.alarms?.getAll && KISSKH_BROWSER_API?.alarms?.create && KISSKH_BROWSER_API?.alarms?.clear, ); } async function kisskhClearAlarms() { if (!KISSKH_BROWSER_API?.alarms?.getAll) return; const alarms = await KISSKH_BROWSER_API.alarms.getAll(); await Promise.all( alarms .filter(alarm => String(alarm.name || "").startsWith(`${KISSKH_SESSION_RULE_ID}:`)) .map(alarm => KISSKH_BROWSER_API.alarms.clear(alarm.name)), ); } async function kisskhRemoveSessionRule() { if (!kisskhHasSessionTransport()) return false; await KISSKH_BROWSER_API.declarativeNetRequest.updateSessionRules({ removeRuleIds: [KISSKH_SESSION_RULE_ID], }); await kisskhClearAlarms(); return true; } function kisskhExactRegex(url) { return `^${url.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$`; } async function kisskhInstallSessionRule(exchange) { if (!kisskhHasSessionTransport()) return false; const update = kisskhSessionRuleQueue.then(async () => { await kisskhClearAlarms(); const requestHeaders = Object.entries(exchange.requiredHeaders).map(([header, value]) => ({ header, operation: "set", value, })); try { await KISSKH_BROWSER_API.declarativeNetRequest.updateSessionRules({ removeRuleIds: [KISSKH_SESSION_RULE_ID], addRules: [{ id: KISSKH_SESSION_RULE_ID, priority: 100, action: { type: "modifyHeaders", requestHeaders }, condition: { regexFilter: kisskhExactRegex(exchange.url), resourceTypes: ["xmlhttprequest", "media", "other"], }, }], }); await KISSKH_BROWSER_API.alarms.create(kisskhAlarmName(exchange.expiresAt), { when: exchange.expiresAt, }); return true; } catch { try { await KISSKH_BROWSER_API.declarativeNetRequest.updateSessionRules({ removeRuleIds: [KISSKH_SESSION_RULE_ID], }); await kisskhClearAlarms(); } catch {} return false; } }); kisskhSessionRuleQueue = update.then(() => undefined, () => undefined); return update; } async function reconcileKisskhSessionRule() { if (!kisskhHasSessionTransport()) return false; const [rules, alarms] = await Promise.all([ KISSKH_BROWSER_API.declarativeNetRequest.getSessionRules(), KISSKH_BROWSER_API.alarms.getAll(), ]); const ruleCount = rules.filter(rule => rule.id === KISSKH_SESSION_RULE_ID).length; const relatedAlarms = alarms.filter(alarm => String(alarm.name || "").startsWith(`${KISSKH_SESSION_RULE_ID}:`)); const expiry = relatedAlarms.length === 1 ? kisskhParseAlarm(relatedAlarms[0]) : null; if (ruleCount === 1 && expiry !== null && expiry > Date.now()) return true; if (ruleCount > 0) { await KISSKH_BROWSER_API.declarativeNetRequest.updateSessionRules({ removeRuleIds: [KISSKH_SESSION_RULE_ID], }); } await kisskhClearAlarms(); return false; } async function kisskhExchangeMedia(fallbackToken) { let response; try { response = await kisskhFetch(`${PROD_API_BASE_URL}/api/kisskh/fallback/${fallbackToken}`, { method: "POST", redirect: "error", credentials: "omit", cache: "no-store", headers: { Accept: "application/json", "Cache-Control": "no-store", }, }); } catch (error) { return kisskhFailure(error?.name === "AbortError" ? "timeout" : "upstream_unavailable"); } if (response.status === 429) return kisskhFailure("provider_rate_limited"); if (!response.ok) return kisskhFailure("upstream_unavailable"); if (!/(?:^|,)\s*no-store\s*(?:,|$)/i.test(response.headers.get("cache-control") || "")) { return kisskhFailure("provider_changed"); } if (!/^application\/json(?:\s*;|$)/i.test(response.headers.get("content-type") || "")) { return kisskhFailure("provider_changed"); } const body = await kisskhReadBoundedBody(response, KISSKH_MAX_EXCHANGE_BYTES); if (body.error) return kisskhFailure(body.error === "response_too_large" ? "provider_changed" : body.error); let rawExchange; try { rawExchange = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(body.bytes)); } catch { return kisskhFailure("provider_changed"); } const exchange = kisskhValidateExchange(rawExchange); if (!exchange) return kisskhFailure("provider_changed"); const headerNames = Object.keys(exchange.requiredHeaders); if (headerNames.length === 0) { if (kisskhHasSessionTransport()) await kisskhRemoveSessionRule(); return { success: true, kind: "media", url: exchange.url, expiresAt: exchange.expiresAt, headersApplied: false, }; } if (!(await kisskhInstallSessionRule(exchange))) return kisskhFailure("unsupported_transport"); return { success: true, kind: "media", url: exchange.url, expiresAt: exchange.expiresAt, headersApplied: true, }; } async function handleKisskhFallback(payload) { if (!kisskhHasExactKeys(payload, payload?.kind === "subtitle" ? ["kind", "sourceUrl"] : ["kind", "fallbackToken"])) { return kisskhFailure("invalid_request"); } if (payload.kind === "subtitle") { const sourceUrl = kisskhValidateSubtitleUrl(payload.sourceUrl); return sourceUrl ? kisskhFetchSubtitle(sourceUrl) : kisskhFailure("invalid_request"); } if ( payload.kind !== "media" || typeof payload.fallbackToken !== "string" || payload.fallbackToken.length < 16 || payload.fallbackToken.length > 2048 || !/^[A-Za-z0-9_-]+$/.test(payload.fallbackToken) ) { return kisskhFailure("invalid_request"); } return kisskhExchangeMedia(payload.fallbackToken); } if (KISSKH_BROWSER_API?.alarms?.onAlarm?.addListener) { KISSKH_BROWSER_API.alarms.onAlarm.addListener(async alarm => { const expiresAt = kisskhParseAlarm(alarm); if (expiresAt === null) return; if (expiresAt > Date.now()) { await KISSKH_BROWSER_API.alarms.create(alarm.name, { when: expiresAt }); return; } await kisskhRemoveSessionRule(); }); } KISSKH_BROWSER_API.runtime.onStartup.addListener(() => { void reconcileKisskhSessionRule().catch(() => {}); }); void reconcileKisskhSessionRule().catch(() => {}); // END KISSKH FALLBACK // 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.fun", "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 "KISSKH_FALLBACK": return await handleKisskhFallback(payload); 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 === function getSeekStreamingPlaybackUrls(result) { const urls = []; const seen = new Set(); const add = (url) => { if (typeof url !== "string" || !url || seen.has(url)) return; seen.add(url); urls.push(url); }; if (Array.isArray(result?.hlsCandidates)) { for (const candidate of result.hlsCandidates) { add(candidate?.url); } } add(result?.hlsUrl); add(result?.m3u8Url); return urls; } /** * 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" }; if (embedType === "seekstreaming") { let extractionRuleId = null; try { const extractionHeaders = await Extractors.setupHeadersForService( embedType, url, url, ); if (extractionHeaders) { extractionRuleId = await addHeadersRule( extractionHeaders.domainPattern, extractionHeaders.headers, ); } const result = await Extractors.extractSingle(embedType, url); if (result.success) { for (const videoUrl of getSeekStreamingPlaybackUrls(result)) { const playbackHeaders = await Extractors.setupHeadersForService(embedType, videoUrl, url); if (playbackHeaders) { await replaceSeekPlaybackRule(playbackHeaders); } } } return result; } finally { await removeHeadersRule(extractionRuleId); } } // 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, url, ); 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`); const seekExtractionRuleIds = []; // 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, item.url, ); if (headerInfo) { const ruleId = await addHeadersRule( headerInfo.domainPattern, headerInfo.headers, ); if (item.type === "seekstreaming" && Number.isInteger(ruleId)) { seekExtractionRuleIds.push(ruleId); } } } 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); } try { 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 videoUrls = result.type === "seekstreaming" ? getSeekStreamingPlaybackUrls(result) : [result.hlsUrl || result.m3u8Url].filter(Boolean); for (const videoUrl of videoUrls) { const headerInfo = await Extractors.setupHeadersForService( result.type, videoUrl, result.url, ); if (headerInfo) { if (result.type === "seekstreaming") { await replaceSeekPlaybackRule(headerInfo); } else { await addHeadersRule( headerInfo.domainPattern, headerInfo.headers, ); } } } } } const successCount = results.filter((r) => r.success).length; return { success: successCount > 0, total: results.length, successCount, results, }; } finally { await Promise.all(seekExtractionRuleIds.map(removeHeadersRule)); } } /** * 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.fun", Referer: "https://movix.fun/", ...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 = /]*class="[^"]*holographic-card[^"]*"[^>]*>[\s\S]*?]*href="[^"]*\/(\d+)-[^"]*"[^>]*>[\s\S]*?<[^>]*class="[^"]*ann-short_price[^"]*"[^>]*>([^<]+) 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[^"]*"[^>]*>([^<]+) 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(/]*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( /]*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(/]+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( /]+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 = /]+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(/]+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 = /]+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; // Reserved below the general 100+ allocator; FCTV owns fixed rule 60. const SEEK_PLAYBACK_RULE_ID_MIN = 61; const SEEK_PLAYBACK_RULE_ID_MAX = 99; function isSeekPlaybackRuleId(ruleId) { return ( Number.isInteger(ruleId) && ruleId >= SEEK_PLAYBACK_RULE_ID_MIN && ruleId <= SEEK_PLAYBACK_RULE_ID_MAX ); } // 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}`; } function createHeadersRule(id, urlPattern, headers) { const requestHeaders = Object.entries(headers).map(([header, value]) => ({ header: header, operation: "set", value: value, })); return { id: id, priority: 10, action: { type: "modifyHeaders", requestHeaders: requestHeaders, }, condition: { urlFilter: urlPattern, resourceTypes: [ "xmlhttprequest", "media", "websocket", "other", "sub_frame", "main_frame", ], }, }; } 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 rule = createHeadersRule(id, urlPattern, headers); try { await browserAPI.declarativeNetRequest.updateDynamicRules({ addRules: [rule], }); return id; } catch (e) { console.error("Failed to add dynamic rule:", e); return null; } } async function removeHeadersRule(ruleId) { if (!Number.isInteger(ruleId)) return; await browserAPI.declarativeNetRequest.updateDynamicRules({ removeRuleIds: [ruleId], }); } async function persistSeekPlaybackRule(headerInfo) { try { const existingRules = await browserAPI.declarativeNetRequest.getDynamicRules(); const seekRules = existingRules.filter((rule) => isSeekPlaybackRuleId(rule.id), ); const matchingRule = seekRules.find( (rule) => rule.condition?.urlFilter === headerInfo.domainPattern, ); const usedIds = new Set(seekRules.map((rule) => rule.id)); let ruleId = matchingRule?.id ?? null; if (ruleId === null) { for ( let candidate = SEEK_PLAYBACK_RULE_ID_MIN; candidate <= SEEK_PLAYBACK_RULE_ID_MAX; candidate += 1 ) { if (!usedIds.has(candidate)) { ruleId = candidate; break; } } } if (ruleId === null) { ruleId = SEEK_PLAYBACK_RULE_ID_MIN; console.warn( "[NEXUS] Seek playback rule capacity reached; evicting oldest slot", ); } const rule = createHeadersRule( ruleId, headerInfo.domainPattern, headerInfo.headers, ); await browserAPI.declarativeNetRequest.updateDynamicRules({ removeRuleIds: [ruleId], addRules: [rule], }); return ruleId; } catch (e) { console.error("Failed to persist Seek playback rule:", e); return null; } } let seekPlaybackRuleUpdateQueue = Promise.resolve(); function replaceSeekPlaybackRule(headerInfo) { const update = seekPlaybackRuleUpdateQueue.then(() => persistSeekPlaybackRule(headerInfo), ); seekPlaybackRuleUpdateQueue = update.then( () => undefined, () => undefined, ); return update; }