/** * 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 { VAVOO_GROUPS, vavooGroupSlug, buildVavooCatalogs, } = require("./utils/vavooCatalogs"); 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.fun/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}`; } // === NORTHLIVE (FREE source — no extension / no VIP) =================== // northlive exposes ~1100 channels, each with a ready-to-embed `player_url` // (iframe). The upstream `category` field dumps ~60% of channels in a single // generic "IPTV" bucket, so we re-classify by channel name into usable content // categories. Scope: France + DOM-TOM only (per product decision). The full // list is refreshed hourly (fetchAllNorthlive) and cached to disk. const NORTHLIVE_BASE = process.env.NORTHLIVE_BASE_URL || "https://northlive.lol/api/v1/index.php"; const NORTHLIVE_API_KEY = process.env.SWIFTFLOW_API_KEY || process.env.NORTHLIVE_API_KEY || ""; const NORTHLIVE_REFRESH_MS = 60 * 60 * 1000; // 1h — user requested hourly refresh // Supplemental channel logos (slug -> url). northlive ships a logo for only ~40% // of channels; this map fills the rest (built from northlive's own logos + an // iptv-org name match — see scripts/build_northlive_logos.mjs). Optional: a // missing file just means no fallback logo. let NORTHLIVE_LOGOS = {}; try { NORTHLIVE_LOGOS = require("./northlive_logos.json"); } catch (e) { console.warn(`[NORTHLIVE] logo map not loaded: ${e.message}`); } // UI categories (catalog id -> label). Order = pill/catalog display order. const NORTHLIVE_CATEGORIES = { northlive_sport: { name: "Sport", emoji: "⚽" }, northlive_cinema: { name: "Cinéma", emoji: "🎬" }, northlive_series: { name: "Séries & Films", emoji: "🎞️" }, northlive_generaliste: { name: "Généraliste", emoji: "📺" }, northlive_info: { name: "Info", emoji: "📰" }, northlive_jeunesse: { name: "Jeunesse", emoji: "🧒" }, northlive_musique: { name: "Musique", emoji: "🎵" }, northlive_docs: { name: "Découverte & Docs", emoji: "🌍" }, northlive_divertissement: { name: "Divertissement", emoji: "🎭" }, northlive_regional: { name: "Régional & Local", emoji: "🏙️" }, northlive_autres: { name: "Autres", emoji: "📦" }, }; // France métropole + DOM-TOM. The upstream `country` field uses "France", // "France Martinique", "France GP", "France Guadeloupe", "France MQ", etc. — // every France-family value starts with "france". function isNorthliveFrance(country) { return typeof country === "string" && country.trim().toLowerCase().startsWith("france"); } // ponytail: name-keyword classifier (heuristic). The upstream "IPTV" bucket has // no sub-genre, so we infer it from the channel name. Tune the arrays below if a // channel lands in the wrong tab. Checked in priority order (first hit wins). const NORTHLIVE_KEYWORDS = [ ["northlive_sport", ["SPORT", "BEIN", "RMC SPORT", "DAZN", "EUROSPORT", "LIGUE 1", "LIGUE1", "FOOT", "MULTISPORT", "GOLF", "EQUIDIA", "TENNIS", "KOMBAT", "MOTO GP", "MOTOGP", "ELEVEN", "INFOSPORT", "SKWEEK", "AUTOMOTO", "AUTO MOTO", "AB MOTEURS", "OL TV", "PSG", "MOTOR", "NAUTICAL"]], ["northlive_jeunesse", ["GULLI", "CANAL J", "CANAL+ KIDS", "PIWI", "TIJI", "DISNEY", "NICK", "CARTOON", "BOOMERANG", "BOING", "TELETOON", "MANGAS", "GAME ONE", "OKOO", "TOONAMI", "BEYBLADE", "YU-GI-OH", "BOB LEPONGE", "CAILLOU", "MR. BEAN", "KIDZ", "KIDS", "JUNIOR", "SCHTROUMPF", "SUPERTOONS", "J-ONE", "GONG", "BABY TV", "TOONS", "TV PITCHOUN", "XILAM", "BEYBLADE"]], ["northlive_cinema", ["CINE", "CINÉ", "CINA", "OCS", "TCM", "BOX OFFICE", "BOXOFFICE", "PARAMOUNT", "GRAND ECRAN", "ALTICE STUDIO", "ACTION", "MOVIE", "FILM", "WILDSIDE", "MOVIESPHERE", "CANAL PLAY"]], ["northlive_series", ["SERIE", "SÉRIE", "WARNER", "SUNDANCE", "SYFY", "BBC DRAMA", "BBC SÉRIES", "BBC SERIES", "SONY", "NOVELA", "POLAR", "CRIME", "HOMICIDE", "DRAMA", "SEASON", "DETECTIVE", "COMEDY", "ENQUÊTE", "ENQUETE", "TELENOVELA", "TELE NOVELA"]], ["northlive_info", ["INFO", "BFM", "CNEWS", "C NEWS", "C-NEWS", "LCI", "EURONEWS", "I24", "CGTN", "RT FRANCE", "LCP", "PUBLIC SENAT", "PUBLIC SÉNAT", "MONACO INFO", "TECH & CO", "B SMART", "BUSINESS", "LE FIGARO", "20 MINUTES", "FRANCE 24", "AFRICA 24", "FRANCOPHONIE 24", "LE MÉDIA"]], ["northlive_musique", ["MUSIC", "MUSIQUE", "MTV", "TRACE", "NRJ", "MCM", "MEZZO", "RFM", "MELODY", "VEVO", "SKYROCK", "DJAZZ", "STINGRAY", "ZOUK", "SALSA", "CUMBIA", "K-POP", "METAL", "JAZZ", "DANCE", "QWEST", "FOLK", "SOUL", "HITS", "TVM 3", "TVM3", "MDL", "DBM"]], ["northlive_docs", ["DISCOVERY", "NAT GEO", "NATIONAL GEO", "PLANETE", "PLANÈTE", "USHUAIA", "USHUAÏA", "DECOUVERTE", "DÉCOUVERTE", "HISTOIRE", "SCIENCE", "NATURE", "ANIMAUX", "CHASSE", "PECHE", "PÊCHE", "TREK", "TRAVELXP", "VOYAGE", "TOP GEAR", "INVESTIGATION", "REPORTAGE", "FUTURA", "SORCIER", "IMEARTH", "DESTINATION NATURE", "CAP TERRE", "RMC STORY", "RMC DECOUVERTE", "RMC DÉCOUVERTE", "TOUTE HISTOIRE", "TOUTE L HISTOIRE", "SEASONS", "USHUAIA"]], ["northlive_generaliste", ["TF1", "TF 1", "TFI", "TFX", "FRANCE 2", "FRANCE 3", "FRANCE 4", "FRANCE 5", "FRANCE O", "FRANCE Ô", "M6", "C8", "W9", "TMC", "6TER", "NRJ 12", "ARTE", "RTL", "CHERIE 25", "CHÉRIE 25", "NOVO19", "TIPIK", "LA UNE", "LA TROIS", "TV5", "PARIS PREMIERE", "PARIS PREMIÈRE", "TEVA", "RTS", "RTBF"]], ]; // Maps the upstream `category` field to a UI category (used when the name gives // no signal — mainly the already-tagged non-"IPTV" channels). const NORTHLIVE_CAT_MAP = { Musique: "northlive_musique", Info: "northlive_info", Cinéma: "northlive_cinema", "Séries-Films": "northlive_series", Jeunesse: "northlive_jeunesse", Généraliste: "northlive_generaliste", Genéraliste: "northlive_generaliste", Sport: "northlive_sport", Régional: "northlive_regional", Local: "northlive_regional", Documentaires: "northlive_docs", Sciences: "northlive_docs", Nature: "northlive_docs", Reportages: "northlive_docs", Divertissement: "northlive_divertissement", Culture: "northlive_divertissement", "Art de vivre": "northlive_divertissement", Sociétal: "northlive_divertissement", Economie: "northlive_info", Voyage: "northlive_docs", }; function classifyNorthlive(name, apiCategory) { const upper = String(name || "").toUpperCase(); for (const [cat, words] of NORTHLIVE_KEYWORDS) { if (words.some((w) => upper.includes(w))) return cat; } if (apiCategory && NORTHLIVE_CAT_MAP[apiCategory]) return NORTHLIVE_CAT_MAP[apiCategory]; return "northlive_autres"; } // Builds the ready-to-embed iframe URL for a channel slug (deterministic — same // shape as the upstream `player_url`, so we never need to store it per-channel). function northliveEmbedUrl(slug) { return `${NORTHLIVE_BASE}?route=tv/${encodeURIComponent(slug)}/player&api_key=${NORTHLIVE_API_KEY}`; } // === VAVOO (FREE source — direct HLS, no extension / no proxy / no VIP) ====== // vavoo.to exposes IPTV channels grouped by country/region (MediaHubMX API). // The catalog is a POST (mediahubmx-catalog.json, paginated via `nextCursor`); // each play URL resolves to a raw .m3u8 (mediahubmx-resolve.json) that the // client plays as-is (_directPlay) — no proxy, no headers. Groups double as UI // categories. Refreshed hourly, cached to disk. const VAVOO_BASE = process.env.VAVOO_BASE_URL || "https://vavoo.to"; const VAVOO_CATALOG_URL = `${VAVOO_BASE}/mediahubmx-catalog.json`; const VAVOO_RESOLVE_URL = `${VAVOO_BASE}/mediahubmx-resolve.json`; const VAVOO_HEADERS = { "User-Agent": "MediaHubMX/2", "Content-Type": "application/json", Accept: "application/json", }; // vavoo.to geoblocks datacenter IPs (HTTP 451), so catalog/resolve calls are // routed through the shared SOCKS5 pool, rotating to the next proxy on failure. // Playback stays direct — the resolved m3u8 is fetched client-side (in-region). const { pickDedicatedSocks5Proxy, getProxyAgent, } = require("./utils/proxyManager"); async function vavooPost(url, payload, timeout = 15000) { let lastErr; for (let attempt = 0; attempt < 15; attempt++) { let agent = null; try { const proxy = await pickDedicatedSocks5Proxy(); if (proxy) agent = getProxyAgent(proxy); } catch { /* pool empty → fall through to a direct call (will likely 451) */ } try { const resp = await axios.post(url, payload, { headers: VAVOO_HEADERS, timeout, ...(agent ? { httpAgent: agent, httpsAgent: agent, proxy: false } : {}), }); return resp.data; } catch (e) { lastErr = e; // rotate to the next proxy and retry } } throw lastErr; } const VAVOO_SLUG_TO_GROUP = Object.fromEntries( VAVOO_GROUPS.map((g) => [vavooGroupSlug(g), g]), ); // Fetch one group's full channel list (paginated via nextCursor), cache 1h. async function fetchVavooGroup(group) { const cacheKey = generateCacheKey(`vavoo_group_${group}_v2`); const disk = await getFromCache(cacheKey, 1); // 1h if (disk && Array.isArray(disk) && disk.length) return disk; const channels = []; const seenNameId = new Set(); const idCounts = new Map(); let cursor = null; for (let page = 0; page < 50; page++) { // hard cap: 50 pages guards against a broken nextCursor loop let data; try { const payload = { language: "fr", region: "FR", catalogId: "iptv", id: "", adult: false, search: "", sort: "name", filter: { group }, cursor: cursor ? Number(cursor) : 0 }; data = await vavooPost(VAVOO_CATALOG_URL, payload); } catch (e) { console.warn(`[VAVOO] group ${group} page ${page} error: ${e.message}`); break; } const items = Array.isArray(data?.items) ? data.items : []; for (const it of items) { const baseId = it?.ids?.id; if (!baseId) continue; const name = it.name || baseId; const dedupKey = `${baseId}~${name}`; if (seenNameId.has(dedupKey)) continue; seenNameId.add(dedupKey); const count = (idCounts.get(baseId) || 0) + 1; idCounts.set(baseId, count); const uniqueId = count === 1 ? baseId : `${baseId}~${count}`; channels.push({ id: `vavoo_${uniqueId}`, type: "tv", name: name, // poster intentionally null — logos hidden for now (name-only landscape // cards); swap back to `it.logo || null` to restore them later. poster: null, }); } cursor = data?.nextCursor ?? null; if (!cursor || items.length === 0) break; } if (channels.length === 0) { // Upstream hiccup — keep serving the last good cache rather than blanking out. const stale = await getFromCache(cacheKey, 24 * 365); if (stale && Array.isArray(stale) && stale.length) return stale; return []; } await saveToCache(cacheKey, channels); return channels; } // Channels for a UI category (vavoo_). async function getVavooChannels(catalogId) { const slug = catalogId.slice("vavoo_".length); const group = VAVOO_SLUG_TO_GROUP[slug]; if (!group) return []; return fetchVavooGroup(group); } // Resolve a channel id to its raw .m3u8 (played client-side as-is). // Each resolve lands on a random edge: usually https on a rotating domain, // sometimes plain http on a bare IP — unplayable from the https site (mixed // content). Re-resolve up to 5 times to get an https URL; if none shows up, // return the http one as a last resort. Every attempt goes through the SOCKS5 // pool (vavooPost) since vavoo 451s datacenter IPs. async function resolveVavooStream(channelId) { let id = channelId.slice("vavoo_".length); if (id.includes("~")) { id = id.split("~")[0]; } const playUrl = `${VAVOO_BASE}/vavoo-iptv/play/${id}`; let httpFallback = null; for (let attempt = 0; attempt < 5; attempt++) { let data; try { data = await vavooPost( VAVOO_RESOLVE_URL, { language: "fr", region: "FR", url: playUrl }, 10000, ); } catch (e) { // vavooPost already rotated through 4 proxies — upstream is down, stop. console.warn(`[VAVOO] resolve ${id} failed: ${e.message}`); break; } const arr = Array.isArray(data) ? data : []; const url = arr[0]?.url || null; if (!url) continue; if (url.startsWith("https://")) return url; if (!httpFallback) httpFallback = url; } return httpFallback; } // 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