// 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 = /