build-iptv.mjs 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312
  1. // Curated IPTV playlist builder — sources from a clone of iptv-org/iptv.
  2. // The clone + validation cache live in /tmp (portable, throwaway — nothing lands
  3. // in the repo). Writes iptv.m3u next to this script; deploy.ts publishes it to GCS.
  4. //
  5. // node build-iptv.mjs --dry assemble + counts only, no validation
  6. // node build-iptv.mjs assemble + validate (cached) + write iptv.m3u
  7. // node build-iptv.mjs --revalidate re-probe every stream, ignoring the cache
  8. //
  9. // Update upstream first with: git -C /tmp/iptv-org-build/iptv-org-repo pull
  10. import { readFileSync, writeFileSync, readdirSync, existsSync, mkdirSync } from 'node:fs';
  11. import { execSync } from 'node:child_process';
  12. import { fileURLToPath } from 'node:url';
  13. import path from 'node:path';
  14. const HERE = path.dirname(fileURLToPath(import.meta.url));
  15. const TMP = '/tmp/iptv-org-build'; // clone + probe cache: portable, throwaway
  16. const REPO = path.join(TMP, 'iptv-org-repo');
  17. const STREAMS = path.join(REPO, 'streams');
  18. const VCACHE = path.join(TMP, 'validation.json');
  19. const OUT = path.join(HERE, 'iptv.m3u');
  20. const REPORT = path.join(HERE, 'iptv-build-report.txt');
  21. const RAW = 'https://raw.githubusercontent.com/iptv-org/iptv/master/streams';
  22. const DRY = process.argv.includes('--dry');
  23. const REVALIDATE = process.argv.includes('--revalidate');
  24. const CONCURRENCY = 50;
  25. const TIMEOUT_MS = 7000;
  26. // ---------- bootstrap upstream clone ----------
  27. if (!existsSync(STREAMS)) {
  28. mkdirSync(TMP, { recursive: true });
  29. process.stderr.write(`Cloning iptv-org/iptv (shallow) into ${TMP} ...\n`);
  30. execSync(`git clone --depth 1 https://github.com/iptv-org/iptv.git "${REPO}"`, { stdio: 'inherit' });
  31. }
  32. // ---------- parsing ----------
  33. const allFiles = readdirSync(STREAMS).filter((f) => f.endsWith('.m3u'));
  34. const cache = {};
  35. function parse(file) {
  36. if (cache[file]) return cache[file];
  37. let text;
  38. try { text = readFileSync(path.join(STREAMS, file), 'utf8'); }
  39. catch { return (cache[file] = []); }
  40. const lines = text.split('\n');
  41. const out = [];
  42. for (let i = 0; i < lines.length; i++) {
  43. if (!lines[i].startsWith('#EXTINF')) continue;
  44. const extinf = lines[i];
  45. const comma = extinf.indexOf(',');
  46. const name = extinf.slice(comma + 1).trim();
  47. let url = '';
  48. for (let j = i + 1; j < lines.length; j++) {
  49. const t = lines[j].trim();
  50. if (!t) continue;
  51. if (t.startsWith('#')) { if (t.startsWith('#EXTINF')) break; continue; }
  52. url = t; break;
  53. }
  54. if (!url) continue;
  55. const m = name.match(/\((\d+)p\)/);
  56. out.push({ name, url, extinf, sourceFile: file, quality: m ? Number(m[1]) : 0,
  57. geo: /\[Geo-blocked\]/i.test(name) });
  58. }
  59. return (cache[file] = out);
  60. }
  61. const everyEntry = () => allFiles.flatMap(parse);
  62. const filesEntries = (files) => files.flatMap(parse);
  63. // ---------- curated matchers ----------
  64. const NEWS = [
  65. /^BBC News/i, /^Al Jazeera English/i, /^France 24( \(|$)/i, /^France 24 English/i,
  66. /^DW( \(|$)/i, /^DW English/i, /^Sky News( \(|$)/i, /^Sky News Australia/i,
  67. /^Euronews( \(|$)/i, /^Euronews English/i, /^CNA( \(|$)/i, /^Channel News ?Asia/i,
  68. /^TRT World/i, /^Africanews( \(|$)/i, /^Africanews English/i, /^GB News/i, /^NHK World/i,
  69. /^CGTN( \(|$)/i, /^CGTN Documentary/i, /^WION/i, /^Arirang TV( \(|$)/i, /^Arirang( \(|$)/i,
  70. /^i24NEWS/i, /^TaiwanPlus/i, /^Bloomberg (Television|Originals|TV US|TV Australia|TV Plus|TV\+)/i,
  71. /^Bloomberg TV( \(|$)/i, /^CNN International/i, /^CNN( \(|$)/i, /^ABC News Live( \(|\[|$)/i,
  72. /^ABC News( \(|$)/i, /^NBC News Now/i, /^CBS News( \(|$|\[)/i, /^Scripps News/i,
  73. /^LiveNOW from FOX/i, /^CBC News( \(|$)/i, /^CBC News Network/i, /^Newsmax/i, /^Global News National/i,
  74. ];
  75. const NEWS_EXCLUDE = /Pashto|Arabic|Mubasher|Русск|Fran[cç]ais|Cha[iî]ne|Portugu|Deutsch|Espa[nñ]ol|Hebrew|Radio|Hindi|Urdu|Bangla|Mongolia|Bulgaria/i;
  76. const ESP_NEWS = [
  77. /CNN en Espa/i, /France 24.*Espa/i, /^DW Espa/i, /Euronews.*Espa/i, /CGTN Espa/i,
  78. /Deutsche Welle.*Espa/i, /Telemundo/i, /Univision/i, /TUDN/i, /Al Jazeera.*Espa/i,
  79. ];
  80. // AR/CO/CL terrestrial & notable — Latin immersion (skips Comedy Central / flagship news already covered)
  81. const LATAM = [
  82. /^Telefe/i, /^El Trece/i, /^El Nueve/i, /^TV Publica/i, /^Am[ée]rica TV/i, /^A24/i,
  83. /^Bravo TV/i, /^Garage TV/i, /^Canal 9 /i,
  84. /^Caracol/i, /RCN/i, /^Canal 1 /i, /^Se[ñn]al Colombia/i, /^Canal Capital/i,
  85. /^Canal Institucional/i, /^NTN24/i, /^Teleantioquia/i, /^Telecaf/i, /^Telecaribe/i,
  86. /^Telemedell/i, /^Cosmovision/i,
  87. /^TVN( |3|$)/i, /^Mega( |noticias|$)/i, /^Canal 13/i, /^La Red/i, /^TV\+/i, /^UCV TV/i,
  88. ];
  89. // Bespoke European terrestrial / region-defining (no Comedy Central, no flagship news dupes)
  90. const EUROPE = [
  91. /^France 2( \(|$)/i, /^France 3( |\(|$)/i, /^France 4/i, /^France 5( \(|$)/i, /^Gulli/i, /^BFM TV/i, /^Arte/i,
  92. /^Das Erste/i, /^ProSieben/i, /^3sat/i, /^Tagesschau 24/i, /^WDR Fernsehen( \(|$)/i,
  93. /^NDR Fernsehen International/i, /^ZDF( \(|$)/i, /^ZDFneo/i,
  94. /^Rai 1 HD/i, /^Rai 2 HD/i, /^Rai 3( \(|$)/i, /^Rai 4( \(|$)/i, /^Rai News/i, /^Rai Storia/i, /^Rai Movie/i, /^La7/i,
  95. /^Canale 5/i, /^Italia 1/i, /^Rete 4/i,
  96. /^La 1( \(|$)/i, /^Antena 3 Internacional/i, /^Canal Sur Andaluc/i, /^ETB ?[12] On/i,
  97. /^BBC One( \(|$)/i, /^BBC Two( \(|$)/i, /^BBC Four/i, /^Channel 4( \(|$)/i, /^Channel 5( \(|$)/i, /^Film4/i,
  98. ];
  99. const isNews = (n) => NEWS.some((r) => r.test(n)) && !NEWS_EXCLUDE.test(n);
  100. const isComedyCentral = (n) => /comedy central/i.test(n);
  101. const isEspNews = (n) => ESP_NEWS.some((r) => r.test(n));
  102. const isLatam = (n) => LATAM.some((r) => r.test(n));
  103. const isEurope = (n) => EUROPE.some((r) => r.test(n));
  104. // Streams confirmed good by hand (e.g. via ffprobe) that the HTTP probe flaps on.
  105. const FORCE_KEEP = [/^Comedy Central \(720p\)/i];
  106. // ---------- section assembly (priority order; first to claim a URL wins) ----------
  107. const seen = new Set();
  108. const sections = [];
  109. function add(meta, entries) {
  110. const kept = [];
  111. for (const e of entries) {
  112. if (seen.has(e.url)) continue;
  113. seen.add(e.url);
  114. kept.push(e);
  115. }
  116. sections.push({ ...meta, entries: kept });
  117. }
  118. add({ title: 'News — flagships & world', group: 'News', dedupeByName: true,
  119. sources: ['(cross-file pattern match across all country files)'],
  120. filter: 'name matches flagship/major-news whitelist' },
  121. everyEntry().filter((e) => isNews(e.name)));
  122. add({ title: 'Comedy Central', group: 'Comedy', dedupeByName: false,
  123. sources: ['(cross-file pattern match across all country files)'],
  124. filter: 'name contains "Comedy Central"' },
  125. everyEntry().filter((e) => isComedyCentral(e.name)));
  126. add({ title: 'Espanol (Latin) — Mexico + Spanish news', group: 'Espanol', dedupeByName: false,
  127. sources: ['mx.m3u', 'mx_pluto.m3u', 'mx_samsung.m3u', 'us_canelatv.m3u', '(+ Spanish-language news cross-file)'],
  128. filter: 'all entries from MX/Canela files + Spanish-language news from any file' },
  129. [...filesEntries(['mx.m3u', 'mx_pluto.m3u', 'mx_samsung.m3u', 'us_canelatv.m3u']),
  130. ...everyEntry().filter((e) => isEspNews(e.name))]);
  131. add({ title: 'Latin America — AR/CO/CL terrestrial', group: 'LatAm', dedupeByName: false,
  132. sources: ['ar.m3u', 'co.m3u', 'cl.m3u'],
  133. filter: 'curated terrestrial/regional whitelist (Argentina, Colombia, Chile)' },
  134. filesEntries(['ar.m3u', 'co.m3u', 'cl.m3u']).filter((e) => isLatam(e.name)));
  135. add({ title: 'Europe — terrestrial & regional', group: 'Europe', dedupeByName: false,
  136. sources: ['fr.m3u', 'de.m3u', 'it.m3u', 'es.m3u', 'nl.m3u', 'uk.m3u'],
  137. filter: 'curated iconic/terrestrial whitelist (excludes news & Comedy Central already covered)' },
  138. filesEntries(['fr.m3u', 'de.m3u', 'it.m3u', 'es.m3u', 'nl.m3u', 'uk.m3u']).filter((e) => isEurope(e.name)));
  139. const bulk = [
  140. ['Australia', 'AU', 'au.m3u'],
  141. ['Australia — Samsung TV Plus', 'AU Samsung', 'au_samsung.m3u'],
  142. ['US — Broadcast', 'US', 'us.m3u'],
  143. ['US — Pluto TV', 'US Pluto', 'us_pluto.m3u'],
  144. ['US — Samsung TV Plus', 'US Samsung', 'us_samsung.m3u'],
  145. ['US — Tubi', 'US Tubi', 'us_tubi.m3u'],
  146. ['US — Xumo', 'US Xumo', 'us_xumo.m3u'],
  147. ];
  148. for (const [title, group, file] of bulk)
  149. add({ title, group, dedupeByName: false, sources: [file], filter: 'all entries' }, parse(file));
  150. // ---------- validation (HTTP reachability, cached by URL) ----------
  151. async function check(url) {
  152. const ctrl = new AbortController();
  153. const timer = setTimeout(() => ctrl.abort(), TIMEOUT_MS);
  154. try {
  155. const res = await fetch(url, { method: 'GET', redirect: 'follow', signal: ctrl.signal,
  156. headers: { 'User-Agent': 'VLC/3.0.20 LibVLC/3.0.20' } });
  157. try { await res.body?.cancel(); } catch {}
  158. return res.status;
  159. } catch { return 0; }
  160. finally { clearTimeout(timer); }
  161. }
  162. async function pool(items, n, fn) {
  163. const ret = new Array(items.length);
  164. let i = 0, done = 0;
  165. async function worker() {
  166. while (i < items.length) {
  167. const idx = i++;
  168. ret[idx] = await fn(items[idx]);
  169. if (++done % 150 === 0) process.stderr.write(` probed ${done}/${items.length}\n`);
  170. }
  171. }
  172. await Promise.all(Array.from({ length: n }, worker));
  173. return ret;
  174. }
  175. const allEntries = sections.flatMap((s) => s.entries);
  176. process.stderr.write(`Assembled ${allEntries.length} unique candidates across ${sections.length} sections.\n`);
  177. if (!DRY) {
  178. let vcache = {};
  179. if (!REVALIDATE) { try { vcache = JSON.parse(readFileSync(VCACHE, 'utf8')); } catch {} }
  180. const toProbe = allEntries.filter((e) => !(e.url in vcache));
  181. process.stderr.write(`Validating ${allEntries.length} streams — ${toProbe.length} to probe, ${allEntries.length - toProbe.length} cached.\n`);
  182. const statuses = await pool(toProbe, CONCURRENCY, (e) => check(e.url));
  183. toProbe.forEach((e, k) => { vcache[e.url] = statuses[k]; });
  184. mkdirSync(TMP, { recursive: true });
  185. writeFileSync(VCACHE, JSON.stringify(vcache));
  186. for (const e of allEntries) {
  187. const st = vcache[e.url] ?? 0;
  188. e.status = st;
  189. e.alive = (st >= 200 && st < 400) || FORCE_KEEP.some((r) => r.test(e.name));
  190. }
  191. }
  192. // ---------- disambiguation ----------
  193. // When a channel name collides within a section, append a source tag (country,
  194. // then provider, then quality) — but only the dimensions that actually differ, so
  195. // single-source dupes don't get noise like "(AU)". Unique names are left untouched.
  196. function disambiguate(entries) {
  197. const baseKey = (n) => n.toLowerCase()
  198. .replace(/\(\d+p\)|\[[^\]]*\]|\((?:hevc|adaptive)\)/gi, '').replace(/\s+/g, ' ').trim();
  199. const cc = (e) => (e.sourceFile.split(/[._]/)[0] || '').toUpperCase();
  200. const prov = (e) => {
  201. const p = e.sourceFile.replace(/\.m3u$/, '').split('_').slice(1).join(' ');
  202. return p ? p.replace(/\b\w/g, (c) => c.toUpperCase()) : '';
  203. };
  204. const nameWith = (e, dims) => {
  205. const tag = dims.map((d) => d(e)).filter(Boolean).join(' ');
  206. return tag ? `${e.name} (${tag})` : e.name;
  207. };
  208. const groups = new Map();
  209. for (const e of entries) {
  210. const k = baseKey(e.name);
  211. if (!groups.has(k)) groups.set(k, []);
  212. groups.get(k).push(e);
  213. }
  214. for (const g of groups.values()) {
  215. if (g.length < 2) continue;
  216. // add the fewest source dimensions (country, then provider) needed to make
  217. // names unique; quality already lives in the name so it's never re-appended.
  218. const distinct = (dims) => new Set(g.map((e) => nameWith(e, dims))).size;
  219. let active = [];
  220. let best = distinct(active);
  221. if (best < g.length) {
  222. for (const d of [cc, prov]) {
  223. const dn = distinct([...active, d]);
  224. if (dn > best) { active = [...active, d]; best = dn; if (best === g.length) break; }
  225. }
  226. }
  227. const counts = {};
  228. for (const e of g) {
  229. let n = nameWith(e, active);
  230. counts[n] = (counts[n] || 0) + 1;
  231. if (counts[n] > 1) n = `${n} (${counts[n]})`;
  232. if (n !== e.name) e.displayName = n;
  233. }
  234. }
  235. }
  236. // ---------- post-process + write ----------
  237. const qrank = (e) => e.quality || 0;
  238. function finalize(s) {
  239. let entries = DRY ? s.entries : s.entries.filter((e) => e.alive);
  240. if (s.dedupeByName) {
  241. const byName = new Map();
  242. for (const e of entries) {
  243. const key = e.name.toLowerCase().replace(/\(\d+p\)|\[[^\]]*\]/g, '').replace(/\s+/g, ' ').trim();
  244. const cur = byName.get(key);
  245. if (!cur || qrank(e) > qrank(cur)) byName.set(key, e);
  246. }
  247. entries = [...byName.values()];
  248. }
  249. entries.sort((a, b) => a.name.localeCompare(b.name));
  250. return entries;
  251. }
  252. function extinfLine(e, group) {
  253. const comma = e.extinf.indexOf(',');
  254. const attrs = e.extinf.slice(0, comma).replace(/\s*group-title="[^"]*"/, '');
  255. return `${attrs} group-title="${group}",${e.displayName ?? e.name}`;
  256. }
  257. const lines = ['#EXTM3U'];
  258. const reportLines = [];
  259. lines.push(
  260. '# ============================================================',
  261. '# Curated IPTV playlist — source: iptv-org/iptv',
  262. '# Built by: build-iptv.mjs (`bun refresh.ts` = playlist + EPG, `bun deploy.ts` = publish)',
  263. '# Rebuild: bun build-iptv.mjs (cached, fast)',
  264. '# Update: git -C /tmp/iptv-org-build/iptv-org-repo pull && bun build-iptv.mjs --revalidate',
  265. `# Validation: ${DRY ? 'SKIPPED (dry run)' : 'HTTP reachability probed from this machine; dead/geo-blocked dropped'}`,
  266. '# Colliding names get a source tag, e.g. "Comedy Central (US)" vs "(DK)".',
  267. '# Per-section comments below record upstream sources so this can be regenerated.',
  268. '# ============================================================');
  269. let totalKept = 0;
  270. for (const s of sections) {
  271. const finals = finalize(s);
  272. disambiguate(finals);
  273. totalKept += finals.length;
  274. reportLines.push(`${s.title.padEnd(40)} kept ${String(finals.length).padStart(4)} / ${String(s.entries.length).padStart(4)} candidates`);
  275. lines.push('', '# ------------------------------------------------------------',
  276. `# SECTION: ${s.title} (group-title="${s.group}")`,
  277. ...s.sources.map((src) => src.startsWith('(') ? `# Upstream: ${src}` : `# Upstream: ${RAW}/${src}`),
  278. `# Filter: ${s.filter}${s.dedupeByName ? ' + deduped by channel name (best quality)' : ''}`,
  279. `# ${DRY ? 'Candidates' : 'Alive'}: ${finals.length}${DRY ? '' : ` / ${s.entries.length}`}`,
  280. '# ------------------------------------------------------------');
  281. for (const e of finals) {
  282. if (/^(News|Comedy|Espanol|Latin|Europe)/.test(s.title)) lines.push(`# src: ${e.sourceFile}`);
  283. lines.push(extinfLine(e, s.group), e.url);
  284. }
  285. }
  286. mkdirSync(path.dirname(OUT), { recursive: true });
  287. writeFileSync(OUT, lines.join('\n') + '\n');
  288. const report = ['IPTV build report (' + (DRY ? 'DRY — not validated' : 'validated') + ')',
  289. '='.repeat(62), ...reportLines, '-'.repeat(62),
  290. `TOTAL kept: ${totalKept}`, `Output: ${OUT}`].join('\n');
  291. writeFileSync(REPORT, report + '\n');
  292. process.stderr.write('\n' + report + '\n');