MovixOpenSource/API/Mainapi/liveTvRoutes.js
Movix 8657a856f8 feat: live TV FCTV, fournisseur OAuth, sources J1F/Cinestream, modération IA & durcissement
feat: live TV FCTV, fournisseur OAuth, sources J1F/Cinestream, modération IA & durcissement

Gros lot de travail synchronisé depuis le repo de dev. Les grands axes :
migration de domaine (movix.date + miroirs), deux nouvelles sources films/séries
(1jour1film, Cinestream) et une n ouvelle source livetv, un fournisseur OAuth « Se connecter avec Movix », une
modération de contenu par IA revue, plus un lot de durcissement mémoire/réseau,
sécurité et perf côté API et front.

## 🌐 Domaines & miroirs

- Domaine canonique `movix.chat`/`movix.golf`/`movix.tax` → **movix.date** partout
  (front, allow-lists CORS/domaine, Discord RPC). Miroir `movix.health` →
  **movix.online** (Footer, Header, pages d'aide CaMarchePlus/Dns/Miroirs). Lien
  Telegram du footer → `mysticsaba.com`. ~10 clés i18n mises à jour.

## 🎬 Sources de streaming (VOD)

- **J1F (1jour1film)** — nouvelle source de bout en bout (`routes/j1f.js`, monté sur
  `/api/j1f`). Résout le domaine tournant via une page `/go/`, décode les blobs base64
  `J1F_SRV`/`j1fEpsData` (Dooplay/WP), ne garde que les players uniques, ignore les
  agrégateurs (frembed/vidsrc/videasy). Cache stale-while-revalidate dédié. Players 1J1F
  lus via l'extension/userscript.
- **Cinestream** — nouvelle source films (`routes/cinestream.js`, scrape Next.js
  `cinestream.info`, match par id TMDB autoritaire). Wiflix bascule ses **films** sur
  Cinestream (bot-shield flemmix pénible sur les films) ; la **TV** reste sur flemmix.
- **Wiflix** — `flemmix.farm` → **`flemmix.golf`** ; handshake cookie
  (`makeWiflixSearchRequest`, `h_check=25`) ; sélecteurs de template corrigés.
- **Cpasmal** — axios+proxy → `makeCpasmalRequest` (CycleTLS JA3) car Cloudflare 403 ;
  recherche GET → POST form (le WAF bloque le GET).
- **Fstream** — cookie anti-bot `fsschal` ; source épisodes JS statique
  (`/static/series/<id>.js`) avant l'AJAX `episodes_p.php` ; fallback `get_seasons.php`.
- **FranceTV** — `fetchSeasonEpisodesViaDeepPage` (pagination JSON « Afficher plus »).
- **Coflix** — `coflix.date` → `coflix.trade` ; logs 429 silencés en double.
- **proxyManager** — cinq helpers de contournement Cloudflare exportés
  (`makeCinestreamRequest`, `make1j1fRequest`, `makeCpasmalRequest`, handshake Wiflix
  `makeWiflixSearchRequest`/`wiflixHandshake`, `classifyCloudflare429` worker-vs-site) ;
  CycleTLS + rotation ProxyScrape, ne throw jamais sur statut HTTP. Coflix/LecteurVideo
  quittent les Cloudflare Workers pour ProxyScrape.

## 📺 Live TV — remplacement de Sosplay/Vavoo par FCTV33

- Source sport **Sosplay → FCTV33** (API RBTV, protobuf brut) réécrite de bout en bout
  dans `liveTvRoutes.js`, **Vavoo retiré** (manifest/catalogue/stream) — tout passe par
  `/api/livetv/*`.
- Pipeline neuf : décodeur protobuf maison, signature `sfver` (bs keys + MD5),
  auto-découverte des domaines API/player tournants (`hubu.ru/fctvlink` + Nuxt SSR /
  `params` rot47, cache 30 min), scraper de matchs (`scrapeFctvMatches` :
  équipes/logos, ligue, pays, statut live, tri live-first), résolution de flux (HLS
  natif d'abord, embed en secours), token CDN (XOR keystream + base64, gate
  `rb-session`).
- Route `GET /api/livetv/fctv/playlist` : proxy m3u8 intelligent (résout le token,
  réécrit les segments, retry sur token périmé, modes `raw` extension / `proxy` VIP).
- **Matchs plus réservés aux VIP** (seul `iptv_` le reste) : flux natif proxifié pour
  les VIP, stub `_fctvLocal` résolu côté client par
  l'extension/userscript (`RESOLVE_FCTV`), fallback embed sinon.
- Front `LiveTV.tsx` : `matches` → `fctv`, UI accordéon par match (logos, badges
  ligue/pays, score/timer live, favori), disclaimer, fix icône `music`. Rotations
  d'hôtes : `witv.team`→`witv.football`, `livetv876.me`→`livetv901.me`.

## 🧩 Extensions · Userscript · Discord RPC

- **FCTV en lecture native** : handlers `SETUP_FCTV_HEADERS`/`RESOLVE_FCTV` (règle DNR
  Referer/Origin/UA sur `/cfall/s*` + résolveur local IP-bindé : protobuf-lite + rot47
  + token XOR).
- **Vavoo retiré** , `cinepulse.lol` → `purstream.mx`.
- Fix extracteur **Sibnet** (`Range: bytes=0-0`) ; fix bug userscript `matchToRegex`
  (patterns ancrés `^…$` cassant `/cfall/s` → dé-ancrés) ; override dev
  `localhost:25565`.
- Bumps de version (Chrome 1.3.3→1.3.8, Firefox 1.5.1→1.5.5, userscript 1.4.0→1.4.7) ;
  PreMid nouveau `clientId` Discord + `regExp` élargi (1.0.3→1.0.4).

## 🔑 OAuth (fournisseur « Se connecter avec Movix ») & codes d'accès

- Refonte du flux d'autorisation OAuth (`OAuthAuthorizePage.tsx`) + routes
  (`routes/authRoutes.js`, `routes/oauth.js`) et stockage des clients OAuth étoffé
  (`utils/oauthClientsDb.js`, `routes/adminOauthApps.js` pour la gestion admin des apps).
- `AccessCodeForm.tsx` retouché en cohérence avec le nouveau flux ; `checkVip.js` ajusté.
- Session centralisée : `accountAuth.ts` expose `clearStoredAuthSession()` /
  `broadcastAuthChange()` (liste `AUTH_KEYS` unifiée), désormais utilisés partout à la
  déconnexion.

## 💬 Modération de contenu (IA)

- Modèle OpenRouter `google/gemini-2.5-flash-lite` → `deepseek/deepseek-v4-flash`
  (`commentsRoutes.js`, `sharedListsRoutes.js`) ; fonctions/logs renommés
  `moderateContentWithGemini` → `moderateContentWithAI` (découplés du nom du modèle).
- Prompt durci (critères INSULTES / DEMANDE_AJOUT resserrés) pour éviter les faux
  positifs sur questions, signalements de bug et plaintes de contenu manquant.
- `AdminComments.tsx` : libellé « par Gemini » → clé i18n générique.

## 🧊 Hydracker / Darkiworld

- **Sortie du debrid** : AllDebrid/RealDebrid supprimés (`utils/hydrackerLive.js` :
  branche provider retirée ; vars `ALLDEBRID_*`/`REALDEBRID_*` sorties du `.env`) —
  hydracker renvoie directement `rawUrl`/`directDL`. Liens servis depuis les snapshots
  SQLite locaux (`utils/darkiworldSqlite.js`, `darkino-backups/`).
- **Anti-scraping** sur `/decode/:id` (`routes/darkiworld.js`) : watchlist + alerte
  webhook Discord (`DISCORD_SCRAPER_WEBHOOK`) et empoisonnement/blocage de réponse pour
  les IP listées (`SCRAPER_BLOCKED_IPS`).
- **Mémoire** : cache d'agents proxy borné (`utils/lruMap.js`) qui détruit les sockets
  des agents évincés.

## 🛠️ Admin — propriété des liens & équipe

- **Propriété des liens** : les liens streaming gagnent l'attribution `added_by` et
  l'ownership est appliquée des deux côtés (uploader = ses liens, admin = tous). GET
  public masqué (`stripLinkOwner`), `GET /admin/streaming-links/:type/:id` authentifié
  conserve le tag ; `DELETE /admin/links` par URL unique ; `PUT /admin/links` masse
  admin-only.
- **Équipe** : `GET/POST/DELETE /admin/admins` + `GET /admin/team/history` (audit),
  identité via `adminIdentity.js` (option `preferProfile` → premier profil Movix). UI
  `AdminTeam.tsx`.
- **Privilèges** (`middleware/auth.js`) : `isAdmin` rejette `role !== 'admin'` (403) —
  les uploaders ne passent plus les routes admin-only. Migration
  `add_role_to_admins.sql`.

## 🎁 Wrapped v2

- Cartes de partage : logique canvas extraite (`utils/wrappedCanvas.ts`,
  `utils/wrappedShareCards.ts`), formats de partage supplémentaires + preview.
- Nouvelles slides : jour record, répartition par jour de semaine, champion de rewatch,
  âge ciné (année de sortie médiane pondérée par le temps de visionnage).
- Perf : données depuis le payload backend (zéro re-fetch TMDB), trailer lazy,
  percentile non bloquant (réchauffe en arrière-plan, lock Redis `NX`), shuffle seedé.

## 🔐 Auth · Profils · VIP

- **Pseudo forcé** : `validateProfileName` (1–32 chars, strip control/zero-width, +
  `maxLength={32}` côté UI Profile/WatchParty) ; les pseudos OAuth trop longs →
  `requiresUsernameChange` + modale bloquante `RequireUsernameChange.tsx`.
- **Sync durci** (`ProfileContext.tsx`, `syncPolicy.js`) : anti-replay cross-user,
  filtrage des ops à clés non-syncables (`access_code`/`access_code_expires` exclues),
  « wipe guard » qui préserve le local sur profil vide inattendu, dédupe des
  `loadProfiles()` concurrents. Logout centralisé (voir OAuth).
- **VIP** : `is_vip` préservé au chargement de profil (corrige un downgrade VIP
  inter-onglets qui relançait la popup pub) ; `vipDonations.js` poll de secours
  `payment-status.php` (via `ipn_token`, colonne `paygate_ipn_token`) → plus d'invoices
  bloquées.

## ▶️ Lecteur (HLS)

- Basse latence opt-in : `isLowLatencyEnabled('movies')` (scope movies/livetv,
  `lowLatencyPref.ts`) au lieu du mode forcé ; `FloatingPlayer` aligné.
- Sous-titres cinep : wrapper m3u8 synthétique pour les URI `.vtt`/`.srt` directes,
  `isSubtitleLoadError` (une piste cassée ne tue plus la vidéo), rendu `<i>/<b>/<u>`.
- Menu sources regroupé par langue (VF/VFQ/VFF/VOSTFR).

## 📡 Cast (AirPlay / Chromecast)

- Retrait du contournement proxy darkibox (`buildApiProxyUrl`) → cast direct de l'URL ;
  `runtime.ts` supprime `API_PROXY_BASE_URL`/`buildApiProxyUrl` devenus morts.
- Paramètre `streamType` (`BUFFERED` par défaut) propagé jusqu'à
  `loadMediaOnCastWithFallback`.

## 📢 Pubs

- Modes de popup (`adPopupMode.ts`) : `normal`, `auto`, `click-anywhere` ; toggle « +18 »
  séparé (`adAdultMode.ts`). `SCRIPT_AD_MODE_ENABLED` → `false`, URL du script changée.

## 🔎 Recherche & priorité des sources

- `searchUtils.ts` : le terme original contourne le filtre « trop générique » (fix
  zéro-résultat sur titres courts : Baki, Gantz).
- `animeSignals.ts` : détection donghua (CN/TW/HK ou `zh` + Animation, hors C-drama).
- `sourceAutoSelect.ts` : `VFF`/`VFQ` normalisés en `vf` (sinon reléguées derrière
  VOSTFR). `sourcePriorityPrefs.ts` : schéma v2 → v3 (nouvel ordre par défaut + `j1f`,
  migration non destructive des ordres personnalisés). `extractionPrefs.ts` : +daddylive.

## 🔒 Sécurité

- Sanitisation d'URL avant rendu en `href` : `CommentsSection.tsx` (liens markdown
  `http(s)://`) et `Greenlight/AdminLinkSubmissions.tsx` — bloque les `javascript:`.

## ⚙️ Infra & perf

- **server.js** : recyclage périodique des workers (12h) + `[memstats]` + hook
  `SIGUSR2` → heap-snapshot.
- **app.js** : monte `/api/j1f`, retire la route statique `/oauth-icons`, passe
  `makeCpasmalRequest` au router Cpasmal, `coflix.date` → `coflix.trade`.
- **lazyWithRetry.ts / main.tsx / ErrorBoundary.tsx** : récupération chunk-load réécrite
  (retry + reload budgété), garde `removeChild/insertBefore`, écran soft « Mise à jour »
  au lieu du crash-report.
- **UI** : `TopProgressBar` écrit `transform` directement sur un ref (compositor-only,
  plus de re-render) ; `blur-text` relâche `will-change` après l'animation.
- **proxiesembed/server.py** : `curl_cffi` (JA3) pour `cinep-proxy` + retry SOCKS5 sur
  403 ; lookup clé VIP réaligné sur `checkVip.js` ; log WARNING par défaut ; SOCKS5
  forcé IPv4 ; wrapper VTT/SRT pour hls.js ; re-label `application/zip` → `video/mp2t` ;
  `cinepulse.lol` → `purstream.mx`.

## 🧹 Nettoyage & suppressions

- **Front** : carte promo « Loadix » sur Download (remplace l'encart beta),
  « 1jour1film » ajouté à « C'est quoi Movix », fallback Darkino/Nightflix gutté en
  no-op (`api.movix.chat` décommissionné), classes CSS mortes retirées, fix
  `fetchPriority` → `fetchpriority`.
- **Backend** : scrapers/utilitaires morts retirés — `routes/dragiv.js`,
  `routes/topstream.js`, `routes/proxy.js`, `utils/cloudflareSession.js`,
  `utils/hydrackerBatch.js`, `utils/hydrackerQueue.js'.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 18:22:13 +02:00

5571 lines
170 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* Live TV Routes - Backend API pour TV en Direct
*
* Ce module gère les requêtes vers l'API Stremio TV Direct
* avec mise en cache et résolution des URLs de streaming.
*/
const express = require("express");
const axios = require("axios");
const path = require("path");
const fsp = require("fs").promises;
const crypto = require("crypto");
const cheerio = require("cheerio");
const { SocksProxyAgent } = require("socks-proxy-agent");
const { verifyAccessKey, requireVip } = require("./checkVip");
const {
DADDYLIVE_BASE_URL,
DADDYLIVE_REFERER,
DADDYLIVE_ORIGIN,
DADDYLIVE_CHANNELS_PATH,
DADDYLIVE_PLACEHOLDER_POSTER,
DADDYLIVE_COUNTRIES,
parseChannelsHtml: parseDaddyliveChannelsHtml,
parsePlayersHtml: parseDaddylivePlayersHtml,
buildDeterministicPlayers: buildDaddyliveDeterministicPlayers,
extractM3u8FromPlayerHtml: extractDaddyliveM3u8,
extractIframeSrc: extractDaddyliveIframeSrc,
} = require("./utils/daddylive");
// SOCKS5H Proxy configuration pour WITV
const WITV_PROXY_URL = process.env.WITV_SOCKS5_PROXY_URL || "";
const WITV_PROXY_AGENT = WITV_PROXY_URL
? new SocksProxyAgent(WITV_PROXY_URL)
: null;
const router = express.Router();
// === CONFIGURATION ===
const TVDIRECT_BASE_URL = "https://tvdirect.ddns.net";
// URL du serveur proxy local (proxiesembed)
const PROXY_SERVER_URL = process.env.PROXY_SERVER_URL;
/*
"https://tvmio.gleeze.com/eyJjb3VudHJpZXMiOlsiRlIiXSwiY2F0ZWdvcmllcyI6eyJGUiI6WyJHZW5lcmFsIPCfk7oiLCJTcG9ydHMg4pq9IiwiRG9jdW1lbnRhaXJlcyDwn4yNIiwiRmlsbXMg8J+OrCIsIkluZm9ybWF0aW9ucyDwn5OwIiwiRW5mYW50cyDwn5G2IiwiTXVzaWMg8J+OtSJdfSwiZW5hYmxlU2VhcmNoIjpmYWxzZX0";
tvmio_general: {
genre: "FR | General",
remoteGenre: "General 📺",
name: "Généraliste 📺",
emoji: "📺",
},
tvmio_sports: {
genre: "FR | Sports",
remoteGenre: "Sports ⚽",
name: "Sports ⚽",
emoji: "⚽",
},
tvmio_documentaires: {
genre: "FR | Documentaires",
remoteGenre: "Documentaires 🌍",
name: "Documentaires 🌍",
emoji: "🌍",
},
tvmio_films: {
genre: "FR | Films",
remoteGenre: "Films 🎬",
name: "Films 🎬",
emoji: "🎬",
},
tvmio_informations: {
genre: "FR | Informations",
remoteGenre: "Informations 📰",
name: "Informations 📰",
emoji: "📰",
},
tvmio_enfants: {
genre: "FR | Enfants",
remoteGenre: "Enfants 👶",
name: "Enfants 👶",
emoji: "👶",
},
tvmio_music: {
genre: "FR | Musique",
remoteGenre: "Music 🎵",
name: "Musique 🎵",
emoji: "🎵",
},
};
*/
const TVMIO_CATEGORIES = {};
// URL de base pour Wiflix (Witv)
const WITV_BASE_URL = "https://witv.football";
// Cloudflare proxy for 403 bypass (livehdtv)
const CF_PROXY_403 = process.env.CF_PROXY_403_URL;
const WITV_CATEGORIES = {
wiflix_generaliste: "/chaines-live/generaliste/",
wiflix_cinema: "/chaines-live/cinema/",
wiflix_sport: "/chaines-live/sport/",
wiflix_documentaire: "/chaines-live/documentaire/",
wiflix_enfants: "/chaines-live/enfants/",
wiflix_info: "/chaines-live/info/",
wiflix_musique: "/chaines-live/musique/",
};
// URL de base pour Sosplay
const SOSPLAY_BASE_URL = "https://streamonsport.art";
// Source Bolaloca/Elitegol (remplace l'ancien catalogue Sosplay pour les chaines)
const BOLALOCA_BASE_URL = "https://bolaloca.my";
const BOLALOCA_PLAYER_VARIANTS = [2, 4, 3, 1];
const BOLALOCA_CHANNELS = [
{
id: "20",
name: "Ligue 1+",
poster: "https://www.lensois.com/wp-content/uploads/2025/07/ligue-1-1.jpg",
},
{
id: "21",
name: "Ligue 1+ CH2",
poster:
"https://www.monpetitforfait.com/comparateur-box-internet/wp-content/uploads/2025/07/ligue-1-plus-2.png",
},
{
id: "22",
name: "Ligue 1+ CH3",
poster:
"https://www.monpetitforfait.com/comparateur-box-internet/wp-content/uploads/2025/07/ligue-1plus-3-box-internet-300x95.png",
},
{
id: "39",
name: "Ligue 1+ CH4",
poster:
"https://www.monpetitforfait.com/comparateur-box-internet/wp-content/uploads/2025/07/ligue-1plus-4-box-internet-300x106.png",
},
{
id: "40",
name: "Ligue 1+ CH5",
poster:
"https://www.monpetitforfait.com/comparateur-box-internet/wp-content/uploads/2025/07/ligue-1-plus-5.png",
},
{
id: "11",
name: "Canal+",
poster:
"https://thumb.canalplus.pro/http/unsafe/epg.canal-plus.com/mycanal/img/CHN43FN/PNG/213X160/CHN43FB_301.PNG",
},
{
id: "12",
name: "Canal+ Foot",
poster: "https://upload.wikimedia.org/wikipedia/fr/3/3b/C%2B_Foot.png",
},
{
id: "13",
name: "Canal+ Sport",
poster:
"https://upload.wikimedia.org/wikipedia/fr/2/2c/C%2B_Sport_%282023%29.png",
},
{
id: "14",
name: "Canal+ Sport 360",
poster: "https://upload.wikimedia.org/wikipedia/fr/1/11/C%2B_Sport_360.png",
},
{
id: "17",
name: "RMC Sport 1",
poster:
"https://i0.wp.com/www.planetecsat.com/wp-content/uploads/2018/07/RMC_SPORT1_PNG_500x500px.png?w=500&ssl=1",
},
{
id: "18",
name: "RMC Sport 2",
poster:
"https://i0.wp.com/www.planetecsat.com/wp-content/uploads/2018/07/RMC_SPORT2_PNG_500x500px.png?fit=500%2C500&ssl=1",
},
{
id: "15",
name: "Eurosport 1",
poster:
"https://2.bp.blogspot.com/-qEkUoydNN-E/WvMoKma36fI/AAAAAAAAG_0/ov-d571uhZ443Nai7gdU9sSIV2IBOkquQCLcBGAs/s1600/europsort-1-HD.jpg",
},
{
id: "16",
name: "Eurosport 2",
poster:
"https://4.bp.blogspot.com/-1bHZ8b5ZnW0/VzDh6KfzayI/AAAAAAAABsI/lKDWcPmyBSk7etoAj2DVr7nvQ5SsMPwzgCLcB/s1600/fhuxmcp92wg1w4y9pd2v4zjz3xs1vmjm.jpg",
},
{
id: "19",
name: "L'Equipe",
poster:
"https://www.cse.fr/wp-content/uploads/2016/02/LEquipe_logo-300x200-300x150.png",
},
{
id: "23",
name: "Automoto",
poster:
"https://moto-station.com/wp-content/uploads/2021/05/05/Automoto-La-Chaine-logo_0.png.jpg",
},
{
id: "31",
name: "Canal+ Live 1",
poster: "https://www.lyngsat.com/logo/tv/cc/canal-plus-live-1-fr.png",
},
{
id: "32",
name: "Canal+ Live 2",
poster: "https://www.lyngsat.com/logo/tv/cc/canal-plus-live-2-fr.png",
},
{
id: "33",
name: "Canal+ Live 3",
poster: "https://www.lyngsat.com/logo/tv/cc/canal-plus-live-3-fr.png",
},
{
id: "1",
name: "beIN Sports 1",
poster:
"https://r2.thesportsdb.com/images/media/channel/logo/BeIn_Sports_1_Australia.png",
},
{
id: "2",
name: "beIN Sports 2",
poster:
"https://r2.thesportsdb.com/images/media/channel/logo/BeIn_Sports_2_Australia.png",
},
{
id: "3",
name: "beIN Sports 3",
poster:
"https://r2.thesportsdb.com/images/media/channel/logo/BeIn_Sports_3_Australia.png",
},
{
id: "4",
name: "beIN Sports MAX 4",
poster:
"https://r2.thesportsdb.com/images/media/channel/logo/BeIn_Sports_Max_4.png",
},
{
id: "5",
name: "beIN Sports MAX 5",
poster:
"https://r2.thesportsdb.com/images/media/channel/logo/BeIn_Sports_Max_5.png",
},
{
id: "6",
name: "beIN Sports MAX 6",
poster:
"https://r2.thesportsdb.com/images/media/channel/logo/BeIn_Sports_Max_6.png",
},
{
id: "7",
name: "beIN Sports MAX 7",
poster:
"https://r2.thesportsdb.com/images/media/channel/logo/BeIn_Sports_Max_7.png",
},
{
id: "8",
name: "beIN Sports MAX 8",
poster:
"https://r2.thesportsdb.com/images/media/channel/logo/BeIn_Sports_Max_8.png",
},
{
id: "9",
name: "beIN Sports MAX 9",
poster:
"https://r2.thesportsdb.com/images/media/channel/logo/BeIn_Sports_Max_9.png",
},
{
id: "10",
name: "beIN Sports MAX 10",
poster:
"https://r2.thesportsdb.com/images/media/channel/logo/BeIn_Sports_Max_10.png",
},
];
const LIVETV_BASE_URL = "https://livetv901.me";
const LIVETV_EMBED_REFERER = `${LIVETV_BASE_URL}/`;
const LIVETV_ALLUPCOMING_PATHS = ["/frx/allupcoming/", "/frx/ads/"];
const LIVETV_CATEGORIES = {
livetv_all: { name: "Tous les sports", emoji: "📅", sportKey: "all" },
livetv_live: { name: "En direct", emoji: "🔴", sportKey: "live" },
livetv_football: { name: "Football", emoji: "⚽", sportKey: "football" },
livetv_hockey: { name: "Hockey", emoji: "🏒", sportKey: "hockey" },
livetv_basketball: {
name: "Basketball",
emoji: "🏀",
sportKey: "basketball",
},
livetv_tennis: { name: "Tennis", emoji: "🎾", sportKey: "tennis" },
livetv_volleyball: {
name: "Volley-ball",
emoji: "🏐",
sportKey: "volleyball",
},
livetv_handball: { name: "Handball", emoji: "🤾", sportKey: "handball" },
livetv_rugby: { name: "Rugby", emoji: "🏉", sportKey: "rugby" },
livetv_combat: { name: "Sports de combat", emoji: "🥊", sportKey: "combat" },
livetv_motorsport: {
name: "Sports mecaniques",
emoji: "🏎️",
sportKey: "motorsport",
},
livetv_winter: { name: "Sports d'hiver", emoji: "🎿", sportKey: "winter" },
livetv_athletics: { name: "Athletisme", emoji: "🏃", sportKey: "athletics" },
livetv_other: { name: "Autres sports", emoji: "🏟️", sportKey: "other" },
};
const LIVETV_SPORT_ALIASES = {
football: ["football", "soccer", "futsal"],
hockey: ["hockey sur glace", "ice hockey", "hockey"],
basketball: ["basketball", "basket"],
tennis: ["tennis"],
volleyball: ["volley-ball", "volleyball", "volley ball", "volley"],
handball: ["handball"],
rugby: ["rugby"],
combat: [
"boxe",
"boxing",
"mma",
"ufc",
"kickboxing",
"muay thai",
"wrestling",
"lutte",
"combat",
],
motorsport: [
"courses",
"motogp",
"formula 1",
"formule 1",
"rally",
"rallye",
"nascar",
"indycar",
"motorsport",
"auto racing",
],
winter: [
"sport d'hiver",
"sports d'hiver",
"winter sports",
"ski",
"biathlon",
"snowboard",
"curling",
"patinage",
],
athletics: [
"course a pied",
"course a pied",
"course à pied",
"athletisme",
"athletism",
"athletisme...",
"athletisme…",
"marathon",
"trail",
"running",
],
};
const LIVETV_SPORT_EMOJIS = Object.fromEntries(
Object.values(LIVETV_CATEGORIES)
.filter(
(config) =>
config.sportKey &&
config.sportKey !== "all" &&
config.sportKey !== "live",
)
.map((config) => [config.sportKey, config.emoji || "🏟️"]),
);
// Sosplay categories
const SOSPLAY_CATEGORIES = {
sosplay_chaines: "/livetv-sport-france-streaming-gratuit",
};
// Sosplay Matches categories (for live sports events)
const SOSPLAY_MATCHES_CATEGORIES = {
matches_football: {
path: "/type.php?type=Football",
name: "Football",
emoji: "⚽",
},
};
// FCTV33 / RBTV-style API used for the matches catalog.
// EU endpoint (defra) is used by default for European users.
// Fallback API base — normally auto-resolved at runtime (getFctvApiBase); this
// hardcoded value is used only when the cache is empty / auto-resolve fails.
const FCTV_API_BASE_URL = "https://apis-data-defra10.tcore131ybdf.ru";
// Fallback embed player origin — normally auto-resolved (getFctvPlayerBaseUrl).
// Used for the iframe player + referer when the lookup is unavailable.
const FCTV_PLAYER_BASE_URL = "https://zac07eo.mpipzni2naturally32kistomach.ru";
// --- Native (HLSPlayer) stream tokenisation -------------------------------
// /api/stream/detail (unsigned, with continent/country/digit) returns an
// `rb-session` header + a masked URL that decodes to the proxy m3u8 path.
// The CDN gate is `/token-<T>/` where T = base64( rbSession XOR keystream ) + "a"
// (a fixed RC4-style keystream; recovered from a known token↔rb-session pair).
// The proxy m3u8 + its TS segments are Referer-gated to the player origin, so
// the native stream must be fetched through PROXY_SERVER_URL with that Referer.
const FCTV_STREAM_DIGIT = "seth";
const FCTV_GEO_CONTINENT = "EU";
const FCTV_GEO_COUNTRY = "FR";
// Keystream (hex). Edit this value when upstream rotates its key (native breaks
// with 403/404 → re-capture one token↔rb-session pair and XOR them,
// scripts/fctv_token_crack.cjs).
const FCTV_TOKEN_KEYSTREAM = Buffer.from(
"15764bab80a419c6abdd5518f3db0ea95bb3b9a2e2b519ce5c159af6917e2000c2d680ae30706a3aba1c9c25786c7c28774eecf20450a3cf414ca17f6472798cfa557c7a8705b7861f06e84f827f8a24676eeab77ce504bfc335b79609b9",
"hex",
);
const FCTV_LANGUAGE_FR = 6;
const FCTV_DEFAULT_SITE_TYPE = 2001;
// Image proxy for FCTV logos: derived from the shared bypass403 proxy
// (${BYPASS403_SERVER_URL}/proxy), with a hardcoded fallback.
const FCTV_IMAGE_PROXY = process.env.BYPASS403_SERVER_URL
? `${process.env.BYPASS403_SERVER_URL.replace(/\/+$/, "")}/proxy`
: "https://proxy.movix.date/proxy";
function proxifyFctvImage(url) {
if (!url) return "";
// Replace dead domains with the working one
let fixedUrl = url;
try {
const urlObj = new URL(url);
if (urlObj.hostname.startsWith("logos1.")) {
urlObj.hostname = "logos1.tcore131ybdf.ru";
}
fixedUrl = urlObj.toString();
} catch (e) {
// fallback if it's not a full URL
fixedUrl = url.replace(/logos1\.[a-z0-9]+\.(cfd|ru|com)/g, "logos1.tcore131ybdf.ru");
}
const proxy = FCTV_IMAGE_PROXY.endsWith("/") ? FCTV_IMAGE_PROXY : FCTV_IMAGE_PROXY + "/";
return `${proxy}${fixedUrl}`;
}
const FCTV_MATCHES_CATEGORIES = {
matches_all: {
name: "All Sports",
emoji: "🏅",
sportKey: "all",
sportType: 0,
},
matches_football: {
name: "Football",
emoji: "\u26bd",
sportKey: "football",
sportType: 1,
},
};
const FCTV_API_HEADERS = {
"User-Agent":
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
Accept: "application/x-protobuf,application/json,*/*",
Origin: FCTV_PLAYER_BASE_URL,
Referer: `${FCTV_PLAYER_BASE_URL}/`,
};
// V mapping: API endpoint paths -> bs code numbers (from upstream client JS)
const FCTV_BS_CODE_MAP = {
"/api/match/live": 100,
"/api/match/schedule": 101,
"/api/match/detail": 102,
"/api/match/event": 103,
"/api/match/statistic": 104,
"/api/match/trend": 105,
"/api/match/lineup": 106,
"/api/match/analysis": 107,
"/api/match/count": 190,
"/api/league/detail": 200,
"/api/league/season/list": 210,
"/api/league/match/list": 220,
"/api/league/team/total/list": 230,
"/api/league/player/total/list": 231,
"/api/league/team/standing/list": 240,
"/api/odds/list": 300,
};
// In-memory cache for bs keys (from /api/common/bs)
let fctvBsKeysCache = null;
let fctvBsKeysCacheTime = 0;
const FCTV_BS_KEYS_TTL = 5 * 60 * 1000; // 5 minutes
/**
* Fetch body-signature keys from /api/common/bs.
* These keys are used to build the sfver path prefix required by the upstream API.
*/
async function fetchFctvBsKeys(sportType = 0) {
// Return cached keys if fresh
if (fctvBsKeysCache && Date.now() - fctvBsKeysCacheTime < FCTV_BS_KEYS_TTL) {
return fctvBsKeysCache;
}
try {
const codes = [100, 101, 102, 103, 104, 105, 106, 107];
const params = new URLSearchParams();
params.set("stream", "true");
params.set("sportType", String(sportType));
codes.forEach((c) => params.append("code", String(c)));
const apiBase = await getFctvApiBase();
const response = await axios.get(`${apiBase}/api/common/bs`, {
params,
responseType: "arraybuffer",
headers: FCTV_API_HEADERS,
timeout: 10000,
});
const { body } = parseFctvBody(response.data);
const kvEntries = fctvAll(body, 1);
const keys = {};
for (const entry of kvEntries) {
const entryFields = fctvFields(entry);
const code = fctvValue(entryFields, 1);
const key = fctvString(fctvValue(entryFields, 2));
if (code != null && key) {
keys[Number(code)] = key;
}
}
console.log(`[FCTV-BS] Fetched ${Object.keys(keys).length} bs keys`);
fctvBsKeysCache = keys;
fctvBsKeysCacheTime = Date.now();
return keys;
} catch (error) {
console.warn(`[FCTV-BS] Error fetching bs keys: ${error.message}`);
return fctvBsKeysCache || {};
}
}
/**
* Build the sfver path prefix for a given API endpoint and params.
* Format: sfver + MD5(JSON.stringify(sortedParams)).slice(0,6) + bsKey
*/
function buildFctvSfverPrefix(pathname, params, bsKeys) {
const au = { ...params };
delete au.usls;
delete au.cBsDataTep;
const ap = ['matchId', 'leagueId', 'seasonId', 'sportType', 'language', 'stream'];
const sortedKeys = Object.keys(au).sort((a, b) => ap.indexOf(a) - ap.indexOf(b));
const sortedObj = {};
for (const key of sortedKeys) {
sortedObj[key] = au[key];
}
const jsonStr = JSON.stringify(sortedObj);
const md5Hash = crypto.createHash("md5").update(jsonStr).digest("hex").slice(0, 6);
// Try to find the bsCode mapping, but for /api/stream/detail it's missing in some clients so default to bsKeys[102]
const bsCode = FCTV_BS_CODE_MAP[pathname];
const bsKey = (bsCode && bsKeys[bsCode]) || bsKeys[102] || bsKeys[100] || "";
return `sfver${md5Hash}${bsKey}`;
}
// Linkzy configuration (FREE source - no extension/VIP required)
const LINKZY_CATEGORIES = {
general: { id: "linkzy_generaliste", name: "Généraliste", emoji: "📺" },
sports: { id: "linkzy_sport", name: "Sport", emoji: "⚽" },
movies: { id: "linkzy_cinema", name: "Cinéma", emoji: "🎬" },
chaines_info: { id: "linkzy_info", name: "Informations", emoji: "📰" },
chaines_jeunesse: { id: "linkzy_jeunesse", name: "Jeunesse", emoji: "👶" },
};
// HTTPS Agent for Sosplay — DNS Cloudflare (1.1.1.1) pour contourner le blocage FAI
const https = require("https");
const dns = require("dns");
const cloudflareDns = new dns.Resolver();
cloudflareDns.setServers(["1.1.1.1", "1.0.0.1"]);
const cloudflareLookup = (hostname, options, callback) => {
if (typeof options === "function") {
callback = options;
options = {};
}
cloudflareDns.resolve4(hostname, (err, addresses) => {
if (err) return callback(err);
if (options.all) {
callback(
null,
addresses.map((addr) => ({ address: addr, family: 4 })),
);
} else {
callback(null, addresses[0], 4);
}
});
};
const SOSPLAY_HTTPS_AGENT = new https.Agent({
rejectUnauthorized: false,
lookup: cloudflareLookup,
maxSockets: 50,
maxFreeSockets: 10,
});
const STREAM_PROXY_USER_AGENT =
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36";
const LIVE_PAGE_USER_AGENT =
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36";
const CACHE_DIR = path.join(__dirname, "cache", "tvdirect");
const CACHE_EXPIRATION_HOURS = 24; // Cache expire après 24h
// Headers Stremio pour les requêtes (principalement TV Direct)
const STREMIO_HEADERS = {
"User-Agent":
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Stremio/4.4.162 Chrome/114.0.0.0 Safari/537.36",
Accept: "application/json, text/plain, */*",
Origin: "https://app.strem.io",
Referer: "https://app.strem.io/",
"Accept-Language": "fr-FR,fr;q=0.9,en-US;q=0.8,en;q=0.7",
"Accept-Encoding": "gzip, deflate, br",
};
// In-memory M3U cache to avoid re-reading/re-parsing on every request
let m3uCache = null;
let m3uCacheTime = 0;
const M3U_CACHE_TTL = 10 * 60 * 1000; // 10 minutes
/**
* Parse local M3U file (cached)
*/
async function parseM3u() {
return [];
// Return cached data if still fresh
if (m3uCache && Date.now() - m3uCacheTime < M3U_CACHE_TTL) {
return m3uCache;
}
try {
const content = await fsp.readFile(M3U_PATH, "utf8");
const lines = content.split(/\r?\n/);
const channels = [];
let currentChannel = null;
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed) continue;
if (trimmed.startsWith("#EXTINF:")) {
currentChannel = {};
// Parse attributes
const attrs = trimmed.match(/([a-zA-Z0-9-]+)="([^"]*)"/g);
if (attrs) {
for (const attr of attrs) {
const [key, val] = attr.split("=");
const value = val.replace(/"/g, "");
if (key === "tvg-id") currentChannel.id = value;
if (key === "tvg-name") currentChannel.name = value;
if (key === "tvg-logo") currentChannel.logo = value;
if (key === "tvg-poster") currentChannel.poster = value;
if (key === "group-title") currentChannel.group = value;
if (key === "tvg-back") currentChannel.background = value;
}
}
// Name after comma
const commaIndex = trimmed.lastIndexOf(",");
if (commaIndex !== -1) {
currentChannel.title = trimmed.substring(commaIndex + 1).trim();
} else {
currentChannel.title = currentChannel.name || "Unknown";
}
} else if (!trimmed.startsWith("#") && currentChannel) {
currentChannel.stream = trimmed;
// If no ID, generate one from title
if (!currentChannel.id) {
currentChannel.id = currentChannel.title.replace(/[^a-zA-Z0-9]/g, "");
}
channels.push(currentChannel);
currentChannel = null; // Reset for next channel
}
}
// Cache the parsed result
m3uCache = channels;
m3uCacheTime = Date.now();
return channels;
} catch (error) {
console.error("[M3U] Error parsing france.m3u:", error.message);
return [];
}
}
function normalizeTvmioImageKey(value) {
if (!value) return "";
return String(value)
.toLowerCase()
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "")
.replace(/\.(fr|be|ch|ca|uk|us)$/g, " ")
.replace(/\b(fr|fhd|uhd|hd|sd|4k)\b/g, " ")
.replace(/[|_\-]+/g, " ")
.replace(/[^a-z0-9 ]+/g, " ")
.replace(/\s+/g, " ")
.trim();
}
async function fetchTvmioCatalogMetas(catalogId) {
const cacheKey = generateCacheKey(`tvmio_remote_catalog_v2_${catalogId}`);
const cached = await getFromCache(cacheKey, 24);
if (cached) return cached;
try {
const categoryConfig = TVMIO_CATEGORIES[catalogId];
if (!categoryConfig?.remoteGenre) {
return [];
}
const encodedGenre = encodeURIComponent(categoryConfig.remoteGenre);
const url = `${TVMIO_BASE_URL}/catalog/tv/tvmio-fr/genre=${encodedGenre}.json`;
const response = await axios.get(url, {
headers: STREMIO_HEADERS,
timeout: 12000,
});
const metas = Array.isArray(response.data?.metas)
? response.data.metas
: [];
await saveToCache(cacheKey, metas);
return metas;
} catch (error) {
console.warn(
`[TVMIO] Unable to fetch remote catalog images for ${catalogId}: ${error.message}`,
);
return [];
}
}
function buildTvmioImagesMap(remoteMetas) {
const imageMap = new Map();
for (const meta of remoteMetas) {
const imageData = {
poster: meta?.poster || null,
logo: meta?.logo || null,
background: meta?.background || null,
};
const normalizedId = normalizeTvmioImageKey(meta?.id);
const normalizedName = normalizeTvmioImageKey(meta?.name);
if (normalizedId) imageMap.set(`id:${normalizedId}`, imageData);
if (normalizedName) imageMap.set(`name:${normalizedName}`, imageData);
}
return imageMap;
}
function getTvmioRemoteImages(channel, imageMap) {
const idRaw = channel?.id || "";
const titleRaw = channel?.title || channel?.name || "";
const idNormalized = normalizeTvmioImageKey(idRaw);
const idBaseNormalized = normalizeTvmioImageKey(idRaw.split(".")[0]);
const titleNormalized = normalizeTvmioImageKey(titleRaw);
return (
imageMap.get(`id:${idNormalized}`) ||
imageMap.get(`id:${idBaseNormalized}`) ||
imageMap.get(`name:${titleNormalized}`) || {
poster: null,
logo: null,
background: null,
}
);
}
// === UTILITAIRES DE CACHE ===
// Créer le dossier de cache s'il n'existe pas
(async () => {
try {
await fsp.access(CACHE_DIR);
} catch {
await fsp.mkdir(CACHE_DIR, { recursive: true });
console.log("✅ Live TV cache directory created");
}
})();
/**
* Génère une clé de cache MD5 basée sur les paramètres
*/
function generateCacheKey(params) {
const stringParams =
typeof params === "string" ? params : JSON.stringify(params);
return crypto.createHash("md5").update(stringParams).digest("hex");
}
/**
* Récupère des données du cache avec vérification d'expiration
*/
async function getFromCache(key, expirationHours = CACHE_EXPIRATION_HOURS) {
try {
const cacheFilePath = path.join(CACHE_DIR, `${key}.json`);
const stats = await fsp.stat(cacheFilePath);
const now = Date.now();
const fileTime = stats.mtime.getTime();
const expirationTime = expirationHours * 60 * 60 * 1000;
if (now - fileTime > expirationTime) {
return null; // Cache expiré
}
const cacheData = JSON.parse(await fsp.readFile(cacheFilePath, "utf8"));
return cacheData;
} catch (error) {
if (error.code === "ENOENT") {
return null;
}
console.error(`[LIVETV CACHE] Erreur lecture cache ${key}:`, error.message);
return null;
}
}
/**
* Récupère des données du cache avec expiration en millisecondes
*/
async function getFromCacheMs(key, expirationMs = 30000) {
try {
const cacheFilePath = path.join(CACHE_DIR, `${key}.json`);
const stats = await fsp.stat(cacheFilePath);
const now = Date.now();
const fileTime = stats.mtime.getTime();
if (now - fileTime > expirationMs) {
return null; // Cache expiré
}
const cacheData = JSON.parse(await fsp.readFile(cacheFilePath, "utf8"));
return cacheData;
} catch (error) {
if (error.code === "ENOENT") {
return null;
}
console.error(`[LIVETV CACHE] Erreur lecture cache ${key}:`, error.message);
return null;
}
}
/**
* Sauvegarde des données en cache
*/
async function saveToCache(key, data) {
try {
const cacheFilePath = path.join(CACHE_DIR, `${key}.json`);
await fsp.writeFile(cacheFilePath, JSON.stringify(data, null, 2), "utf8");
return true;
} catch (error) {
console.error(
`[LIVETV CACHE] Erreur sauvegarde cache ${key}:`,
error.message,
);
return false;
}
}
/**
* Résout l'URL de lecture pour obtenir le lien m3u8 final
* Suit les redirections et récupère le header Location
*/
async function resolvePlayUrl(playUrl) {
try {
const response = await axios.get(playUrl, {
headers: STREMIO_HEADERS,
timeout: 10000,
maxRedirects: 0, // Ne pas suivre les redirections automatiquement
validateStatus: (status) => status >= 200 && status < 400, // Accepter 2xx et 3xx
});
// Si c'est une redirection (302, 301), récupérer le header Location
if (response.status === 302 || response.status === 301) {
return response.headers.location || null;
}
// Sinon retourner l'URL originale (peut-être déjà un m3u8)
return playUrl;
} catch (error) {
// Si erreur avec response (ex: 302), essayer de récupérer Location
if (
error.response &&
(error.response.status === 302 || error.response.status === 301)
) {
return error.response.headers.location || null;
}
console.error(`[LIVETV] Erreur résolution URL ${playUrl}:`, error.message);
return null;
}
}
// === WITV (WIFLIX) SCRAPING FUNCTIONS ===
/**
* Scrape les chaînes disponibles depuis une catégorie Witv
*/
async function scrapeWitvChannels(categoryKey) {
try {
const pathUrl = WITV_CATEGORIES[categoryKey];
if (!pathUrl) {
console.log(`[WITV] Unknown category: ${categoryKey}`);
return [];
}
const fullUrl = `${WITV_BASE_URL}${pathUrl}`;
console.log(`[WITV] Scraping category ${categoryKey} from ${fullUrl}`);
const response = await axios.get(fullUrl, {
headers: {
"User-Agent":
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
Accept:
"text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8",
},
timeout: 15000,
httpAgent: WITV_PROXY_AGENT,
httpsAgent: WITV_PROXY_AGENT,
});
const $ = cheerio.load(response.data);
const channels = [];
console.log(`[WITV] Page loaded, length: ${response.data.length} bytes`);
// Pattern 1: .holographic-card with .ann-short_price for name
$(".holographic-card").each((index, element) => {
const $el = $(element);
// Name: .ann-short_price
const name = $el.find(".ann-short_price").text().trim();
// Link: a href
const link = $el.find("a").attr("href");
if (name && link) {
// Extract ID from link (e.g. .../40-mtv.html -> 40)
const idMatch = link.match(/\/(\d+)-/);
if (idMatch) {
const id = idMatch[1];
// Also extract the slug for the channel page URL
const slugMatch = link.match(/\/(\d+-[^\/]+\.html)$/);
const pageSlug = slugMatch ? slugMatch[1] : null;
channels.push({
id: `wiflix_${id}`,
type: "tv",
name: name,
poster: null,
genres: [categoryKey.replace("wiflix_", "")],
_pageSlug: pageSlug, // Store for stream resolution
});
}
}
});
console.log(
`[WITV] Pattern 1 (.holographic-card): found ${channels.length} channels`,
);
// Pattern 2 fallback: Look for .e-grid-cat or similar grid structures
if (channels.length === 0) {
console.log("[WITV] Trying Pattern 2 (.e-grid-cat)...");
$(".e-grid-cat a, .grid-item a, .channel-item a").each(
(index, element) => {
const $el = $(element);
const link = $el.attr("href");
const name =
$el.find(".ann-short_price, .channel-name, .title").text().trim() ||
$el.text().trim();
if (name && link) {
const idMatch = link.match(/\/(\d+)-/);
if (idMatch) {
const id = idMatch[1];
if (!channels.find((c) => c.id === `wiflix_${id}`)) {
const slugMatch = link.match(/\/(\d+-[^\/]+\.html)$/);
const pageSlug = slugMatch ? slugMatch[1] : null;
channels.push({
id: `wiflix_${id}`,
type: "tv",
name: name,
poster: null,
genres: [categoryKey.replace("wiflix_", "")],
_pageSlug: pageSlug,
});
}
}
}
},
);
console.log(`[WITV] Pattern 2: found ${channels.length} channels`);
}
// Pattern 3: Ultra-simple - find all links with /ID-slug pattern
if (channels.length === 0) {
console.log("[WITV] Trying Pattern 3 (regex on all links)...");
const html = response.data;
const linkRegex = /href="[^"]*\/(\d+)-([^"\.]+)/gi;
let match;
const seenIds = new Set();
while ((match = linkRegex.exec(html)) !== null) {
const id = match[1];
const slug = match[2];
// Convert slug to readable name
const name = slug
.replace(/-/g, " ")
.replace(/\b\w/g, (c) => c.toUpperCase());
if (id && !seenIds.has(id)) {
seenIds.add(id);
channels.push({
id: `wiflix_${id}`,
type: "tv",
name: name,
poster: null,
genres: [categoryKey.replace("wiflix_", "")],
_pageSlug: `${id}-${slug}.html`,
});
}
}
console.log(`[WITV] Pattern 3: found ${channels.length} channels`);
}
// Debug: Log HTML preview if no channels found
if (channels.length === 0) {
console.log("[WITV] No channels found! HTML preview:");
console.log(response.data.substring(0, 2000));
}
console.log(
`[WITV] Total scraped ${channels.length} channels for ${categoryKey}`,
);
return channels;
} catch (error) {
console.error(`[WITV] Error scraping ${categoryKey}:`, error.message);
return [];
}
}
/**
* Trouve l'URL de la page d'une chaîne Witv à partir de son ID
*/
async function findWitvChannelPageUrl(channelId) {
// channelId format: wiflix_XX (ex: wiflix_23)
const id = channelId.replace("wiflix_", "");
// Chercher dans le cache des catalogues
for (const categoryKey of Object.keys(WITV_CATEGORIES)) {
const catalogCacheKey = generateCacheKey(`catalog_tv_${categoryKey}`);
const catalogCache = await getFromCache(catalogCacheKey, 24);
if (catalogCache && catalogCache.metas) {
const channel = catalogCache.metas.find((m) => m.id === channelId);
if (channel && channel._pageSlug) {
// Déterminer la catégorie pour construire l'URL complète
const categoryPath = WITV_CATEGORIES[categoryKey];
return `${WITV_BASE_URL}${categoryPath}${channel._pageSlug}`;
}
}
}
// Fallback: Si pas trouvé dans le cache, chercher dynamiquement
console.log(`[WITV] Channel ${channelId} not found in cache, searching...`);
for (const [categoryKey, pathUrl] of Object.entries(WITV_CATEGORIES)) {
try {
const fullUrl = `${WITV_BASE_URL}${pathUrl}`;
const response = await axios.get(fullUrl, {
headers: {
"User-Agent":
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
},
timeout: 10000,
httpAgent: WITV_PROXY_AGENT,
httpsAgent: WITV_PROXY_AGENT,
});
// Chercher le lien de la chaîne avec l'ID correspondant
const regex = new RegExp(`href="([^"]*/${id}-[^"\.]+\.html)"`, "i");
const match = response.data.match(regex);
if (match) {
const channelPath = match[1];
return channelPath.startsWith("http")
? channelPath
: `${WITV_BASE_URL}${channelPath}`;
}
} catch (e) {
console.warn(`[WITV] Error searching in ${categoryKey}: ${e.message}`);
}
}
return null;
}
/**
* Résout le lien m3u8 pour une chaîne Witv
* Gère 2 types d'embeds: witv-player.php et livehdtv.com
*/
async function resolveWitvStream(channelId) {
try {
console.log(`[WITV] Resolving stream for channel ${channelId}`);
// Étape 1: Trouver l'URL de la page de la chaîne
const channelPageUrl = await findWitvChannelPageUrl(channelId);
if (!channelPageUrl) {
console.warn(`[WITV] Could not find page URL for ${channelId}`);
return null;
}
console.log(`[WITV] Channel page URL: ${channelPageUrl}`);
// Étape 2: Récupérer la page de la chaîne
const pageResponse = await axios.get(channelPageUrl, {
headers: {
"User-Agent":
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
Referer: WITV_BASE_URL,
},
timeout: 15000,
httpAgent: WITV_PROXY_AGENT,
httpsAgent: WITV_PROXY_AGENT,
});
const $ = cheerio.load(pageResponse.data);
// Étape 3: Trouver l'iframe embed
const iframe = $("iframe").first();
let embedSrc = iframe.attr("src");
if (!embedSrc) {
console.warn(`[WITV] No iframe found on page ${channelPageUrl}`);
return null;
}
console.log(`[WITV] Found embed iframe: ${embedSrc}`);
// Étape 4: Gérer les différents types d'embeds
// Type 1: witv-player.php (ex: /player/playerjs/witv-player.php?id=20)
if (embedSrc.includes("witv-player.php")) {
const playerUrl = embedSrc.startsWith("http")
? embedSrc
: `${WITV_BASE_URL}${embedSrc}`;
console.log(`[WITV] Type 1: witv-player detected, fetching ${playerUrl}`);
const playerResponse = await axios.get(playerUrl, {
headers: {
"User-Agent":
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
Referer: channelPageUrl,
},
timeout: 15000,
httpAgent: WITV_PROXY_AGENT,
httpsAgent: WITV_PROXY_AGENT,
});
const html = playerResponse.data;
// Chercher: var streamUrl = "..."
const streamMatch = html.match(/var\s+streamUrl\s*=\s*["']([^"']+)["']/);
if (streamMatch && streamMatch[1]) {
console.log(`[WITV] Found streamUrl: ${streamMatch[1]}`);
return streamMatch[1];
}
// Alternative: chercher file: "..." (JWPlayer setup)
const fileMatch = html.match(/file:\s*["']([^"']+\.m3u8[^"']*)["']/);
if (fileMatch && fileMatch[1]) {
console.log(`[WITV] Found JWPlayer file: ${fileMatch[1]}`);
return fileMatch[1];
}
console.warn(`[WITV] No stream URL found in witv-player`);
return null;
}
// Type 2: livehdtv.com (ex: https://www.livehdtv.com/yayin/?kanal=182)
if (embedSrc.includes("livehdtv.com")) {
console.log(`[WITV] Type 2: livehdtv detected, following embed chain...`);
// Use Cloudflare proxy to bypass 403
const proxiedLivehdtvUrl = `${CF_PROXY_403}${encodeURIComponent(embedSrc)}`;
// Étape 4a: Récupérer la page livehdtv/yayin
const livehdtvResponse = await axios.get(proxiedLivehdtvUrl, {
headers: {
"User-Agent":
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
Referer: "https://www.livehdtv.com/",
},
timeout: 15000,
});
const $livehdtv = cheerio.load(livehdtvResponse.data);
const innerIframe = $livehdtv("iframe").first();
let tokenPhpUrl = innerIframe.attr("src");
if (!tokenPhpUrl) {
console.warn(`[WITV] No inner iframe found in livehdtv page`);
return null;
}
console.log(`[WITV] Found token.php iframe: ${tokenPhpUrl}`);
// Étape 4b: Récupérer la page token.php (contient le JWPlayer avec le m3u8)
if (!tokenPhpUrl.startsWith("http")) {
tokenPhpUrl = `https://www.livehdtv.com${tokenPhpUrl}`;
}
// Use got-scraping to bypass Cloudflare protection for token.php
const { gotScraping } = await import("got-scraping");
const tokenResponse = await gotScraping({
url: tokenPhpUrl,
headers: {
Referer: embedSrc, // Critical for validation
},
headerGeneratorOptions: {
browsers: [{ name: "chrome", minVersion: 120 }],
devices: ["desktop"],
locales: ["fr-FR", "en-US"],
operatingSystems: ["windows"],
},
});
const tokenHtml = tokenResponse.body;
// Extraire: file: "https://livetvde.net/gulli/index.m3u8?token=..."
const fileMatch = tokenHtml.match(/file:\s*["']([^"']+\.m3u8[^"']*)["']/);
if (fileMatch && fileMatch[1]) {
console.log(`[WITV] Found m3u8 from token.php: ${fileMatch[1]}`);
return fileMatch[1];
}
console.warn(
`[WITV] No m3u8 URL found in token.php response. Response preview:`,
);
console.log(tokenHtml.substring(0, 500));
return null;
}
// Type inconnu: tenter d'extraire directement un m3u8
console.log(
`[WITV] Unknown embed type, attempting direct extraction from: ${embedSrc}`,
);
const unknownUrl = embedSrc.startsWith("http")
? embedSrc
: `${WITV_BASE_URL}${embedSrc}`;
const unknownResponse = await axios.get(unknownUrl, {
headers: {
"User-Agent":
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
Referer: channelPageUrl,
},
timeout: 15000,
httpAgent: WITV_PROXY_AGENT,
httpsAgent: WITV_PROXY_AGENT,
});
const unknownHtml = unknownResponse.data;
// Chercher n'importe quel m3u8
const m3u8Match = unknownHtml.match(
/["'](https?:\/\/[^"']+\.m3u8[^"']*)["']/,
);
if (m3u8Match && m3u8Match[1]) {
console.log(`[WITV] Found m3u8 via generic extraction: ${m3u8Match[1]}`);
return m3u8Match[1];
}
console.warn(`[WITV] Could not extract stream URL from ${channelId}`);
return null;
} catch (error) {
console.error(
`[WITV] Error resolving stream for ${channelId}:`,
error.message,
);
return null;
}
}
// === SOSPLAY SCRAPING FUNCTIONS ===
/**
* Scrape les chaînes disponibles depuis Sosplay
*/
async function scrapeSosplayChannels(categoryKey) {
try {
if (categoryKey !== "sosplay_chaines") {
console.log(`[BOLALOCA] Unknown category: ${categoryKey}`);
return [];
}
const channels = BOLALOCA_CHANNELS.map((channel) => ({
id: `sosplay_${channel.id}`,
type: "tv",
name: channel.name,
poster: channel.poster || null,
genres: ["chaines"],
_channelId: channel.id,
}));
console.log(
`[BOLALOCA] Returning ${channels.length} static channels for ${categoryKey}`,
);
return channels;
} catch (error) {
console.error(`[BOLALOCA] Error building ${categoryKey}:`, error.message);
return [];
}
}
/**
* Scrape les matchs disponibles depuis Sosplay (Football, etc.)
*/
function readFctvVarint(buffer, offset) {
let result = 0n;
let shift = 0n;
let cursor = offset;
while (cursor < buffer.length) {
const byte = buffer[cursor++];
result |= BigInt(byte & 0x7f) << shift;
if ((byte & 0x80) === 0) {
return { value: result, offset: cursor };
}
shift += 7n;
if (shift > 70n) {
throw new Error("Invalid protobuf varint");
}
}
throw new Error("Unexpected protobuf EOF");
}
function fctvNumber(value) {
if (typeof value === "number") return value;
if (typeof value === "bigint") {
return value <= BigInt(Number.MAX_SAFE_INTEGER)
? Number(value)
: value.toString();
}
if (typeof value === "string" && /^\d+$/.test(value)) return Number(value);
return value;
}
function fctvLooksText(value) {
if (!value) return false;
let printable = 0;
for (const char of value) {
const code = char.charCodeAt(0);
if ((code >= 32 && code <= 126) || code >= 160) printable++;
}
return printable / value.length > 0.7;
}
function decodeFctvProtoMessage(input, depth = 0) {
const buffer = Buffer.isBuffer(input) ? input : Buffer.from(input || []);
const fields = [];
let offset = 0;
while (offset < buffer.length) {
const tag = readFctvVarint(buffer, offset);
offset = tag.offset;
const field = Number(tag.value >> 3n);
const wireType = Number(tag.value & 7n);
const entry = { field, wireType };
if (wireType === 0) {
const parsed = readFctvVarint(buffer, offset);
offset = parsed.offset;
entry.value = fctvNumber(parsed.value);
} else if (wireType === 1) {
entry.value = buffer.subarray(offset, offset + 8).toString("hex");
offset += 8;
} else if (wireType === 2) {
const parsedLength = readFctvVarint(buffer, offset);
offset = parsedLength.offset;
const length = Number(parsedLength.value);
const bytes = buffer.subarray(offset, offset + length);
offset += length;
const text = bytes.toString("utf8");
if (fctvLooksText(text)) entry.value = text;
if (depth < 6 && bytes.length > 0) {
try {
const children = decodeFctvProtoMessage(bytes, depth + 1);
if (children.length > 0) entry.children = children;
} catch {
// Plain strings are also length-delimited; ignore decode failures.
}
}
} else if (wireType === 5) {
entry.value = buffer.subarray(offset, offset + 4).toString("hex");
offset += 4;
} else {
throw new Error(`Unsupported protobuf wire type ${wireType}`);
}
fields.push(entry);
}
return fields;
}
function fctvFields(input) {
return Array.isArray(input) ? input : input?.children || [];
}
function fctvFirst(input, field) {
return fctvFields(input).find((entry) => entry.field === field);
}
function fctvAll(input, field) {
return fctvFields(input).filter((entry) => entry.field === field);
}
function fctvValue(input, field) {
return fctvFirst(input, field)?.value;
}
function fctvChildren(input, field) {
return fctvFirst(input, field)?.children || [];
}
function fctvString(value) {
return typeof value === "string" ? value.trim() : "";
}
function parseFctvBody(data) {
const root = decodeFctvProtoMessage(Buffer.from(data || []));
return {
status: fctvString(fctvValue(root, 3)),
body: fctvChildren(root, 10),
};
}
async function fetchFctvApi(pathname, params, options = {}) {
// Build sfver prefix if bs keys are available
const apiBase = await getFctvApiBase();
let url = `${apiBase}${pathname}`;
if (!options.skipSfver) {
try {
const bsKeys = await fetchFctvBsKeys(params?.sportType || 0);
const sfver = buildFctvSfverPrefix(pathname, params, bsKeys);
if (sfver) {
url = `${apiBase}/${sfver}${pathname}`;
}
} catch (err) {
console.warn(`[FCTV] sfver prefix failed, using direct path: ${err.message}`);
}
}
return axios.get(url, {
params,
responseType: "arraybuffer",
headers: FCTV_API_HEADERS,
timeout: 15000,
});
}
function parseFctvLocalizedName(fields) {
const localizedName = fctvString(fctvValue(fctvChildren(fields, 3), 2));
if (localizedName) return localizedName;
return fctvString(fctvValue(fields, 2));
}
function parseFctvTeam(teamField) {
const team = fctvChildren(teamField, 10);
if (team.length === 0) return null;
return {
id: fctvNumber(fctvValue(team, 1)),
name: parseFctvLocalizedName(team),
logo: proxifyFctvImage(fctvString(fctvValue(team, 4))),
};
}
function slugifyFctv(value) {
return String(value || "")
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "")
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "");
}
function formatFctvTime(timestamp) {
if (!timestamp) return "";
try {
return new Intl.DateTimeFormat("fr-FR", {
day: "2-digit",
month: "2-digit",
hour: "2-digit",
minute: "2-digit",
timeZone: "Europe/Paris",
}).format(new Date(timestamp));
} catch {
return new Date(timestamp).toISOString();
}
}
function buildFctvMatchPageUrl(match) {
const leagueSlug = match.leagueSlug || "football";
const matchSlug = match.matchSlug || slugifyFctv(match.name);
return `${FCTV_PLAYER_BASE_URL}/fr/${match.sportKey || "football"}/${leagueSlug}-${match.matchId}/${matchSlug}.html?icg=RlI&ilang=fr`;
}
// Embeddable upstream player. Loads the match's own server selector + handles
// the v3b tokenisation / service-worker / playback internally. This is the
// robust playback path (the masked stream URLs are token-gated and the token
// is computed client-side, so we cannot replay them server-side reliably).
// player.html expects: mdata = base64(`${matchId}_${sportType}`).
function buildFctvPlayerEmbedUrl(matchId, sportType, playerBase) {
const base = playerBase || FCTV_PLAYER_BASE_URL;
const mdata = Buffer.from(`${matchId}_${sportType}`, "utf8").toString("base64");
return `${base}/fr/player.html?mdata=${encodeURIComponent(mdata)}&ilang=fr`;
}
// Auto-discover the current FCTV API base. The bio-link page hubu.ru/fctvlink
// points at the live front domain (rotates: .mom / .motorcycles / …), and that
// front's /fr page embeds the API base in its Nuxt SSR payload (unicode-escaped
// `apis-data-defra<N>.<host>`). 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(/<a[^>]+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(/<iframe[^>]+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(/<iframe[^>]+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 = /<iframe[^>]+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(`<div>${String(value || "")}</div>`)
.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 = /<table[^>]+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(/<img[^>]+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(/<iframe[^>]+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(`<div>${String(value || "")}</div>`)
.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 =
/<a(?=[^>]*\bclass=["'][^"']*\bmain\b[^"']*["'])(?=[^>]*href=["'][^"']*\/frx\/allupcomingsports\/\d+\/?[^"']*["'])[^>]*>\s*(?:<b[^>]*>)?([\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|Aujourdhui|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 =
/<img[^>]+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 =
/<a[^>]+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(/<br\s*\/?>/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_<code>). 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 <iframe> whose page holds the Clappr
// `window.atob('<m3u8>')` source, so follow one iframe hop if the m3u8 is not
// inlined on the player page itself.
async function resolveDaddyliveStream(channelId, options = {}) {
const rawId = channelId.replace("daddylive_", "");
const requested =
Number.isInteger(options.sourceIndex) && options.sourceIndex >= 0
? options.sourceIndex
: 0;
const players = await fetchDaddylivePlayers(rawId);
const player = players[requested] || players[0];
if (!player) return [];
try {
// Hop 1: player page (Referer = the watch.php page).
const playerHtml = await fetchDaddyliveHtml(
player.dataUrl,
`${DADDYLIVE_BASE_URL}/watch.php?id=${rawId}`,
);
let m3u8 = extractDaddyliveM3u8(playerHtml);
// Hop 2: follow the embedded iframe (Referer = dlhd root) if not inlined.
if (!m3u8) {
const iframeSrc = extractDaddyliveIframeSrc(playerHtml, player.dataUrl);
if (iframeSrc) {
const iframeHtml = await fetchDaddyliveHtml(
iframeSrc,
DADDYLIVE_REFERER,
);
m3u8 = extractDaddyliveM3u8(iframeHtml);
}
}
if (!m3u8) return [];
return [
{
title: player.title || `Player ${requested + 1}`,
url: m3u8,
referer: DADDYLIVE_REFERER,
userAgent: LIVE_PAGE_USER_AGENT,
_sourceIndex: requested,
},
];
} catch (error) {
console.error(
`[DADDYLIVE] Error resolving stream ${channelId} (player ${requested}):`,
error.message,
);
return [];
}
}
// === LINKZY CHANNELS (loaded from linkzy.json) ===
const LINKZY_CHANNELS = require("./linkzy.json");
/**
* Returns Linkzy channels from linkzy.json (no API call / decryption needed)
*/
function fetchLinkzyChannels() {
return LINKZY_CHANNELS.filter((ch) => ch.active);
}
/**
* Get Linkzy channels for a specific category
*/
function getLinkzyChannelsByCategory(categoryId) {
try {
// Map catalog ID to API category string
const categoryMap = {
linkzy_generaliste: "general",
linkzy_sport: "sports",
linkzy_cinema: "movies",
linkzy_info: "chaines_info",
linkzy_jeunesse: "chaines_jeunesse",
};
const apiCategory = categoryMap[categoryId];
if (!apiCategory) {
console.error(`[LINKZY] Unknown category: ${categoryId}`);
return [];
}
const allChannels = fetchLinkzyChannels();
const filteredChannels = allChannels.filter(
(ch) => ch.category === apiCategory,
);
console.log(
`[LINKZY] Category ${categoryId} (${apiCategory}): ${filteredChannels.length} channels`,
);
return filteredChannels.map((ch) => ({
id: `linkzy_${ch.id}`,
type: "tv",
name: ch.name,
poster: ch.logo || null,
}));
} catch (error) {
console.error(
"[LINKZY] Error getting channels by category:",
error.message,
);
return [];
}
}
/**
* Get stream URLs for a Linkzy channel (returns array of {label, url})
*/
function getLinkzyStream(channelId) {
try {
const numericId = parseInt(channelId.replace("linkzy_", ""));
const allChannels = fetchLinkzyChannels();
const channel = allChannels.find((ch) => ch.id === numericId);
if (!channel) {
console.error(`[LINKZY] Channel ${channelId} not found`);
return [];
}
// streamUrl is a JSON string containing an array of {label, url}
let sources = [];
try {
sources =
typeof channel.streamUrl === "string"
? JSON.parse(channel.streamUrl)
: channel.streamUrl;
} catch (parseErr) {
// Fallback: treat as a plain URL string
console.warn(
`[LINKZY] Could not parse streamUrl as JSON, using as plain URL`,
);
sources = [{ label: "Principal", url: channel.streamUrl }];
}
if (!Array.isArray(sources)) {
sources = [{ label: "Principal", url: String(sources) }];
}
console.log(
`[LINKZY] Stream for ${channel.name}: ${sources.length} source(s)`,
);
return sources;
} catch (error) {
console.error("[LINKZY] Error getting stream:", error.message);
return [];
}
}
// === ROUTES ===
/**
* GET /api/livetv/resolve-livehdtv
* Resolve a livehdtv.com embed URL to get the m3u8 stream URL
* Uses got-scraping to bypass Cloudflare protection
* Called by the browser extension
*/
router.get("/resolve-livehdtv", async (req, res) => {
try {
const { embedUrl } = req.query;
if (!embedUrl) {
return res.status(400).json({ error: "Missing embedUrl parameter" });
}
console.log(`[LIVEHDTV-API] Resolving embed URL: ${embedUrl}`);
// Step 1: Fetch the livehdtv/yayin page to find the token.php iframe
const { gotScraping } = await import("got-scraping");
const livehdtvResponse = await gotScraping({
url: embedUrl,
headers: {
Referer: "https://www.livehdtv.com/",
},
headerGeneratorOptions: {
browsers: [{ name: "chrome", minVersion: 120 }],
devices: ["desktop"],
locales: ["fr-FR", "en-US"],
operatingSystems: ["windows"],
},
});
const livehdtvHtml = livehdtvResponse.body;
console.log(`[LIVEHDTV-API] livehdtv page length: ${livehdtvHtml.length}`);
// Find the inner iframe (token.php)
const innerIframeMatch = livehdtvHtml.match(
/<iframe[^>]*src=["']([^"']+)["']/i,
);
if (!innerIframeMatch) {
console.error("[LIVEHDTV-API] No inner iframe found in livehdtv page");
return res.status(404).json({ error: "No token.php iframe found" });
}
let tokenPhpUrl = innerIframeMatch[1];
console.log(`[LIVEHDTV-API] Found token.php iframe: ${tokenPhpUrl}`);
if (!tokenPhpUrl.startsWith("http")) {
tokenPhpUrl = `https://www.livehdtv.com${tokenPhpUrl}`;
}
// Step 2: Fetch token.php with got-scraping (to bypass Cloudflare)
const tokenResponse = await gotScraping({
url: tokenPhpUrl,
headers: {
Referer: embedUrl, // Critical for validation
},
headerGeneratorOptions: {
browsers: [{ name: "chrome", minVersion: 120 }],
devices: ["desktop"],
locales: ["fr-FR", "en-US"],
operatingSystems: ["windows"],
},
});
const tokenHtml = tokenResponse.body;
console.log(
`[LIVEHDTV-API] token.php response length: ${tokenHtml.length}`,
);
// Extract file: "...m3u8..." from JWPlayer setup
const fileMatch = tokenHtml.match(/file:\s*["']([^"']+\.m3u8[^"']*)["']/);
if (!fileMatch) {
console.error("[LIVEHDTV-API] Could not extract m3u8 from token.php");
console.log(
"[LIVEHDTV-API] token.php preview:",
tokenHtml.substring(0, 500),
);
return res
.status(404)
.json({ error: "Could not extract m3u8 from token.php" });
}
const m3u8Url = fileMatch[1];
console.log(`[LIVEHDTV-API] Found m3u8: ${m3u8Url}`);
return res.json({
success: true,
m3u8Url: m3u8Url,
source: "livehdtv",
});
} catch (error) {
console.error("[LIVEHDTV-API] Error:", error.message);
return res.status(500).json({ error: error.message });
}
});
/**
* GET /api/livetv/manifest
* Récupère le manifest combiné (TV Direct + Matches + WITV + LiveTV + Daddylive)
*/
router.get("/manifest", async (req, res) => {
try {
// v12 = removed dead Vavoo (tvvoo.hayd.uk) source
const cacheKey = generateCacheKey("manifest_combined_v12");
// Vérifier le cache
const cached = await getFromCache(cacheKey);
if (cached) {
return res.json(cached);
}
// Récupérer le manifest TV Direct (catch pour ne pas bloquer si échec)
const tvDirectRes = await axios
.get(`${TVDIRECT_BASE_URL}/manifest.json`, {
headers: STREMIO_HEADERS,
timeout: 5000,
})
.catch((e) => ({ error: e }));
// Manifeste de base
const manifest = {
id: "org.stremio.merged",
version: "1.0.0",
name: "Merged Live TV",
description:
"Merged TV sources (TV Direct, Matches, WITV, Bolaloca, LiveTV)",
catalogs: [],
resources: ["catalog", "meta", "stream"],
types: ["tv"],
idPrefixes: [],
};
// Fusionner TV Direct (User request: Don't use default tv-general etc.)
// We only keep idPrefixes/resources if needed, but not catalogs
if (tvDirectRes && !tvDirectRes.error && tvDirectRes.data) {
const data = tvDirectRes.data;
// if (data.catalogs) manifest.catalogs.push(...data.catalogs);
if (data.idPrefixes) manifest.idPrefixes.push(...data.idPrefixes);
}
// Ajouter les catalogues WITV (Wiflix)
const wiflixNames = {
wiflix_generaliste: "Généraliste",
wiflix_cinema: "Cinéma",
wiflix_sport: "Sport",
wiflix_documentaire: "Documentaire",
wiflix_enfants: "Enfants",
wiflix_info: "Info",
wiflix_musique: "Musique",
};
for (const [key, name] of Object.entries(wiflixNames)) {
manifest.catalogs.push({
type: "tv",
id: key,
name: name,
});
}
manifest.idPrefixes.push("wiflix_");
// Ajouter les catalogues Sosplay
const sosplayNames = {
sosplay_chaines: "Bolaloca",
};
for (const [key, name] of Object.entries(sosplayNames)) {
manifest.catalogs.push({
type: "tv",
id: key,
name: name,
});
}
manifest.idPrefixes.push("sosplay_");
for (const [key, config] of Object.entries(LIVETV_CATEGORIES)) {
manifest.catalogs.push({
type: "tv",
id: key,
name: `${config.emoji || "📺"} ${config.name}`,
});
}
manifest.idPrefixes.push("livetv_");
for (const [code, cfg] of Object.entries(DADDYLIVE_COUNTRIES)) {
manifest.catalogs.push({
type: "tv",
id: `daddylive_${code}`,
name: cfg.name,
});
}
manifest.idPrefixes.push("daddylive_");
// Ajouter les catalogues Matches (FCTV) en premier dans la liste
const matchesCatalogs = [];
for (const [key, config] of Object.entries(FCTV_MATCHES_CATEGORIES)) {
matchesCatalogs.push({
type: "tv",
id: key,
name: config.name,
});
}
// Prepend matches catalogs to the beginning
manifest.catalogs = [...matchesCatalogs, ...manifest.catalogs];
manifest.idPrefixes.push("match_");
// [DÉSACTIVÉ] Linkzy temporairement désactivé
// for (const [catId, config] of Object.entries(LINKZY_CATEGORIES)) {
// manifest.catalogs.push({
// type: 'tv',
// id: config.id,
// name: config.name,
// _free: true,
// });
// }
// manifest.idPrefixes.push('linkzy_');
// Sauvegarder en cache
await saveToCache(cacheKey, manifest);
res.json(manifest);
} catch (error) {
console.error("[LIVETV] Erreur manifest:", error.message);
res.status(500).json({ error: "Impossible de charger le manifest" });
}
});
/**
* GET /api/livetv/catalog/:type/:catalogId
* Récupère un catalogue de chaînes
*/
router.get("/catalog/:type/:catalogId", async (req, res) => {
const { type, catalogId } = req.params;
try {
const catalogCacheVersion = catalogId.startsWith("matches_")
? "v2"
: catalogId.startsWith("sosplay_") || catalogId.startsWith("livetv_")
? "v5"
: "v1";
const cacheKey = generateCacheKey(
`catalog_${type}_${catalogId}_${catalogCacheVersion}`,
);
const isMatchesCatalog = catalogId.startsWith("matches_");
const isDynamicCatalog =
isMatchesCatalog || catalogId.startsWith("livetv_");
// Vérifier le cache (1 minute pour matches/LiveTV, 24h pour le reste)
const cached = isDynamicCatalog
? await getFromCacheMs(cacheKey, 60000) // 1 minute pour matches/LiveTV
: await getFromCache(cacheKey);
if (cached) {
return res.json(cached);
}
let catalog = null;
// Déterminer la source en fonction de l'ID du catalogue
if (catalogId.startsWith("tvmio_")) {
// Source TVMio (Local M3U)
console.log(`[LIVETV] Fetching TVMio (M3U) catalog: ${catalogId}`);
const tvmioConfig = TVMIO_CATEGORIES[catalogId];
if (tvmioConfig) {
const allChannels = await parseM3u();
const remoteMetas = await fetchTvmioCatalogMetas(catalogId);
const tvmioImagesMap = buildTvmioImagesMap(remoteMetas);
const targetGroup = tvmioConfig.genre; // e.g. "FR | General"
// Filter by group
let filtered = allChannels.filter((ch) => ch.group === targetGroup);
// Deduplicate by ID (pick first one for the catalog entry)
const unique = [];
const seen = new Set();
for (const ch of filtered) {
if (!seen.has(ch.id)) {
seen.add(ch.id);
const remoteImages = getTvmioRemoteImages(ch, tvmioImagesMap);
unique.push({
id: `tvmio-${ch.id}`, // Prefix for routing
type: "tv",
name: ch.title,
poster: remoteImages.poster,
logo: remoteImages.logo,
background: remoteImages.background,
description: ch.group,
});
}
}
catalog = { metas: unique };
} else {
catalog = { metas: [] };
}
console.log(
`[TVMIO] Result: ${catalog.metas ? catalog.metas.length : 0} channels`,
);
// [DÉSACTIVÉ] Linkzy temporairement désactivé
// } else if (catalogId.startsWith('linkzy_')) {
// const channels = await getLinkzyChannelsByCategory(catalogId);
// catalog = { metas: channels };
} else if (catalogId.startsWith("matches_")) {
// Source Matches (FCTV33)
console.log(`[LIVETV] Fetching Matches catalog: ${catalogId}`);
const matches = await scrapeFctvMatches(catalogId);
console.log(`[LIVETV] Matches result: ${matches.length} matches`);
catalog = {
metas: matches,
};
} else if (catalogId.startsWith("sosplay_")) {
// Source Bolaloca/Elitegol (compat prefix sosplay_)
console.log(`[LIVETV] Fetching Bolaloca catalog: ${catalogId}`);
const channels = await scrapeSosplayChannels(catalogId);
console.log(
`[LIVETV] Bolaloca channels result: ${channels.length} channels`,
);
catalog = {
metas: channels,
};
} else if (catalogId.startsWith("livetv_")) {
console.log(`[LIVETV] Fetching LiveTV catalog: ${catalogId}`);
const channels = await scrapeLivetvChannels(catalogId);
console.log(`[LIVETV] LiveTV channels result: ${channels.length} events`);
catalog = {
metas: channels,
};
} else if (catalogId.startsWith("daddylive_")) {
console.log(`[LIVETV] Fetching Daddylive catalog: ${catalogId}`);
const channels = await scrapeDaddyliveChannels(catalogId);
catalog = {
metas: channels,
};
} else if (catalogId.startsWith("wiflix_")) {
// Source Witv (Wiflix)
console.log(`[LIVETV] Fetching Wiflix catalog: ${catalogId}`);
const channels = await scrapeWitvChannels(catalogId);
console.log(
`[LIVETV] Wiflix channels result: ${channels.length} channels`,
);
catalog = {
metas: channels,
};
console.log(
`[LIVETV] Wiflix catalog response: ${JSON.stringify(catalog).substring(0, 500)}`,
);
} else {
// Source TV Direct (Défaut)
const url = `${TVDIRECT_BASE_URL}/catalog/${type}/${catalogId}.json`;
const response = await axios.get(url, {
headers: STREMIO_HEADERS,
timeout: 10000,
});
catalog = response.data;
}
// Sauvegarder en cache
await saveToCache(cacheKey, catalog);
res.json(catalog);
} catch (error) {
console.error(`[LIVETV] Erreur catalog ${catalogId}:`, error.message);
res.status(500).json({ error: "Impossible de charger le catalogue" });
}
});
/**
* GET /api/livetv/stream/:type/:channelId
* Récupère les flux d'une chaîne
*/
router.get("/stream/:type/:channelId", async (req, res) => {
const { type, channelId } = req.params;
const mode =
String(req.query.mode || "").toLowerCase() === "sources"
? "sources"
: "stream";
const parsedSourceIndex = Number.parseInt(
String(req.query.sourceIndex ?? ""),
10,
);
const sourceIndex =
Number.isInteger(parsedSourceIndex) && parsedSourceIndex >= 0
? parsedSourceIndex
: null;
try {
const accessKey = req.headers["x-access-key"];
let isVip = { vip: false };
if (accessKey) {
isVip = await verifyAccessKey(accessKey);
}
// Block VIP-only sources immediately if not VIP.
// Matches (match_/matches_) are free: the IP-locked native stream is resolved
// client-side by the extension/userscript (RESOLVE_FCTV), and free users without
// it fall back to the embed player (see the match_ branch below). Only IPTV is
// strictly VIP.
if (channelId.startsWith("iptv_") && !isVip.vip) {
return res.status(403).json({ error: "Réservé aux membres VIP" });
}
// IPTV Xtream — return proxified m3u8 URL directly
if (channelId.startsWith("iptv_")) {
const streamId = channelId.replace("iptv_", "");
const directUrl = `${XTREAM_URL}/live/${XTREAM_USER}/${XTREAM_PASS}/${streamId}.m3u8`;
const proxiedUrl = `${IPTV_STREAM_PROXY}/${directUrl}`;
return res.json({
streams: [
{
url: proxiedUrl,
title: "IPTV Stream",
behaviorHints: { notWebReady: false },
},
],
});
}
const skipOuterCache =
channelId.startsWith("livetv_") || channelId.startsWith("daddylive_");
const cacheKey = generateCacheKey(
`stream_${type}_${channelId}_${mode}_${sourceIndex ?? "all"}_v6_${isVip.vip ? "vip" : "free"}`,
);
const streamCacheVariant = isVip.vip ? "vip" : "free";
// Vérifier le cache (expiration plus courte pour les streams: 1h)
const cached = skipOuterCache ? null : await getFromCache(cacheKey, 1);
if (cached) {
return res.json(cached);
}
let streamData = null;
if (channelId.startsWith("tvmio-")) {
// === SOURCE TVMIO (Local M3U) ===
console.log(`[TVMIO] Getting stream for ${channelId}`);
const targetId = channelId.replace("tvmio-", "");
const allChannels = await parseM3u();
// Find all entries for this ID (support multiple qualities/sources)
const matches = allChannels.filter((ch) => ch.id === targetId);
if (matches.length > 0) {
console.log(
`[TVMIO] Found ${matches.length} stream(s) for ${channelId}`,
);
const streams = matches.map((match, index) => {
// Try to guess quality/label from title
// Pattern: "FR | TF1 FHD"
let title = match.title || `Source ${index + 1}`;
// Simplify title if it contains common quality markers,
// otherwise keep full title or index
if (title.includes("FHD")) title = "FHD";
else if (title.includes("HD") && !title.includes("UHD")) title = "HD";
else if (title.includes("4K") || title.includes("UHD")) title = "4K";
else if (title.includes("SD")) title = "SD";
else if (index > 0 && title === matches[0].title)
title = `Source ${index + 1}`;
const originalUrl = match.stream;
const encodedUrl = encodeURIComponent(originalUrl);
// Use proxy server to help with CORS on some players (only if VIP)
const proxyUrl = isVip.vip
? `${PROXY_SERVER_URL}?url=${encodedUrl}`
: originalUrl;
return {
title: title,
url: proxyUrl,
originalUrl: originalUrl,
_isTvmio: true,
behaviorHints: {
notWebReady: false,
},
};
});
streamData = { streams: streams };
} else {
console.log(`[TVMIO] No streams found for ${channelId} in M3U`);
streamData = { streams: [] };
}
// [DÉSACTIVÉ] Linkzy temporairement désactivé
// } else if (channelId.startsWith('linkzy_')) {
// const sources = await getLinkzyStream(channelId);
// if (sources && sources.length > 0) {
// streamData = {
// streams: sources.map(source => ({
// title: `${source.label || 'Linkzy Stream'}`,
// url: source.url,
// behaviorHints: { notWebReady: false }
// }))
// };
// } else {
// streamData = { streams: [] };
// }
} else if (channelId.startsWith("match_")) {
// === SOURCE MATCHES (FCTV33) ===
const matchCacheKey = generateCacheKey(
`fctv_match_stream_${channelId}_ua1_${streamCacheVariant}`,
);
// Check for fresh cache (30 seconds)
const cachedMatch = await getFromCacheMs(matchCacheKey, 30000);
if (cachedMatch) {
console.log(`[FCTV-MATCHES] Serving cached stream for ${channelId}`);
return res.json(cachedMatch);
}
// No cache or expired - fetch fresh
console.log(`[FCTV-MATCHES] Fetching fresh stream for ${channelId}`);
const matchStreams = await resolveFctvMatchStream(channelId);
if (!matchStreams || matchStreams.length === 0) {
return res.status(404).json({ error: "Flux match introuvable" });
}
// Public base of THIS API, so the native playlist endpoint URL is absolute.
const publicApiBase =
process.env.PUBLIC_API_BASE ||
`${req.headers["x-forwarded-proto"] || req.protocol}://${req.get("host")}`;
// VIP -> server proxies the segments. Free -> "raw" mode: the browser
// extension / userscript injects the player Referer (no proxy needed).
const fctvNativeMode = isVip.vip ? "proxy" : "raw";
const fctvPlayerBase = await getFctvPlayerBaseUrl();
const fctvApiBase = await getFctvApiBase();
// Build streams array with all FCTV servers.
const streams = matchStreams.map((stream) => {
// Embed player (iframe) is loaded directly in an <iframe>; never proxy it.
if (stream._isEmbed) {
return {
title: stream.title,
url: stream.url,
originalUrl: stream.url,
_isEmbed: true,
behaviorHints: { notWebReady: false },
};
}
if (stream._fctvNative) {
const p = stream._fctvNative;
// VIP: the server proxies the (IP-consistent) segments via the smart
// playlist endpoint + PROXY_SERVER_URL.
if (fctvNativeMode === "proxy") {
const url =
`${publicApiBase}/api/livetv/fctv/playlist?matchId=${encodeURIComponent(p.matchId)}` +
`&streamId=${encodeURIComponent(p.streamId)}&siteType=${encodeURIComponent(p.siteType)}` +
`&sportType=${encodeURIComponent(p.sportType)}&mode=proxy`;
return {
title: stream.title,
url,
originalUrl: url,
behaviorHints: { notWebReady: false },
};
}
// Free: the stream is IP-locked, so it must be resolved in the user's
// browser by the extension/userscript (RESOLVE_FCTV). Empty url here;
// the frontend fills it. Without an extension the stub is dropped and
// the user falls back to the embed player.
return {
title: stream.title,
url: "",
_fctvLocal: {
matchId: p.matchId,
streamId: p.streamId,
siteType: p.siteType,
sportType: p.sportType,
apiBase: fctvApiBase,
},
_fctvReferer: `${fctvPlayerBase}/`,
behaviorHints: { notWebReady: false },
};
}
const encodedUrl = encodeURIComponent(stream.url);
const refererBase = stream.referer || FCTV_PLAYER_BASE_URL;
const headers = JSON.stringify(
buildStreamProxyHeaders(
refererBase,
refererBase,
stream.userAgent || STREAM_PROXY_USER_AGENT,
),
);
const encodedHeaders = encodeURIComponent(headers);
// FCTV native m3u8 + TS segments are Referer-gated to the player origin,
// so they MUST be proxied (the proxy injects that Referer) for every
// user — not just VIP. Other streams keep the VIP-only proxy behaviour.
const mustProxy = stream.needsProxy === true;
const proxyUrl = PROXY_SERVER_URL && (mustProxy || isVip.vip)
? `${PROXY_SERVER_URL}?url=${encodedUrl}&headers=${encodedHeaders}`
: stream.url;
return {
title: stream.title,
url: proxyUrl,
originalUrl: stream.url,
referer: stream.referer,
userAgent: stream.userAgent || STREAM_PROXY_USER_AGENT,
behaviorHints: {
notWebReady: false,
},
};
});
streamData = {
streams: streams,
_cacheTime: Date.now(),
};
// Save to cache with timestamp
await saveToCache(matchCacheKey, streamData);
} else if (channelId.startsWith("sosplay_")) {
// === SOURCE BOLALOCA (compat prefix sosplay_) ===
const sosplayCacheKey = generateCacheKey(
`sosplay_stream_${channelId}_ua2_${streamCacheVariant}`,
);
// Check for fresh cache (30 seconds) - stale-while-revalidate pattern
const cachedSosplay = await getFromCacheMs(sosplayCacheKey, 30000);
if (cachedSosplay) {
console.log(`[BOLALOCA] Serving cached stream for ${channelId}`);
return res.json(cachedSosplay);
}
// No cache or expired - fetch fresh
console.log(`[BOLALOCA] Fetching fresh stream for ${channelId}`);
const sosplayStreams = await resolveSosplayStream(channelId);
if (!sosplayStreams || sosplayStreams.length === 0) {
return res.status(404).json({ error: "Flux Bolaloca introuvable" });
}
// Build streams array with all servers
const streams = sosplayStreams.map((stream) => {
if (stream._isEmbed) {
return {
title: `Bolaloca - ${stream.title}`,
url: stream.url,
originalUrl: stream.url,
referer: stream.referer,
userAgent: stream.userAgent || LIVE_PAGE_USER_AGENT,
_isEmbed: true,
behaviorHints: {
notWebReady: false,
},
};
}
const encodedUrl = encodeURIComponent(stream.url);
const headers = JSON.stringify(
buildStreamProxyHeaders(
stream.referer || BOLALOCA_BASE_URL,
BOLALOCA_BASE_URL,
stream.userAgent || LIVE_PAGE_USER_AGENT,
),
);
const encodedHeaders = encodeURIComponent(headers);
// Only proxyify if VIP
const proxyUrl = isVip.vip
? `${PROXY_SERVER_URL}?url=${encodedUrl}&headers=${encodedHeaders}`
: stream.url;
return {
title: `Bolaloca - ${stream.title}`,
url: proxyUrl,
originalUrl: stream.url,
referer: stream.referer, // Pass referer for extension
userAgent: stream.userAgent || LIVE_PAGE_USER_AGENT,
behaviorHints: {
notWebReady: false,
},
};
});
streamData = {
streams: streams,
_cacheTime: Date.now(),
};
// Save to cache with timestamp
await saveToCache(sosplayCacheKey, streamData);
} else if (channelId.startsWith("livetv_")) {
if (mode === "sources") {
const livetvSourcesCacheKey = generateCacheKey(
`livetv_sources_${channelId}_v1`,
);
const cachedLiveTvSources = await getFromCacheMs(
livetvSourcesCacheKey,
30000,
);
if (cachedLiveTvSources) {
console.log(`[LIVETV] Serving cached source list for ${channelId}`);
return res.json(cachedLiveTvSources);
}
console.log(`[LIVETV] Fetching source list for ${channelId}`);
const sources = await getLivetvSources(channelId);
if (!sources || sources.length === 0) {
return res.status(404).json({ error: "Sources LiveTV introuvables" });
}
streamData = {
sources,
_cacheTime: Date.now(),
};
await saveToCache(livetvSourcesCacheKey, streamData);
} else {
const livetvCacheKey = generateCacheKey(
`livetv_stream_${channelId}_source_${sourceIndex ?? "all"}_ua3_${streamCacheVariant}`,
);
const cachedLiveTv = await getFromCacheMs(livetvCacheKey, 30000);
if (cachedLiveTv) {
console.log(
`[LIVETV] Serving cached stream for ${channelId} (source ${sourceIndex ?? "all"})`,
);
return res.json(cachedLiveTv);
}
console.log(
`[LIVETV] Fetching fresh stream for ${channelId}${sourceIndex !== null ? ` (source ${sourceIndex + 1})` : ""}`,
);
const livetvStreams = await resolveLivetvStream(channelId, {
sourceIndex,
});
if (!livetvStreams || livetvStreams.length === 0) {
return res.status(404).json({ error: "Flux LiveTV introuvable" });
}
const streams = livetvStreams.map((stream) => {
if (stream._isEmbed) {
return {
title: `LiveTV - ${stream.title}`,
url: stream.url,
originalUrl: stream.url,
referer: stream.referer,
userAgent: stream.userAgent || LIVE_PAGE_USER_AGENT,
_isEmbed: true,
_sourceIndex: stream._sourceIndex,
behaviorHints: {
notWebReady: false,
},
};
}
const encodedUrl = encodeURIComponent(stream.url);
const headers = JSON.stringify(
buildStreamProxyHeaders(
stream.referer || LIVETV_BASE_URL,
LIVETV_BASE_URL,
stream.userAgent || LIVE_PAGE_USER_AGENT,
),
);
const encodedHeaders = encodeURIComponent(headers);
const proxyUrl = isVip.vip
? `${PROXY_SERVER_URL}?url=${encodedUrl}&headers=${encodedHeaders}`
: stream.url;
return {
title: `LiveTV - ${stream.title}`,
url: proxyUrl,
originalUrl: stream.url,
referer: stream.referer,
userAgent: stream.userAgent || LIVE_PAGE_USER_AGENT,
_sourceIndex: stream._sourceIndex,
behaviorHints: {
notWebReady: false,
},
};
});
streamData = {
streams,
_cacheTime: Date.now(),
};
await saveToCache(livetvCacheKey, streamData);
}
} else if (channelId.startsWith("daddylive_")) {
if (mode === "sources") {
const ddSourcesCacheKey = generateCacheKey(
`daddylive_sources_${channelId}_v1`,
);
const cachedDdSources = await getFromCacheMs(ddSourcesCacheKey, 600000); // 10 min
if (cachedDdSources) {
return res.json(cachedDdSources);
}
const sources = await getDaddyliveSources(channelId);
if (!sources || sources.length === 0) {
return res
.status(404)
.json({ error: "Sources Daddylive introuvables" });
}
streamData = { sources, _cacheTime: Date.now() };
await saveToCache(ddSourcesCacheKey, streamData);
} else {
const ddCacheKey = generateCacheKey(
`daddylive_stream_${channelId}_source_${sourceIndex ?? "all"}_${streamCacheVariant}`,
);
const cachedDd = await getFromCacheMs(ddCacheKey, 30000); // 30s (m3u8 has `expires`)
if (cachedDd) {
return res.json(cachedDd);
}
const ddStreams = await resolveDaddyliveStream(channelId, {
sourceIndex,
});
if (!ddStreams || ddStreams.length === 0) {
return res.status(404).json({ error: "Flux Daddylive introuvable" });
}
const streams = ddStreams.map((stream) => {
const encodedUrl = encodeURIComponent(stream.url);
const headers = JSON.stringify(
buildStreamProxyHeaders(
stream.referer || DADDYLIVE_REFERER,
DADDYLIVE_ORIGIN,
stream.userAgent || LIVE_PAGE_USER_AGENT,
),
);
const encodedHeaders = encodeURIComponent(headers);
const proxyUrl = isVip.vip
? `${PROXY_SERVER_URL}?url=${encodedUrl}&headers=${encodedHeaders}`
: stream.url;
return {
title: `Daddylive - ${stream.title}`,
url: proxyUrl,
originalUrl: stream.url,
referer: stream.referer || DADDYLIVE_REFERER,
userAgent: stream.userAgent || LIVE_PAGE_USER_AGENT,
_sourceIndex: stream._sourceIndex,
behaviorHints: { notWebReady: false },
};
});
streamData = { streams, _cacheTime: Date.now() };
await saveToCache(ddCacheKey, streamData);
}
} else if (channelId.startsWith("wiflix_")) {
// === SOURCE WITV (WIFLIX) ===
const wiflixCacheKey = generateCacheKey(
`wiflix_stream_${channelId}_${streamCacheVariant}`,
);
// Check for fresh cache (30 seconds) - stale-while-revalidate pattern
const cachedWiflix = await getFromCacheMs(wiflixCacheKey, 30000); // 30 seconds in ms
if (cachedWiflix) {
console.log(`[WITV] Serving cached stream for ${channelId}`);
// Trigger background update if cache is older than 30 seconds
const cacheAge = Date.now() - (cachedWiflix._cacheTime || 0);
if (cacheAge > 30000) {
// 30 seconds
console.log(
`[WITV] Cache is ${cacheAge}ms old, triggering background update`,
);
// Background update - don't await
(async () => {
try {
const freshM3u8 = await resolveWitvStream(channelId);
if (freshM3u8) {
const encodedUrl = encodeURIComponent(freshM3u8);
const proxyUrl = isVip.vip
? `${PROXY_SERVER_URL}?url=${encodedUrl}`
: freshM3u8;
const freshData = {
streams: [
{
title: "Orca",
url: proxyUrl,
originalUrl: freshM3u8,
behaviorHints: { notWebReady: false },
},
],
_cacheTime: Date.now(),
};
await saveToCache(wiflixCacheKey, freshData);
console.log(`[WITV] Background cache updated for ${channelId}`);
}
} catch (e) {
console.error(`[WITV] Background update failed: ${e.message}`);
}
})();
}
return res.json(cachedWiflix);
}
// No cache or expired - fetch fresh
console.log(`[WITV] Fetching fresh stream for ${channelId}`);
const m3u8Url = await resolveWitvStream(channelId);
if (!m3u8Url) {
return res.status(404).json({ error: "Flux Wiflix introuvable" });
}
// For API access (VIP users), wrap URL in proxiesembed proxy
// Extension users will use originalUrl to handle extraction themselves
// Only proxyify if VIP
const encodedUrl = encodeURIComponent(m3u8Url);
const proxyUrl = isVip.vip
? `${PROXY_SERVER_URL}?url=${encodedUrl}`
: m3u8Url; // Non-VIPs get raw URL (will fail without extension)
streamData = {
streams: [
{
title: "Orca",
url: proxyUrl,
originalUrl: m3u8Url, // Keep original for debugging/extension use
behaviorHints: {
notWebReady: false,
},
},
],
_cacheTime: Date.now(),
};
// Save to cache with timestamp
await saveToCache(wiflixCacheKey, streamData);
} else {
// === SOURCE TV DIRECT ===
const url = `${TVDIRECT_BASE_URL}/stream/${type}/${channelId}.json`;
const response = await axios.get(url, {
headers: STREMIO_HEADERS,
timeout: 10000,
});
streamData = response.data;
if (streamData.streams) {
// Résoudre les URLs (spécifique TV Direct)
const resolvedStreams = await Promise.all(
streamData.streams.map(async (stream) => {
if (stream.url) {
const resolvedUrl = await resolvePlayUrl(stream.url);
return {
...stream,
url: resolvedUrl || stream.url,
originalUrl: stream.url,
};
}
return stream;
}),
);
streamData.streams = resolvedStreams.filter((s) => {
// Filter out invalid streams
if (!s.url) return false;
// Filter out ALL FamilyRestream sources (User Request: "non fonctionnel")
if (
s.url.includes("familyrestream.com") ||
(s.originalUrl && s.originalUrl.includes("familyrestream.com"))
) {
return false;
}
return true;
});
}
}
if (
mode !== "sources" &&
(!streamData.streams || streamData.streams.length === 0)
) {
return res.status(404).json({ error: "Aucun flux disponible" });
}
// Sauvegarder en cache
if (!skipOuterCache) {
await saveToCache(cacheKey, streamData);
}
res.json(streamData);
} catch (error) {
console.error(`[LIVETV] Erreur stream ${channelId}:`, error.message);
res.status(500).json({ error: "Impossible de charger les flux" });
}
});
/**
* GET /api/livetv/fctv/playlist?matchId=&streamId=&siteType=&sportType=
* FCTV native HLS smart-playlist. Resolves a fresh token, fetches the upstream
* tokenised m3u8, and rewrites every segment to the playlist host + path-token
* form (handles cdnSmartLink absolute/&token segments), routed through
* PROXY_SERVER_URL so the player-origin Referer is injected. This is what makes
* the Referer-gated v3b streams playable natively in HLSPlayer.
*/
router.get("/fctv/playlist", async (req, res) => {
const matchId = req.query.matchId;
const streamId = req.query.streamId;
const siteType = req.query.siteType || FCTV_DEFAULT_SITE_TYPE;
const sportType = req.query.sportType || 1;
// proxy = segments via PROXY_SERVER_URL (VIP). raw = bare CDN urls; the
// browser extension / userscript injects the player Referer (free users).
const mode = req.query.mode === "raw" ? "raw" : "proxy";
if (!matchId || !streamId) {
return res.status(400).send("missing matchId/streamId");
}
try {
const playerBase = await getFctvPlayerBaseUrl();
const upstreamHeaders = {
"User-Agent": STREAM_PROXY_USER_AGENT,
Accept: "*/*",
Origin: playerBase,
Referer: `${playerBase}/`,
};
const fetchPlaylist = (data) =>
axios.get(data.playlistUrl, {
responseType: "text",
headers: upstreamHeaders,
timeout: 15000,
validateStatus: () => true,
});
const ok = (r) =>
r && r.status === 200 && typeof r.data === "string" && r.data.includes("#EXTM3U");
// First try the (briefly cached) token; if the upstream rejects it (token
// expired), re-resolve a fresh token and retry once.
let resolved = await resolveFctvUpstreamPlaylist(streamId, siteType, matchId, sportType);
let pr = resolved ? await fetchPlaylist(resolved) : null;
if (!ok(pr)) {
resolved = await resolveFctvUpstreamPlaylist(streamId, siteType, matchId, sportType, true);
pr = resolved ? await fetchPlaylist(resolved) : null;
}
if (!resolved || !ok(pr)) {
console.warn(`[FCTV-PLAYLIST] upstream ${pr ? pr.status : "no-resolve"} for match ${matchId} stream ${streamId}`);
return res.status(502).send("upstream playlist failed");
}
// NOTE: the segment CDN's Referer ACL requires the TRAILING SLASH
// (`https://host/` returns 200, `https://host` returns 403).
const segProxyHeaders = encodeURIComponent(
JSON.stringify(
buildStreamProxyHeaders(`${playerBase}/`, playerBase, STREAM_PROXY_USER_AGENT),
),
);
const rewritten = pr.data
.split("\n")
.map((line) => {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith("#")) return line;
let abs;
try {
abs = new URL(trimmed, resolved.playlistUrl);
} catch {
return line;
}
// Normalise to: <playlist origin>/token-<T>/<path-after-host>?<querytoken>
abs.searchParams.delete("token");
const path = abs.pathname.replace(/^\/token-[^/]+/, "");
const norm = `${resolved.origin}/token-${resolved.token}${path}${abs.search}`;
// raw mode (free + extension/userscript): bare CDN url; the extension
// injects the player Referer on the request -> no proxy needed.
if (mode === "raw") return norm;
return PROXY_SERVER_URL
? `${PROXY_SERVER_URL}?url=${encodeURIComponent(norm)}&headers=${segProxyHeaders}`
: norm;
})
.join("\n");
res.set("Content-Type", "application/vnd.apple.mpegurl");
res.set("Access-Control-Allow-Origin", "*");
res.set("Cache-Control", "no-store");
return res.send(rewritten);
} catch (error) {
console.error(`[FCTV-PLAYLIST] error match ${matchId} stream ${streamId}:`, error.message);
return res.status(502).send("fctv playlist error");
}
});
/**
* GET /api/livetv/resolve/:playId
* Résout une URL de lecture spécifique (TV Direct)
*/
router.get("/resolve/:playId", async (req, res) => {
const { playId } = req.params;
try {
const playUrl = `${TVDIRECT_BASE_URL}/play/${playId}`;
const resolvedUrl = await resolvePlayUrl(playUrl);
if (!resolvedUrl) {
return res.status(404).json({ error: "Impossible de résoudre l'URL" });
}
res.json({
originalUrl: playUrl,
resolvedUrl: resolvedUrl,
});
} catch (error) {
console.error(`[LIVETV] Erreur resolve ${playId}:`, error.message);
res.status(500).json({ error: "Erreur lors de la résolution" });
}
});
/**
* DELETE /api/livetv/cache
* Nettoie le cache Live TV (admin only)
*/
router.delete("/cache", async (req, res) => {
try {
const files = await fsp.readdir(CACHE_DIR);
let deletedCount = 0;
for (const file of files) {
if (file.endsWith(".json")) {
await fsp.unlink(path.join(CACHE_DIR, file));
deletedCount++;
}
}
res.json({
success: true,
message: `Cache Live TV nettoyé: ${deletedCount} fichiers supprimés`,
});
} catch (error) {
console.error("[LIVETV] Erreur nettoyage cache:", error.message);
res.status(500).json({ error: "Erreur lors du nettoyage du cache" });
}
});
// === IPTV WEB (Xtream API) — VIP ONLY ===
const XTREAM_URL = (process.env.XTREAM_URL || "").replace(/\/+$/, "");
const XTREAM_USER = process.env.XTREAM_USER || "";
const XTREAM_PASS = process.env.XTREAM_PASS || "";
const IPTV_IMAGE_PROXY = "https://proxy.movix.blog/proxy";
const IPTV_STREAM_PROXY = "https://proxiesembed.movix.blog/proxy";
// Cache catégories IPTV en mémoire (change rarement)
let iptvCategoriesCache = null;
let iptvCategoriesCacheTime = 0;
const IPTV_CATEGORIES_TTL = 30 * 60 * 1000; // 30 min
// Auto-clear IPTV cache after TTL to free memory when not actively used
setInterval(() => {
if (
iptvCategoriesCache &&
Date.now() - iptvCategoriesCacheTime > IPTV_CATEGORIES_TTL
) {
iptvCategoriesCache = null;
}
}, IPTV_CATEGORIES_TTL).unref();
/**
* GET /api/livetv/iptv/categories
* Récupère les catégories live de l'API Xtream (VIP only)
*/
router.get("/iptv/categories", requireVip, async (req, res) => {
try {
const now = Date.now();
if (
iptvCategoriesCache &&
now - iptvCategoriesCacheTime < IPTV_CATEGORIES_TTL
) {
return res.json(iptvCategoriesCache);
}
const response = await axios.get(`${XTREAM_URL}/player_api.php`, {
params: {
username: XTREAM_USER,
password: XTREAM_PASS,
action: "get_live_categories",
},
timeout: 15000,
});
const categories = (response.data || []).map((cat) => ({
category_id: cat.category_id,
category_name: cat.category_name,
parent_id: cat.parent_id || 0,
}));
iptvCategoriesCache = { categories };
iptvCategoriesCacheTime = now;
res.json({ categories });
} catch (error) {
console.error("[IPTV] Erreur get_live_categories:", error.message);
res
.status(500)
.json({ error: "Erreur lors de la récupération des catégories IPTV" });
}
});
/**
* GET /api/livetv/iptv/streams/:categoryId
* Récupère les chaînes d'une catégorie Xtream (VIP only)
* Images proxifiées via proxy.movix.blog
*/
router.get("/iptv/streams/:categoryId", requireVip, async (req, res) => {
const { categoryId } = req.params;
try {
const response = await axios.get(`${XTREAM_URL}/player_api.php`, {
params: {
username: XTREAM_USER,
password: XTREAM_PASS,
action: "get_live_streams",
category_id: categoryId,
},
timeout: 15000,
});
const streams = (response.data || []).map((stream) => ({
stream_id: stream.stream_id,
name: stream.name,
stream_icon: stream.stream_icon
? `${IPTV_IMAGE_PROXY}/${stream.stream_icon}`
: null,
epg_channel_id: stream.epg_channel_id || null,
category_id: stream.category_id,
}));
res.json({ streams });
} catch (error) {
console.error(
`[IPTV] Erreur get_live_streams cat=${categoryId}:`,
error.message,
);
res
.status(500)
.json({ error: "Erreur lors de la récupération des chaînes IPTV" });
}
});
/**
* GET /api/livetv/iptv/stream-url/:streamId
* Construit l'URL m3u8 proxifiée pour un stream Xtream (VIP only)
*/
router.get("/iptv/stream-url/:streamId", requireVip, async (req, res) => {
const { streamId } = req.params;
try {
const directUrl = `${XTREAM_URL}/live/${XTREAM_USER}/${XTREAM_PASS}/${streamId}.m3u8`;
const proxiedUrl = `${IPTV_STREAM_PROXY}/${directUrl}`;
res.json({
streams: [
{
url: proxiedUrl,
title: "IPTV Stream",
behaviorHints: { notWebReady: false },
},
],
});
} catch (error) {
console.error(`[IPTV] Erreur stream-url ${streamId}:`, error.message);
res.status(500).json({ error: "Erreur lors de la construction de l'URL" });
}
});
module.exports = router;