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