xtream bundler
Build and Push Docker Images / build-and-push (push) Successful in 36s

This commit is contained in:
Bram committed 2026-11-06 19:24:39 +01:00
1 parent d7aea0be65
commit 91f22b54b4
30 files changed
+2475

No files matched your search

+8
View File
@@ -0,0 +1,8 @@
node_modules
npm-debug.log
test
.git
.gitignore
.dockerignore
docker-compose.example.yml
README.md
+2
View File
@@ -0,0 +1,2 @@
node_modules/
npm-debug.log
+31
View File
@@ -0,0 +1,31 @@
FROM node:22-alpine
WORKDIR /app
COPY package.json ./
COPY server.js ./
COPY src ./src/
RUN addgroup -g 1001 -S nodejs \
&& adduser -S nodejs -u 1001 \
&& chown -R nodejs:nodejs /app
USER nodejs
EXPOSE 8080
ENV LISTEN_PORT=8080 \
MAX_CHANNELS=3 \
LANGUAGE=nl \
EVENT_DURATION_MIN=150 \
START_GRACE_MIN=10 \
REFRESH_INTERVAL_SEC=60 \
TIMEZONE=Europe/Brussels \
UPSTREAM_TIMEOUT_MS=15000 \
PROXY_INSECURE_TLS=0 \
LOG_LEVEL=info
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
CMD node -e "require('http').get('http://127.0.0.1:'+(process.env.LISTEN_PORT||8080)+'/health',(r)=>{let d='';r.on('data',c=>d+=c);r.on('end',()=>{try{process.exit(JSON.parse(d).ok?0:1)}catch{process.exit(1)}})}).on('error',()=>process.exit(1))"
CMD ["node", "server.js"]
+123
View File
@@ -0,0 +1,123 @@
# Xtream PPV bundler
Bundelt alle live-streams van één Xtream-panel waarvan de kanaalnaam een zoekterm bevat
(bijv. `Jupiler Pro League`) in maximaal 3 vaste uitgangskanalen. Toewijzing gebeurt via
FIFO: zodra een wedstrijd klaar is, neemt de oudste wachtende wedstrijd het vrijgekomen
slot over.
De app leest periodiek de kanaalnamen van de upstream. Daaruit haalt hij wedstrijd,
competitie, taal en starttijd, bijvoorbeeld:
```text
View BE| DAZN 03 [Standard de Liège - SK Beveren | Jupiler Pro League (nl) (2026-11-06 19:25:00)]
```
Alleen streams met taalcode `(nl)` of zonder taalcode worden gebruikt. Een expliciete
`(fr)`- of andere taalcode wordt overgeslagen.
## Snel starten
```bash
docker build -t xtream-ppv-bundler .
docker run -d --name xtream-ppv-bundler -p 8080:8080 \
-e XTREAM_URL="http://panel.example.com:8080" \
-e XTREAM_USERNAME="user" \
-e XTREAM_PASSWORD="pass" \
-e SEARCH_TERM="Jupiler Pro League" \
xtream-ppv-bundler
```
Of gebruik `docker-compose.example.yml`:
```bash
cp docker-compose.example.yml docker-compose.yml
# zet XTREAM_URL, XTREAM_USERNAME, XTREAM_PASSWORD en SEARCH_TERM in .env
docker compose up -d
```
Voeg daarna in je IPTV-client toe:
- **Playlist:** `http://<host>:8080/playlist.m3u`
- **EPG (XMLTV):** `http://<host>:8080/epg.xml`
## Endpoints
| Endpoint | Functie |
| --- | --- |
| `GET /playlist.m3u` | M3U met altijd `MAX_CHANNELS` vaste kanalen |
| `GET /stream/<slot>` | Live proxy naar de wedstrijd op dat slot |
| `GET /epg.xml` of `/xmltv.xml` | XMLTV-programmagids voor de 3 kanalen |
| `GET /status` | JSON met slots, FIFO-wachtrij, laatste refresh en waarschuwingen |
| `GET /health` | Healthcheck voor Docker |
| `GET /` | Eenvoudige statuspagina |
De playlist bevat altijd `Jupiler 1` t/m `Jupiler 3`. Is een slot leeg, dan heet het
`Jupiler N | Geen wedstrijd` en geeft `/stream/N` een 503 tot er een wedstrijd op het slot
staat.
## Configuratie
| Env | Default | Verplicht | Betekenis |
| --- | --- | --- | --- |
| `XTREAM_URL` | - | ja | Basis-URL van het Xtream-panel, mag een path-prefix hebben |
| `XTREAM_USERNAME` | - | ja | Upstream username |
| `XTREAM_PASSWORD` | - | ja | Upstream password |
| `SEARCH_TERM` | - | ja | Case-insensitive substring in de kanaalnaam |
| `MAX_CHANNELS` | `3` | nee | Aantal vaste uitgangsslots (1-10) |
| `LANGUAGE` | `nl` | nee | Toegestane expliciete taalcode; ontbrekende code wordt toegelaten |
| `EVENT_DURATION_MIN` | `150` | nee | Duur waarna een event het slot verlaat |
| `START_GRACE_MIN` | `10` | nee | Minuten voor aanvang dat een event eligibel wordt |
| `REFRESH_INTERVAL_SEC` | `60` | nee | Hoe vaak kanaalnamen opnieuw worden gelezen |
| `TIMEZONE` | `Europe/Brussels` | nee | Tijdzone van de tijdstempels in kanaalnamen |
| `LISTEN_PORT` | `8080` | nee | HTTP-poort |
| `UPSTREAM_TIMEOUT_MS` | `15000` | nee | Timeout voor upstream-verzoeken |
| `PROXY_INSECURE_TLS` | `0` | nee | `1` = self-signed upstream-certificaten accepteren |
| `HLS_SECRET` | random | nee | Secret voor ondertekende HLS-segment-URL's |
| `LOG_LEVEL` | `info` | nee | `debug`, `info`, `warn`, `error` |
## Werking
1. Elke `REFRESH_INTERVAL_SEC` haalt de app `player_api.php?action=get_live_streams` op.
2. Kanaalnamen met `SEARCH_TERM` worden geparsed naar wedstrijd, competitie, taal en
starttijd. Streams met een expliciete andere taal dan `LANGUAGE` vallen af.
3. Bronnen met dezelfde wedstrijd en starttijd worden één event. Meerdere DAZN-kanalen
voor dezelfde wedstrijd zijn dus fallback-bronnen, geen extra FIFO-item.
4. Een event is eligibel vanaf `START_GRACE_MIN` voor de start tot
`EVENT_DURATION_MIN` na de start.
5. Vrije slots worden gevuld op starttijd en daarna op moment van binnenkomst (FIFO).
Een bestaande toewijzing wordt niet gepreempt.
6. Een toegewezen event houdt zijn slot tot de berekende eindtijd, ook als de provider de
kanaalnaam intussen naar de volgende wedstrijd zet. Nieuwe kijkers krijgen de actuele
toewijzing; bestaande verbindingen lopen door tot de client disconnect.
## EPG
`/epg.xml` genereert XMLTV voor de vaste kanalen. De gids gebruikt:
- de actuele toewijzing per slot;
- alle toekomstige wedstrijden uit de laatste succesvolle kanaal-refresh, geprojecteerd
over de slots volgens dezelfde FIFO-regels.
De EPG is dus een projectie op basis van de kanaalnamen. Nieuwe of gewijzigde uitzendingen
verschijnen na de volgende refresh. Bij een mislukte refresh blijft de laatst bekende
gids staan.
## Stream-URL's en HLS
De app bouwt stream-URL's primair als `/live/<user>/<pass>/<stream_id>.ts`, met
`.m3u8`- en padvarianten als fallback. Werkt geen enkele kandidaat, dan wordt eenmalig de
volledige `get.php?type=m3u_plus`-playlist opgehaald en gecacht om de exacte URL te vinden.
MPEG-TS wordt 1-op-1 geproxied. Bij HLS worden playlist-URI's herschreven naar
ondertekende URLs op deze app, zodat upstream-credentials nooit naar de client lekken.
Segmenten, keys en geneste playlists worden via dezelfde proxy opgehaald.
## Beperkingen
- Het kanaalnaamformaat is provider-specifiek. Onparseerbare namen worden overgeslagen en
verschijnen in `/status` onder `warnings`.
- Zonder upstream-EPG is de eindtijd een aanname (`EVENT_DURATION_MIN`).
- De EPG is een projectie en kan afwijken van de werkelijke toewijzing als de provider
kanaalnamen onverwacht wijzigt.
- De output heeft geen authenticatie; bedoeld voor LAN/self-host.
- Meerdere kijkers betekenen meerdere upstream-verbindingen.
@@ -0,0 +1,20 @@
services:
xtream-ppv-bundler:
build: .
container_name: xtream-ppv-bundler
ports:
- "8080:8080"
environment:
XTREAM_URL: "${XTREAM_URL}"
XTREAM_USERNAME: "${XTREAM_USERNAME}"
XTREAM_PASSWORD: "${XTREAM_PASSWORD}"
SEARCH_TERM: "${SEARCH_TERM:-Jupiler Pro League}"
MAX_CHANNELS: "3"
LANGUAGE: "nl"
EVENT_DURATION_MIN: "150"
START_GRACE_MIN: "10"
REFRESH_INTERVAL_SEC: "60"
TIMEZONE: "Europe/Brussels"
# PROXY_INSECURE_TLS: "1" # alleen nodig bij self-signed upstream-certificaten
# LOG_LEVEL: "debug"
restart: unless-stopped
+13
View File
@@ -0,0 +1,13 @@
{
"name": "xtream-ppv-bundler",
"version": "1.0.0",
"private": true,
"description": "Bundles matching Xtream live streams into fixed FIFO output channels",
"scripts": {
"start": "node server.js",
"test": "node --test"
},
"engines": {
"node": ">=20"
}
}
+146
View File
@@ -0,0 +1,146 @@
'use strict';
const http = require('http');
const { settings, validate } = require('./src/settings');
const logger = require('./src/logger');
const { createScheduler } = require('./src/scheduler');
const { handleStream, handleHls } = require('./src/proxy');
const { buildPlaylist, buildIndex } = require('./src/playlist');
const { buildXmltv } = require('./src/epg');
const { healthPayload } = require('./src/health');
const { sendText, sendJson, sendHtml } = require('./src/http-utils');
const scheduler = createScheduler();
function sendM3u(res, body) {
res.writeHead(200, {
'Content-Type': 'application/x-mpegURL; charset=utf-8',
'Cache-Control': 'no-store',
'Content-Length': Buffer.byteLength(body),
});
res.end(body);
}
function sendXml(res, body) {
res.writeHead(200, {
'Content-Type': 'application/xml; charset=utf-8',
'Cache-Control': 'no-store',
'Content-Length': Buffer.byteLength(body),
});
res.end(body);
}
function statusPayload() {
return {
...scheduler.summarize(),
config: {
searchTerm: settings.searchTerm,
maxChannels: settings.maxChannels,
language: settings.language,
eventDurationMin: settings.eventDurationMin,
startGraceMin: settings.startGraceMin,
refreshIntervalSec: settings.refreshIntervalSec,
timezone: settings.timezone,
},
};
}
const server = http.createServer((req, res) => {
let reqUrl;
try {
reqUrl = new URL(req.url, 'http://bundler.invalid');
} catch {
sendText(res, 400, 'Bad request');
return;
}
const path = reqUrl.pathname.replace(/\/+$/, '') || '/';
if (req.method !== 'GET' && req.method !== 'HEAD') {
sendText(res, 405, 'Method not allowed', { Allow: 'GET, HEAD' });
return;
}
if (path === '/health' || path === '/healthz') {
sendJson(res, 200, healthPayload(scheduler));
return;
}
if (path === '/') {
sendHtml(res, 200, buildIndex(req, scheduler));
return;
}
if (path === '/playlist.m3u' || path === '/channels.m3u') {
sendM3u(res, buildPlaylist(req, scheduler));
return;
}
if (path === '/epg.xml' || path === '/xmltv.xml') {
sendXml(res, buildXmltv(scheduler));
return;
}
if (path === '/status') {
sendJson(res, 200, statusPayload());
return;
}
const streamMatch = /^\/stream\/(\d+)$/.exec(path);
if (streamMatch) {
handleStream(req, res, Number(streamMatch[1]), scheduler).catch((error) => {
logger.error('Stream handler failed:', error.message || error);
if (!res.headersSent) sendText(res, 500, 'Internal error');
});
return;
}
const hlsMatch = /^\/hls\/(\d+)\/([^/]+)$/.exec(path);
if (hlsMatch) {
let token = hlsMatch[2];
try {
token = decodeURIComponent(token);
} catch {}
handleHls(req, res, Number(hlsMatch[1]), token).catch((error) => {
logger.error('HLS handler failed:', error.message || error);
if (!res.headersSent) sendText(res, 500, 'Internal error');
});
return;
}
sendText(res, 404, 'Not found');
});
function shutdown(signal) {
logger.info(`Received ${signal}, shutting down`);
scheduler.stopRefreshLoop();
server.close(() => process.exit(0));
setTimeout(() => process.exit(0), 5000).unref();
}
process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('SIGINT', () => shutdown('SIGINT'));
async function main() {
try {
validate();
} catch (error) {
logger.error(error.message);
process.exit(1);
}
await scheduler.refreshOnce();
server.listen(settings.listenPort, () => {
logger.info(`Xtream PPV bundler listening on :${settings.listenPort}`);
logger.info(
`Search "${settings.searchTerm}", ${settings.maxChannels} slots, language ${settings.language}, refresh ${settings.refreshIntervalSec}s`
);
scheduler.startRefreshLoop();
});
}
main().catch((error) => {
logger.error(error);
process.exit(1);
});
@@ -0,0 +1,57 @@
'use strict';
const { parseLocalTimestamp } = require('./time');
function normalizeText(value) {
return String(value || '')
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.toLowerCase()
.replace(/\s+/g, ' ')
.trim();
}
function lastBracketContent(value) {
const matches = [...String(value).matchAll(/\[([^[\]]*)\]/g)];
if (matches.length) return matches[matches.length - 1][1];
return null;
}
function parseChannelName(rawName, { timezone }) {
const raw = String(rawName || '').trim();
if (!raw) return null;
const bracket = lastBracketContent(raw);
const content = bracket === null ? raw : bracket;
const tsMatch = content.match(/\((\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})\)\s*$/);
if (!tsMatch) return null;
let before = content.slice(0, tsMatch.index).trim();
let language = null;
const langMatch = before.match(/\(([A-Za-z]{2,3})\)\s*$/);
if (langMatch) {
language = langMatch[1].toLowerCase();
before = before.slice(0, langMatch.index).trim();
}
const startAt = parseLocalTimestamp(tsMatch[1], timezone);
if (!startAt) return null;
const pipeIndex = before.indexOf('|');
let title;
let league;
if (pipeIndex === -1) {
title = before;
league = '';
} else {
title = before.slice(0, pipeIndex).trim();
league = before.slice(pipeIndex + 1).trim();
}
if (!title) return null;
return { title, league, language, startAt };
}
module.exports = { parseChannelName, normalizeText };
+116
View File
@@ -0,0 +1,116 @@
'use strict';
const { settings } = require('./settings');
const { compareEvents } = require('./events');
const EPG_HORIZON_MS = 14 * 24 * 60 * 60 * 1000;
const EPG_PAST_MS = 6 * 60 * 60 * 1000;
function xmlEscape(value) {
return String(value == null ? '' : value)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&apos;');
}
function xmltvDate(date) {
return `${date.toISOString().slice(0, 19).replace(/[-:T]/g, '')} +0000`;
}
function buildXmltv(scheduler, now = Date.now()) {
const state = scheduler.state;
const count = state.slots.length;
const programmes = [];
const slotEnds = new Array(count).fill(now);
const assignedKeys = new Set();
for (let index = 0; index < count; index += 1) {
const slot = state.slots[index];
const event = slot && slot.eventKey ? state.events.get(slot.eventKey) : null;
if (!event) continue;
assignedKeys.add(event.key);
programmes.push({ slot: index, event, startAt: event.startAt, endAt: event.endAt });
slotEnds[index] = Math.max(slotEnds[index], event.endAt.getTime());
}
const future = (state.upcoming || [])
.filter((event) => !assignedKeys.has(event.key))
.filter((event) => event.endAt.getTime() > now)
.filter((event) => event.startAt.getTime() <= now + EPG_HORIZON_MS)
.slice()
.sort(compareEvents);
for (const event of future) {
const duration = event.endAt.getTime() - event.startAt.getTime();
let bestSlot = -1;
let bestEnd = Infinity;
for (let index = 0; index < count; index += 1) {
if (slotEnds[index] <= event.startAt.getTime()) {
if (slotEnds[index] < bestEnd) {
bestEnd = slotEnds[index];
bestSlot = index;
}
}
}
if (bestSlot === -1) {
for (let index = 0; index < count; index += 1) {
if (slotEnds[index] < bestEnd) {
bestEnd = slotEnds[index];
bestSlot = index;
}
}
}
if (bestSlot === -1) continue;
const startAt = Math.max(event.startAt.getTime(), bestEnd);
const endAt = startAt + duration;
if (event.endAt.getTime() <= bestEnd) continue;
programmes.push({
slot: bestSlot,
event,
startAt: new Date(startAt),
endAt: new Date(endAt),
});
slotEnds[bestSlot] = endAt;
}
const bySlot = Array.from({ length: count }, () => []);
for (const programme of programmes) {
if (programme.endAt.getTime() < now - EPG_PAST_MS) continue;
bySlot[programme.slot].push(programme);
}
for (const list of bySlot) {
list.sort((a, b) => a.startAt.getTime() - b.startAt.getTime());
}
const lines = ['<?xml version="1.0" encoding="UTF-8"?>'];
lines.push('<!DOCTYPE tv SYSTEM "xmltv.dtd">');
lines.push('<tv generator-info-name="xtream-ppv-bundler">');
for (let index = 0; index < count; index += 1) {
lines.push(` <channel id="jupiler-${index + 1}">`);
lines.push(` <display-name>Jupiler ${index + 1}</display-name>`);
lines.push(' </channel>');
}
for (let index = 0; index < count; index += 1) {
for (const programme of bySlot[index]) {
const event = programme.event;
lines.push(
` <programme start="${xmltvDate(programme.startAt)}" stop="${xmltvDate(programme.endAt)}" channel="jupiler-${index + 1}">`
);
lines.push(` <title lang="nl">${xmlEscape(event.title)}</title>`);
if (event.league) {
lines.push(` <sub-title lang="nl">${xmlEscape(event.league)}</sub-title>`);
}
lines.push(
` <desc lang="nl">${xmlEscape(`[${event.league || settings.searchTerm}] ${event.title}`)}</desc>`
);
lines.push(` <category lang="nl">Sport</category>`);
lines.push(' </programme>');
}
}
lines.push('</tv>');
return `${lines.join('\n')}\n`;
}
module.exports = { buildXmltv, xmlEscape, xmltvDate };
+76
View File
@@ -0,0 +1,76 @@
'use strict';
const { parseChannelName, normalizeText } = require('./channel-name');
const { liveUrlCandidates } = require('./xtream-client');
function defaultUrlBuilder(streamId) {
return liveUrlCandidates(streamId);
}
function compareEvents(a, b) {
const byStart = a.startAt.getTime() - b.startAt.getTime();
if (byStart !== 0) return byStart;
const bySeq = (a.firstSeenSeq ?? 0) - (b.firstSeenSeq ?? 0);
if (bySeq !== 0) return bySeq;
return a.key.localeCompare(b.key);
}
function isEligible(event, now, { startGraceMs }) {
return now >= event.startAt.getTime() - startGraceMs && now < event.endAt.getTime();
}
function buildEvents(streams, options) {
const {
searchTerm,
language,
timezone,
eventDurationMs,
urlBuilder = defaultUrlBuilder,
} = options;
const term = normalizeText(searchTerm);
const events = new Map();
const warnings = [];
for (const stream of streams || []) {
const rawName = String(stream && stream.name ? stream.name : '');
if (!rawName) continue;
if (!term || !normalizeText(rawName).includes(term)) continue;
const parsed = parseChannelName(rawName, { timezone });
if (!parsed) {
warnings.push({ streamId: stream.stream_id, name: rawName, reason: 'unparseable_name' });
continue;
}
if (parsed.language && parsed.language !== language) continue;
const key = `${normalizeText(parsed.title)}|${parsed.startAt.toISOString()}`;
let event = events.get(key);
if (!event) {
event = {
key,
title: parsed.title,
league: parsed.league,
language: parsed.language || language,
startAt: parsed.startAt,
endAt: new Date(parsed.startAt.getTime() + eventDurationMs),
sources: [],
};
events.set(key, event);
}
const streamId = String(stream.stream_id);
if (!event.sources.some((source) => source.streamId === streamId)) {
event.sources.push({
streamId,
name: rawName,
candidates: urlBuilder(stream.stream_id).slice(),
});
}
}
return { events, warnings };
}
module.exports = { buildEvents, isEligible, compareEvents };
+18
View File
@@ -0,0 +1,18 @@
'use strict';
const { settings } = require('./settings');
function healthPayload(scheduler) {
const state = scheduler.state;
const slotsFilled = state.slots.filter((slot) => slot.eventKey).length;
return {
ok: state.consecutiveFailures < settings.healthFailThreshold,
lastRefreshAt: state.lastRefreshAt,
lastRefreshOk: state.lastRefreshOk,
consecutiveFailures: state.consecutiveFailures,
slotsFilled,
slotsTotal: state.slots.length,
};
}
module.exports = { healthPayload };
+108
View File
@@ -0,0 +1,108 @@
'use strict';
const crypto = require('crypto');
const secret = process.env.HLS_SECRET || crypto.randomBytes(32).toString('hex');
function toBase64Url(buffer) {
return Buffer.from(buffer)
.toString('base64')
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/, '');
}
function fromBase64Url(value) {
return Buffer.from(value.replace(/-/g, '+').replace(/_/g, '/'), 'base64');
}
function signature(slot, payload) {
return crypto
.createHmac('sha256', secret)
.update(`${slot}.${payload}`)
.digest('hex')
.slice(0, 32);
}
function signUrl(url, slot) {
const payload = toBase64Url(Buffer.from(url, 'utf8'));
return `${payload}.${signature(slot, payload)}`;
}
function verifyToken(slot, token) {
const index = String(token).lastIndexOf('.');
if (index <= 0) return null;
const payload = String(token).slice(0, index);
const provided = String(token).slice(index + 1);
const expected = signature(slot, payload);
const a = Buffer.from(provided);
const b = Buffer.from(expected);
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) return null;
try {
return fromBase64Url(payload).toString('utf8');
} catch {
return null;
}
}
function rewriteUri(uri, baseUrl, slot, origin) {
let absolute;
try {
absolute = new URL(uri, baseUrl);
} catch {
return uri;
}
if (absolute.protocol !== 'http:' && absolute.protocol !== 'https:') return uri;
return `${origin}/hls/${slot}/${signUrl(absolute.href, slot)}`;
}
function rewritePlaylist(body, baseUrl, slot, origin) {
const lines = String(body).split(/\r?\n/);
const output = [];
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed) {
output.push(line);
continue;
}
if (trimmed.startsWith('#')) {
output.push(
line.replace(/URI="([^"]*)"/g, (_match, uri) => {
const rewritten = rewriteUri(uri, baseUrl, slot, origin);
return `URI="${rewritten}"`;
})
);
} else {
output.push(rewriteUri(trimmed, baseUrl, slot, origin));
}
}
return output.join('\n');
}
function isPlaylistContentType(contentType) {
if (!contentType) return false;
const type = contentType.split(';')[0].trim().toLowerCase();
return (
type === 'application/vnd.apple.mpegurl' ||
type === 'application/x-mpegurl' ||
type === 'audio/mpegurl' ||
type === 'audio/x-mpegurl'
);
}
function isPlaylistUrl(url) {
try {
return new URL(url).pathname.toLowerCase().endsWith('.m3u8');
} catch {
return false;
}
}
module.exports = {
signUrl,
verifyToken,
rewritePlaylist,
rewriteUri,
isPlaylistContentType,
isPlaylistUrl,
};
@@ -0,0 +1,90 @@
'use strict';
const HOP_BY_HOP = new Set([
'connection',
'keep-alive',
'proxy-authenticate',
'proxy-authorization',
'te',
'trailers',
'transfer-encoding',
'upgrade',
]);
function sendText(res, statusCode, text, extraHeaders = {}) {
res.writeHead(statusCode, {
'Content-Type': 'text/plain; charset=utf-8',
'Cache-Control': 'no-store',
...extraHeaders,
});
res.end(text);
}
function sendJson(res, statusCode, body, extraHeaders = {}) {
const payload = JSON.stringify(body);
res.writeHead(statusCode, {
'Content-Type': 'application/json; charset=utf-8',
'Cache-Control': 'no-store',
'Content-Length': Buffer.byteLength(payload),
...extraHeaders,
});
res.end(payload);
}
function sendHtml(res, statusCode, body) {
res.writeHead(statusCode, {
'Content-Type': 'text/html; charset=utf-8',
'Cache-Control': 'no-store',
'Content-Length': Buffer.byteLength(body),
});
res.end(body);
}
function firstHeader(value) {
if (Array.isArray(value)) value = value[0];
if (typeof value !== 'string') return '';
return value.split(',')[0].trim();
}
function getProxyOrigin(req) {
const proto =
firstHeader(req.headers['x-forwarded-proto']) ||
(req.socket.encrypted ? 'https' : 'http');
const host =
firstHeader(req.headers['x-forwarded-host']) || req.headers.host || 'localhost';
return `${proto}://${host}`;
}
function filterHopByHop(headers) {
const result = {};
if (!headers) return result;
for (const [key, value] of Object.entries(headers)) {
if (value === undefined) continue;
if (HOP_BY_HOP.has(key.toLowerCase())) continue;
result[key] = value;
}
return result;
}
function filterResponseHeaders(headers, { dropLength = false } = {}) {
const result = {};
for (const [key, value] of Object.entries(headers || {})) {
if (value === undefined) continue;
const lower = key.toLowerCase();
if (HOP_BY_HOP.has(lower)) continue;
if (dropLength && lower === 'content-length') continue;
result[key] = value;
}
return result;
}
module.exports = {
HOP_BY_HOP,
sendText,
sendJson,
sendHtml,
firstHeader,
getProxyOrigin,
filterHopByHop,
filterResponseHeaders,
};
+33
View File
@@ -0,0 +1,33 @@
'use strict';
const { settings } = require('./settings');
const LEVELS = { error: 0, warn: 1, info: 2, debug: 3 };
function redact(value) {
let text = typeof value === 'string' ? value : String(value);
if (settings.xtreamPassword) {
text = text.split(settings.xtreamPassword).join('***');
}
if (settings.xtreamUsername && settings.xtreamUsername.length >= 4) {
text = text.split(settings.xtreamUsername).join('***');
}
text = text.replace(/([?&](?:username|password)=)[^&\s]+/gi, '$1***');
text = text.replace(/(\/live\/)[^/\s]+\/[^/\s]+/gi, '$1***/***');
return text;
}
function emit(level, args) {
if ((LEVELS[level] ?? 2) > (LEVELS[settings.logLevel] ?? 2)) return;
const line = args.map(redact).join(' ');
const stamp = new Date().toISOString();
process.stdout.write(`${stamp} [${level.toUpperCase()}] ${line}\n`);
}
module.exports = {
debug: (...args) => emit('debug', args),
info: (...args) => emit('info', args),
warn: (...args) => emit('warn', args),
error: (...args) => emit('error', args),
redact,
};
@@ -0,0 +1,52 @@
'use strict';
const { settings } = require('./settings');
const { getProxyOrigin } = require('./http-utils');
const { formatLocalTime } = require('./time');
function buildPlaylist(req, scheduler) {
const origin = getProxyOrigin(req);
const count = scheduler.getSlotCount();
const lines = ['#EXTM3U'];
for (let i = 0; i < count; i += 1) {
const event = scheduler.getSlotEvent(i);
const label = event
? `${event.title} (${formatLocalTime(event.startAt, settings.timezone)})`
: 'Geen wedstrijd';
lines.push(
`#EXTINF:-1 tvg-id="jupiler-${i + 1}" tvg-name="Jupiler ${i + 1}",Jupiler ${i + 1} | ${label}`
);
lines.push(`${origin}/stream/${i + 1}`);
}
return `${lines.join('\n')}\n`;
}
function buildIndex(req, scheduler) {
const origin = getProxyOrigin(req);
const summary = scheduler.summarize();
const rows = summary.slots
.map((slot) => {
const title = slot.event
? `${slot.event.title} (${formatLocalTime(new Date(slot.event.startAt), settings.timezone)})`
: 'Geen wedstrijd';
return `<li><strong>Jupiler ${slot.slot}</strong>: ${escapeHtml(title)}</li>`;
})
.join('');
return `<!DOCTYPE html><html><head><meta charset="utf-8"><title>Xtream PPV bundler</title></head><body>
<h1>Xtream PPV bundler</h1>
<p>Playlist: <code>${origin}/playlist.m3u</code></p>
<p>EPG (XMLTV): <code>${origin}/epg.xml</code></p>
<p>Status: <a href="${origin}/status">${origin}/status</a></p>
<h2>Kanalen</h2><ul>${rows}</ul>
</body></html>`;
}
function escapeHtml(value) {
return String(value)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
module.exports = { buildPlaylist, buildIndex };
+223
View File
@@ -0,0 +1,223 @@
'use strict';
const http = require('http');
const https = require('https');
const { settings } = require('./settings');
const logger = require('./logger');
const { resolveFallbackUrls } = require('./xtream-client');
const {
rewritePlaylist,
verifyToken,
isPlaylistContentType,
isPlaylistUrl,
} = require('./hls');
const {
sendText,
getProxyOrigin,
filterResponseHeaders,
} = require('./http-utils');
const MAX_PLAYLIST_BYTES = 10 * 1024 * 1024;
const MAX_REDIRECTS = 5;
function requestUpstream(urlString, headers, redirectsLeft) {
return new Promise((resolve, reject) => {
let target;
try {
target = new URL(urlString);
} catch (error) {
reject(error);
return;
}
const lib = target.protocol === 'https:' ? https : http;
const requestHeaders = {
'User-Agent': settings.userAgent,
Accept: '*/*',
...headers,
};
const req = lib.request(
{
protocol: target.protocol,
hostname: target.hostname,
port: target.port || undefined,
path: `${target.pathname}${target.search}`,
method: 'GET',
headers: requestHeaders,
rejectUnauthorized: settings.rejectUnauthorized,
},
(res) => {
if (
res.statusCode >= 300 &&
res.statusCode < 400 &&
res.headers.location &&
redirectsLeft > 0
) {
res.resume();
resolve(requestUpstream(new URL(res.headers.location, target).href, headers, redirectsLeft - 1));
return;
}
resolve({ req, res, url: target });
}
);
req.setTimeout(settings.upstreamTimeoutMs, () => {
try {
req.destroy(new Error('upstream_timeout'));
} catch {}
});
req.on('error', reject);
req.end();
});
}
function readBody(stream, limit) {
return new Promise((resolve, reject) => {
const chunks = [];
let size = 0;
stream.on('data', (chunk) => {
size += chunk.length;
if (size > limit) {
stream.destroy();
reject(new Error('body_too_large'));
return;
}
chunks.push(chunk);
});
stream.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
stream.on('error', reject);
});
}
async function pump(clientRes, upstream, { slot, origin }) {
const upstreamRes = upstream.res;
const contentType = upstreamRes.headers['content-type'] || '';
const playlist = isPlaylistContentType(contentType) || isPlaylistUrl(upstream.url.href);
if (playlist) {
let body;
try {
body = await readBody(upstreamRes, MAX_PLAYLIST_BYTES);
} catch (error) {
try {
clientRes.destroy();
} catch {}
logger.warn('Playlist read failed:', error.message || error);
return;
}
const rewritten = rewritePlaylist(body, upstream.url.href, slot, origin);
const headers = filterResponseHeaders(upstreamRes.headers, { dropLength: true });
headers['content-type'] = 'application/vnd.apple.mpegurl; charset=utf-8';
headers['cache-control'] = 'no-store';
headers['content-length'] = String(Buffer.byteLength(rewritten));
try {
clientRes.writeHead(upstreamRes.statusCode || 200, headers);
clientRes.end(rewritten);
} catch {}
return;
}
const headers = filterResponseHeaders(upstreamRes.headers);
headers['cache-control'] = 'no-store';
try {
clientRes.writeHead(upstreamRes.statusCode || 200, headers);
} catch {
upstreamRes.destroy();
return;
}
upstreamRes.pipe(clientRes);
upstreamRes.on('error', () => {
try {
clientRes.destroy();
} catch {}
});
}
function requestHeaders(clientReq) {
const headers = {};
const range = clientReq.headers.range;
if (range) headers.Range = range;
const icy = clientReq.headers['icy-metadata'];
if (icy) headers['Icy-MetaData'] = icy;
return headers;
}
async function serveUpstream(clientReq, clientRes, url, slot, origin) {
let upstream;
try {
upstream = await requestUpstream(url, requestHeaders(clientReq), MAX_REDIRECTS);
} catch (error) {
logger.debug('Upstream failed:', url, error.message || error);
return false;
}
if (upstream.res.statusCode < 200 || upstream.res.statusCode >= 300) {
logger.debug('Upstream status', upstream.res.statusCode, url);
try {
upstream.res.resume();
} catch {}
try {
upstream.req.destroy();
} catch {}
return false;
}
await pump(clientRes, upstream, { slot, origin });
return true;
}
async function proxyEvent(clientReq, clientRes, event, slot) {
const origin = getProxyOrigin(clientReq);
const primary = [];
for (const source of event.sources) {
for (const url of source.candidates) primary.push(url);
}
let closed = false;
clientRes.on('close', () => {
closed = true;
});
for (const url of primary) {
if (closed) return;
if (await serveUpstream(clientReq, clientRes, url, slot, origin)) return;
}
for (const source of event.sources) {
if (closed) return;
let fallbacks = [];
try {
fallbacks = await resolveFallbackUrls(source.streamId, source.name);
} catch (error) {
logger.debug('Fallback lookup failed:', error.message || error);
}
for (const url of fallbacks) {
if (closed) return;
if (await serveUpstream(clientReq, clientRes, url, slot, origin)) return;
}
}
if (!closed && !clientRes.headersSent) {
sendText(clientRes, 503, 'No working stream available', { 'Retry-After': '5' });
}
}
async function handleStream(req, res, slot, scheduler) {
const event = scheduler.getSlotEvent(slot - 1);
if (!event) {
sendText(res, 503, 'No match assigned to this slot', { 'Retry-After': '10' });
return;
}
await proxyEvent(req, res, event, slot);
}
async function handleHls(req, res, slot, token) {
const upstreamUrl = verifyToken(slot, token);
if (!upstreamUrl) {
sendText(res, 403, 'Invalid HLS token');
return;
}
const origin = getProxyOrigin(req);
const ok = await serveUpstream(req, res, upstreamUrl, slot, origin);
if (!ok && !res.headersSent) {
sendText(res, 502, 'HLS upstream request failed');
}
}
module.exports = { handleStream, handleHls, requestUpstream, requestHeaders };
+230
View File
@@ -0,0 +1,230 @@
'use strict';
const { settings } = require('./settings');
const logger = require('./logger');
const { fetchLiveStreams } = require('./xtream-client');
const { buildEvents, isEligible, compareEvents } = require('./events');
function createState(maxChannels = settings.maxChannels) {
return {
slots: Array.from({ length: maxChannels }, () => ({ eventKey: null, assignedAt: null })),
events: new Map(),
upcoming: [],
nextSeq: 0,
warnings: [],
lastRefreshAt: null,
lastRefreshOk: false,
consecutiveFailures: 0,
lastError: null,
};
}
function ensureSlots(state, maxChannels) {
while (state.slots.length < maxChannels) {
state.slots.push({ eventKey: null, assignedAt: null });
}
while (state.slots.length > maxChannels) {
state.slots.pop();
}
}
function releaseSlot(state, eventKey) {
for (const slot of state.slots) {
if (slot.eventKey === eventKey) {
slot.eventKey = null;
slot.assignedAt = null;
}
}
}
function mergeSources(existing, fresh) {
const result = existing ? existing.slice() : [];
const seen = new Set(result.map((source) => source.streamId));
for (const source of fresh || []) {
if (seen.has(source.streamId)) continue;
seen.add(source.streamId);
result.push(source);
}
return result;
}
function reconcile(state, currentEvents, now, options) {
const { maxChannels, startGraceMs } = options;
ensureSlots(state, maxChannels);
const assignedNow = new Set(
state.slots.filter((slot) => slot.eventKey).map((slot) => slot.eventKey)
);
for (const [key, event] of [...state.events]) {
const fresh = currentEvents.get(key);
if (fresh) {
event.sources = mergeSources(event.sources, fresh.sources);
event.title = fresh.title;
event.league = fresh.league;
event.endAt = fresh.endAt;
event.missingCount = 0;
if (assignedNow.has(key)) event.status = 'assigned';
} else {
event.missingCount = (event.missingCount || 0) + 1;
}
if (now >= event.endAt.getTime()) {
releaseSlot(state, key);
assignedNow.delete(key);
state.events.delete(key);
continue;
}
if (!assignedNow.has(key) && event.missingCount >= 2) {
state.events.delete(key);
}
}
for (const [key, fresh] of currentEvents) {
if (state.events.has(key)) continue;
if (!isEligible(fresh, now, { startGraceMs })) continue;
if (!fresh.sources.length) continue;
state.events.set(key, {
...fresh,
firstSeenSeq: state.nextSeq++,
missingCount: 0,
status: 'queued',
});
}
const assigned = new Set(
state.slots.filter((slot) => slot.eventKey).map((slot) => slot.eventKey)
);
const waiting = [...state.events.values()]
.filter((event) => !assigned.has(event.key) && isEligible(event, now, { startGraceMs }))
.sort(compareEvents);
for (const slot of state.slots) {
if (slot.eventKey) continue;
const next = waiting.shift();
if (!next) break;
slot.eventKey = next.key;
slot.assignedAt = now;
next.status = 'assigned';
}
return state;
}
function summarize(state) {
const assignedKeys = new Set(
state.slots.filter((slot) => slot.eventKey).map((slot) => slot.eventKey)
);
const eventView = (event) => ({
key: event.key,
title: event.title,
league: event.league,
language: event.language,
startAt: event.startAt.toISOString(),
endAt: event.endAt.toISOString(),
status: event.status,
sourceCount: event.sources.length,
sourceNames: event.sources.map((source) => source.name),
});
return {
slots: state.slots.map((slot, index) => ({
slot: index + 1,
assigned: Boolean(slot.eventKey),
event: slot.eventKey && state.events.has(slot.eventKey)
? eventView(state.events.get(slot.eventKey))
: null,
})),
queue: [...state.events.values()]
.filter((event) => !assignedKeys.has(event.key))
.sort(compareEvents)
.map(eventView),
warnings: state.warnings,
lastRefreshAt: state.lastRefreshAt,
lastRefreshOk: state.lastRefreshOk,
consecutiveFailures: state.consecutiveFailures,
lastError: state.lastError,
};
}
function createScheduler() {
const state = createState();
let refreshTimer = null;
let refreshing = false;
async function refreshOnce() {
if (refreshing) return { ok: false, skipped: true };
refreshing = true;
try {
const streams = await fetchLiveStreams();
const { events, warnings } = buildEvents(streams, {
searchTerm: settings.searchTerm,
language: settings.language,
timezone: settings.timezone,
eventDurationMs: settings.eventDurationMs,
});
const now = Date.now();
reconcile(state, events, now, {
maxChannels: settings.maxChannels,
startGraceMs: settings.startGraceMs,
});
state.warnings = warnings.slice(0, 50);
state.upcoming = [...events.values()].sort(compareEvents);
state.lastRefreshAt = new Date(now).toISOString();
state.lastRefreshOk = true;
state.consecutiveFailures = 0;
state.lastError = null;
const filled = state.slots.filter((slot) => slot.eventKey).length;
logger.info(
`Refresh ok: ${events.size} matching event(s), ${filled}/${state.slots.length} slot(s) filled`
);
for (const warning of state.warnings) {
logger.debug('Unparseable matching stream:', warning.name);
}
return { ok: true };
} catch (error) {
state.consecutiveFailures += 1;
state.lastRefreshOk = false;
state.lastError = logger.redact(error.message || String(error));
logger.error('Refresh failed:', state.lastError);
return { ok: false, error };
} finally {
refreshing = false;
}
}
function startRefreshLoop() {
stopRefreshLoop();
refreshTimer = setInterval(() => {
refreshOnce().catch(() => {});
}, settings.refreshIntervalSec * 1000);
if (refreshTimer.unref) refreshTimer.unref();
}
function stopRefreshLoop() {
if (refreshTimer) {
clearInterval(refreshTimer);
refreshTimer = null;
}
}
return {
state,
refreshOnce,
startRefreshLoop,
stopRefreshLoop,
getSlotEvent(index) {
const slot = state.slots[index];
if (!slot || !slot.eventKey) return null;
return state.events.get(slot.eventKey) || null;
},
getSlotCount() {
return state.slots.length;
},
summarize() {
return summarize(state);
},
};
}
module.exports = { createState, reconcile, mergeSources, summarize, createScheduler };
@@ -0,0 +1,70 @@
'use strict';
function readNumber(value, fallback, min, max) {
const n = Number(value);
if (value === undefined || value === null || value === '' || !Number.isFinite(n)) {
return fallback;
}
return Math.min(Math.max(n, min), max);
}
const settings = {
xtreamUrl: (process.env.XTREAM_URL || '').trim().replace(/\/+$/, ''),
xtreamUsername: process.env.XTREAM_USERNAME || '',
xtreamPassword: process.env.XTREAM_PASSWORD || '',
searchTerm: (process.env.SEARCH_TERM || '').trim(),
maxChannels: Math.round(readNumber(process.env.MAX_CHANNELS, 3, 1, 10)),
language: (process.env.LANGUAGE || 'nl').trim().toLowerCase(),
eventDurationMin: readNumber(process.env.EVENT_DURATION_MIN, 150, 1, 1440),
startGraceMin: readNumber(process.env.START_GRACE_MIN, 10, 0, 1440),
refreshIntervalSec: readNumber(process.env.REFRESH_INTERVAL_SEC, 60, 10, 3600),
timezone: (process.env.TIMEZONE || 'Europe/Brussels').trim() || 'Europe/Brussels',
listenPort: Math.round(readNumber(process.env.LISTEN_PORT, 8080, 1, 65535)),
upstreamTimeoutMs: readNumber(process.env.UPSTREAM_TIMEOUT_MS, 15000, 1000, 120000),
rejectUnauthorized: process.env.PROXY_INSECURE_TLS !== '1',
logLevel: (process.env.LOG_LEVEL || 'info').trim().toLowerCase(),
userAgent: process.env.USER_AGENT || 'xtream-ppv-bundler/1.0',
healthFailThreshold: 3,
eventDurationMs: 0,
startGraceMs: 0,
};
settings.eventDurationMs = settings.eventDurationMin * 60 * 1000;
settings.startGraceMs = settings.startGraceMin * 60 * 1000;
function validateTimezone(timezone) {
try {
new Intl.DateTimeFormat('en-US', { timeZone: timezone });
} catch {
throw new Error(`Invalid TIMEZONE: ${timezone}`);
}
}
function validate() {
const missing = [];
if (!settings.xtreamUrl) missing.push('XTREAM_URL');
if (!settings.xtreamUsername) missing.push('XTREAM_USERNAME');
if (!settings.xtreamPassword) missing.push('XTREAM_PASSWORD');
if (!settings.searchTerm) missing.push('SEARCH_TERM');
if (missing.length) {
throw new Error(`Missing required environment variables: ${missing.join(', ')}`);
}
let parsed;
try {
parsed = new URL(settings.xtreamUrl);
} catch {
throw new Error(`XTREAM_URL is not a valid URL: ${settings.xtreamUrl}`);
}
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
throw new Error('XTREAM_URL must use http or https');
}
validateTimezone(settings.timezone);
if (!settings.language) {
throw new Error('LANGUAGE must not be empty');
}
}
module.exports = { settings, validate };
+62
View File
@@ -0,0 +1,62 @@
'use strict';
function offsetMsFor(dateMs, timeZone) {
const dtf = new Intl.DateTimeFormat('en-US', {
timeZone,
hour12: false,
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
});
const parts = dtf.formatToParts(new Date(dateMs));
const map = {};
for (const part of parts) {
if (part.type !== 'literal') map[part.type] = part.value;
}
let hour = Number(map.hour);
if (hour === 24) hour = 0;
const asUtc = Date.UTC(
Number(map.year),
Number(map.month) - 1,
Number(map.day),
hour,
Number(map.minute),
Number(map.second)
);
return asUtc - dateMs;
}
function zonedTimeToUtc(year, month, day, hour, minute, second, timeZone) {
const utcGuess = Date.UTC(year, month - 1, day, hour, minute, second);
const offset = offsetMsFor(utcGuess, timeZone);
let result = utcGuess - offset;
const check = offsetMsFor(result, timeZone);
if (check !== offset) result = utcGuess - check;
return new Date(result);
}
const TIMESTAMP_RE = /^(\d{4})-(\d{2})-(\d{2})[ T](\d{2}):(\d{2}):(\d{2})$/;
function parseLocalTimestamp(value, timeZone) {
const match = TIMESTAMP_RE.exec(String(value).trim());
if (!match) return null;
const [, y, mo, d, h, mi, s] = match.map(Number);
if (mo < 1 || mo > 12 || d < 1 || d > 31 || h > 23 || mi > 59 || s > 60) {
return null;
}
const date = zonedTimeToUtc(y, mo, d, h, mi, s, timeZone);
return Number.isNaN(date.getTime()) ? null : date;
}
function formatLocalTime(date, timeZone) {
return new Intl.DateTimeFormat('nl-BE', {
timeZone,
hour: '2-digit',
minute: '2-digit',
}).format(date);
}
module.exports = { zonedTimeToUtc, parseLocalTimestamp, formatLocalTime, offsetMsFor };
@@ -0,0 +1,239 @@
'use strict';
const http = require('http');
const https = require('https');
const { settings } = require('./settings');
const { normalizeText } = require('./channel-name');
const logger = require('./logger');
function baseUrlWithPrefix() {
const parsed = new URL(settings.xtreamUrl);
parsed.hash = '';
parsed.search = '';
return parsed;
}
function originBase() {
const parsed = baseUrlWithPrefix();
return `${parsed.origin}`;
}
function prefixedBase() {
const parsed = baseUrlWithPrefix();
const path = parsed.pathname.replace(/\/+$/, '');
return `${parsed.origin}${path === '/' ? '' : path}`;
}
function candidateBases() {
const withPrefix = prefixedBase();
const origin = originBase();
const bases = [withPrefix];
if (withPrefix !== origin) bases.push(origin);
return bases;
}
function encode(value) {
return encodeURIComponent(value);
}
function liveUrlCandidates(streamId) {
const id = encode(streamId);
const user = encode(settings.xtreamUsername);
const pass = encode(settings.xtreamPassword);
const urls = [];
for (const base of candidateBases()) {
urls.push(
`${base}/live/${user}/${pass}/${id}.ts`,
`${base}/live/${user}/${pass}/${id}.m3u8`,
`${base}/${user}/${pass}/${id}.ts`,
`${base}/${user}/${pass}/${id}.m3u8`,
`${base}/${user}/${pass}/${id}`
);
}
return urls;
}
function apiUrl(params) {
const url = baseUrlWithPrefix();
const base = `${url.origin}${url.pathname.replace(/\/+$/, '')}`;
return `${base}/player_api.php?${new URLSearchParams(params).toString()}`;
}
function getPhpUrl(extra = {}) {
const url = baseUrlWithPrefix();
const base = `${url.origin}${url.pathname.replace(/\/+$/, '')}`;
const params = new URLSearchParams({
username: settings.xtreamUsername,
password: settings.xtreamPassword,
type: 'm3u_plus',
output: 'ts',
...extra,
});
return `${base}/get.php?${params.toString()}`;
}
function httpGetText(url, { timeoutMs = settings.upstreamTimeoutMs, maxBytes = 64 * 1024 * 1024, redirectsLeft = 5 } = {}) {
return new Promise((resolve, reject) => {
let target;
try {
target = new URL(url);
} catch (error) {
reject(error);
return;
}
const lib = target.protocol === 'https:' ? https : http;
const req = lib.request(
{
protocol: target.protocol,
hostname: target.hostname,
port: target.port || undefined,
path: `${target.pathname}${target.search}`,
method: 'GET',
headers: {
'User-Agent': settings.userAgent,
Accept: '*/*',
'Accept-Encoding': 'identity',
Connection: 'close',
},
rejectUnauthorized: settings.rejectUnauthorized,
},
(res) => {
if (
res.statusCode >= 300 &&
res.statusCode < 400 &&
res.headers.location &&
redirectsLeft > 0
) {
res.resume();
resolve(
httpGetText(new URL(res.headers.location, target).href, {
timeoutMs,
maxBytes,
redirectsLeft: redirectsLeft - 1,
})
);
return;
}
if (res.statusCode < 200 || res.statusCode >= 300) {
res.resume();
reject(new Error(`HTTP ${res.statusCode}`));
return;
}
let body = '';
let size = 0;
res.setEncoding('utf8');
res.on('data', (chunk) => {
size += Buffer.byteLength(chunk);
if (size > maxBytes) {
req.destroy(new Error('response_too_large'));
return;
}
body += chunk;
});
res.on('end', () => resolve(body));
res.on('error', reject);
}
);
req.setTimeout(timeoutMs, () => {
try {
req.destroy(new Error('timeout'));
} catch {}
});
req.on('error', reject);
req.end();
});
}
async function fetchLiveStreams() {
const url = apiUrl({
username: settings.xtreamUsername,
password: settings.xtreamPassword,
action: 'get_live_streams',
});
const text = await httpGetText(url);
let data;
try {
data = JSON.parse(text);
} catch {
throw new Error('player_api get_live_streams returned invalid JSON');
}
if (!Array.isArray(data)) {
throw new Error('player_api get_live_streams did not return an array');
}
return data;
}
let m3uCache = { at: 0, promise: null, index: null };
const M3U_CACHE_TTL_MS = 5 * 60 * 1000;
function parseM3u(text) {
const byStreamId = new Map();
const byName = new Map();
const lines = text.split(/\r?\n/);
let pending = null;
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed) continue;
if (trimmed.startsWith('#EXTINF')) {
pending = trimmed;
continue;
}
if (trimmed.startsWith('#')) continue;
if (!pending) continue;
const name = (pending.split(',').slice(1).join(',') || '').trim();
const nameMatch = pending.match(/tvg-name="([^"]*)"/);
const displayName = nameMatch ? nameMatch[1] : name;
const url = trimmed;
const idMatch = url.match(/\/([^/?#]+?)(?:\.\w+)?(?:[?#].*)?$/);
const streamId = idMatch ? idMatch[1] : null;
if (streamId) byStreamId.set(String(streamId), url);
if (displayName) byName.set(normalizeText(displayName), url);
pending = null;
}
return { byStreamId, byName };
}
function loadM3uIndex() {
const now = Date.now();
if (m3uCache.index && now - m3uCache.at < M3U_CACHE_TTL_MS) {
return Promise.resolve(m3uCache.index);
}
if (m3uCache.promise) return m3uCache.promise;
m3uCache.promise = (async () => {
try {
const text = await httpGetText(getPhpUrl());
const index = parseM3u(text);
m3uCache = { at: Date.now(), promise: null, index };
return index;
} catch (error) {
m3uCache.promise = null;
logger.warn('get.php fallback failed:', error.message || error);
return null;
}
})();
return m3uCache.promise;
}
async function resolveFallbackUrls(streamId, name) {
const index = await loadM3uIndex();
if (!index) return [];
const urls = [];
const byId = index.byStreamId.get(String(streamId));
if (byId) urls.push(byId);
const byName = index.byName.get(normalizeText(name));
if (byName && !urls.includes(byName)) urls.push(byName);
return urls;
}
function resetM3uCache() {
m3uCache = { at: 0, promise: null, index: null };
}
module.exports = {
liveUrlCandidates,
fetchLiveStreams,
resolveFallbackUrls,
parseM3u,
candidateBases,
resetM3uCache,
};
@@ -0,0 +1,41 @@
'use strict';
const test = require('node:test');
const assert = require('node:assert/strict');
const { parseChannelName, normalizeText } = require('../src/channel-name');
const sample =
'View BE| DAZN 03 [Standard de Liège - SK Beveren | Jupiler Pro League (nl) (2026-11-06 19:25:00)]';
test('parses the DAZN sample', () => {
const parsed = parseChannelName(sample, { timezone: 'Europe/Brussels' });
assert.equal(parsed.title, 'Standard de Liège - SK Beveren');
assert.equal(parsed.league, 'Jupiler Pro League');
assert.equal(parsed.language, 'nl');
assert.equal(parsed.startAt.toISOString(), '2026-11-06T18:25:00.000Z');
});
test('parses a French stream', () => {
const parsed = parseChannelName(
'View BE| DAZN 03 [Standard de Liège - SK Beveren | Jupiler Pro League (fr) (2026-11-06 19:25:00)]',
{ timezone: 'Europe/Brussels' }
);
assert.equal(parsed.language, 'fr');
});
test('allows a missing language code', () => {
const parsed = parseChannelName(
'DAZN 03 [Standard de Liège - SK Beveren | Jupiler Pro League (2026-11-06 19:25:00)]',
{ timezone: 'Europe/Brussels' }
);
assert.equal(parsed.language, null);
});
test('returns null for unparseable names', () => {
assert.equal(parseChannelName('DAZN 03', { timezone: 'Europe/Brussels' }), null);
assert.equal(parseChannelName('', { timezone: 'Europe/Brussels' }), null);
});
test('normalizes diacritics and whitespace', () => {
assert.equal(normalizeText(' Standard de Liège - SK Beveren '), 'standard de liege - sk beveren');
});
@@ -0,0 +1,47 @@
'use strict';
const test = require('node:test');
const assert = require('node:assert/strict');
const { buildXmltv, xmlEscape, xmltvDate } = require('../src/epg');
function event(key, title, startIso, endIso, league = 'Jupiler Pro League') {
return {
key,
title,
league,
language: 'nl',
startAt: new Date(startIso),
endAt: new Date(endIso),
sources: [],
};
}
test('builds XMLTV with channels and projected programmes', () => {
const a = event('a', 'Standard de Liège - SK Beveren', '2026-11-06T18:25:00Z', '2026-11-06T20:55:00Z');
const b = event('b', 'Anderlecht & Genk', '2026-11-06T20:55:00Z', '2026-11-06T23:25:00Z');
const c = event('c', 'Club Brugge - Antwerp', '2026-11-06T20:55:00Z', '2026-11-06T23:25:00Z');
const state = {
slots: [{ eventKey: 'a' }, { eventKey: null }, { eventKey: null }],
events: new Map([['a', a]]),
upcoming: [a, b, c],
};
const scheduler = { state, getSlotCount: () => 3 };
const xml = buildXmltv(scheduler, Date.parse('2026-11-06T19:00:00Z'));
assert.ok(xml.startsWith('<?xml version="1.0" encoding="UTF-8"?>'));
assert.ok(xml.includes('<channel id="jupiler-1">'));
assert.ok(xml.includes('<channel id="jupiler-3">'));
assert.ok(xml.includes('start="20261106182500 +0000"'));
assert.ok(xml.includes('stop="20261106205500 +0000"'));
assert.ok(xml.includes('channel="jupiler-1"'));
assert.ok(xml.includes('Standard de Li&#232;ge') === false);
assert.ok(xml.includes('Standard de Liège - SK Beveren'));
assert.ok(xml.includes('Anderlecht &amp; Genk'));
assert.ok(xml.includes('Club Brugge - Antwerp'));
});
test('escapes XML and formats UTC dates', () => {
assert.equal(xmlEscape('a & <b> "c" \'d\''), 'a &amp; &lt;b&gt; &quot;c&quot; &apos;d&apos;');
assert.equal(xmltvDate(new Date('2026-11-06T18:25:00Z')), '20261106182500 +0000');
});
@@ -0,0 +1,86 @@
'use strict';
const test = require('node:test');
const assert = require('node:assert/strict');
const { buildEvents, compareEvents, isEligible } = require('../src/events');
const timezone = 'Europe/Brussels';
function stream(id, name) {
return { stream_id: id, name };
}
function build(streams, overrides = {}) {
return buildEvents(streams, {
searchTerm: 'Jupiler Pro League',
language: 'nl',
timezone,
eventDurationMs: 150 * 60 * 1000,
urlBuilder: (id) => [`http://upstream/live/u/p/${id}.ts`],
...overrides,
});
}
test('filters by search term and language, dedupes the same match', () => {
const { events } = build([
stream(
1,
'DAZN 01 [Standard de Liège - SK Beveren | Jupiler Pro League (nl) (2026-11-06 19:25:00)]'
),
stream(
2,
'DAZN 02 [Standard de Liège - SK Beveren | Jupiler Pro League (nl) (2026-11-06 19:25:00)]'
),
stream(
3,
'DAZN 03 [Standard de Liège - SK Beveren | Jupiler Pro League (fr) (2026-11-06 19:25:00)]'
),
stream(4, 'DAZN 04 [Anderlecht - Genk | Jupiler Pro League (2026-11-06 19:25:00)]'),
stream(5, 'DAZN 05 [Other League (nl) (2026-11-06 19:25:00)]'),
]);
assert.equal(events.size, 2);
const [first, second] = [...events.values()];
assert.equal(first.title, 'Standard de Liège - SK Beveren');
assert.deepEqual(first.sources.map((source) => source.streamId), ['1', '2']);
assert.equal(first.language, 'nl');
assert.equal(second.sources[0].streamId, '4');
});
test('reports unparseable matching names as warnings', () => {
const { events, warnings } = build([stream(1, 'DAZN 01 | Jupiler Pro League zonder tijd')]);
assert.equal(events.size, 0);
assert.equal(warnings.length, 1);
assert.equal(warnings[0].reason, 'unparseable_name');
});
test('an event is eligible inside the start grace and before its end', () => {
const event = {
startAt: new Date('2026-11-06T18:25:00.000Z'),
endAt: new Date('2026-11-06T20:55:00.000Z'),
};
const options = { startGraceMs: 10 * 60 * 1000 };
assert.equal(isEligible(event, Date.parse('2026-11-06T18:16:00.000Z'), options), true);
assert.equal(isEligible(event, Date.parse('2026-11-06T18:14:00.000Z'), options), false);
assert.equal(isEligible(event, Date.parse('2026-11-06T20:54:00.000Z'), options), true);
assert.equal(isEligible(event, Date.parse('2026-11-06T20:56:00.000Z'), options), false);
});
test('sorting is by start time and then arrival order', () => {
const a = {
key: 'a',
startAt: new Date('2026-11-06T19:00:00Z'),
firstSeenSeq: 2,
};
const b = {
key: 'b',
startAt: new Date('2026-11-06T19:00:00Z'),
firstSeenSeq: 1,
};
const c = {
key: 'c',
startAt: new Date('2026-11-06T18:00:00Z'),
firstSeenSeq: 3,
};
assert.deepEqual([a, b, c].sort(compareEvents).map((event) => event.key), ['c', 'b', 'a']);
});
@@ -0,0 +1,46 @@
'use strict';
const test = require('node:test');
const assert = require('node:assert/strict');
const { rewritePlaylist, signUrl, verifyToken } = require('../src/hls');
test('rewrites playlist URIs to signed proxy URLs', () => {
const base = 'http://upstream.example/live/user/pass/index.m3u8';
const body = [
'#EXTM3U',
'#EXT-X-KEY:METHOD=AES-128,URI="keys/key.bin"',
'#EXTINF:6,',
'segment1.ts',
'#EXTINF:6,',
'http://cdn.example/segment2.ts',
'#EXT-X-ENDLIST',
].join('\n');
const rewritten = rewritePlaylist(body, base, 2, 'http://proxy.example');
const lines = rewritten.split('\n');
assert.equal(lines[0], '#EXTM3U');
assert.match(lines[1], /^#EXT-X-KEY:METHOD=AES-128,URI="http:\/\/proxy\.example\/hls\/2\/.+"/);
assert.match(lines[3], /^http:\/\/proxy\.example\/hls\/2\/.+/);
assert.match(lines[5], /^http:\/\/proxy\.example\/hls\/2\/.+/);
const token = lines[3].split('/hls/2/')[1];
assert.equal(
verifyToken(2, token),
'http://upstream.example/live/user/pass/segment1.ts'
);
const keyToken = lines[1].match(/URI="http:\/\/proxy\.example\/hls\/2\/([^"]+)"/)[1];
assert.equal(
verifyToken(2, keyToken),
'http://upstream.example/live/user/pass/keys/key.bin'
);
});
test('rejects tampered tokens', () => {
const token = signUrl('http://upstream.example/segment.ts', 1);
assert.equal(verifyToken(1, token), 'http://upstream.example/segment.ts');
assert.equal(verifyToken(2, token), null);
const tampered = token.slice(0, -1) + (token.endsWith('a') ? 'b' : 'a');
assert.equal(verifyToken(1, tampered), null);
});
@@ -0,0 +1,35 @@
'use strict';
const test = require('node:test');
const assert = require('node:assert/strict');
const { buildPlaylist } = require('../src/playlist');
function fakeRequest() {
return { headers: { host: 'example.test:8080' }, socket: {} };
}
const event = {
key: 'a',
title: 'Standard de Liège - SK Beveren',
startAt: new Date('2026-11-06T18:25:00Z'),
endAt: new Date('2026-11-06T20:55:00Z'),
};
test('playlist always contains three fixed channels', () => {
const scheduler = {
getSlotCount: () => 3,
getSlotEvent: (index) => (index === 0 ? event : null),
};
const body = buildPlaylist(fakeRequest(), scheduler);
const lines = body.trim().split('\n');
assert.equal(lines[0], '#EXTM3U');
assert.equal(
lines[1],
'#EXTINF:-1 tvg-id="jupiler-1" tvg-name="Jupiler 1",Jupiler 1 | Standard de Liège - SK Beveren (19:25)'
);
assert.equal(lines[2], 'http://example.test:8080/stream/1');
assert.ok(body.includes('Jupiler 2 | Geen wedstrijd'));
assert.ok(body.includes('Jupiler 3 | Geen wedstrijd'));
assert.ok(body.includes('http://example.test:8080/stream/3'));
});
@@ -0,0 +1,120 @@
'use strict';
const test = require('node:test');
const assert = require('node:assert/strict');
const { createState, reconcile, summarize } = require('../src/scheduler');
const options = { maxChannels: 3, startGraceMs: 24 * 60 * 60 * 1000 };
function makeEvent(id, startIso, endIso) {
return {
key: id,
title: id,
league: 'Jupiler Pro League',
language: 'nl',
startAt: new Date(startIso),
endAt: new Date(endIso),
sources: [{ streamId: id, name: id, candidates: [`http://upstream/${id}.ts`] }],
missingCount: 0,
};
}
function current(...events) {
return new Map(events.map((event) => [event.key, event]));
}
function slotKeys(state) {
return state.slots.map((slot) => slot.eventKey);
}
test('fills the first three slots by start time and queues the rest', () => {
const state = createState(3);
const events = current(
makeEvent('A', '2026-11-06T16:00:00Z', '2026-11-06T20:00:00Z'),
makeEvent('B', '2026-11-06T16:30:00Z', '2026-11-06T20:30:00Z'),
makeEvent('C', '2026-11-06T17:00:00Z', '2026-11-06T21:00:00Z'),
makeEvent('D', '2026-11-06T17:30:00Z', '2026-11-06T21:30:00Z'),
makeEvent('E', '2026-11-06T18:00:00Z', '2026-11-06T22:00:00Z')
);
reconcile(state, events, Date.parse('2026-11-06T18:00:00Z'), options);
assert.deepEqual(slotKeys(state), ['A', 'B', 'C']);
const summary = summarize(state);
assert.deepEqual(summary.queue.map((event) => event.key), ['D', 'E']);
});
test('a freed slot is filled by the oldest waiting event', () => {
const state = createState(3);
const events = current(
makeEvent('A', '2026-11-06T16:00:00Z', '2026-11-06T20:00:00Z'),
makeEvent('B', '2026-11-06T16:30:00Z', '2026-11-06T20:30:00Z'),
makeEvent('C', '2026-11-06T17:00:00Z', '2026-11-06T21:00:00Z'),
makeEvent('D', '2026-11-06T17:30:00Z', '2026-11-06T21:30:00Z'),
makeEvent('E', '2026-11-06T18:00:00Z', '2026-11-06T22:00:00Z')
);
reconcile(state, events, Date.parse('2026-11-06T18:00:00Z'), options);
assert.deepEqual(slotKeys(state), ['A', 'B', 'C']);
reconcile(state, events, Date.parse('2026-11-06T20:01:00Z'), options);
assert.deepEqual(slotKeys(state), ['D', 'B', 'C']);
});
test('an existing assignment is not preempted by an earlier event', () => {
const state = createState(3);
const events = current(
makeEvent('A', '2026-11-06T16:00:00Z', '2026-11-06T20:00:00Z'),
makeEvent('B', '2026-11-06T16:30:00Z', '2026-11-06T20:30:00Z'),
makeEvent('C', '2026-11-06T17:00:00Z', '2026-11-06T21:00:00Z')
);
reconcile(state, events, Date.parse('2026-11-06T18:00:00Z'), options);
const withEarlier = current(
...events.values(),
makeEvent('X', '2026-11-06T17:45:00Z', '2026-11-06T21:45:00Z')
);
reconcile(state, withEarlier, Date.parse('2026-11-06T18:10:00Z'), options);
assert.deepEqual(slotKeys(state), ['A', 'B', 'C']);
});
test('an assigned event keeps its slot until its end even when it disappears', () => {
const state = createState(3);
const a = makeEvent('A', '2026-11-06T16:00:00Z', '2026-11-06T20:00:00Z');
const b = makeEvent('B', '2026-11-06T16:30:00Z', '2026-11-06T20:30:00Z');
reconcile(state, current(a, b), Date.parse('2026-11-06T18:00:00Z'), options);
assert.deepEqual(slotKeys(state), ['A', 'B', null]);
reconcile(state, current(), Date.parse('2026-11-06T19:00:00Z'), options);
reconcile(state, current(), Date.parse('2026-11-06T19:30:00Z'), options);
assert.deepEqual(slotKeys(state), ['A', 'B', null]);
reconcile(state, current(), Date.parse('2026-11-06T20:31:00Z'), options);
assert.deepEqual(slotKeys(state), [null, null, null]);
});
test('an unassigned event that disappears twice is dropped from the queue', () => {
const state = createState(3);
const events = current(
makeEvent('A', '2026-11-06T16:00:00Z', '2026-11-06T20:00:00Z'),
makeEvent('B', '2026-11-06T16:30:00Z', '2026-11-06T20:30:00Z'),
makeEvent('C', '2026-11-06T17:00:00Z', '2026-11-06T21:00:00Z'),
makeEvent('D', '2026-11-06T17:30:00Z', '2026-11-06T21:30:00Z'),
makeEvent('E', '2026-11-06T18:00:00Z', '2026-11-06T22:00:00Z')
);
reconcile(state, events, Date.parse('2026-11-06T18:00:00Z'), options);
const withoutD = current(
events.get('A'),
events.get('B'),
events.get('C'),
events.get('E')
);
reconcile(state, withoutD, Date.parse('2026-11-06T18:20:00Z'), options);
reconcile(state, withoutD, Date.parse('2026-11-06T18:40:00Z'), options);
reconcile(state, withoutD, Date.parse('2026-11-06T20:01:00Z'), options);
assert.deepEqual(slotKeys(state), ['E', 'B', 'C']);
});
@@ -0,0 +1,25 @@
'use strict';
const test = require('node:test');
const assert = require('node:assert/strict');
const { parseLocalTimestamp, zonedTimeToUtc } = require('../src/time');
test('parses winter time in Europe/Brussels', () => {
const date = parseLocalTimestamp('2026-11-06 19:25:00', 'Europe/Brussels');
assert.equal(date.toISOString(), '2026-11-06T18:25:00.000Z');
});
test('parses summer time in Europe/Brussels', () => {
const date = parseLocalTimestamp('2026-07-01 19:25:00', 'Europe/Brussels');
assert.equal(date.toISOString(), '2026-07-01T17:25:00.000Z');
});
test('zonedTimeToUtc handles UTC', () => {
const date = zonedTimeToUtc(2026, 11, 6, 19, 25, 0, 'UTC');
assert.equal(date.toISOString(), '2026-11-06T19:25:00.000Z');
});
test('rejects invalid timestamps', () => {
assert.equal(parseLocalTimestamp('not-a-date', 'Europe/Brussels'), null);
assert.equal(parseLocalTimestamp('2026-13-06 19:25:00', 'Europe/Brussels'), null);
});
@@ -0,0 +1,90 @@
'use strict';
const test = require('node:test');
const assert = require('node:assert/strict');
const http = require('node:http');
const { settings } = require('../src/settings');
const {
fetchLiveStreams,
liveUrlCandidates,
parseM3u,
resolveFallbackUrls,
resetM3uCache,
} = require('../src/xtream-client');
function listen(server) {
return new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
}
function close(server) {
return new Promise((resolve) => server.close(resolve));
}
test('fetches live streams from player_api and resolves get.php fallback URLs', async () => {
const server = http.createServer((req, res) => {
if (req.url.startsWith('/player_api.php')) {
res.setHeader('Content-Type', 'application/json');
res.end(
JSON.stringify([
{
stream_id: 101,
name: 'DAZN 01 [A - B | Jupiler Pro League (nl) (2026-11-06 19:25:00)]',
},
])
);
return;
}
if (req.url.startsWith('/get.php')) {
res.setHeader('Content-Type', 'application/x-mpegurl');
res.end(
[
'#EXTM3U',
'#EXTINF:-1 tvg-id="dazn1" tvg-name="DAZN 01",DAZN 01 [A - B | Jupiler Pro League (nl) (2026-11-06 19:25:00)]',
'http://cdn.example/live/u/p/101.ts',
].join('\n')
);
return;
}
res.statusCode = 404;
res.end('nope');
});
await listen(server);
const port = server.address().port;
const previous = { ...settings };
settings.xtreamUrl = `http://127.0.0.1:${port}`;
settings.xtreamUsername = 'user';
settings.xtreamPassword = 'pass';
resetM3uCache();
try {
const streams = await fetchLiveStreams();
assert.equal(streams.length, 1);
assert.equal(streams[0].stream_id, 101);
const urls = liveUrlCandidates(101);
assert.ok(urls.includes('http://127.0.0.1:' + port + '/live/user/pass/101.ts'));
const fallbacks = await resolveFallbackUrls(101, 'DAZN 01');
assert.deepEqual(fallbacks, ['http://cdn.example/live/u/p/101.ts']);
} finally {
Object.assign(settings, previous);
resetM3uCache();
await close(server);
}
});
test('parses M3U entries by stream id and tvg-name', () => {
const index = parseM3u(
[
'#EXTM3U',
'#EXTINF:-1 tvg-id="x" tvg-name="DAZN 01 [A - B | Jupiler Pro League (nl) (2026-11-06 19:25:00)]",display',
'http://cdn.example/live/u/p/55.m3u8?token=1',
].join('\n')
);
assert.equal(index.byStreamId.get('55'), 'http://cdn.example/live/u/p/55.m3u8?token=1');
assert.equal(
index.byName.get('dazn 01 [a - b | jupiler pro league (nl) (2026-11-06 19:25:00)]'),
'http://cdn.example/live/u/p/55.m3u8?token=1'
);
});
+1
View File
@@ -0,0 +1 @@
latest