This commit is contained in:
1 parent
d7aea0be65
commit
91f22b54b4
30 files changed
+2475
No files matched your search
@@ -0,0 +1,8 @@
|
||||
node_modules
|
||||
npm-debug.log
|
||||
test
|
||||
.git
|
||||
.gitignore
|
||||
.dockerignore
|
||||
docker-compose.example.yml
|
||||
README.md
|
||||
@@ -0,0 +1,2 @@
|
||||
node_modules/
|
||||
npm-debug.log
|
||||
@@ -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"]
|
||||
@@ -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
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
module.exports = { buildPlaylist, buildIndex };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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ège') === false);
|
||||
assert.ok(xml.includes('Standard de Liège - SK Beveren'));
|
||||
assert.ok(xml.includes('Anderlecht & Genk'));
|
||||
assert.ok(xml.includes('Club Brugge - Antwerp'));
|
||||
});
|
||||
|
||||
test('escapes XML and formats UTC dates', () => {
|
||||
assert.equal(xmlEscape('a & <b> "c" \'d\''), 'a & <b> "c" 'd'');
|
||||
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'
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
latest
|
||||
Reference in new issue
Block a user