diff --git a/API/Mainapi/liveTvRoutes.js b/API/Mainapi/liveTvRoutes.js index e715542..e214ba3 100644 --- a/API/Mainapi/liveTvRoutes.js +++ b/API/Mainapi/liveTvRoutes.js @@ -13,6 +13,19 @@ const crypto = require("crypto"); const cheerio = require("cheerio"); const { SocksProxyAgent } = require("socks-proxy-agent"); const { verifyAccessKey, requireVip } = require("./checkVip"); +const { + DADDYLIVE_BASE_URL, + DADDYLIVE_REFERER, + DADDYLIVE_ORIGIN, + DADDYLIVE_CHANNELS_PATH, + DADDYLIVE_PLACEHOLDER_POSTER, + DADDYLIVE_COUNTRIES, + parseChannelsHtml: parseDaddyliveChannelsHtml, + parsePlayersHtml: parseDaddylivePlayersHtml, + buildDeterministicPlayers: buildDaddyliveDeterministicPlayers, + extractM3u8FromPlayerHtml: extractDaddyliveM3u8, + extractIframeSrc: extractDaddyliveIframeSrc, +} = require("./utils/daddylive"); // SOCKS5H Proxy configuration pour WITV const WITV_PROXY_URL = process.env.WITV_SOCKS5_PROXY_URL || ""; @@ -24,8 +37,6 @@ const router = express.Router(); // === CONFIGURATION === const TVDIRECT_BASE_URL = "https://tvdirect.ddns.net"; -// URL de base pour la source Vavoo (configuration France) -const VAVOO_BASE_URL = "https://tvvoo.hayd.uk/cfg-fr"; // URL du serveur proxy local (proxiesembed) const PROXY_SERVER_URL = process.env.PROXY_SERVER_URL; /* @@ -265,7 +276,7 @@ const BOLALOCA_CHANNELS = [ }, ]; -const LIVETV_BASE_URL = "https://livetv882.me"; +const LIVETV_BASE_URL = "https://livetv876.me"; const LIVETV_EMBED_REFERER = `${LIVETV_BASE_URL}/`; const LIVETV_ALLUPCOMING_PATHS = ["/frx/allupcoming/", "/frx/ads/"]; const LIVETV_CATEGORIES = { @@ -375,6 +386,184 @@ const SOSPLAY_MATCHES_CATEGORIES = { }, }; +// 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.chat/proxy"; + +function proxifyFctvImage(url) { + if (!url) return ""; + + // Replace dead domains with the working one + let fixedUrl = url; + try { + const urlObj = new URL(url); + if (urlObj.hostname.startsWith("logos1.")) { + urlObj.hostname = "logos1.tcore131ybdf.ru"; + } + fixedUrl = urlObj.toString(); + } catch (e) { + // fallback if it's not a full URL + fixedUrl = url.replace(/logos1\.[a-z0-9]+\.(cfd|ru|com)/g, "logos1.tcore131ybdf.ru"); + } + + const proxy = FCTV_IMAGE_PROXY.endsWith("/") ? FCTV_IMAGE_PROXY : FCTV_IMAGE_PROXY + "/"; + return `${proxy}${fixedUrl}`; +} + +const FCTV_MATCHES_CATEGORIES = { + matches_all: { + name: "All Sports", + emoji: "🏅", + sportKey: "all", + sportType: 0, + }, + matches_football: { + name: "Football", + emoji: "\u26bd", + sportKey: "football", + sportType: 1, + }, +}; +const FCTV_API_HEADERS = { + "User-Agent": + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", + Accept: "application/x-protobuf,application/json,*/*", + Origin: FCTV_PLAYER_BASE_URL, + Referer: `${FCTV_PLAYER_BASE_URL}/`, +}; + +// V mapping: API endpoint paths -> bs code numbers (from upstream client JS) +const FCTV_BS_CODE_MAP = { + "/api/match/live": 100, + "/api/match/schedule": 101, + "/api/match/detail": 102, + "/api/match/event": 103, + "/api/match/statistic": 104, + "/api/match/trend": 105, + "/api/match/lineup": 106, + "/api/match/analysis": 107, + "/api/match/count": 190, + "/api/league/detail": 200, + "/api/league/season/list": 210, + "/api/league/match/list": 220, + "/api/league/team/total/list": 230, + "/api/league/player/total/list": 231, + "/api/league/team/standing/list": 240, + "/api/odds/list": 300, +}; + +// In-memory cache for bs keys (from /api/common/bs) +let fctvBsKeysCache = null; +let fctvBsKeysCacheTime = 0; +const FCTV_BS_KEYS_TTL = 5 * 60 * 1000; // 5 minutes + +/** + * Fetch body-signature keys from /api/common/bs. + * These keys are used to build the sfver path prefix required by the upstream API. + */ +async function fetchFctvBsKeys(sportType = 0) { + // Return cached keys if fresh + if (fctvBsKeysCache && Date.now() - fctvBsKeysCacheTime < FCTV_BS_KEYS_TTL) { + return fctvBsKeysCache; + } + + try { + const codes = [100, 101, 102, 103, 104, 105, 106, 107]; + const params = new URLSearchParams(); + params.set("stream", "true"); + params.set("sportType", String(sportType)); + codes.forEach((c) => params.append("code", String(c))); + + const apiBase = await getFctvApiBase(); + const response = await axios.get(`${apiBase}/api/common/bs`, { + params, + responseType: "arraybuffer", + headers: FCTV_API_HEADERS, + timeout: 10000, + }); + + const { body } = parseFctvBody(response.data); + const kvEntries = fctvAll(body, 1); + const keys = {}; + + for (const entry of kvEntries) { + const entryFields = fctvFields(entry); + const code = fctvValue(entryFields, 1); + const key = fctvString(fctvValue(entryFields, 2)); + if (code != null && key) { + keys[Number(code)] = key; + } + } + + console.log(`[FCTV-BS] Fetched ${Object.keys(keys).length} bs keys`); + fctvBsKeysCache = keys; + fctvBsKeysCacheTime = Date.now(); + return keys; + } catch (error) { + console.warn(`[FCTV-BS] Error fetching bs keys: ${error.message}`); + return fctvBsKeysCache || {}; + } +} + +/** + * Build the sfver path prefix for a given API endpoint and params. + * Format: sfver + MD5(JSON.stringify(sortedParams)).slice(0,6) + bsKey + */ +function buildFctvSfverPrefix(pathname, params, bsKeys) { + const au = { ...params }; + delete au.usls; + delete au.cBsDataTep; + + const ap = ['matchId', 'leagueId', 'seasonId', 'sportType', 'language', 'stream']; + + const sortedKeys = Object.keys(au).sort((a, b) => ap.indexOf(a) - ap.indexOf(b)); + + const sortedObj = {}; + for (const key of sortedKeys) { + sortedObj[key] = au[key]; + } + + const jsonStr = JSON.stringify(sortedObj); + const md5Hash = crypto.createHash("md5").update(jsonStr).digest("hex").slice(0, 6); + + // Try to find the bsCode mapping, but for /api/stream/detail it's missing in some clients so default to bsKeys[102] + const bsCode = FCTV_BS_CODE_MAP[pathname]; + const bsKey = (bsCode && bsKeys[bsCode]) || bsKeys[102] || bsKeys[100] || ""; + + return `sfver${md5Hash}${bsKey}`; +} + // Linkzy configuration (FREE source - no extension/VIP required) const LINKZY_CATEGORIES = { general: { id: "linkzy_generaliste", name: "Généraliste", emoji: "📺" }, @@ -1135,6 +1324,622 @@ async function scrapeSosplayChannels(categoryKey) { /** * 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]; @@ -1958,7 +2763,7 @@ function shouldIgnoreLiveTvIframeUrl(rawUrl) { const combined = `${hostname}${pathname}${search}`; if ( - hostname === "ads.livetv882.me" || + hostname === "ads.livetv876.me" || hostname.startsWith("ads.") || hostname.startsWith("ad.") ) { @@ -3251,6 +4056,150 @@ async function getLivetvSources(channelId) { } } +// ===== 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