make-epg.mjs 5.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114
  1. // EPG binder. Run AFTER build-iptv.mjs (refresh.ts does both + grab). It:
  2. // 1. matches each playlist channel to an iptv-org/epg source:
  3. // - real tvg-id -> epg channel with the same BASE id (ignoring @feed suffix)
  4. // - Pluto channels -> /plu-<id>/ in the URL IS epg's pluto.tv site_id, so we
  5. // synthesise a tvg-id even when upstream left it blank
  6. // 2. rewrites the playlist's tvg-id to a canonical id + adds the url-tvg header
  7. // 3. writes /tmp/iptv-org-build/custom.channels.xml for `grab`
  8. // Samsung & Tubi have no epg site upstream, so they only bind if a real tvg-id matches.
  9. import { readFileSync, writeFileSync, readdirSync, existsSync } from 'node:fs';
  10. import { fileURLToPath } from 'node:url';
  11. import path from 'node:path';
  12. const HERE = path.dirname(fileURLToPath(import.meta.url));
  13. const M3U = path.join(HERE, 'iptv.m3u');
  14. const SITES = '/tmp/iptv-org-build/epg/sites';
  15. const OUTXML = '/tmp/iptv-org-build/custom.channels.xml';
  16. // Public URL where guide.xml is served — must match deploy.ts's bucket path.
  17. const EPG_URL = 'https://chillidonut.com/junk/iptv/guide.xml';
  18. if (!existsSync(SITES)) {
  19. process.stderr.write(`epg clone not found at ${SITES}\n` +
  20. 'Run: git clone --depth 1 https://github.com/iptv-org/epg.git /tmp/iptv-org-build/epg (or use refresh.ts)\n');
  21. process.exit(1);
  22. }
  23. const baseId = (x) => (x || '').split('@')[0].trim();
  24. const cleanName = (n) => n.replace(/\s*\(\d+p\)|\s*\[[^\]]*\]|\s*\([^)]*\)\s*$/g, '').trim() || n;
  25. // ---------- index epg channel mappings ----------
  26. const chanRe = /<channel\s+([^>]*?)>([^<]*)<\/channel>/g;
  27. const attrRe = /([\w]+)="([^"]*)"/g;
  28. const baseIdx = new Map(); // base(xmltv_id) -> {site, site_id, lang}
  29. const plutoIdx = new Map(); // pluto site_id (hex) -> {xmltv_id, lang}
  30. for (const site of readdirSync(SITES)) {
  31. let dir;
  32. try { dir = readdirSync(path.join(SITES, site)); } catch { continue; }
  33. for (const f of dir) {
  34. if (!f.endsWith('.channels.xml')) continue;
  35. for (const c of readFileSync(path.join(SITES, site, f), 'utf8').matchAll(chanRe)) {
  36. const a = {};
  37. for (const m of c[1].matchAll(attrRe)) a[m[1]] = m[2];
  38. if (site === 'pluto.tv' && a.site_id) plutoIdx.set(a.site_id, { xmltv_id: a.xmltv_id, lang: a.lang || 'en' });
  39. const b = baseId(a.xmltv_id);
  40. if (b && a.site_id && !baseIdx.has(b)) baseIdx.set(b, { site: a.site, site_id: a.site_id, lang: a.lang || 'en' });
  41. }
  42. }
  43. }
  44. // ---------- resolve each playlist channel ----------
  45. function resolve(url, tvgid, name) {
  46. const pm = url.match(/plu-([0-9a-f]{16,})/i);
  47. if (pm && plutoIdx.has(pm[1])) {
  48. const e = plutoIdx.get(pm[1]);
  49. return { id: baseId(e.xmltv_id) || `pluto-${pm[1]}`, site: 'pluto.tv', site_id: pm[1], lang: e.lang, name: cleanName(name) };
  50. }
  51. const b = baseId(tvgid);
  52. if (b && baseIdx.has(b)) {
  53. const e = baseIdx.get(b);
  54. return { id: b, site: e.site, site_id: e.site_id, lang: e.lang, name: cleanName(name) };
  55. }
  56. return { id: b }; // keep base id if any (no grab source), else ''
  57. }
  58. // ---------- rewrite playlist + collect channels ----------
  59. const lines = readFileSync(M3U, 'utf8').split('\n');
  60. const channels = new Map(); // site|site_id -> row
  61. const seenIds = new Set(); // one grab source (and one guide <channel>) per id
  62. let plutoMatched = 0, idMatched = 0;
  63. const bySection = {};
  64. let section = '';
  65. for (let i = 0; i < lines.length; i++) {
  66. const l = lines[i];
  67. if (l.startsWith('# SECTION:')) section = l.replace(/.*SECTION:\s*/, '').replace(/\s{2,}.*/, '');
  68. if (l === '#EXTM3U') { lines[i] = `#EXTM3U url-tvg="${EPG_URL}"`; continue; }
  69. if (!l.startsWith('#EXTINF')) continue;
  70. let url = '';
  71. for (let j = i + 1; j < lines.length; j++) {
  72. const t = lines[j].trim();
  73. if (!t || t.startsWith('#')) { if (t.startsWith('#EXTINF')) break; continue; }
  74. url = t; break;
  75. }
  76. if (!url) continue;
  77. const tvgid = (l.match(/tvg-id="([^"]*)"/) || [])[1] || '';
  78. const name = l.slice(l.indexOf(',') + 1);
  79. const r = resolve(url, tvgid, name);
  80. lines[i] = l.replace(/tvg-id="[^"]*"/, `tvg-id="${r.id || ''}"`);
  81. bySection[section] = bySection[section] || { have: 0, total: 0 };
  82. bySection[section].total++;
  83. if (r.site && r.site_id) {
  84. bySection[section].have++;
  85. if (!seenIds.has(r.id)) {
  86. seenIds.add(r.id);
  87. if (r.site === 'pluto.tv') plutoMatched++; else idMatched++;
  88. channels.set(`${r.site}|${r.site_id}`, r);
  89. }
  90. }
  91. }
  92. writeFileSync(M3U, lines.join('\n'));
  93. // ---------- write channels.xml ----------
  94. const esc = (s) => String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
  95. const rows = [...channels.values()].sort((a, b) => a.id.localeCompare(b.id));
  96. const xml = ['<?xml version="1.0" encoding="UTF-8"?>', '<channels>'];
  97. for (const r of rows)
  98. xml.push(` <channel site="${esc(r.site)}" lang="${esc(r.lang)}" xmltv_id="${esc(r.id)}" site_id="${esc(r.site_id)}">${esc(r.name)}</channel>`);
  99. xml.push('</channels>');
  100. writeFileSync(OUTXML, xml.join('\n') + '\n');
  101. // ---------- report ----------
  102. process.stderr.write(`EPG url-tvg: ${EPG_URL}\n`);
  103. process.stderr.write(`grab channels: ${rows.length} (pluto: ${plutoMatched}, by-id: ${idMatched}) -> ${OUTXML}\n\n`);
  104. process.stderr.write('EPG-bound channels per section:\n');
  105. for (const [s, v] of Object.entries(bySection).sort())
  106. process.stderr.write(` ${String(v.have).padStart(4)} / ${String(v.total).padEnd(4)} ${s}\n`);