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

@@ -0,0 +1,267 @@
# Xtream PPV FIFO bundler
## Doel
Nieuwe Docker-app die met één Xtream-upstream (URL, username, password) en één zoekterm
(bijv. `Jupiler Pro League`) alle matchende live-streams bundelt in maximaal 3 vaste
uitgangskanalen. De app bepaalt zelf welke wedstrijd op welk kanaal zit en schuift via
FIFO door wanneer een wedstrijd klaar is.
Voorbeeld-upstreamnaam:
```text
View BE| DAZN 03 [Standard de Liège - SK Beveren | Jupiler Pro League (nl) (2026-11-06 19:25:00)]
```
## Scope en beslissingen
- **Nieuwe app**: `Dockers/xtream-ppv-bundler`. `xtream-proxy` blijft ongewijzigd; patronen
(proxy, health, Docker/CI-conventies) worden overgenomen, niet de load-balancer-logica.
- **Runtime**: Node.js 22, uitsluitend built-in modules. Tests met `node:test`.
- **Uitvoer v1**: M3U-playlist + stream-URLs + XMLTV EPG. Nog geen Xtream-compatible
API (player_api/get.php output).
- **Max kanalen**: standaard 3, configureerbaar via `MAX_CHANNELS`.
- **FIFO-gedrag**: 3 vaste slots. Actieve/komende events bezetten een vrij slot; een
vrijgekomen slot wordt gevuld met het oudste wachtende event (sortering op starttijd,
daarna volgorde van binnenkomst). Geen preemptie van een al toegewezen event.
- **Altijd 3 slots**: ook bij 0, 1 of 2 wedstrijden blijven `Jupiler 1` t/m `Jupiler 3`
in de playlist staan. Een leeg slot heet `Geen wedstrijd` en `/stream/<slot>` geeft 503.
- **Live-bepaling**: starttijd uit de kanaalnaam + instelbare duur. Een event is eligibel
vanaf `startAt - START_GRACE_MIN` tot `startAt + EVENT_DURATION_MIN`.
- **Einde event**: op basis van de berekende eindtijd, niet op basis van het verdwijnen
van de naam. De provider kan de naam immers alvast naar de volgende wedstrijd zetten.
Een toegewezen event houdt zijn laatst bekende bron-URLs tot de eindtijd.
- **Dedupe**: bronnen met dezelfde teams + starttijd zijn één event. Meerdere
upstream-kanalen voor dezelfde wedstrijd worden fallback-bronnen, geen extra FIFO-item.
- **Taal**: alleen bronnen met `(nl)` of zonder taalcode. Een expliciete andere taalcode
(zoals `(fr)`) wordt overgeslagen, ook als er geen nl-bron is.
- **Bestaande kijkers**: nieuwe requests op een slot gebruiken de nieuwe toewijzing;
een al lopende verbinding blijft de oude bron volgen tot de client disconnect.
- **Geen meer dan 3 gelijktijdige wedstrijden** is een aanname van de gebruiker. De
FIFO-wachtrij is vooral bedoeld voor de volgende wedstrijd zodra een slot vrijkomt.
- **Herstart**: toewijzing wordt in-memory opnieuw opgebouwd; kanaalnummers kunnen na
een herstart anders zijn. Persistentie is buiten scope voor v1.
## Configuratie
| Env | Default | Verplicht | Betekenis |
| --- | --- | --- | --- |
| `XTREAM_URL` | - | ja | Basis-URL van het Xtream-panel, mag een path-prefix bevatten |
| `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 | Hoeveel minuten voor aanvang een event eligibel wordt |
| `REFRESH_INTERVAL_SEC` | `60` | nee | Interval voor upstream-refresh |
| `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 |
| `LOG_LEVEL` | `info` | nee | `debug`, `info`, `warn`, `error` |
Geheimen nooit loggen of in `/status` tonen. URL's in logs/status redigeren.
## Werking
### 1. Upstream uitlezen
- Primair: `GET {XTREAM_URL}/player_api.php?username=...&password=...&action=get_live_streams`.
- Filter op `SEARCH_TERM` in `name` (case-insensitive, diacrieten genormaliseerd).
- Gebruik `stream_id`, `name`, `stream_icon`, `epg_channel_id` en provider-volgorde.
- Bouw per stream kandidaat-URL's:
1. `{base}/live/{user}/{pass}/{stream_id}.ts`
2. `{base}/live/{user}/{pass}/{stream_id}.m3u8`
3. `{base}/{user}/{pass}/{stream_id}.ts`
4. `{base}/{user}/{pass}/{stream_id}.m3u8`
5. `{base}/{user}/{pass}/{stream_id}`
- Ook varianten zonder path-prefix proberen als `XTREAM_URL` een pad bevat.
- Fallback: als alle kandidaat-URL's voor een bron falen, één keer
`get.php?username=...&password=...&type=m3u_plus&output=ts` ophalen (cachen), parsen en
de exacte URL opzoeken via `stream_id` of `tvg-name`. Deze fallback is lazy en mag
meerdere MB's zijn; nooit elke refresh standaard downloaden.
- Bij een mislukte refresh: bestaande toestand behouden, fout loggen en `/health`
ongezond maken na 3 opeenvolgende mislukte refreshes. Niet herschikken op foutdata.
### 2. Kanaalnaam parsen
Ondersteun minimaal het opgegeven DAZN-formaat:
```text
... [<title> | <league> (<lang>) (<YYYY-MM-DD HH:mm:ss>)]
```
- Neem de laatste `[...]` als inhoud; splits op de eerste `|` in `title` en `rest`.
- Parse uit `rest`: league, optionele taalcode en verplichte starttijd.
- `title`, `league`, `language`, `startAt` (UTC) en `endAt`.
- Tijdzone via `TIMEZONE`, correct omgaan met zomertijd (Intl-offsetberekening, geen
afhankelijkheid van de proces-TZ).
- Als een matchende naam niet parsebaar is: stream overslaan, één keer loggen en in
`/status` als waarschuwing tonen. Geen gokken met "nu actief", om toekomstige streams
niet te vroeg te tonen.
- Taal: expliciete code leeg of gelijk aan `LANGUAGE` toestaan; anders overslaan.
### 3. Events en dedupe
- Event-key: genormaliseerde `title` + `startAt` (lowercase, diacrieten weg, whitespace
collapsed). Voorbeeld-key:
`standard de liege - sk beveren|2026-11-06T18:25:00.000Z`.
- Alle toegestane bronnen met dezelfde key worden één event met een geordende
kandidatenlijst. Eerste providerbron eerst; per bron de URL-kandidaten in bovengenoemde
volgorde. Dedupe identieke URL's.
- Event bevat: key, title, league, language, startAt, endAt, sources[], firstSeenSeq,
status (`queued` / `assigned` / `failed`).
### 4. FIFO-slots
State:
```text
slots[0..MAX_CHANNELS-1] = { eventKey, event, assignedAt }
queue = [eventKey...] // oudste eerst
events = Map<key, event>
```
Bij elke geslaagde refresh:
1. Bepaal de huidige events uit de upstreamlijst.
2. Werk bronnen van bestaande events bij; behoud laatst bekende bronnen als een
toegewezen event tijdelijk niet meer in de lijst voorkomt.
3. Maak slots vrij waarvoor `now >= event.endAt`.
4. Verwijder uit queue: events die niet meer eligibel zijn of waarvan `now >= endAt`.
5. Voeg nieuwe eligibele events toe aan de queue op starttijd en dan `firstSeenSeq`.
6. Vul vrije slots in oplopende slotindex met `queue.shift()`.
7. Bestaande toewijzingen nooit preempten.
Gevolg: bij 3 gelijktijdige wedstrijden bezetten die slots 1-3. Komt er een vierde
wedstrijd bij, dan wacht die op het eerst vrijkomende slot. Bij minder dan 3 wedstrijden
blijven de overige slots leeg (naam `Geen wedstrijd`).
### 5. Streamen
- `GET /stream/<slot>` zoekt de toewijzing op het moment van het request en probeert de
kandidaten van dat event sequentieel tot er responseheaders zijn.
- Volg HTTP-redirects (max. 5), behoud Range-requests en stuur client-disconnect door
naar de upstream-request.
- MPEG-TS / onbekend: 1-op-1 doorpipen met correcte content-type en `Cache-Control:
no-store`.
- HLS (`.m3u8` of `application/vnd.apple.mpegurl`): playlist ophalen, alle URI's
(segmenten, variant-playlists, keys, `URI="..."`) herschrijven naar een HMAC-ondertekende
proxy-URL op deze app. Segmenten/keys via dezelfde proxy ophalen; geneste playlists
opnieuw rewriten. Zo komen upstream-credentials nooit in de playlist van de client.
- Bij falen van alle kandidaten: 502/503 met korte `Retry-After`; event blijft toegewezen
tot `endAt`. Een event dat nooit een bron had wordt niet toegewezen.
- Geen credential-lekkage in foutmeldingen of logs.
## Endpoints
- `GET /` - eenvoudige HTML met playlist-link en kanaalstatus.
- `GET /playlist.m3u` - altijd `MAX_CHANNELS` entries:
`#EXTINF:-1 tvg-id="jupiler-1" tvg-name="Jupiler 1",Jupiler 1 | <titel> (<start>)`
en daaronder `http://<host>/stream/1`.
- `GET /epg.xml` of `/xmltv.xml` - XMLTV-gids voor de vaste kanalen. De gids toont de
actuele toewijzing plus een FIFO-projectie van toekomstige wedstrijden uit de laatste
kanaalnamen-refresh.
- `GET /stream/<slot>` - live proxy naar de toegewezen bron.
- `GET /hls/<slot>/<token>` - HMAC-beveiligde HLS playlist/segment/key proxy.
- `GET /health` - `{ ok, lastRefreshAt, lastRefreshOk, slotsFilled }`, 503 bij 3
opeenvolgende mislukte refreshes.
- `GET /status` - JSON met slots, queue, events, bronnen (zonder credentials), laatste
refresh, parse-waarschuwingen en foutredenen. Bedoeld voor debugging.
Playlist- en statusresponses `Cache-Control: no-store`.
## Bestandsstructuur
```text
Dockers/xtream-ppv-bundler/
Dockerfile
docker-compose.example.yml
package.json
version # verplicht voor CI (image-tag)
README.md
.dockerignore
.gitignore
server.js
src/
settings.js
http-utils.js
logger.js
time.js # lokale tijd + IANA-zone -> UTC
channel-name.js # parser
xtream-client.js # player_api, URL-bouwers, get.php-fallback
events.js # filteren, dedupe, eligibility
scheduler.js # refresh-loop + FIFO-reconciliatie
proxy.js # TS/HTTP streamproxy + failover
hls.js # m3u8 rewriting + signed URLs
playlist.js # M3U-generatie
epg.js # XMLTV-generatie en FIFO-projectie
health.js
test/
channel-name.test.js
time.test.js
events.test.js
scheduler.test.js
playlist.test.js
integration.test.js # mock upstream
```
## Implementatietaken
1. Scaffold de nieuwe map met `package.json`, `version`, Dockerfile, compose-example en
ignore-bestanden. Geen runtime-dependencies; scripts `start` en `test`.
2. Implementeer `settings.js` met validatie (verplichte env, grenzen, URL-protocol).
3. Implementeer `time.js` en `channel-name.js`; test exact het opgegeven DAZN-voorbeeld
en een `(fr)`-variant.
4. Implementeer `xtream-client.js`: `get_live_streams`, URL-kandidaten, redirects,
timeouts, `get.php`-fallback en foutclassificatie.
5. Implementeer `events.js`: zoekfilter, taalregel, dedupe op titel+starttijd,
kandidaten sorteren en eligibility-venster.
6. Implementeer `scheduler.js`: periodieke refresh, failure-gedrag, queue/slot-reconciliatie
en atomische state-updates (geen half-gerede toewijzingen publiceren).
7. Implementeer `playlist.js`, `epg.js` en `health.js`, daarna `server.js` met routing en
shutdown. De EPG toont de actuele slots plus een FIFO-projectie van toekomstige events
uit de laatste kanaalnamen-refresh.
8. Implementeer `proxy.js` (TS/HTTP) en `hls.js` (m3u8 + segmenten + HMAC), inclusief
Range, redirects, client-abort en no-cache headers.
9. Schrijf README met configuratietabel, voorbeeld-compose, endpoints, FIFO-uitleg en
bekende beperkingen.
10. Werk Dockerfile/healthcheck af volgens de bestaande `xtream-proxy`-conventies
(node:22-alpine, non-root, `EXPOSE 8080`).
## Tests en validatie
- Unit: parse voorbeeldnaam, ontbrekende taalcode, `(fr)` overslaan, onparseerbare naam.
- Unit: tijdzone zomer/winter (bijv. 2026-07-01 en 2026-11-06).
- Unit: dedupe van meerdere DAZN-kanalen voor dezelfde wedstrijd; nl-bron gaat voor.
- Unit: FIFO-scenario's:
- 3 gelijktijdige events -> slots 1, 2, 3.
- vierde event wacht; na einde van slot 1 schuift het naar slot 1.
- event dat verloopt zonder slot wordt uit de queue verwijderd.
- toegewezen event behoudt slot tot `endAt`, ook als de naam naar de volgende
wedstrijd verspringt.
- Unit: playlist bevat altijd 3 entries; leeg slot toont `Geen wedstrijd`.
- Unit/EPG: XMLTV bevat de 3 kanaal-ids, actuele programma's en FIFO-geprojecteerde
toekomstige wedstrijden; XML-escaping en UTC-tijdformaat.
- Integratie met mock-upstream: `player_api` JSON, TS-stream, `.ts` faalt maar `.m3u8`
werkt, `get.php`-fallback, HLS-playlist met relatieve en absolute segment-URL's.
- Commando's: `npm test`, `node --check` op gewijzigde bestanden,
`docker build -t xtream-ppv-bundler Dockers/xtream-ppv-bundler`.
- Handmatige rooktest: container starten met mock/echte credentials, `/health`,
`/playlist.m3u` en `/status` controleren, daarna `/stream/1` in VLC/TiviMate.
## Risico's en beperkingen
- Kanaalnaamformaat is provider-specifiek. De parser is tolerant voor het gegeven
DAZN-formaat; afwijkende namen worden overgeslagen en zichtbaar in `/status`.
- Zonder EPG is de eindtijd een aanname (`EVENT_DURATION_MIN`). Te kort betekent vroeg
vrijgeven; te lang betekent dat een slot langer bezet blijft.
- De provider kan de naam al naar de volgende wedstrijd zetten. Daarom houdt een
toegewezen event zijn oude bron-URLs tot de berekende eindtijd.
- Bij zeer grote upstream-playlists kan `get.php` zwaar zijn; daarom alleen lazy als
fallback en met cache.
- Meerdere kijkers betekenen meerdere upstream-verbindingen; dit valt buiten de
connection-limit-logica van de provider.
- Geen authenticatie op de output in v1: bedoeld voor LAN/self-host. Upstream-credentials
worden wel nooit aan de client getoond.
+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