/** * Live TV Routes - Backend API pour TV en Direct * * Ce module gère les requêtes vers l'API Stremio TV Direct * avec mise en cache et résolution des URLs de streaming. */ const express = require("express"); const axios = require("axios"); const path = require("path"); const fsp = require("fs").promises; const crypto = require("crypto"); const cheerio = require("cheerio"); const { SocksProxyAgent } = require("socks-proxy-agent"); const { verifyAccessKey, requireVip } = require("./checkVip"); const { DADDYLIVE_BASE_URL, DADDYLIVE_REFERER, DADDYLIVE_ORIGIN, DADDYLIVE_CHANNELS_PATH, DADDYLIVE_PLACEHOLDER_POSTER, DADDYLIVE_COUNTRIES, parseChannelsHtml: parseDaddyliveChannelsHtml, parsePlayersHtml: parseDaddylivePlayersHtml, buildDeterministicPlayers: buildDaddyliveDeterministicPlayers, extractM3u8FromPlayerHtml: extractDaddyliveM3u8, extractIframeSrc: extractDaddyliveIframeSrc, } = require("./utils/daddylive"); // SOCKS5H Proxy configuration pour WITV const WITV_PROXY_URL = process.env.WITV_SOCKS5_PROXY_URL || ""; const WITV_PROXY_AGENT = WITV_PROXY_URL ? new SocksProxyAgent(WITV_PROXY_URL) : null; const router = express.Router(); // === CONFIGURATION === const TVDIRECT_BASE_URL = "https://tvdirect.ddns.net"; // URL du serveur proxy local (proxiesembed) const PROXY_SERVER_URL = process.env.PROXY_SERVER_URL; /* "https://tvmio.gleeze.com/eyJjb3VudHJpZXMiOlsiRlIiXSwiY2F0ZWdvcmllcyI6eyJGUiI6WyJHZW5lcmFsIPCfk7oiLCJTcG9ydHMg4pq9IiwiRG9jdW1lbnRhaXJlcyDwn4yNIiwiRmlsbXMg8J+OrCIsIkluZm9ybWF0aW9ucyDwn5OwIiwiRW5mYW50cyDwn5G2IiwiTXVzaWMg8J+OtSJdfSwiZW5hYmxlU2VhcmNoIjpmYWxzZX0"; tvmio_general: { genre: "FR | General", remoteGenre: "General 📺", name: "Généraliste 📺", emoji: "📺", }, tvmio_sports: { genre: "FR | Sports", remoteGenre: "Sports ⚽", name: "Sports ⚽", emoji: "⚽", }, tvmio_documentaires: { genre: "FR | Documentaires", remoteGenre: "Documentaires 🌍", name: "Documentaires 🌍", emoji: "🌍", }, tvmio_films: { genre: "FR | Films", remoteGenre: "Films 🎬", name: "Films 🎬", emoji: "🎬", }, tvmio_informations: { genre: "FR | Informations", remoteGenre: "Informations 📰", name: "Informations 📰", emoji: "📰", }, tvmio_enfants: { genre: "FR | Enfants", remoteGenre: "Enfants 👶", name: "Enfants 👶", emoji: "👶", }, tvmio_music: { genre: "FR | Musique", remoteGenre: "Music 🎵", name: "Musique 🎵", emoji: "🎵", }, }; */ const TVMIO_CATEGORIES = {}; // URL de base pour Wiflix (Witv) const WITV_BASE_URL = "https://witv.football"; // Cloudflare proxy for 403 bypass (livehdtv) const CF_PROXY_403 = process.env.CF_PROXY_403_URL; const WITV_CATEGORIES = { wiflix_generaliste: "/chaines-live/generaliste/", wiflix_cinema: "/chaines-live/cinema/", wiflix_sport: "/chaines-live/sport/", wiflix_documentaire: "/chaines-live/documentaire/", wiflix_enfants: "/chaines-live/enfants/", wiflix_info: "/chaines-live/info/", wiflix_musique: "/chaines-live/musique/", }; // URL de base pour Sosplay const SOSPLAY_BASE_URL = "https://streamonsport.art"; // Source Bolaloca/Elitegol (remplace l'ancien catalogue Sosplay pour les chaines) const BOLALOCA_BASE_URL = "https://bolaloca.my"; const BOLALOCA_PLAYER_VARIANTS = [2, 4, 3, 1]; const BOLALOCA_CHANNELS = [ { id: "20", name: "Ligue 1+", poster: "https://www.lensois.com/wp-content/uploads/2025/07/ligue-1-1.jpg", }, { id: "21", name: "Ligue 1+ CH2", poster: "https://www.monpetitforfait.com/comparateur-box-internet/wp-content/uploads/2025/07/ligue-1-plus-2.png", }, { id: "22", name: "Ligue 1+ CH3", poster: "https://www.monpetitforfait.com/comparateur-box-internet/wp-content/uploads/2025/07/ligue-1plus-3-box-internet-300x95.png", }, { id: "39", name: "Ligue 1+ CH4", poster: "https://www.monpetitforfait.com/comparateur-box-internet/wp-content/uploads/2025/07/ligue-1plus-4-box-internet-300x106.png", }, { id: "40", name: "Ligue 1+ CH5", poster: "https://www.monpetitforfait.com/comparateur-box-internet/wp-content/uploads/2025/07/ligue-1-plus-5.png", }, { id: "11", name: "Canal+", poster: "https://thumb.canalplus.pro/http/unsafe/epg.canal-plus.com/mycanal/img/CHN43FN/PNG/213X160/CHN43FB_301.PNG", }, { id: "12", name: "Canal+ Foot", poster: "https://upload.wikimedia.org/wikipedia/fr/3/3b/C%2B_Foot.png", }, { id: "13", name: "Canal+ Sport", poster: "https://upload.wikimedia.org/wikipedia/fr/2/2c/C%2B_Sport_%282023%29.png", }, { id: "14", name: "Canal+ Sport 360", poster: "https://upload.wikimedia.org/wikipedia/fr/1/11/C%2B_Sport_360.png", }, { id: "17", name: "RMC Sport 1", poster: "https://i0.wp.com/www.planetecsat.com/wp-content/uploads/2018/07/RMC_SPORT1_PNG_500x500px.png?w=500&ssl=1", }, { id: "18", name: "RMC Sport 2", poster: "https://i0.wp.com/www.planetecsat.com/wp-content/uploads/2018/07/RMC_SPORT2_PNG_500x500px.png?fit=500%2C500&ssl=1", }, { id: "15", name: "Eurosport 1", poster: "https://2.bp.blogspot.com/-qEkUoydNN-E/WvMoKma36fI/AAAAAAAAG_0/ov-d571uhZ443Nai7gdU9sSIV2IBOkquQCLcBGAs/s1600/europsort-1-HD.jpg", }, { id: "16", name: "Eurosport 2", poster: "https://4.bp.blogspot.com/-1bHZ8b5ZnW0/VzDh6KfzayI/AAAAAAAABsI/lKDWcPmyBSk7etoAj2DVr7nvQ5SsMPwzgCLcB/s1600/fhuxmcp92wg1w4y9pd2v4zjz3xs1vmjm.jpg", }, { id: "19", name: "L'Equipe", poster: "https://www.cse.fr/wp-content/uploads/2016/02/LEquipe_logo-300x200-300x150.png", }, { id: "23", name: "Automoto", poster: "https://moto-station.com/wp-content/uploads/2021/05/05/Automoto-La-Chaine-logo_0.png.jpg", }, { id: "31", name: "Canal+ Live 1", poster: "https://www.lyngsat.com/logo/tv/cc/canal-plus-live-1-fr.png", }, { id: "32", name: "Canal+ Live 2", poster: "https://www.lyngsat.com/logo/tv/cc/canal-plus-live-2-fr.png", }, { id: "33", name: "Canal+ Live 3", poster: "https://www.lyngsat.com/logo/tv/cc/canal-plus-live-3-fr.png", }, { id: "1", name: "beIN Sports 1", poster: "https://r2.thesportsdb.com/images/media/channel/logo/BeIn_Sports_1_Australia.png", }, { id: "2", name: "beIN Sports 2", poster: "https://r2.thesportsdb.com/images/media/channel/logo/BeIn_Sports_2_Australia.png", }, { id: "3", name: "beIN Sports 3", poster: "https://r2.thesportsdb.com/images/media/channel/logo/BeIn_Sports_3_Australia.png", }, { id: "4", name: "beIN Sports MAX 4", poster: "https://r2.thesportsdb.com/images/media/channel/logo/BeIn_Sports_Max_4.png", }, { id: "5", name: "beIN Sports MAX 5", poster: "https://r2.thesportsdb.com/images/media/channel/logo/BeIn_Sports_Max_5.png", }, { id: "6", name: "beIN Sports MAX 6", poster: "https://r2.thesportsdb.com/images/media/channel/logo/BeIn_Sports_Max_6.png", }, { id: "7", name: "beIN Sports MAX 7", poster: "https://r2.thesportsdb.com/images/media/channel/logo/BeIn_Sports_Max_7.png", }, { id: "8", name: "beIN Sports MAX 8", poster: "https://r2.thesportsdb.com/images/media/channel/logo/BeIn_Sports_Max_8.png", }, { id: "9", name: "beIN Sports MAX 9", poster: "https://r2.thesportsdb.com/images/media/channel/logo/BeIn_Sports_Max_9.png", }, { id: "10", name: "beIN Sports MAX 10", poster: "https://r2.thesportsdb.com/images/media/channel/logo/BeIn_Sports_Max_10.png", }, ]; const LIVETV_BASE_URL = "https://livetv901.me"; const LIVETV_EMBED_REFERER = `${LIVETV_BASE_URL}/`; const LIVETV_ALLUPCOMING_PATHS = ["/frx/allupcoming/", "/frx/ads/"]; const LIVETV_CATEGORIES = { livetv_all: { name: "Tous les sports", emoji: "📅", sportKey: "all" }, livetv_live: { name: "En direct", emoji: "🔴", sportKey: "live" }, livetv_football: { name: "Football", emoji: "⚽", sportKey: "football" }, livetv_hockey: { name: "Hockey", emoji: "🏒", sportKey: "hockey" }, livetv_basketball: { name: "Basketball", emoji: "🏀", sportKey: "basketball", }, livetv_tennis: { name: "Tennis", emoji: "🎾", sportKey: "tennis" }, livetv_volleyball: { name: "Volley-ball", emoji: "🏐", sportKey: "volleyball", }, livetv_handball: { name: "Handball", emoji: "🤾", sportKey: "handball" }, livetv_rugby: { name: "Rugby", emoji: "🏉", sportKey: "rugby" }, livetv_combat: { name: "Sports de combat", emoji: "🥊", sportKey: "combat" }, livetv_motorsport: { name: "Sports mecaniques", emoji: "🏎️", sportKey: "motorsport", }, livetv_winter: { name: "Sports d'hiver", emoji: "🎿", sportKey: "winter" }, livetv_athletics: { name: "Athletisme", emoji: "🏃", sportKey: "athletics" }, livetv_other: { name: "Autres sports", emoji: "🏟️", sportKey: "other" }, }; const LIVETV_SPORT_ALIASES = { football: ["football", "soccer", "futsal"], hockey: ["hockey sur glace", "ice hockey", "hockey"], basketball: ["basketball", "basket"], tennis: ["tennis"], volleyball: ["volley-ball", "volleyball", "volley ball", "volley"], handball: ["handball"], rugby: ["rugby"], combat: [ "boxe", "boxing", "mma", "ufc", "kickboxing", "muay thai", "wrestling", "lutte", "combat", ], motorsport: [ "courses", "motogp", "formula 1", "formule 1", "rally", "rallye", "nascar", "indycar", "motorsport", "auto racing", ], winter: [ "sport d'hiver", "sports d'hiver", "winter sports", "ski", "biathlon", "snowboard", "curling", "patinage", ], athletics: [ "course a pied", "course a pied", "course à pied", "athletisme", "athletism", "athletisme...", "athletisme…", "marathon", "trail", "running", ], }; const LIVETV_SPORT_EMOJIS = Object.fromEntries( Object.values(LIVETV_CATEGORIES) .filter( (config) => config.sportKey && config.sportKey !== "all" && config.sportKey !== "live", ) .map((config) => [config.sportKey, config.emoji || "🏟️"]), ); // Sosplay categories const SOSPLAY_CATEGORIES = { sosplay_chaines: "/livetv-sport-france-streaming-gratuit", }; // Sosplay Matches categories (for live sports events) const SOSPLAY_MATCHES_CATEGORIES = { matches_football: { path: "/type.php?type=Football", name: "Football", emoji: "⚽", }, }; // FCTV33 / RBTV-style API used for the matches catalog. // EU endpoint (defra) is used by default for European users. // Fallback API base — normally auto-resolved at runtime (getFctvApiBase); this // hardcoded value is used only when the cache is empty / auto-resolve fails. const FCTV_API_BASE_URL = "https://apis-data-defra10.tcore131ybdf.ru"; // Fallback embed player origin — normally auto-resolved (getFctvPlayerBaseUrl). // Used for the iframe player + referer when the lookup is unavailable. const FCTV_PLAYER_BASE_URL = "https://zac07eo.mpipzni2naturally32kistomach.ru"; // --- Native (HLSPlayer) stream tokenisation ------------------------------- // /api/stream/detail (unsigned, with continent/country/digit) returns an // `rb-session` header + a masked URL that decodes to the proxy m3u8 path. // The CDN gate is `/token-/` where T = base64( rbSession XOR keystream ) + "a" // (a fixed RC4-style keystream; recovered from a known token↔rb-session pair). // The proxy m3u8 + its TS segments are Referer-gated to the player origin, so // the native stream must be fetched through PROXY_SERVER_URL with that Referer. const FCTV_STREAM_DIGIT = "seth"; const FCTV_GEO_CONTINENT = "EU"; const FCTV_GEO_COUNTRY = "FR"; // Keystream (hex). Edit this value when upstream rotates its key (native breaks // with 403/404 → re-capture one token↔rb-session pair and XOR them, // scripts/fctv_token_crack.cjs). const FCTV_TOKEN_KEYSTREAM = Buffer.from( "15764bab80a419c6abdd5518f3db0ea95bb3b9a2e2b519ce5c159af6917e2000c2d680ae30706a3aba1c9c25786c7c28774eecf20450a3cf414ca17f6472798cfa557c7a8705b7861f06e84f827f8a24676eeab77ce504bfc335b79609b9", "hex", ); const FCTV_LANGUAGE_FR = 6; const FCTV_DEFAULT_SITE_TYPE = 2001; // Image proxy for FCTV logos: derived from the shared bypass403 proxy // (${BYPASS403_SERVER_URL}/proxy), with a hardcoded fallback. const FCTV_IMAGE_PROXY = process.env.BYPASS403_SERVER_URL ? `${process.env.BYPASS403_SERVER_URL.replace(/\/+$/, "")}/proxy` : "https://proxy.movix.date/proxy"; function proxifyFctvImage(url) { if (!url) return ""; // Replace dead domains with the working one let fixedUrl = url; try { const urlObj = new URL(url); if (urlObj.hostname.startsWith("logos1.")) { urlObj.hostname = "logos1.tcore131ybdf.ru"; } fixedUrl = urlObj.toString(); } catch (e) { // fallback if it's not a full URL fixedUrl = url.replace(/logos1\.[a-z0-9]+\.(cfd|ru|com)/g, "logos1.tcore131ybdf.ru"); } const proxy = FCTV_IMAGE_PROXY.endsWith("/") ? FCTV_IMAGE_PROXY : FCTV_IMAGE_PROXY + "/"; return `${proxy}${fixedUrl}`; } const FCTV_MATCHES_CATEGORIES = { matches_all: { name: "All Sports", emoji: "🏅", sportKey: "all", sportType: 0, }, matches_football: { name: "Football", emoji: "\u26bd", sportKey: "football", sportType: 1, }, }; const FCTV_API_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: "application/x-protobuf,application/json,*/*", Origin: FCTV_PLAYER_BASE_URL, Referer: `${FCTV_PLAYER_BASE_URL}/`, }; // V mapping: API endpoint paths -> bs code numbers (from upstream client JS) const FCTV_BS_CODE_MAP = { "/api/match/live": 100, "/api/match/schedule": 101, "/api/match/detail": 102, "/api/match/event": 103, "/api/match/statistic": 104, "/api/match/trend": 105, "/api/match/lineup": 106, "/api/match/analysis": 107, "/api/match/count": 190, "/api/league/detail": 200, "/api/league/season/list": 210, "/api/league/match/list": 220, "/api/league/team/total/list": 230, "/api/league/player/total/list": 231, "/api/league/team/standing/list": 240, "/api/odds/list": 300, }; // In-memory cache for bs keys (from /api/common/bs) let fctvBsKeysCache = null; let fctvBsKeysCacheTime = 0; const FCTV_BS_KEYS_TTL = 5 * 60 * 1000; // 5 minutes /** * Fetch body-signature keys from /api/common/bs. * These keys are used to build the sfver path prefix required by the upstream API. */ async function fetchFctvBsKeys(sportType = 0) { // Return cached keys if fresh if (fctvBsKeysCache && Date.now() - fctvBsKeysCacheTime < FCTV_BS_KEYS_TTL) { return fctvBsKeysCache; } try { const codes = [100, 101, 102, 103, 104, 105, 106, 107]; const params = new URLSearchParams(); params.set("stream", "true"); params.set("sportType", String(sportType)); codes.forEach((c) => params.append("code", String(c))); const apiBase = await getFctvApiBase(); const response = await axios.get(`${apiBase}/api/common/bs`, { params, responseType: "arraybuffer", headers: FCTV_API_HEADERS, timeout: 10000, }); const { body } = parseFctvBody(response.data); const kvEntries = fctvAll(body, 1); const keys = {}; for (const entry of kvEntries) { const entryFields = fctvFields(entry); const code = fctvValue(entryFields, 1); const key = fctvString(fctvValue(entryFields, 2)); if (code != null && key) { keys[Number(code)] = key; } } console.log(`[FCTV-BS] Fetched ${Object.keys(keys).length} bs keys`); fctvBsKeysCache = keys; fctvBsKeysCacheTime = Date.now(); return keys; } catch (error) { console.warn(`[FCTV-BS] Error fetching bs keys: ${error.message}`); return fctvBsKeysCache || {}; } } /** * Build the sfver path prefix for a given API endpoint and params. * Format: sfver + MD5(JSON.stringify(sortedParams)).slice(0,6) + bsKey */ function buildFctvSfverPrefix(pathname, params, bsKeys) { const au = { ...params }; delete au.usls; delete au.cBsDataTep; const ap = ['matchId', 'leagueId', 'seasonId', 'sportType', 'language', 'stream']; const sortedKeys = Object.keys(au).sort((a, b) => ap.indexOf(a) - ap.indexOf(b)); const sortedObj = {}; for (const key of sortedKeys) { sortedObj[key] = au[key]; } const jsonStr = JSON.stringify(sortedObj); const md5Hash = crypto.createHash("md5").update(jsonStr).digest("hex").slice(0, 6); // Try to find the bsCode mapping, but for /api/stream/detail it's missing in some clients so default to bsKeys[102] const bsCode = FCTV_BS_CODE_MAP[pathname]; const bsKey = (bsCode && bsKeys[bsCode]) || bsKeys[102] || bsKeys[100] || ""; return `sfver${md5Hash}${bsKey}`; } // Linkzy configuration (FREE source - no extension/VIP required) const LINKZY_CATEGORIES = { general: { id: "linkzy_generaliste", name: "Généraliste", emoji: "📺" }, sports: { id: "linkzy_sport", name: "Sport", emoji: "⚽" }, movies: { id: "linkzy_cinema", name: "Cinéma", emoji: "🎬" }, chaines_info: { id: "linkzy_info", name: "Informations", emoji: "📰" }, chaines_jeunesse: { id: "linkzy_jeunesse", name: "Jeunesse", emoji: "👶" }, }; // HTTPS Agent for Sosplay — DNS Cloudflare (1.1.1.1) pour contourner le blocage FAI const https = require("https"); const dns = require("dns"); const cloudflareDns = new dns.Resolver(); cloudflareDns.setServers(["1.1.1.1", "1.0.0.1"]); const cloudflareLookup = (hostname, options, callback) => { if (typeof options === "function") { callback = options; options = {}; } cloudflareDns.resolve4(hostname, (err, addresses) => { if (err) return callback(err); if (options.all) { callback( null, addresses.map((addr) => ({ address: addr, family: 4 })), ); } else { callback(null, addresses[0], 4); } }); }; const SOSPLAY_HTTPS_AGENT = new https.Agent({ rejectUnauthorized: false, lookup: cloudflareLookup, maxSockets: 50, maxFreeSockets: 10, }); 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"; const LIVE_PAGE_USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36"; const CACHE_DIR = path.join(__dirname, "cache", "tvdirect"); const CACHE_EXPIRATION_HOURS = 24; // Cache expire après 24h // Headers Stremio pour les requêtes (principalement TV Direct) const STREMIO_HEADERS = { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Stremio/4.4.162 Chrome/114.0.0.0 Safari/537.36", Accept: "application/json, text/plain, */*", Origin: "https://app.strem.io", Referer: "https://app.strem.io/", "Accept-Language": "fr-FR,fr;q=0.9,en-US;q=0.8,en;q=0.7", "Accept-Encoding": "gzip, deflate, br", }; // In-memory M3U cache to avoid re-reading/re-parsing on every request let m3uCache = null; let m3uCacheTime = 0; const M3U_CACHE_TTL = 10 * 60 * 1000; // 10 minutes /** * Parse local M3U file (cached) */ async function parseM3u() { return []; // Return cached data if still fresh if (m3uCache && Date.now() - m3uCacheTime < M3U_CACHE_TTL) { return m3uCache; } try { const content = await fsp.readFile(M3U_PATH, "utf8"); const lines = content.split(/\r?\n/); const channels = []; let currentChannel = null; for (const line of lines) { const trimmed = line.trim(); if (!trimmed) continue; if (trimmed.startsWith("#EXTINF:")) { currentChannel = {}; // Parse attributes const attrs = trimmed.match(/([a-zA-Z0-9-]+)="([^"]*)"/g); if (attrs) { for (const attr of attrs) { const [key, val] = attr.split("="); const value = val.replace(/"/g, ""); if (key === "tvg-id") currentChannel.id = value; if (key === "tvg-name") currentChannel.name = value; if (key === "tvg-logo") currentChannel.logo = value; if (key === "tvg-poster") currentChannel.poster = value; if (key === "group-title") currentChannel.group = value; if (key === "tvg-back") currentChannel.background = value; } } // Name after comma const commaIndex = trimmed.lastIndexOf(","); if (commaIndex !== -1) { currentChannel.title = trimmed.substring(commaIndex + 1).trim(); } else { currentChannel.title = currentChannel.name || "Unknown"; } } else if (!trimmed.startsWith("#") && currentChannel) { currentChannel.stream = trimmed; // If no ID, generate one from title if (!currentChannel.id) { currentChannel.id = currentChannel.title.replace(/[^a-zA-Z0-9]/g, ""); } channels.push(currentChannel); currentChannel = null; // Reset for next channel } } // Cache the parsed result m3uCache = channels; m3uCacheTime = Date.now(); return channels; } catch (error) { console.error("[M3U] Error parsing france.m3u:", error.message); return []; } } function normalizeTvmioImageKey(value) { if (!value) return ""; return String(value) .toLowerCase() .normalize("NFD") .replace(/[\u0300-\u036f]/g, "") .replace(/\.(fr|be|ch|ca|uk|us)$/g, " ") .replace(/\b(fr|fhd|uhd|hd|sd|4k)\b/g, " ") .replace(/[|_\-]+/g, " ") .replace(/[^a-z0-9 ]+/g, " ") .replace(/\s+/g, " ") .trim(); } async function fetchTvmioCatalogMetas(catalogId) { const cacheKey = generateCacheKey(`tvmio_remote_catalog_v2_${catalogId}`); const cached = await getFromCache(cacheKey, 24); if (cached) return cached; try { const categoryConfig = TVMIO_CATEGORIES[catalogId]; if (!categoryConfig?.remoteGenre) { return []; } const encodedGenre = encodeURIComponent(categoryConfig.remoteGenre); const url = `${TVMIO_BASE_URL}/catalog/tv/tvmio-fr/genre=${encodedGenre}.json`; const response = await axios.get(url, { headers: STREMIO_HEADERS, timeout: 12000, }); const metas = Array.isArray(response.data?.metas) ? response.data.metas : []; await saveToCache(cacheKey, metas); return metas; } catch (error) { console.warn( `[TVMIO] Unable to fetch remote catalog images for ${catalogId}: ${error.message}`, ); return []; } } function buildTvmioImagesMap(remoteMetas) { const imageMap = new Map(); for (const meta of remoteMetas) { const imageData = { poster: meta?.poster || null, logo: meta?.logo || null, background: meta?.background || null, }; const normalizedId = normalizeTvmioImageKey(meta?.id); const normalizedName = normalizeTvmioImageKey(meta?.name); if (normalizedId) imageMap.set(`id:${normalizedId}`, imageData); if (normalizedName) imageMap.set(`name:${normalizedName}`, imageData); } return imageMap; } function getTvmioRemoteImages(channel, imageMap) { const idRaw = channel?.id || ""; const titleRaw = channel?.title || channel?.name || ""; const idNormalized = normalizeTvmioImageKey(idRaw); const idBaseNormalized = normalizeTvmioImageKey(idRaw.split(".")[0]); const titleNormalized = normalizeTvmioImageKey(titleRaw); return ( imageMap.get(`id:${idNormalized}`) || imageMap.get(`id:${idBaseNormalized}`) || imageMap.get(`name:${titleNormalized}`) || { poster: null, logo: null, background: null, } ); } // === UTILITAIRES DE CACHE === // Créer le dossier de cache s'il n'existe pas (async () => { try { await fsp.access(CACHE_DIR); } catch { await fsp.mkdir(CACHE_DIR, { recursive: true }); console.log("✅ Live TV cache directory created"); } })(); /** * Génère une clé de cache MD5 basée sur les paramètres */ function generateCacheKey(params) { const stringParams = typeof params === "string" ? params : JSON.stringify(params); return crypto.createHash("md5").update(stringParams).digest("hex"); } /** * Récupère des données du cache avec vérification d'expiration */ async function getFromCache(key, expirationHours = CACHE_EXPIRATION_HOURS) { try { const cacheFilePath = path.join(CACHE_DIR, `${key}.json`); const stats = await fsp.stat(cacheFilePath); const now = Date.now(); const fileTime = stats.mtime.getTime(); const expirationTime = expirationHours * 60 * 60 * 1000; if (now - fileTime > expirationTime) { return null; // Cache expiré } const cacheData = JSON.parse(await fsp.readFile(cacheFilePath, "utf8")); return cacheData; } catch (error) { if (error.code === "ENOENT") { return null; } console.error(`[LIVETV CACHE] Erreur lecture cache ${key}:`, error.message); return null; } } /** * Récupère des données du cache avec expiration en millisecondes */ async function getFromCacheMs(key, expirationMs = 30000) { try { const cacheFilePath = path.join(CACHE_DIR, `${key}.json`); const stats = await fsp.stat(cacheFilePath); const now = Date.now(); const fileTime = stats.mtime.getTime(); if (now - fileTime > expirationMs) { return null; // Cache expiré } const cacheData = JSON.parse(await fsp.readFile(cacheFilePath, "utf8")); return cacheData; } catch (error) { if (error.code === "ENOENT") { return null; } console.error(`[LIVETV CACHE] Erreur lecture cache ${key}:`, error.message); return null; } } /** * Sauvegarde des données en cache */ async function saveToCache(key, data) { try { const cacheFilePath = path.join(CACHE_DIR, `${key}.json`); await fsp.writeFile(cacheFilePath, JSON.stringify(data, null, 2), "utf8"); return true; } catch (error) { console.error( `[LIVETV CACHE] Erreur sauvegarde cache ${key}:`, error.message, ); return false; } } /** * Résout l'URL de lecture pour obtenir le lien m3u8 final * Suit les redirections et récupère le header Location */ async function resolvePlayUrl(playUrl) { try { const response = await axios.get(playUrl, { headers: STREMIO_HEADERS, timeout: 10000, maxRedirects: 0, // Ne pas suivre les redirections automatiquement validateStatus: (status) => status >= 200 && status < 400, // Accepter 2xx et 3xx }); // Si c'est une redirection (302, 301), récupérer le header Location if (response.status === 302 || response.status === 301) { return response.headers.location || null; } // Sinon retourner l'URL originale (peut-être déjà un m3u8) return playUrl; } catch (error) { // Si erreur avec response (ex: 302), essayer de récupérer Location if ( error.response && (error.response.status === 302 || error.response.status === 301) ) { return error.response.headers.location || null; } console.error(`[LIVETV] Erreur résolution URL ${playUrl}:`, error.message); return null; } } // === WITV (WIFLIX) SCRAPING FUNCTIONS === /** * Scrape les chaînes disponibles depuis une catégorie Witv */ async function scrapeWitvChannels(categoryKey) { try { const pathUrl = WITV_CATEGORIES[categoryKey]; if (!pathUrl) { console.log(`[WITV] Unknown category: ${categoryKey}`); return []; } const fullUrl = `${WITV_BASE_URL}${pathUrl}`; console.log(`[WITV] Scraping category ${categoryKey} from ${fullUrl}`); const response = await axios.get(fullUrl, { 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", }, timeout: 15000, httpAgent: WITV_PROXY_AGENT, httpsAgent: WITV_PROXY_AGENT, }); const $ = cheerio.load(response.data); const channels = []; console.log(`[WITV] Page loaded, length: ${response.data.length} bytes`); // Pattern 1: .holographic-card with .ann-short_price for name $(".holographic-card").each((index, element) => { const $el = $(element); // Name: .ann-short_price const name = $el.find(".ann-short_price").text().trim(); // Link: a href const link = $el.find("a").attr("href"); if (name && link) { // Extract ID from link (e.g. .../40-mtv.html -> 40) const idMatch = link.match(/\/(\d+)-/); if (idMatch) { const id = idMatch[1]; // Also extract the slug for the channel page URL const slugMatch = link.match(/\/(\d+-[^\/]+\.html)$/); const pageSlug = slugMatch ? slugMatch[1] : null; channels.push({ id: `wiflix_${id}`, type: "tv", name: name, poster: null, genres: [categoryKey.replace("wiflix_", "")], _pageSlug: pageSlug, // Store for stream resolution }); } } }); console.log( `[WITV] Pattern 1 (.holographic-card): found ${channels.length} channels`, ); // Pattern 2 fallback: Look for .e-grid-cat or similar grid structures if (channels.length === 0) { console.log("[WITV] Trying Pattern 2 (.e-grid-cat)..."); $(".e-grid-cat a, .grid-item a, .channel-item a").each( (index, element) => { const $el = $(element); const link = $el.attr("href"); const name = $el.find(".ann-short_price, .channel-name, .title").text().trim() || $el.text().trim(); if (name && link) { const idMatch = link.match(/\/(\d+)-/); if (idMatch) { const id = idMatch[1]; if (!channels.find((c) => c.id === `wiflix_${id}`)) { const slugMatch = link.match(/\/(\d+-[^\/]+\.html)$/); const pageSlug = slugMatch ? slugMatch[1] : null; channels.push({ id: `wiflix_${id}`, type: "tv", name: name, poster: null, genres: [categoryKey.replace("wiflix_", "")], _pageSlug: pageSlug, }); } } } }, ); console.log(`[WITV] Pattern 2: found ${channels.length} channels`); } // Pattern 3: Ultra-simple - find all links with /ID-slug pattern if (channels.length === 0) { console.log("[WITV] Trying Pattern 3 (regex on all links)..."); const html = response.data; const linkRegex = /href="[^"]*\/(\d+)-([^"\.]+)/gi; let match; const seenIds = new Set(); while ((match = linkRegex.exec(html)) !== null) { const id = match[1]; const slug = match[2]; // Convert slug to readable name const name = slug .replace(/-/g, " ") .replace(/\b\w/g, (c) => c.toUpperCase()); if (id && !seenIds.has(id)) { seenIds.add(id); channels.push({ id: `wiflix_${id}`, type: "tv", name: name, poster: null, genres: [categoryKey.replace("wiflix_", "")], _pageSlug: `${id}-${slug}.html`, }); } } console.log(`[WITV] Pattern 3: found ${channels.length} channels`); } // Debug: Log HTML preview if no channels found if (channels.length === 0) { console.log("[WITV] No channels found! HTML preview:"); console.log(response.data.substring(0, 2000)); } console.log( `[WITV] Total scraped ${channels.length} channels for ${categoryKey}`, ); return channels; } catch (error) { console.error(`[WITV] Error scraping ${categoryKey}:`, error.message); return []; } } /** * Trouve l'URL de la page d'une chaîne Witv à partir de son ID */ async function findWitvChannelPageUrl(channelId) { // channelId format: wiflix_XX (ex: wiflix_23) const id = channelId.replace("wiflix_", ""); // Chercher dans le cache des catalogues for (const categoryKey of Object.keys(WITV_CATEGORIES)) { const catalogCacheKey = generateCacheKey(`catalog_tv_${categoryKey}`); const catalogCache = await getFromCache(catalogCacheKey, 24); if (catalogCache && catalogCache.metas) { const channel = catalogCache.metas.find((m) => m.id === channelId); if (channel && channel._pageSlug) { // Déterminer la catégorie pour construire l'URL complète const categoryPath = WITV_CATEGORIES[categoryKey]; return `${WITV_BASE_URL}${categoryPath}${channel._pageSlug}`; } } } // Fallback: Si pas trouvé dans le cache, chercher dynamiquement console.log(`[WITV] Channel ${channelId} not found in cache, searching...`); for (const [categoryKey, pathUrl] of Object.entries(WITV_CATEGORIES)) { try { const fullUrl = `${WITV_BASE_URL}${pathUrl}`; const response = await axios.get(fullUrl, { 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", }, timeout: 10000, httpAgent: WITV_PROXY_AGENT, httpsAgent: WITV_PROXY_AGENT, }); // Chercher le lien de la chaîne avec l'ID correspondant const regex = new RegExp(`href="([^"]*/${id}-[^"\.]+\.html)"`, "i"); const match = response.data.match(regex); if (match) { const channelPath = match[1]; return channelPath.startsWith("http") ? channelPath : `${WITV_BASE_URL}${channelPath}`; } } catch (e) { console.warn(`[WITV] Error searching in ${categoryKey}: ${e.message}`); } } return null; } /** * Résout le lien m3u8 pour une chaîne Witv * Gère 2 types d'embeds: witv-player.php et livehdtv.com */ async function resolveWitvStream(channelId) { try { console.log(`[WITV] Resolving stream for channel ${channelId}`); // Étape 1: Trouver l'URL de la page de la chaîne const channelPageUrl = await findWitvChannelPageUrl(channelId); if (!channelPageUrl) { console.warn(`[WITV] Could not find page URL for ${channelId}`); return null; } console.log(`[WITV] Channel page URL: ${channelPageUrl}`); // Étape 2: Récupérer la page de la chaîne const pageResponse = await axios.get(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, }, timeout: 15000, httpAgent: WITV_PROXY_AGENT, httpsAgent: WITV_PROXY_AGENT, }); const $ = cheerio.load(pageResponse.data); // Étape 3: Trouver l'iframe embed const iframe = $("iframe").first(); let embedSrc = iframe.attr("src"); if (!embedSrc) { console.warn(`[WITV] No iframe found on page ${channelPageUrl}`); return null; } console.log(`[WITV] Found embed iframe: ${embedSrc}`); // Étape 4: Gérer les différents types d'embeds // Type 1: witv-player.php (ex: /player/playerjs/witv-player.php?id=20) 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 axios.get(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, }, timeout: 15000, httpAgent: WITV_PROXY_AGENT, httpsAgent: WITV_PROXY_AGENT, }); const html = playerResponse.data; // Chercher: var streamUrl = "..." const streamMatch = html.match(/var\s+streamUrl\s*=\s*["']([^"']+)["']/); if (streamMatch && streamMatch[1]) { console.log(`[WITV] Found streamUrl: ${streamMatch[1]}`); return streamMatch[1]; } // Alternative: chercher file: "..." (JWPlayer setup) const fileMatch = html.match(/file:\s*["']([^"']+\.m3u8[^"']*)["']/); if (fileMatch && fileMatch[1]) { console.log(`[WITV] Found JWPlayer file: ${fileMatch[1]}`); return fileMatch[1]; } console.warn(`[WITV] No stream URL found in witv-player`); return null; } // Type 2: livehdtv.com (ex: https://www.livehdtv.com/yayin/?kanal=182) if (embedSrc.includes("livehdtv.com")) { console.log(`[WITV] Type 2: livehdtv detected, following embed chain...`); // Use Cloudflare proxy to bypass 403 const proxiedLivehdtvUrl = `${CF_PROXY_403}${encodeURIComponent(embedSrc)}`; // Étape 4a: Récupérer la page livehdtv/yayin const livehdtvResponse = await axios.get(proxiedLivehdtvUrl, { 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: "https://www.livehdtv.com/", }, timeout: 15000, }); const $livehdtv = cheerio.load(livehdtvResponse.data); const innerIframe = $livehdtv("iframe").first(); let tokenPhpUrl = innerIframe.attr("src"); if (!tokenPhpUrl) { console.warn(`[WITV] No inner iframe found in livehdtv page`); return null; } console.log(`[WITV] Found token.php iframe: ${tokenPhpUrl}`); // Étape 4b: Récupérer la page token.php (contient le JWPlayer avec le m3u8) if (!tokenPhpUrl.startsWith("http")) { tokenPhpUrl = `https://www.livehdtv.com${tokenPhpUrl}`; } // Use got-scraping to bypass Cloudflare protection for token.php const { gotScraping } = await import("got-scraping"); const tokenResponse = await gotScraping({ url: tokenPhpUrl, headers: { Referer: embedSrc, // Critical for validation }, headerGeneratorOptions: { browsers: [{ name: "chrome", minVersion: 120 }], devices: ["desktop"], locales: ["fr-FR", "en-US"], operatingSystems: ["windows"], }, }); const tokenHtml = tokenResponse.body; // Extraire: file: "https://livetvde.net/gulli/index.m3u8?token=..." const fileMatch = tokenHtml.match(/file:\s*["']([^"']+\.m3u8[^"']*)["']/); if (fileMatch && fileMatch[1]) { console.log(`[WITV] Found m3u8 from token.php: ${fileMatch[1]}`); return fileMatch[1]; } console.warn( `[WITV] No m3u8 URL found in token.php response. Response preview:`, ); console.log(tokenHtml.substring(0, 500)); return null; } // Type inconnu: tenter d'extraire directement un m3u8 console.log( `[WITV] Unknown embed type, attempting direct extraction from: ${embedSrc}`, ); const unknownUrl = embedSrc.startsWith("http") ? embedSrc : `${WITV_BASE_URL}${embedSrc}`; const unknownResponse = await axios.get(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, }, timeout: 15000, httpAgent: WITV_PROXY_AGENT, httpsAgent: WITV_PROXY_AGENT, }); const unknownHtml = unknownResponse.data; // Chercher n'importe quel m3u8 const m3u8Match = unknownHtml.match( /["'](https?:\/\/[^"']+\.m3u8[^"']*)["']/, ); if (m3u8Match && m3u8Match[1]) { console.log(`[WITV] Found m3u8 via generic extraction: ${m3u8Match[1]}`); return m3u8Match[1]; } console.warn(`[WITV] Could not extract stream URL from ${channelId}`); return null; } catch (error) { console.error( `[WITV] Error resolving stream for ${channelId}:`, error.message, ); return null; } } // === SOSPLAY SCRAPING FUNCTIONS === /** * Scrape les chaînes disponibles depuis Sosplay */ async function scrapeSosplayChannels(categoryKey) { try { if (categoryKey !== "sosplay_chaines") { console.log(`[BOLALOCA] Unknown category: ${categoryKey}`); return []; } const channels = BOLALOCA_CHANNELS.map((channel) => ({ id: `sosplay_${channel.id}`, type: "tv", name: channel.name, poster: channel.poster || null, genres: ["chaines"], _channelId: channel.id, })); console.log( `[BOLALOCA] Returning ${channels.length} static channels for ${categoryKey}`, ); return channels; } catch (error) { console.error(`[BOLALOCA] Error building ${categoryKey}:`, error.message); return []; } } /** * Scrape les matchs disponibles depuis Sosplay (Football, etc.) */ function readFctvVarint(buffer, offset) { let result = 0n; let shift = 0n; let cursor = offset; while (cursor < buffer.length) { const byte = buffer[cursor++]; result |= BigInt(byte & 0x7f) << shift; if ((byte & 0x80) === 0) { return { value: result, offset: cursor }; } shift += 7n; if (shift > 70n) { throw new Error("Invalid protobuf varint"); } } throw new Error("Unexpected protobuf EOF"); } function fctvNumber(value) { if (typeof value === "number") return value; if (typeof value === "bigint") { return value <= BigInt(Number.MAX_SAFE_INTEGER) ? Number(value) : value.toString(); } if (typeof value === "string" && /^\d+$/.test(value)) return Number(value); return value; } function fctvLooksText(value) { if (!value) return false; let printable = 0; for (const char of value) { const code = char.charCodeAt(0); if ((code >= 32 && code <= 126) || code >= 160) printable++; } return printable / value.length > 0.7; } function decodeFctvProtoMessage(input, depth = 0) { const buffer = Buffer.isBuffer(input) ? input : Buffer.from(input || []); const fields = []; let offset = 0; while (offset < buffer.length) { const tag = readFctvVarint(buffer, offset); offset = tag.offset; const field = Number(tag.value >> 3n); const wireType = Number(tag.value & 7n); const entry = { field, wireType }; if (wireType === 0) { const parsed = readFctvVarint(buffer, offset); offset = parsed.offset; entry.value = fctvNumber(parsed.value); } else if (wireType === 1) { entry.value = buffer.subarray(offset, offset + 8).toString("hex"); offset += 8; } else if (wireType === 2) { const parsedLength = readFctvVarint(buffer, offset); offset = parsedLength.offset; const length = Number(parsedLength.value); const bytes = buffer.subarray(offset, offset + length); offset += length; const text = bytes.toString("utf8"); if (fctvLooksText(text)) entry.value = text; if (depth < 6 && bytes.length > 0) { try { const children = decodeFctvProtoMessage(bytes, depth + 1); if (children.length > 0) entry.children = children; } catch { // Plain strings are also length-delimited; ignore decode failures. } } } else if (wireType === 5) { entry.value = buffer.subarray(offset, offset + 4).toString("hex"); offset += 4; } else { throw new Error(`Unsupported protobuf wire type ${wireType}`); } fields.push(entry); } return fields; } function fctvFields(input) { return Array.isArray(input) ? input : input?.children || []; } function fctvFirst(input, field) { return fctvFields(input).find((entry) => entry.field === field); } function fctvAll(input, field) { return fctvFields(input).filter((entry) => entry.field === field); } function fctvValue(input, field) { return fctvFirst(input, field)?.value; } function fctvChildren(input, field) { return fctvFirst(input, field)?.children || []; } function fctvString(value) { return typeof value === "string" ? value.trim() : ""; } function parseFctvBody(data) { const root = decodeFctvProtoMessage(Buffer.from(data || [])); return { status: fctvString(fctvValue(root, 3)), body: fctvChildren(root, 10), }; } async function fetchFctvApi(pathname, params, options = {}) { // Build sfver prefix if bs keys are available const apiBase = await getFctvApiBase(); let url = `${apiBase}${pathname}`; if (!options.skipSfver) { try { const bsKeys = await fetchFctvBsKeys(params?.sportType || 0); const sfver = buildFctvSfverPrefix(pathname, params, bsKeys); if (sfver) { url = `${apiBase}/${sfver}${pathname}`; } } catch (err) { console.warn(`[FCTV] sfver prefix failed, using direct path: ${err.message}`); } } return axios.get(url, { params, responseType: "arraybuffer", headers: FCTV_API_HEADERS, timeout: 15000, }); } function parseFctvLocalizedName(fields) { const localizedName = fctvString(fctvValue(fctvChildren(fields, 3), 2)); if (localizedName) return localizedName; return fctvString(fctvValue(fields, 2)); } function parseFctvTeam(teamField) { const team = fctvChildren(teamField, 10); if (team.length === 0) return null; return { id: fctvNumber(fctvValue(team, 1)), name: parseFctvLocalizedName(team), logo: proxifyFctvImage(fctvString(fctvValue(team, 4))), }; } function slugifyFctv(value) { return String(value || "") .normalize("NFD") .replace(/[\u0300-\u036f]/g, "") .toLowerCase() .replace(/[^a-z0-9]+/g, "-") .replace(/^-+|-+$/g, ""); } function formatFctvTime(timestamp) { if (!timestamp) return ""; try { return new Intl.DateTimeFormat("fr-FR", { day: "2-digit", month: "2-digit", hour: "2-digit", minute: "2-digit", timeZone: "Europe/Paris", }).format(new Date(timestamp)); } catch { return new Date(timestamp).toISOString(); } } function buildFctvMatchPageUrl(match) { const leagueSlug = match.leagueSlug || "football"; const matchSlug = match.matchSlug || slugifyFctv(match.name); return `${FCTV_PLAYER_BASE_URL}/fr/${match.sportKey || "football"}/${leagueSlug}-${match.matchId}/${matchSlug}.html?icg=RlI&ilang=fr`; } // Embeddable upstream player. Loads the match's own server selector + handles // the v3b tokenisation / service-worker / playback internally. This is the // robust playback path (the masked stream URLs are token-gated and the token // is computed client-side, so we cannot replay them server-side reliably). // player.html expects: mdata = base64(`${matchId}_${sportType}`). function buildFctvPlayerEmbedUrl(matchId, sportType, playerBase) { const base = playerBase || FCTV_PLAYER_BASE_URL; const mdata = Buffer.from(`${matchId}_${sportType}`, "utf8").toString("base64"); return `${base}/fr/player.html?mdata=${encodeURIComponent(mdata)}&ilang=fr`; } // Auto-discover the current FCTV API base. The bio-link page hubu.ru/fctvlink // points at the live front domain (rotates: .mom / .motorcycles / …), and that // front's /fr page embeds the API base in its Nuxt SSR payload (unicode-escaped // `apis-data-defra.`). Cached; on any failure falls back to the last // good value / hardcoded default. const FCTV_BIOLINK_URL = process.env.FCTV_BIOLINK_URL || "https://hubu.ru/fctvlink"; let fctvApiBaseCache = null; let fctvApiBaseCacheTime = 0; const FCTV_API_BASE_TTL = 30 * 60 * 1000; // 30 min async function getFctvApiBase() { if ( fctvApiBaseCache && Date.now() - fctvApiBaseCacheTime < FCTV_API_BASE_TTL ) { return fctvApiBaseCache; } const ua = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"; try { // 1) bio-link page -> current front domain (the "FCTV33" button). const hub = await axios.get(FCTV_BIOLINK_URL, { headers: { "User-Agent": ua }, timeout: 10000, responseType: "text", transformResponse: [(d) => d], }); const front = [...String(hub.data).matchAll(/]+href="([^"]+)"/gi)] .map((mm) => mm[1]) .find((h) => /fctv33hd/i.test(h)); if (!front) throw new Error("front domain not found on bio-link page"); // 2) front /fr -> API base embedded (unicode-escaped) in the SSR payload. const idx = await axios.get(front.replace(/\/+$/, "") + "/fr", { headers: { "User-Agent": ua, Referer: new URL(FCTV_BIOLINK_URL).origin + "/", }, timeout: 10000, responseType: "text", transformResponse: [(d) => d], }); const html = String(idx.data).replace(/\\u002[fF]/g, "/"); const m = html.match(/https?:\/\/apis-data-defra\d+\.[a-z0-9.-]+/i); if (!m) throw new Error("API base not found on front page"); fctvApiBaseCache = m[0].replace(/\/+$/, ""); fctvApiBaseCacheTime = Date.now(); console.log(`[FCTV] Auto API base: ${fctvApiBaseCache} (via ${front})`); return fctvApiBaseCache; } catch (error) { console.warn(`[FCTV] API base auto-fetch failed: ${error.message}`); // Cache the fallback so we don't hammer hubu/front for the next TTL window. fctvApiBaseCache = fctvApiBaseCache || FCTV_API_BASE_URL; fctvApiBaseCacheTime = Date.now(); return fctvApiBaseCache; } } // Auto-discover the current player origin (used for the embed iframe AND as the // Referer for native streams) — same way the site does, from /api/common/params // `iframePlayerDomains`. Cached; rotates upstream. let fctvPlayerBaseCache = null; let fctvPlayerBaseCacheTime = 0; const FCTV_PLAYER_BASE_TTL = 30 * 60 * 1000; // 30 min async function getFctvPlayerBaseUrl() { if (fctvPlayerBaseCache && Date.now() - fctvPlayerBaseCacheTime < FCTV_PLAYER_BASE_TTL) { return fctvPlayerBaseCache; } try { const apiBase = await getFctvApiBase(); const response = await axios.get(`${apiBase}/api/common/params`, { responseType: "arraybuffer", headers: FCTV_API_HEADERS, timeout: 10000, }); // params is rot47'd, and iframePlayerDomains sits inside a nested (escaped) // JSON string — strip backslashes so the list is matchable. const decoded = rot47(Buffer.from(response.data).toString("utf8")).replace(/\\/g, ""); const domains = []; const re = /"iframePlayerDomains"\s*:\s*\[([^\]]+)\]/g; let m; while ((m = re.exec(decoded))) { for (const part of m[1].split(",")) { const host = part.replace(/[\\"\s]/g, ""); if (host && /^[a-z0-9.-]+\.[a-z]{2,}$/i.test(host)) domains.push(host); } } if (domains.length) { fctvPlayerBaseCache = `https://${domains[0]}`; fctvPlayerBaseCacheTime = Date.now(); console.log(`[FCTV] Auto player origin: ${fctvPlayerBaseCache} (${domains.length} candidates)`); return fctvPlayerBaseCache; } } catch (error) { console.warn(`[FCTV] Player origin auto-fetch failed: ${error.message}`); } return fctvPlayerBaseCache || FCTV_PLAYER_BASE_URL; } function parseFctvMatch(matchField, categoryConfig, streamCountByMatchId, streamsByMatchId) { const fields = fctvFields(matchField); const matchId = fctvNumber(fctvValue(fields, 1)); if (!matchId) return null; const sportType = fctvNumber(fctvValue(fields, 2)) || categoryConfig.sportType; const timestamp = fctvNumber(fctvValue(fields, 3)) || 0; const statusCode = fctvNumber(fctvValue(fields, 4)); const league = fctvChildren(fields, 10); const country = fctvChildren(league, 80); const matchParts = fctvAll(fields, 30); const matchName = fctvString(fctvValue(fctvFields(matchParts[0]), 2)) || matchParts.map(parseFctvTeam).filter(Boolean).map((team) => team.name).join(" vs "); const teams = matchParts.map(parseFctvTeam).filter(Boolean); const extra = fctvChildren(fields, 150); const matchSlug = fctvString(fctvValue(extra, 20)) || slugifyFctv(matchName); const leagueSlug = fctvString(fctvValue(extra, 21)) || slugifyFctv(parseFctvLocalizedName(league)); const now = Date.now(); const recentlyStarted = timestamp > 0 && timestamp <= now && now - timestamp < 4 * 60 * 60 * 1000; const isLive = Number(statusCode) >= 10000 || recentlyStarted; const serverCount = streamCountByMatchId.get(Number(matchId)) || 0; const matchStreams = streamsByMatchId.get(Number(matchId)) || []; const parsedMatch = { matchId, sportKey: categoryConfig.sportKey, name: matchName || `Match ${matchId}`, leagueSlug, matchSlug, }; return { id: `match_fctv_${matchId}_sport_${sportType}`, type: "tv", name: parsedMatch.name, poster: teams[0]?.logo || teams[1]?.logo || proxifyFctvImage(fctvString(fctvValue(league, 4))) || "", genres: [categoryConfig.name.toLowerCase()], _pageUrl: buildFctvMatchPageUrl(parsedMatch), _timestamp: timestamp || undefined, _timeText: formatFctvTime(timestamp), _competition: parseFctvLocalizedName(league), _leagueLogo: proxifyFctvImage(fctvString(fctvValue(league, 4))), _country: parseFctvLocalizedName(country), _countryLogo: proxifyFctvImage(fctvString(fctvValue(country, 4))), _homeTeam: teams[0]?.name || "", _awayTeam: teams[1]?.name || "", _homeLogo: teams[0]?.logo || "", _awayLogo: teams[1]?.logo || "", _isLive: isLive, _status: isLive ? "live" : "upcoming", _serverCount: serverCount, _servers: matchStreams, _sport: categoryConfig.name, _sportKey: categoryConfig.sportKey, _sportType: sportType, _emoji: categoryConfig.emoji, }; } function parseFctvStreamFields(fields) { return { id: fctvNumber(fctvValue(fields, 1)), sportType: fctvNumber(fctvValue(fields, 2)), name: fctvString(fctvValue(fields, 3)), rawUrl: fctvString(fctvValue(fields, 4)), status: fctvNumber(fctvValue(fields, 5)), order: fctvNumber(fctvValue(fields, 8)), siteType: fctvNumber(fctvValue(fields, 9)) || FCTV_DEFAULT_SITE_TYPE, streamStatus: fctvNumber(fctvValue(fields, 11)), flag: fctvNumber(fctvValue(fields, 30)), matchId: fctvNumber(fctvValue(fields, 50)), }; } function parseFctvStream(streamField) { return parseFctvStreamFields(fctvFields(streamField)); } async function scrapeFctvMatches(categoryKey) { try { const categoryConfig = FCTV_MATCHES_CATEGORIES[categoryKey]; if (!categoryConfig) { console.log(`[FCTV-MATCHES] Unknown category: ${categoryKey}`); return []; } console.log(`[FCTV-MATCHES] Fetching live matches for ${categoryKey}`); const response = await fetchFctvApi("/api/match/live", { language: FCTV_LANGUAGE_FR, sportType: categoryConfig.sportType, stream: true, }); const { body } = parseFctvBody(response.data); const streamCountByMatchId = new Map(); const streamsByMatchId = new Map(); for (const streamRef of fctvAll(body, 2)) { try { const streamObj = parseFctvStreamFields(fctvFields(streamRef)); const matchId = Number(streamObj.matchId); if (matchId) { streamCountByMatchId.set( matchId, (streamCountByMatchId.get(matchId) || 0) + 1, ); if (!streamsByMatchId.has(matchId)) { streamsByMatchId.set(matchId, []); } streamsByMatchId.get(matchId).push({ id: streamObj.id || Math.floor(Math.random() * 100000), name: streamObj.name || `Serveur ${streamObj.id || "Auto"}`, siteType: streamObj.siteType, sportType: streamObj.sportType, }); } } catch (err) { console.warn("[FCTV-MATCHES] Error parsing stream ref in list:", err.message); } } const matches = fctvAll(body, 1) .map((matchField) => parseFctvMatch(matchField, categoryConfig, streamCountByMatchId, streamsByMatchId), ) .filter(Boolean) .sort((a, b) => { if (a._isLive && !b._isLive) return -1; if (!a._isLive && b._isLive) return 1; return (a._timestamp || 0) - (b._timestamp || 0); }); console.log(`[FCTV-MATCHES] Loaded ${matches.length} matches`); return matches; } catch (error) { console.error(`[FCTV-MATCHES] Error fetching ${categoryKey}:`, error.message); return []; } } async function fetchFctvMatchDetail(matchId, sportType = 1) { const response = await fetchFctvApi("/api/match/detail", { language: FCTV_LANGUAGE_FR, matchId, sportType, stream: true, }); const { body } = parseFctvBody(response.data); return { match: fctvAll(body, 1)[0] || null, streams: fctvAll(body, 2) .map(parseFctvStream) .filter((stream) => stream.id && stream.name), }; } function rot47(value) { return String(value || "") .split("") .map((char) => { const code = char.charCodeAt(0); if (code < 33 || code > 126) return char; return String.fromCharCode(33 + ((code - 33 + 47) % 94)); }) .join(""); } // Decode the masked stream URL from /api/stream/detail. // Format: <8-char per-request nonce> + rot47(originUrl); slice(8) drops the nonce. // The resulting origin URL is referer-ACL / token gated upstream (the token is // computed client-side by the player's service-worker), so this is only a // best-effort "native" source — the embed player is the reliable path. function decodeFctvStreamUrl(maskedUrl) { if (!maskedUrl) return null; try { const decoded = rot47(maskedUrl).slice(8); new URL(decoded); // validate it parsed to a real URL return decoded; } catch { return null; } } // token = base64( rbSession XOR keystream ) + "a", URL-encoded. function makeFctvToken(rbSession) { const pt = Buffer.from(String(rbSession || ""), "utf8"); if (!pt.length) return null; const n = Math.min(pt.length, FCTV_TOKEN_KEYSTREAM.length); const ct = Buffer.alloc(n); for (let i = 0; i < n; i++) ct[i] = pt[i] ^ FCTV_TOKEN_KEYSTREAM[i]; return encodeURIComponent(`${ct.toString("base64")}a`); } // Resolve the upstream tokenised playlist for a single server (fresh rb-session // + token). Cached briefly so HLS live-edge re-fetches don't hammer the API. const fctvUpstreamCache = new Map(); // `${matchId}_${streamId}_${siteType}` -> { data, time } const FCTV_UPSTREAM_TTL = 20 * 1000; async function resolveFctvUpstreamPlaylist(streamId, siteType, matchId, sportType, forceFresh = false) { const key = `${matchId}_${streamId}_${siteType}`; if (forceFresh) { fctvUpstreamCache.delete(key); } else { const cached = fctvUpstreamCache.get(key); if (cached && Date.now() - cached.time < FCTV_UPSTREAM_TTL) return cached.data; } // Unsigned call with continent/country/digit => returns the `rb-session` // header and a masked URL that decodes to the proxy m3u8 path. No // `usls`/`language` and no sfver prefix here. const response = await fetchFctvApi( "/api/stream/detail", { streamId, siteType: siteType || FCTV_DEFAULT_SITE_TYPE, continent: FCTV_GEO_CONTINENT, country: FCTV_GEO_COUNTRY, digit: FCTV_STREAM_DIGIT, matchId, sportType, }, { skipSfver: true }, ); const { body } = parseFctvBody(response.data); const streamBody = fctvChildren(body, 2).length ? fctvChildren(body, 2) : body; const detail = parseFctvStreamFields(streamBody); const proxyUrl = decodeFctvStreamUrl(detail.rawUrl); const rbSession = response.headers["rb-session"]; if (!proxyUrl || !rbSession) return null; const token = makeFctvToken(rbSession); const u = new URL(proxyUrl); const data = { origin: u.origin, token, playlistUrl: `${u.origin}/token-${token}${u.pathname}${u.search}`, }; fctvUpstreamCache.set(key, { data, time: Date.now() }); return data; } function extractFctvMatchAndStreamId(channelId) { const match = String(channelId || "").match(/^match_(?:fctv_)?(\d+)(?:_sport_(\d+))?(?:_stream_([a-zA-Z0-9_]+))?$/); if (!match) return { matchId: null, sportType: 1, streamId: null }; return { matchId: Number(match[1]), sportType: match[2] ? Number(match[2]) : 1, streamId: match[3] || null, }; } function extractFctvMatchId(channelId) { const { matchId } = extractFctvMatchAndStreamId(channelId); return matchId; } async function resolveFctvMatchStream(channelId) { const { matchId, sportType } = extractFctvMatchAndStreamId(channelId); if (!matchId) { console.warn(`[FCTV-MATCHES] Invalid match id: ${channelId}`); return []; } const playerBase = await getFctvPlayerBaseUrl(); const streams = []; // 1. Native HLS servers (from match/detail). Each plays via the FCTV // smart-playlist endpoint, which resolves a fresh token and proxies the // Referer-gated segments. First in the picker => HLSPlayer is the default. try { const detail = await fetchFctvMatchDetail(matchId, sportType); const servers = detail.streams .filter((stream) => stream.id && stream.siteType) .sort((a, b) => { const orderA = Number.isFinite(Number(a.order)) ? Number(a.order) : 9999; const orderB = Number.isFinite(Number(b.order)) ? Number(b.order) : 9999; return orderA - orderB; }); for (const server of servers) { streams.push({ id: server.id, title: server.name || `Serveur ${server.id}`, _fctvNative: { matchId, streamId: server.id, siteType: server.siteType || FCTV_DEFAULT_SITE_TYPE, sportType: server.sportType || sportType, }, behaviorHints: { notWebReady: false }, }); } console.log(`[FCTV-MATCHES] match ${matchId}: ${streams.length} native servers`); } catch (error) { console.warn(`[FCTV-MATCHES] match/detail failed for ${channelId}: ${error.message}`); } // 2. Embed player fallback — always works (its own server selector + SW). streams.push({ id: "embed", title: "Lecteur intégré ⭐", url: buildFctvPlayerEmbedUrl(matchId, sportType, playerBase), _isEmbed: true, behaviorHints: { notWebReady: false }, }); return streams; } async function scrapeSosplayMatches(categoryKey) { try { const categoryConfig = SOSPLAY_MATCHES_CATEGORIES[categoryKey]; if (!categoryConfig) { console.log(`[SOSPLAY-MATCHES] Unknown category: ${categoryKey}`); return []; } const fullUrl = `${SOSPLAY_BASE_URL}${categoryConfig.path}`; console.log(`[SOSPLAY-MATCHES] Scraping matches from ${fullUrl}`); const response = await axios.get(fullUrl, { 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", }, timeout: 15000, httpsAgent: SOSPLAY_HTTPS_AGENT, }); const $ = cheerio.load(response.data); const matches = []; const now = Date.now(); console.log( `[SOSPLAY-MATCHES] Page loaded, length: ${response.data.length} bytes`, ); // Parse each .row element containing a match $(".events .row").each((index, element) => { const $el = $(element); // Get match name (team1 - team2) const name = $el.find(".game-name span").text().trim(); const link = $el.find(".game-name").attr("href"); // Get timestamp from data-timestamp attribute const timeElement = $el.find("time.DISDATE"); const timestamp = parseInt(timeElement.attr("data-timestamp")) || 0; const timeText = timeElement.text().trim(); // Get competition/league name const houreText = $el.find(".houre").text().trim(); const competitionMatch = houreText.match(/·\s*(.+)$/); const competition = competitionMatch ? competitionMatch[1].trim() : ""; // Check if match is live (timestamp is in the past or has "Direct" link) const isLive = timestamp <= now || $el.find(".scdw a").text().includes("Direct"); // Get countdown/status const countdownEl = $el.find(".scdw"); const countdownText = countdownEl.text().trim(); const status = countdownText.includes("Direct") ? "live" : "upcoming"; // Get country flag from .map class const flagClass = $el.find(".map").attr("class") || ""; const countryCode = flagClass.replace("map", "").trim(); if (name && link) { // Create a unique ID from the link const slugMatch = link.match(/\/regarder-([^/]+)/); const slug = slugMatch ? slugMatch[1] : link.replace(/\//g, "-").replace(/^\//, ""); matches.push({ id: `match_${slug}`, type: "tv", name: name, poster: null, genres: [categoryConfig.name.toLowerCase()], _pageUrl: link.startsWith("http") ? link : `${SOSPLAY_BASE_URL}${link}`, _timestamp: timestamp, _timeText: timeText, _competition: competition, _isLive: isLive, _status: status, _countryCode: countryCode, }); } }); // Sort: Live matches first, then by timestamp (soonest first) matches.sort((a, b) => { if (a._isLive && !b._isLive) return -1; if (!a._isLive && b._isLive) return 1; return a._timestamp - b._timestamp; }); console.log( `[SOSPLAY-MATCHES] Scraped ${matches.length} matches (${matches.filter((m) => m._isLive).length} live)`, ); return matches; } catch (error) { console.error( `[SOSPLAY-MATCHES] Error scraping ${categoryKey}:`, error.message, ); return []; } } /** * Récupère les serveurs disponibles pour un match Sosplay (French first, fallback to other languages) */ async function getSosplayMatchServers(matchPageUrl) { try { console.log(`[SOSPLAY-MATCHES] Getting servers from: ${matchPageUrl}`); const response = await axios.get(matchPageUrl, { 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, }, timeout: 15000, httpsAgent: SOSPLAY_HTTPS_AGENT, }); const $ = cheerio.load(response.data); const frenchServers = []; const otherServers = []; // Language flag to emoji mapping const langEmojis = { fr: "🇫🇷", es: "🇪🇸", de: "🇩🇪", pt: "🇵🇹", it: "🇮🇹", gb: "🇬🇧", en: "🇬🇧", }; // Parse .servideo div for server options $(".servideo .change-video").each((index, element) => { const $el = $(element); const name = $el.text().trim(); const embedPath = $el.attr("data-embed"); const imgAlt = $el.find("img").attr("alt") || ""; if (embedPath) { // Extract channel and server IDs from path like "/part/Ll.php?id=1/46" const match = embedPath.match(/id=(\d+)\/(\d+)/); if (match) { const serverInfo = { name: name, channelNum: match[1], serverNum: match[2], embedPath: embedPath, language: imgAlt, langEmoji: langEmojis[imgAlt] || `[${imgAlt.toUpperCase()}]`, }; // Separate French and other servers if (imgAlt === "fr") { frenchServers.push(serverInfo); } else { otherServers.push(serverInfo); } } } }); console.log( `[SOSPLAY-MATCHES] Found ${frenchServers.length} French servers, ${otherServers.length} other servers`, ); // If we have French servers, return them if (frenchServers.length > 0) { return frenchServers; } // Fallback: No French servers, get up to 3 servers from other languages // Prioritize by getting one of each type (Wigi, Hoca, Obcast) from same language if possible console.log( `[SOSPLAY-MATCHES] No French servers, falling back to other languages`, ); // Group other servers by language const serversByLang = {}; for (const server of otherServers) { if (!serversByLang[server.language]) { serversByLang[server.language] = []; } serversByLang[server.language].push(server); } // Find the language with most servers (likely the best coverage) let bestLang = null; let maxServers = 0; for (const [lang, servers] of Object.entries(serversByLang)) { if (servers.length > maxServers) { maxServers = servers.length; bestLang = lang; } } if (bestLang && serversByLang[bestLang]) { // Return up to 3 servers from the best language const fallbackServers = serversByLang[bestLang].slice(0, 3); console.log( `[SOSPLAY-MATCHES] Using ${fallbackServers.length} servers from language: ${bestLang}`, ); return fallbackServers; } // Last resort: return first 3 servers from any language return otherServers.slice(0, 3); } catch (error) { console.error(`[SOSPLAY-MATCHES] Error getting servers:`, error.message); return []; } } /** * Résout le lien m3u8 pour un match Sosplay */ async function resolveSosplayMatchStream(matchId) { try { console.log(`[SOSPLAY-MATCHES] Resolving stream for match ${matchId}`); // Try to find the match page URL from cache first let matchPageUrl = null; // Check matches_football catalog cache for (const categoryKey of Object.keys(SOSPLAY_MATCHES_CATEGORIES)) { const catalogCacheKey = generateCacheKey(`catalog_tv_${categoryKey}`); const catalogCache = await getFromCache(catalogCacheKey, 1); // 1 hour cache if (catalogCache && catalogCache.metas) { const match = catalogCache.metas.find((m) => m.id === matchId); if (match && match._pageUrl) { matchPageUrl = match._pageUrl; break; } } } if (!matchPageUrl) { // Fallback: reconstruct URL from match ID const slug = matchId.replace("match_", ""); matchPageUrl = `${SOSPLAY_BASE_URL}/regarder-${slug}`; } console.log(`[SOSPLAY-MATCHES] Match page URL: ${matchPageUrl}`); // Get available servers (French first, fallback to other languages) const servers = await getSosplayMatchServers(matchPageUrl); if (servers.length === 0) { console.warn(`[SOSPLAY-MATCHES] No servers found for ${matchId}`); return []; } // Collect streams from all servers const allStreams = []; for (const server of servers) { try { console.log(`[SOSPLAY-MATCHES] Trying server: ${server.name}`); // Fetch the intermediate embed page (e.g. /part/Ll.php?id=1/20) const embedUrl = `${SOSPLAY_BASE_URL}${server.embedPath}`; console.log(`[SOSPLAY-MATCHES] Fetching embed page: ${embedUrl}`); const embedPageResponse = await axios.get(embedUrl, { 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: matchPageUrl, }, timeout: 15000, httpsAgent: SOSPLAY_HTTPS_AGENT, }); const embedHtml = embedPageResponse.data; // Extract iframe src (e.g. /Zoz/1/20 or external URL) const iframeMatch = embedHtml.match(/]+src=["']([^"']+)/i); if (!iframeMatch) { console.warn( `[SOSPLAY-MATCHES] No iframe found in embed page response`, ); continue; } let iframeSrc = iframeMatch[1]; // Handle relative URLs if (iframeSrc.startsWith("/")) { iframeSrc = `${SOSPLAY_BASE_URL}${iframeSrc}`; } console.log(`[SOSPLAY-MATCHES] Found iframe: ${iframeSrc}`); // Résoudre les iframes imbriqués jusqu'au contenu final const resolved = await resolveNestedIframes( iframeSrc, embedUrl, 3, "[SOSPLAY-MATCHES]", ); if (!resolved) continue; const { html: finalHtml, finalUrl } = resolved; // Essayer les deux décodeurs let m3u8Url = null; if ( server.name.toLowerCase().includes("hoca") || finalUrl.includes("hoca") ) { console.log(`[SOSPLAY-MATCHES] Processing Hoca embed`); m3u8Url = decodeHocaStream(finalHtml); } if (!m3u8Url) { console.log(`[SOSPLAY-MATCHES] Processing Wigi/generic embed`); m3u8Url = decodeWigiStream(finalHtml); } if (m3u8Url) { console.log(`[SOSPLAY-MATCHES] Found stream: ${m3u8Url}`); allStreams.push({ title: `${server.langEmoji || "🇫🇷"} ${server.name}`, url: m3u8Url, referer: finalUrl, }); } } catch (serverError) { console.warn( `[SOSPLAY-MATCHES] Error with server ${server.name}:`, serverError.message, ); continue; } } console.log(`[SOSPLAY-MATCHES] Total streams found: ${allStreams.length}`); return allStreams; } catch (error) { console.error( `[SOSPLAY-MATCHES] Error resolving stream for ${matchId}:`, error.message, ); return []; } } /** * Récupère les serveurs disponibles pour une chaîne Sosplay */ async function getSosplayServers(channelPageUrl) { try { console.log(`[SOSPLAY] Getting servers from: ${channelPageUrl}`); const response = await axios.get(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, }, timeout: 15000, httpsAgent: SOSPLAY_HTTPS_AGENT, }); const $ = cheerio.load(response.data); const servers = []; // Parse .servideo div for server options $(".servideo .change-video").each((index, element) => { const $el = $(element); const name = $el.text().trim(); const embedPath = $el.attr("data-embed"); if (embedPath) { // Extract channel and server IDs from path like "/part/Ll.php?id=1/20" const match = embedPath.match(/id=(\d+)\/(\d+)/); if (match) { servers.push({ name: name, channelNum: match[1], serverNum: match[2], embedPath: embedPath, }); } } }); console.log(`[SOSPLAY] Found ${servers.length} servers`); return servers; } catch (error) { console.error(`[SOSPLAY] Error getting servers:`, error.message); return []; } } /** * Dean Edwards Packer unpacker * Decodes eval(function(p,a,c,k,e,d){...}) obfuscated JavaScript */ 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; } /** * Résout les iframes imbriqués jusqu'à trouver le contenu final (avec m3u8/player) * Chaîne: /part/Ll.php → /Zoz/1/20 → @sosplay_...html (contient le player) */ async function resolveNestedIframes( url, referer, maxDepth = 3, logPrefix = "[SOSPLAY]", ) { let currentUrl = url; let currentReferer = referer; for (let depth = 0; depth < maxDepth; depth++) { console.log(`${logPrefix} Resolving depth ${depth}: ${currentUrl}`); const response = await axios.get(currentUrl, { headers: { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36", Referer: currentReferer, }, timeout: 15000, httpsAgent: SOSPLAY_HTTPS_AGENT, }); const html = response.data; // Si on trouve un m3u8 ou un player, c'est la page finale if ( html.includes(".m3u8") || html.includes("Clappr") || html.includes("eval(function(p,a,c,k,e,d)") ) { console.log(`${logPrefix} Found final content at depth ${depth}`); return { html, finalUrl: currentUrl, referer: currentReferer }; } // Sinon chercher un iframe imbriqué const iframeMatch = html.match(/]+src=["']([^"']+)/i); if (!iframeMatch) { console.log( `${logPrefix} No more iframes at depth ${depth}, returning current content`, ); return { html, finalUrl: currentUrl, referer: currentReferer }; } let nextUrl = iframeMatch[1]; if (nextUrl.startsWith("/")) { nextUrl = `${SOSPLAY_BASE_URL}${nextUrl}`; } else if (!nextUrl.startsWith("http")) { // URL relative au chemin courant const baseUrl = currentUrl.substring(0, currentUrl.lastIndexOf("/") + 1); nextUrl = `${baseUrl}${nextUrl}`; } console.log(`${logPrefix} Following nested iframe: ${nextUrl}`); currentReferer = currentUrl; currentUrl = nextUrl; } console.warn(`${logPrefix} Max depth reached without finding stream content`); return null; } function decodeWigiStream(html) { try { // Strategy 1: Look for ALL Dean Edwards Packer obfuscated code blocks const packerRegex = /eval\(function\(p,a,c,k,e,d\)\{.*?return p\}\('(.*?)',(\d+),(\d+),'(.*?)'\.split\('\|'\),0,\{\}\)\)/gs; const packerMatches = [...html.matchAll(packerRegex)]; 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("|"); // Create empty dictionary object const d = {}; // Unpack the code const decodedScript = unpackPacker( payload, radix, count, keywords, null, d, ); // Check if this block contains Clappr/player or m3u8 if ( !decodedScript.includes("Clappr") && !decodedScript.includes(".m3u8") && !decodedScript.includes("hls") ) { continue; } // Extract m3u8 URLs from unpacked code const urlRegex = /["'](https?:\/\/[^"']+\.m3u8[^"']*)["']/g; let urlMatch; const urls = []; while ((urlMatch = urlRegex.exec(decodedScript)) !== null) { let url = urlMatch[1]; url = url.replace(/\\\//g, "/"); urls.push(url); } if (urls.length > 0) { urls.forEach((url, idx) => console.log(` ${idx + 1}. ${url}`)); // Prefer vuunov/live URL (backup/direct) over shop/srvagu (P2P) const backupUrl = urls.find( (u) => u.includes("vuunov") || u.includes("live"), ); const primaryUrl = urls.find( (u) => u.includes("shop") || u.includes("srvagu"), ); // Return backup if available, otherwise primary return backupUrl || primaryUrl || urls[0]; } } if (packerMatches.length === 0) { } else { } // Strategy 2: Direct src: pattern search const srcMatch = html.match(/src:\s*["']([^"']+\.m3u8[^"']*)/i); if (srcMatch) { let url = srcMatch[1]; url = url.replace(/\\\//g, "/"); return url; } // Strategy 3: Direct m3u8 URL search const streamMatch = html.match(/["'](https?:\/\/[^"']*\.m3u8[^"']*)/); if (streamMatch) { let url = streamMatch[1]; url = url.replace(/\\\//g, "/"); return url; } // Strategy 4: var src = "..." pattern const varSrcMatch = html.match(/var\s+src\s*=\s*["']([^"']+)/i); if (varSrcMatch && varSrcMatch[1].includes(".m3u8")) { let url = varSrcMatch[1]; url = url.replace(/\\\//g, "/"); return url; } return null; } catch (error) { return null; } } // Function to decode Hoca stream function decodeHocaStream(html) { try { // Pattern from hoca.html: atob('...') contains the path const atobMatch = html.match(/atob\(['"]([^'"]+)['"]\)/); if (atobMatch) { const decoded = Buffer.from(atobMatch[1], "base64").toString("utf-8"); console.log(`[SOSPLAY] Decoded atob: ${decoded}`); } // Look for telgHtrptU function which returns the full URL // Pattern: array of characters joined together const urlArrayMatch = html.match(/return\s*\(\[([^\]]+)\]\.join/); if (urlArrayMatch) { try { // Parse the array of characters const chars = urlArrayMatch[1].match(/"([^"]*)"/g); if (chars) { let url = chars.map((c) => c.replace(/"/g, "")).join(""); // Fix escaped slashes (https:\/\/...) url = url.replace(/\\\//g, "/"); if (url.includes(".m3u8")) { return url; } } } catch (e) { console.error("[SOSPLAY] Error parsing Hoca array:", e.message); } } // Direct m3u8 search const m3u8Match = html.match(/["'](https?:\/\/[^"']+\.m3u8[^"']*)/); if (m3u8Match) { let url = m3u8Match[1]; // Fix escaped slashes url = url.replace(/\\\//g, "/"); return url; } return null; } catch (error) { console.error("[SOSPLAY] Error decoding Hoca stream:", error.message); return null; } } /** * Résout le lien m3u8 pour une chaîne Sosplay */ function buildLivePageHeaders(referer = null) { return { "User-Agent": LIVE_PAGE_USER_AGENT, Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8", ...(referer ? { Referer: referer } : {}), }; } function buildStreamProxyHeaders( referer, fallbackOrigin, userAgent = STREAM_PROXY_USER_AGENT, ) { const safeReferer = referer || fallbackOrigin; let origin = fallbackOrigin; try { origin = new URL(safeReferer).origin; } catch { origin = fallbackOrigin; } const headers = { "User-Agent": userAgent || STREAM_PROXY_USER_AGENT, }; if (safeReferer) { headers.Referer = safeReferer; } if (origin) { headers.Origin = origin; } return headers; } function absolutizeLiveUrl(rawUrl, currentUrl = "", fallbackBase = "") { if (!rawUrl) return null; const normalized = String(rawUrl) .trim() .replace(/\\u0026/g, "&") .replace(/\\\//g, "/") .replace(/\s+/g, ""); if (!normalized) return null; if (normalized.startsWith("//")) { const protocol = currentUrl.startsWith("http://") ? "http:" : "https:"; return `${protocol}${normalized}`; } try { return new URL(normalized, currentUrl || fallbackBase || undefined).href; } catch (error) { return null; } } function dedupeByKey(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 encodeUrlToken(value) { return Buffer.from(String(value), "utf8") .toString("base64") .replace(/\+/g, "-") .replace(/\//g, "_") .replace(/=+$/g, ""); } function decodeUrlToken(value) { try { const normalized = String(value).replace(/-/g, "+").replace(/_/g, "/"); const paddingLength = (4 - (normalized.length % 4 || 4)) % 4; return Buffer.from( `${normalized}${"=".repeat(paddingLength)}`, "base64", ).toString("utf8"); } catch (error) { return null; } } function revealPipeSplit(html) { const cleaned = String(html || "").replace(/\\/g, ""); const results = [ ...cleaned.matchAll(/return p\}\(([\s\S]*?),'([^']*)'\.split\('\|'\)/g), ]; if (results.length === 0) { return ""; } const numericMode = !/[a-j]\b/.test(cleaned); const tokenToInt = (token) => { if (numericMode) return Number(token); if (/^[a-z]$/.test(token)) return token.charCodeAt(0) - 87; if (/^[A-Z]$/.test(token)) return token.charCodeAt(0) - 29; if (/^\d$/.test(token)) return Number(token); return Number(token) + 52; }; return results .map((match) => { const mask = match[1]; const keywords = match[2].split("|"); return mask.replace(/\b[0-9a-zA-Z]+\b/g, (token) => { const index = tokenToInt(token); return Number.isInteger(index) && index < keywords.length && keywords[index] ? keywords[index] : token; }); }) .join("\n"); } function revealCharByCharUrl(html) { const cleaned = String(html || "").replace(/\\/g, ""); const match = cleaned.match(/((?:".",)+".")\]\.join\(""\)/); if (!match) return ""; return match[1] .split(",") .map((entry) => entry.replace(/"/g, "")) .join(""); } function extractIframeUrlsFromHtml(html, currentUrl, fallbackBase = "") { const iframeUrls = []; const regex = /]+src=["']([^"']+)["']/gi; let match; while ((match = regex.exec(String(html || ""))) !== null) { const absoluteUrl = absolutizeLiveUrl(match[1], currentUrl, fallbackBase); if (absoluteUrl && !shouldIgnoreLiveTvIframeUrl(absoluteUrl)) { iframeUrls.push(absoluteUrl); } } return dedupeByKey(iframeUrls, (url) => url); } 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, fallbackBase = LIVETV_BASE_URL) { try { const parsed = new URL(rawUrl, fallbackBase || LIVETV_BASE_URL); return /\/export\/webplayer\.iframe\.php$/i.test(parsed.pathname); } catch (error) { return false; } } function shouldFollowLiveTvIframeForExtraction( iframeUrl, pageUrl, fallbackBase = LIVETV_BASE_URL, ) { try { const page = new URL(pageUrl, fallbackBase || LIVETV_BASE_URL); if (!isLiveTvExportIframePage(page.href, fallbackBase)) { 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 extractDirectMediaCandidatesFromHtml( html, referer, fallbackBase = "", ) { const candidates = []; const htmlVariants = [String(html || "")]; const addCandidate = (rawUrl, candidateReferer = referer) => { const absoluteUrl = absolutizeLiveUrl( rawUrl, candidateReferer, fallbackBase, ); if (!absoluteUrl) return; if (!/\.(m3u8|mpd)(?:[?#]|$)/i.test(absoluteUrl)) return; candidates.push({ url: absoluteUrl, referer: candidateReferer, }); }; const pipeDecoded = revealPipeSplit(htmlVariants[0]); if (pipeDecoded) htmlVariants.push(pipeDecoded); const charDecoded = revealCharByCharUrl(htmlVariants[0]); if (charDecoded) addCandidate(charDecoded, referer); const packedPattern = /eval\(function\(p,a,c,k,e,(?:d|r)\)\{[\s\S]*?return p\}\((['"])([\s\S]*?)\1,\s*(\d+),\s*(\d+),\s*(['"])([\s\S]*?)\5\.split\(['"]\|['"]\)\s*(?:,\s*0\s*,\s*\{\})?\s*\)\)/gs; for (const packedMatch of String(html || "").matchAll(packedPattern)) { try { const decoded = unpackPacker( packedMatch[2], parseInt(packedMatch[3], 10), parseInt(packedMatch[4], 10), packedMatch[6].split("|"), null, {}, ); if (decoded) htmlVariants.push(decoded); } catch (error) { console.warn("[LIVE-TV] Unable to decode packed block:", error.message); } } for (const fragment of htmlVariants) { const cleaned = String(fragment || "").replace(/\\\//g, "/"); for (const atobMatch of cleaned.matchAll(/atob\(['"]([^'"]+)['"]\)/g)) { try { addCandidate( Buffer.from(atobMatch[1], "base64").toString("utf8"), referer, ); } catch (error) { // Ignore invalid payloads } } const mimeTypeMatch = cleaned.match( /mimeType:\s*"application\/x-mpegURL"[\s\S]*?source:\s*'([^']+)'/i, ); if (mimeTypeMatch) addCandidate(mimeTypeMatch[1], referer); for (const playerInitMatch of cleaned.matchAll( /pl\.init\(\s*['"]([^'"]+)['"]\s*\)/gi, )) { addCandidate(playerInitMatch[1], referer); } for (const manifestUrlMatch of cleaned.matchAll( /manifestUrl\s*:\s*['"]([^'"]+)['"]/gi, )) { addCandidate(manifestUrlMatch[1], referer); } const loadSourceMatch = cleaned.match( /player\.load\(\{source:\s*([A-Za-z0-9_$]+)\(/, ); if (loadSourceMatch) { const functionPattern = new RegExp( `function ${loadSourceMatch[1]}\\(\\)\\s*\\{\\s*return\\(\\[([^\\]]+)`, "s", ); const functionMatch = cleaned.match(functionPattern); if (functionMatch) { addCandidate( functionMatch[1] .replace(/"/g, "") .replace(/,/g, "") .replace(/\\/g, "") .replace(/\/\/\/\//g, "//"), referer, ); } } const playerMatchA = cleaned.match( /new Player\("100%","100%","player","(.+?)",\{"(.+?)":/, ); if (playerMatchA) addCandidate( `https://${playerMatchA[2]}/hls/${playerMatchA[1]}/live.m3u8`, referer, ); const playerMatchB = cleaned.match( /new Player\("100%","100%","player","(.+?)".+?,"([^"]+)"/, ); if (playerMatchB) addCandidate( `https://${playerMatchB[2]}/hls/${playerMatchB[1]}/live.m3u8`, referer, ); for (const inlineMatch of cleaned.matchAll( /(?:file|source|src)\s*[:=]\s*["']([^"']+\.(?:m3u8|mpd)[^"']*)["']/gi, )) { addCandidate(inlineMatch[1], referer); } for (const quotedMatch of cleaned.matchAll( /["'](https?:\/\/[^"']+\.(?:m3u8|mpd)[^"']*)["']/gi, )) { addCandidate(quotedMatch[1], referer); } for (const varSrcMatch of cleaned.matchAll( /;var.+?src=["']([^"']+)["']/gi, )) { addCandidate(varSrcMatch[1], referer); } } return dedupeByKey( candidates, (item) => `${item.url}__${item.referer || ""}`, ); } function buildLivetvIframeExportUrl(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 (!/^cdn\./i.test(cdnHost)) { 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 rawHtml = String(html || ""); const $ = cheerio.load(rawHtml); const entries = []; const cleanText = (value) => cheerio .load(`
${String(value || "")}
`) .text() .replace(/\u00a0/g, " ") .replace(/\s+/g, " ") .trim(); const addEntry = (rawUrl, metadata = {}) => { const webplayerUrl = absolutizeLiveUrl(rawUrl, eventUrl, LIVETV_BASE_URL); if (!webplayerUrl) return; let streamType = ""; try { streamType = new URL(webplayerUrl).searchParams.get("t") || ""; } catch (error) { streamType = ""; } if (streamType.toLowerCase() === "acestream") { return; } const exportUrl = buildLivetvIframeExportUrl(webplayerUrl, eventUrl); const language = String(metadata.language || "").trim(); const bitrate = String(metadata.bitrate || "").trim(); const hoster = String(metadata.hoster || "").trim(); const sourceType = String(metadata.sourceType || streamType || "").trim(); const title = String( metadata.title || [language, hoster || sourceType, bitrate].filter(Boolean).join(" - ") || sourceType || "Stream", ).trim(); entries.push({ title, language, bitrate, hoster, sourceType, webplayerUrl, exportUrl, }); }; 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 language = cleanText(rowHtml.match(/]+title=["']([^"']+)["']/i)?.[1] || "") || ""; const bitrate = cleanText( rowHtml.match(/class=["']bitrate["'][^>]*>([\s\S]*?)<\/td>/i)?.[1] || "", ) || ""; const hoster = cleanText( rowHtml.match(/class=["']lnktyt["'][^>]*>([\s\S]*?)<\/td>/i)?.[1] || "", ) || ""; const title = [language, hoster, bitrate].filter(Boolean).join(" - "); addEntry(hrefMatch[1], { title, language, bitrate, hoster, }); } if (entries.length === 0) { $('#links_block a[href*="/webplayer"]').each((_, link) => { const href = $(link).attr("href"); if (!href) return; const row = $(link).closest("table.lnktbj"); const language = row.find("img[title]").first().attr("title") || ""; const bitrate = row.find("td.bitrate").first().text().trim(); const hoster = row .find("td.lnktyt") .first() .text() .replace(/\s+/g, " ") .trim(); const title = [language, hoster, bitrate].filter(Boolean).join(" - "); addEntry(href, { title, language, bitrate, hoster, }); }); } if (entries.length === 0) { for (const hrefMatch of rawHtml.matchAll( /href=["']([^"']*\/webplayer(?:2)?\.php[^"']*)["']/gi, )) { addEntry(hrefMatch[1], { title: "Stream", }); } } if (entries.length === 0) { const onclickPattern = /show_webplayer\(\s*['"]([^'"]+)['"]\s*,\s*['"]([^'"]+)['"]\s*,\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*,\s*['"]([^'"]+)['"]\s*\)/gi; for (const match of rawHtml.matchAll(onclickPattern)) { const [, type, contentId, eventId, linkId, countryId, streamId, lang] = match; const fallbackUrl = `/webplayer2.php?t=${encodeURIComponent(type)}&c=${encodeURIComponent(contentId)}&lang=${encodeURIComponent(lang)}&eid=${eventId}&lid=${linkId}&ci=${countryId}&si=${streamId}`; addEntry(fallbackUrl, { title: type || "Stream", language: lang || "", sourceType: type || "", }); } } console.log( `[LIVETV] Raw parser signals for ${eventUrl}: href=${(rawHtml.match(/\/webplayer(?:2)?\.php/gi) || []).length}, onclick=${(rawHtml.match(/show_webplayer\(/gi) || []).length}`, ); return dedupeByKey( entries, (item) => `${item.exportUrl}__${item.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 || "", })); } async function fetchLivetvEventEntries(channelId) { const encodedPath = channelId.replace("livetv_", ""); const decodedPath = decodeUrlToken(encodedPath); if (!decodedPath) { console.warn(`[LIVETV] Could not decode event id ${channelId}`); return { eventUrl: "", hosterLinks: [] }; } const eventUrl = absolutizeLiveUrl( decodedPath, LIVETV_BASE_URL, LIVETV_BASE_URL, ); if (!eventUrl) { return { eventUrl: "", hosterLinks: [] }; } const eventResponse = await axios.get(eventUrl, { headers: buildLivePageHeaders(LIVETV_BASE_URL), timeout: 15000, httpsAgent: SOSPLAY_HTTPS_AGENT, }); const eventHtml = String(eventResponse.data || ""); const hosterLinks = extractLivetvWebplayerEntries(eventHtml, eventUrl); console.log( `[LIVETV] Extracted ${hosterLinks.length} webplayer links from ${channelId}`, ); return { eventUrl, hosterLinks }; } async function resolveRecursiveIframeStreams(startUrl, referer, options = {}) { const { maxDepth = 4, visited = new Set(), fallbackBase = "", logPrefix = "[LIVE-TV]", preferEmbedForExternalIframes = false, embedReferer = null, } = options; const absoluteStartUrl = absolutizeLiveUrl(startUrl, referer, fallbackBase); if (!absoluteStartUrl || visited.has(absoluteStartUrl)) { return { streams: [], embeds: [] }; } visited.add(absoluteStartUrl); if (/\.(m3u8|mpd)(?:[?#]|$)/i.test(absoluteStartUrl)) { return { streams: [ { url: absoluteStartUrl, referer: referer || absoluteStartUrl }, ], embeds: [], }; } try { const response = await axios.get(absoluteStartUrl, { headers: buildLivePageHeaders(referer), timeout: 15000, httpsAgent: SOSPLAY_HTTPS_AGENT, maxRedirects: 5, transformResponse: [(data) => data], }); const finalUrl = response.request?.res?.responseUrl || absoluteStartUrl; const html = typeof response.data === "string" ? response.data : String(response.data ?? ""); let streams = extractDirectMediaCandidatesFromHtml( html, finalUrl, fallbackBase, ); let embeds = []; const iframeUrls = extractIframeUrlsFromHtml(html, finalUrl, fallbackBase); if (iframeUrls.length === 0) { return { streams, embeds }; } if (maxDepth <= 0) { return { streams, embeds: iframeUrls.map((url) => ({ url, referer: embedReferer || finalUrl, })), }; } for (const iframeUrl of iframeUrls) { if ( preferEmbedForExternalIframes && !shouldFollowLiveTvIframeForExtraction( iframeUrl, finalUrl, fallbackBase, ) ) { embeds.push({ url: iframeUrl, referer: embedReferer || finalUrl }); continue; } console.log(`${logPrefix} Following iframe: ${iframeUrl}`); const nested = await resolveRecursiveIframeStreams(iframeUrl, finalUrl, { maxDepth: maxDepth - 1, visited, fallbackBase, logPrefix, preferEmbedForExternalIframes, embedReferer, }); streams = streams.concat(nested.streams); embeds = embeds.concat(nested.embeds); if (nested.streams.length === 0 && nested.embeds.length === 0) { embeds.push({ url: iframeUrl, referer: embedReferer || finalUrl }); } } return { streams: dedupeByKey( streams, (item) => `${item.url}__${item.referer || ""}`, ), embeds: dedupeByKey( embeds, (item) => `${item.url}__${item.referer || ""}`, ), }; } catch (error) { console.warn( `${logPrefix} Failed to resolve ${absoluteStartUrl}: ${error.message}`, ); return { streams: [], embeds: [] }; } } async function resolveSosplayStream(channelId) { try { const rawChannelId = channelId.replace("sosplay_", ""); const channelConfig = BOLALOCA_CHANNELS.find( (channel) => channel.id === rawChannelId, ); if (!channelConfig) { console.warn(`[BOLALOCA] Unknown channel ${channelId}`); return []; } const resolvedStreams = []; const resolvedEmbeds = []; for (const variant of BOLALOCA_PLAYER_VARIANTS) { const playerUrl = `${BOLALOCA_BASE_URL}/player/${variant}/${rawChannelId}`; const resolved = await resolveRecursiveIframeStreams( playerUrl, BOLALOCA_BASE_URL, { maxDepth: 5, visited: new Set(), fallbackBase: BOLALOCA_BASE_URL, logPrefix: "[BOLALOCA]", }, ); resolvedStreams.push( ...resolved.streams.map((stream, index) => ({ title: `${channelConfig.name} - Lien ${variant}${resolved.streams.length > 1 ? `.${index + 1}` : ""}`, url: stream.url, referer: stream.referer || playerUrl, })), ); resolvedEmbeds.push( ...resolved.embeds.map((embed, index) => ({ title: `${channelConfig.name} - Embed ${variant}${resolved.embeds.length > 1 ? `.${index + 1}` : ""}`, url: embed.url, referer: embed.referer || playerUrl, _isEmbed: true, })), ); } const uniqueStreams = dedupeByKey( resolvedStreams, (item) => `${item.url}__${item.referer || ""}`, ); const uniqueEmbeds = dedupeByKey( resolvedEmbeds, (item) => `${item.url}__${item.referer || ""}`, ); console.log( `[BOLALOCA] Resolved ${uniqueStreams.length} direct streams and ${uniqueEmbeds.length} embeds for ${channelId}`, ); return uniqueStreams.length > 0 ? uniqueStreams : uniqueEmbeds; console.log(`[SOSPLAY] Resolving stream for channel ${channelId}`); // Find the channel page URL from cache const catalogCacheKey = generateCacheKey("catalog_tv_sosplay_chaines"); const catalogCache = await getFromCache(catalogCacheKey, 24); let channelPageUrl = null; if (catalogCache && catalogCache.metas) { const channel = catalogCache.metas.find((m) => m.id === channelId); if (channel && channel._pageUrl) { channelPageUrl = channel._pageUrl; } } if (!channelPageUrl) { // Fallback: reconstruct URL from channel ID const slug = channelId.replace("sosplay_", ""); channelPageUrl = `${SOSPLAY_BASE_URL}/regardertv-${slug}-streaming-direct`; } console.log(`[SOSPLAY] Channel page URL: ${channelPageUrl}`); // Get available servers const servers = await getSosplayServers(channelPageUrl); if (servers.length === 0) { console.warn(`[SOSPLAY] No servers found for ${channelId}`); return []; } // Collect streams from ALL servers const allStreams = []; for (const server of servers) { try { console.log(`[SOSPLAY] Trying server: ${server.name}`); // Fetch the intermediate embed page (e.g. /part/Ll.php?id=1/20) const embedUrl = `${SOSPLAY_BASE_URL}${server.embedPath}`; console.log(`[SOSPLAY] Fetching embed page: ${embedUrl}`); const embedPageResponse = await axios.get(embedUrl, { 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, }, timeout: 15000, httpsAgent: SOSPLAY_HTTPS_AGENT, }); const embedHtml = embedPageResponse.data; // Extract iframe src (e.g. /Zoz/1/20 or external URL) const iframeMatch = embedHtml.match(/]+src=["']([^"']+)/i); if (!iframeMatch) { console.warn(`[SOSPLAY] No iframe found in embed page response`); continue; } let iframeSrc = iframeMatch[1]; // Handle relative URLs if (iframeSrc.startsWith("/")) { iframeSrc = `${SOSPLAY_BASE_URL}${iframeSrc}`; } console.log(`[SOSPLAY] Found iframe: ${iframeSrc}`); // Résoudre les iframes imbriqués jusqu'au contenu final const resolved = await resolveNestedIframes( iframeSrc, embedUrl, 3, "[SOSPLAY]", ); if (!resolved) continue; const { html: finalHtml, finalUrl } = resolved; // Essayer les deux décodeurs let m3u8Url = null; if ( server.name.toLowerCase().includes("hoca") || finalUrl.includes("hoca") ) { console.log(`[SOSPLAY] Processing Hoca embed`); m3u8Url = decodeHocaStream(finalHtml); } if (!m3u8Url) { console.log(`[SOSPLAY] Processing Wigi/generic embed`); m3u8Url = decodeWigiStream(finalHtml); } if (m3u8Url) { console.log(`[SOSPLAY] Found stream: ${m3u8Url}`); allStreams.push({ title: server.name, url: m3u8Url, referer: finalUrl, }); } } catch (serverError) { console.warn( `[SOSPLAY] Error with server ${server.name}:`, serverError.message, ); continue; } } console.log(`[SOSPLAY] Total streams found: ${allStreams.length}`); if (allStreams.length === 0) { console.warn( `[SOSPLAY] Could not find any working stream for ${channelId}`, ); return []; } return allStreams; } catch (error) { console.error( `[BOLALOCA] Error resolving stream for ${channelId}:`, error.message, ); return []; } } function normalizeLiveTvCatalogText(value) { return cheerio .load(`
${String(value || "")}
`) .text() .replace(/\u00a0/g, " ") .replace(/\s+/g, " ") .trim(); } function normalizeLiveTvComparableText(value) { return normalizeLiveTvCatalogText(value) .normalize("NFD") .replace(/[\u0300-\u036f]/g, "") .replace(/[’']/g, "'") .toLowerCase(); } function humanizeLiveTvSportKey(sportKey) { const category = Object.values(LIVETV_CATEGORIES).find( (entry) => entry.sportKey === sportKey, ); return category?.name || "Sport"; } function classifyLiveTvSportKey(...values) { const normalizedValues = values .map((value) => normalizeLiveTvComparableText(value)) .filter(Boolean); for (const [sportKey, aliases] of Object.entries(LIVETV_SPORT_ALIASES)) { if ( normalizedValues.some((value) => aliases.some((alias) => value.includes(alias)), ) ) { return sportKey; } } return "other"; } function extractLiveTvSportInfo(rawSportHint, fallbackText) { const sportHint = normalizeLiveTvCatalogText(rawSportHint); const hintParts = sportHint .split(".") .map((part) => normalizeLiveTvCatalogText(part)) .filter(Boolean); const sportKey = classifyLiveTvSportKey(sportHint, fallbackText); const sportLabel = hintParts[0] || humanizeLiveTvSportKey(sportKey); const competitionFromHint = hintParts.slice(1).join(". ").trim(); return { sportKey, sportLabel, competitionFromHint, }; } function extractLiveTvSectionSportLabel(contextHtml) { const rawContext = String(contextHtml || ""); if (!rawContext) { return ""; } let lastSectionLabel = ""; const sectionHeaderPattern = /]*\bclass=["'][^"']*\bmain\b[^"']*["'])(?=[^>]*href=["'][^"']*\/frx\/allupcomingsports\/\d+\/?[^"']*["'])[^>]*>\s*(?:]*>)?([\s\S]*?)(?:<\/b>)?\s*<\/a>/gi; for (const match of rawContext.matchAll(sectionHeaderPattern)) { const candidate = normalizeLiveTvCatalogText(match[1]); if (candidate) { lastSectionLabel = candidate; } } return lastSectionLabel; } function extractLiveTvSectionDateText(rawContext) { const contextText = normalizeLiveTvCatalogText(rawContext); if (!contextText) return ""; let lastRelativeMatch = null; const relativePattern = /(Aujourd'hui|Aujourd’hui|Today|Demain|Tomorrow|Hier|Yesterday)\s*(?:\(([^)]+)\))?/gi; for (const match of contextText.matchAll(relativePattern)) { lastRelativeMatch = match; } if (lastRelativeMatch) { return [ lastRelativeMatch[1], lastRelativeMatch[2] ? `(${lastRelativeMatch[2]})` : "", ] .filter(Boolean) .join(" ") .trim(); } let lastExplicitMatch = null; const explicitPattern = /(\d{1,2}\s+(?:janvier|fevrier|février|mars|avril|mai|juin|juillet|aout|août|septembre|octobre|novembre|decembre|décembre|january|february|march|april|may|june|july|august|september|october|november|december))/gi; for (const match of contextText.matchAll(explicitPattern)) { lastExplicitMatch = match; } return lastExplicitMatch ? normalizeLiveTvCatalogText(lastExplicitMatch[1]) : ""; } function buildLiveTvBaseDate(sectionDateText) { const normalized = normalizeLiveTvComparableText(sectionDateText); if (!normalized) return null; const now = new Date(); const today = new Date(now.getFullYear(), now.getMonth(), now.getDate()); if ( normalized.includes("aujourd'hui") || normalized.includes("aujourdhui") || normalized.includes("today") ) { return today; } if (normalized.includes("demain") || normalized.includes("tomorrow")) { return new Date(today.getTime() + 24 * 60 * 60 * 1000); } if (normalized.includes("hier") || normalized.includes("yesterday")) { return new Date(today.getTime() - 24 * 60 * 60 * 1000); } const monthMap = { janvier: 0, fevrier: 1, mars: 2, avril: 3, mai: 4, juin: 5, juillet: 6, aout: 7, septembre: 8, octobre: 9, novembre: 10, decembre: 11, january: 0, february: 1, march: 2, april: 3, may: 4, june: 5, july: 6, august: 7, september: 8, october: 9, november: 10, december: 11, }; const dateMatch = normalized.match(/(\d{1,2})\s+([a-z]+)/); if (!dateMatch) { return null; } const day = Number.parseInt(dateMatch[1], 10); const monthIndex = monthMap[dateMatch[2]]; if (!Number.isFinite(day) || monthIndex === undefined) { return null; } let year = today.getFullYear(); let candidate = new Date(year, monthIndex, day); if (candidate.getTime() < today.getTime() - 7 * 24 * 60 * 60 * 1000) { candidate = new Date(year + 1, monthIndex, day); } return candidate; } function buildLiveTvTimestamp(sectionDateText, scheduledTime, isLive = false) { const timeMatch = String(scheduledTime || "").match(/(\d{1,2}):(\d{2})/); if (!timeMatch) { return null; } const now = new Date(); const baseDate = buildLiveTvBaseDate(sectionDateText) || new Date(now.getFullYear(), now.getMonth(), now.getDate()); baseDate.setHours( Number.parseInt(timeMatch[1], 10), Number.parseInt(timeMatch[2], 10), 0, 0, ); if ( !sectionDateText && !isLive && baseDate.getTime() < now.getTime() - 6 * 60 * 60 * 1000 ) { baseDate.setDate(baseDate.getDate() + 1); } return baseDate.getTime(); } function extractLiveTvTimingInfo(segmentText, anchorHtml, eventBodyHtml = "") { const timeMatches = Array.from( segmentText.matchAll(/\b(\d{1,2}:\d{2})\b/g), ).map((match) => match[1]); const uniqueTimes = [...new Set(timeMatches)]; const timeLineMatches = Array.from( segmentText.matchAll(/(\d{1,2}:\d{2})\s*\(([^)]+)\)/g), ); const lastTimeLine = timeLineMatches.length > 0 ? timeLineMatches[timeLineMatches.length - 1] : null; const scheduledTime = lastTimeLine?.[1] || uniqueTimes[uniqueTimes.length - 1] || ""; const competition = normalizeLiveTvCatalogText(lastTimeLine?.[2] || ""); const liveMarkerHtml = `${eventBodyHtml || ""}${anchorHtml || ""}`; const rawIsLive = /]+src=["'][^"']*\/img\/live\.gif(?:\?[^"']*)?["']/i.test( liveMarkerHtml, ); const scoreCandidate = rawIsLive ? uniqueTimes.find((value) => value !== scheduledTime) || "" : ""; return { scheduledTime, competition, score: scoreCandidate, isLive: rawIsLive, }; } function extractLiveTvCatalogEvents(html) { const rawHtml = String(html || ""); const linkPattern = /]+href=["']([^"']*\/frx\/eventinfo\/[^"']+)["'][^>]*>([\s\S]*?)<\/a>/gi; const linkMatches = Array.from(rawHtml.matchAll(linkPattern)); const events = []; for (let index = 0; index < linkMatches.length; index += 1) { const match = linkMatches[index]; const rawHref = match[1]; const rawName = match[2]; const name = normalizeLiveTvCatalogText(rawName); if (!rawHref || !name || name.length < 3) { continue; } const pageUrl = absolutizeLiveUrl( rawHref, LIVETV_BASE_URL, LIVETV_BASE_URL, ); if (!pageUrl) { continue; } let pagePath = rawHref; try { pagePath = new URL(pageUrl).pathname; } catch { pagePath = rawHref; } const matchIndex = match.index || 0; const nextIndex = linkMatches[index + 1]?.index ?? rawHtml.length; const segmentStart = Math.max(0, matchIndex - 1200); const segmentEnd = Math.min(rawHtml.length, nextIndex); const segmentHtml = rawHtml.slice(segmentStart, segmentEnd); const eventBodyHtml = rawHtml.slice(matchIndex, segmentEnd); const beforeHtml = rawHtml.slice( Math.max(0, matchIndex - 5000), matchIndex, ); const anchorHtml = match[0] || ""; const segmentText = normalizeLiveTvCatalogText( segmentHtml .replace(/<\/(tr|table|div|p|li|td|h\d)>/gi, "\n") .replace(//gi, "\n"), ); const imgHintMatches = Array.from( beforeHtml.matchAll( /<(?:img|span)[^>]+(?:alt|title)=["']([^"']{3,180})["'][^>]*>/gi, ), ); const inlineHint = normalizeLiveTvCatalogText( imgHintMatches.length > 0 ? imgHintMatches[imgHintMatches.length - 1][1] : "", ); const sectionSportLabel = extractLiveTvSectionSportLabel(beforeHtml); const sportHint = sectionSportLabel || inlineHint; const timingInfo = extractLiveTvTimingInfo( segmentText, anchorHtml, eventBodyHtml, ); if (!timingInfo.scheduledTime && !sportHint) { continue; } const sectionDateText = extractLiveTvSectionDateText(beforeHtml); const timestamp = buildLiveTvTimestamp( sectionDateText, timingInfo.scheduledTime, timingInfo.isLive, ); const sportInfo = extractLiveTvSportInfo(sportHint, segmentText); const competition = timingInfo.competition || sportInfo.competitionFromHint || ""; const timeText = timingInfo.scheduledTime ? [timingInfo.scheduledTime, competition ? `(${competition})` : ""] .filter(Boolean) .join(" ") : sectionDateText; const descriptionParts = [ sportInfo.sportLabel, competition, timingInfo.isLive ? "Live" : timeText, ].filter(Boolean); events.push({ id: `livetv_${encodeUrlToken(pagePath)}`, type: "tv", name, poster: null, description: descriptionParts.join(" • "), genres: [sportInfo.sportKey === "other" ? "sport" : sportInfo.sportKey], _pagePath: pagePath, _timestamp: timestamp, _timeText: timeText, _competition: competition, _isLive: timingInfo.isLive, _status: timingInfo.isLive ? "live" : "upcoming", _sport: sportInfo.sportLabel, _sportKey: sportInfo.sportKey, _score: timingInfo.score, _emoji: LIVETV_SPORT_EMOJIS[sportInfo.sportKey] || "🏟️", }); } return dedupeByKey(events, (item) => item.id).sort((a, b) => { if (a._isLive && !b._isLive) return -1; if (!a._isLive && b._isLive) return 1; if (a._timestamp && b._timestamp) return a._timestamp - b._timestamp; if (a._timestamp) return -1; if (b._timestamp) return 1; return String(a.name || "").localeCompare(String(b.name || ""), "fr", { sensitivity: "base", }); }); } async function fetchLiveTvAllUpcomingHtml() { let bestResponse = null; for (const pagePath of LIVETV_ALLUPCOMING_PATHS) { const pageUrl = `${LIVETV_BASE_URL}${pagePath}`; try { const response = await axios.get(pageUrl, { headers: buildLivePageHeaders(LIVETV_BASE_URL), timeout: 15000, httpsAgent: SOSPLAY_HTTPS_AGENT, }); const html = String(response.data || ""); const score = (html.match(/\/frx\/eventinfo\//g) || []).length; if (!bestResponse || score > bestResponse.score) { bestResponse = { html, pageUrl, score }; } } catch (error) { console.warn( `[LIVETV] Failed allupcoming page ${pageUrl}:`, error.message, ); } } if (!bestResponse) { throw new Error("No LiveTV allupcoming page could be fetched"); } return bestResponse; } async function scrapeLivetvChannels(categoryKey) { try { const categoryConfig = LIVETV_CATEGORIES[categoryKey]; if (!categoryConfig) { console.log(`[LIVETV] Unknown category: ${categoryKey}`); return []; } const { html, pageUrl, score } = await fetchLiveTvAllUpcomingHtml(); const events = extractLiveTvCatalogEvents(html); const filteredEvents = events.filter((event) => { if (categoryConfig.sportKey === "all") { return true; } if (categoryConfig.sportKey === "live") { return Boolean(event._isLive); } if (categoryConfig.sportKey === "other") { return event._sportKey === "other"; } return event._sportKey === categoryConfig.sportKey; }); console.log( `[LIVETV] Scraped ${filteredEvents.length}/${events.length} events for ${categoryKey} ` + `from ${pageUrl} (${score} raw event links, ${filteredEvents.filter((item) => item._isLive).length} live)`, ); return filteredEvents; } catch (error) { console.error(`[LIVETV] Error scraping ${categoryKey}:`, error.message); return []; } } async function resolveLivetvStream(channelId, options = {}) { try { const requestedSourceIndex = Number.isInteger(options.sourceIndex) && options.sourceIndex >= 0 ? options.sourceIndex : null; const { eventUrl, hosterLinks } = await fetchLivetvEventEntries(channelId); if (!eventUrl || hosterLinks.length === 0) { return []; } const selectedHosterLinks = hosterLinks .map((hoster, index) => ({ ...hoster, _sourceIndex: index + 1, _sourceOffset: index, })) .filter( (hoster) => requestedSourceIndex === null || hoster._sourceOffset === requestedSourceIndex, ); const resolvedStreams = []; const resolvedEmbeds = []; for (const hoster of selectedHosterLinks) { const candidateUrls = dedupeByKey( [hoster.exportUrl, hoster.webplayerUrl].filter(Boolean), (url) => url, ); let resolved = { streams: [], embeds: [] }; for (const candidateUrl of candidateUrls) { resolved = await resolveRecursiveIframeStreams(candidateUrl, eventUrl, { maxDepth: 6, visited: new Set(), fallbackBase: LIVETV_BASE_URL, logPrefix: "[LIVETV]", preferEmbedForExternalIframes: true, embedReferer: LIVETV_EMBED_REFERER, }); if (resolved.streams.length > 0 || resolved.embeds.length > 0) { break; } } resolvedStreams.push( ...resolved.streams.map((stream, streamIndex) => ({ title: `${hoster.title}${resolved.streams.length > 1 ? ` ${streamIndex + 1}` : ""}`, url: stream.url, referer: stream.referer || hoster.exportUrl || hoster.webplayerUrl || eventUrl, _sourceIndex: hoster._sourceIndex, })), ); resolvedEmbeds.push( ...resolved.embeds.map((embed, embedIndex) => ({ title: `${hoster.title} Embed${resolved.embeds.length > 1 ? ` ${embedIndex + 1}` : ""}`, url: embed.url, referer: embed.referer || LIVETV_EMBED_REFERER, _isEmbed: true, _sourceIndex: hoster._sourceIndex, })), ); } const uniqueStreams = dedupeByKey( resolvedStreams, (item) => `${item.url}__${item.referer || ""}`, ); const uniqueEmbeds = dedupeByKey( resolvedEmbeds, (item) => `${item.url}__${item.referer || ""}`, ); console.log( `[LIVETV] Resolved ${uniqueStreams.length} direct streams and ${uniqueEmbeds.length} embeds for ${channelId}`, ); return uniqueStreams.length > 0 ? uniqueStreams : uniqueEmbeds; } catch (error) { console.error( `[LIVETV] Error resolving stream for ${channelId}:`, error.message, ); return []; } } async function getLivetvSources(channelId) { try { const { eventUrl, hosterLinks } = await fetchLivetvEventEntries(channelId); if (!eventUrl || hosterLinks.length === 0) { return []; } return buildLivetvSourceOptions(hosterLinks); } catch (error) { console.error( `[LIVETV] Error fetching source list for ${channelId}:`, error.message, ); return []; } } // ===== DADDYLIVE (dlhd.pk) ===== // Fetch + parse the full 24/7 channel grid once; cache 24h. -> [{rawId,name,country}] async function fetchDaddyliveAllChannels() { const cacheKey = generateCacheKey("daddylive_all_channels_v1"); const cached = await getFromCache(cacheKey, 24); // 24h if (cached && Array.isArray(cached.rows)) return cached.rows; try { const url = `${DADDYLIVE_BASE_URL}${DADDYLIVE_CHANNELS_PATH}`; const resp = await axios.get(url, { headers: buildLivePageHeaders(DADDYLIVE_REFERER), timeout: 15000, }); const rows = parseDaddyliveChannelsHtml(resp.data); if (rows.length > 0) { await saveToCache(cacheKey, { rows }); } return rows; } catch (error) { console.error("[DADDYLIVE] Error fetching channel list:", error.message); return []; } } // Channels for one catalog id (daddylive_). Unknown country -> bucketed into `other`. async function scrapeDaddyliveChannels(catalogId) { const code = catalogId.replace("daddylive_", ""); if (!DADDYLIVE_COUNTRIES[code]) return []; const rows = await fetchDaddyliveAllChannels(); const countryName = DADDYLIVE_COUNTRIES[code].name; return rows .filter( (r) => (DADDYLIVE_COUNTRIES[r.country] ? r.country : "other") === code, ) .map((r) => ({ id: `daddylive_${r.rawId}`, type: "tv", name: r.name, poster: DADDYLIVE_PLACEHOLDER_POSTER, genres: [countryName], })); } // Fetch the ordered player list for a channel (cache 10min). Falls back to the // 6 deterministic paths if watch.php cannot be parsed. async function fetchDaddylivePlayers(rawId) { const cacheKey = generateCacheKey(`daddylive_players_${rawId}_v1`); const cached = await getFromCacheMs(cacheKey, 600000); // 10 min if (cached && Array.isArray(cached.players) && cached.players.length) { return cached.players; } let players = []; try { const url = `${DADDYLIVE_BASE_URL}/watch.php?id=${rawId}`; const resp = await axios.get(url, { headers: buildLivePageHeaders(DADDYLIVE_REFERER), timeout: 15000, }); players = parseDaddylivePlayersHtml(resp.data); } catch (error) { console.error( `[DADDYLIVE] Error fetching players for ${rawId}:`, error.message, ); } if (!players.length) players = buildDaddyliveDeterministicPlayers(rawId); await saveToCache(cacheKey, { players }); return players; } // Source picker options for mode='sources'. async function getDaddyliveSources(channelId) { const rawId = channelId.replace("daddylive_", ""); const players = await fetchDaddylivePlayers(rawId); return players.map((p, index) => ({ index, title: p.title || `Player ${index + 1}`, hoster: "Daddylive", sourceType: "embed", })); } // GET helper for daddylive pages. async function fetchDaddyliveHtml(url, referer) { const resp = await axios.get(url, { headers: buildLivePageHeaders(referer), timeout: 15000, }); return resp.data; } // Resolve the chosen player to a raw m3u8 + referer. Returns [] on miss. // The player page now embeds an