This commit is contained in:
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.
|
||||
@@ -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