From 91f22b54b438b0fafbf48799698260372b3f84cb Mon Sep 17 00:00:00 2001 From: Bram Date: Fri, 6 Nov 2026 19:24:39 +0100 Subject: [PATCH] xtream bundler --- .../plans/1793988235237-xtream-ppv-bundler.md | 267 ++++++++++++++++++ Dockers/xtream-ppv-bundler/.dockerignore | 8 + Dockers/xtream-ppv-bundler/.gitignore | 2 + Dockers/xtream-ppv-bundler/Dockerfile | 31 ++ Dockers/xtream-ppv-bundler/README.md | 123 ++++++++ .../docker-compose.example.yml | 20 ++ Dockers/xtream-ppv-bundler/package.json | 13 + Dockers/xtream-ppv-bundler/server.js | 146 ++++++++++ .../xtream-ppv-bundler/src/channel-name.js | 57 ++++ Dockers/xtream-ppv-bundler/src/epg.js | 116 ++++++++ Dockers/xtream-ppv-bundler/src/events.js | 76 +++++ Dockers/xtream-ppv-bundler/src/health.js | 18 ++ Dockers/xtream-ppv-bundler/src/hls.js | 108 +++++++ Dockers/xtream-ppv-bundler/src/http-utils.js | 90 ++++++ Dockers/xtream-ppv-bundler/src/logger.js | 33 +++ Dockers/xtream-ppv-bundler/src/playlist.js | 52 ++++ Dockers/xtream-ppv-bundler/src/proxy.js | 223 +++++++++++++++ Dockers/xtream-ppv-bundler/src/scheduler.js | 230 +++++++++++++++ Dockers/xtream-ppv-bundler/src/settings.js | 70 +++++ Dockers/xtream-ppv-bundler/src/time.js | 62 ++++ .../xtream-ppv-bundler/src/xtream-client.js | 239 ++++++++++++++++ .../test/channel-name.test.js | 41 +++ Dockers/xtream-ppv-bundler/test/epg.test.js | 47 +++ .../xtream-ppv-bundler/test/events.test.js | 86 ++++++ Dockers/xtream-ppv-bundler/test/hls.test.js | 46 +++ .../xtream-ppv-bundler/test/playlist.test.js | 35 +++ .../xtream-ppv-bundler/test/scheduler.test.js | 120 ++++++++ Dockers/xtream-ppv-bundler/test/time.test.js | 25 ++ .../test/xtream-client.test.js | 90 ++++++ Dockers/xtream-ppv-bundler/version | 1 + 30 files changed, 2475 insertions(+) create mode 100644 .kilo/plans/1793988235237-xtream-ppv-bundler.md create mode 100644 Dockers/xtream-ppv-bundler/.dockerignore create mode 100644 Dockers/xtream-ppv-bundler/.gitignore create mode 100644 Dockers/xtream-ppv-bundler/Dockerfile create mode 100644 Dockers/xtream-ppv-bundler/README.md create mode 100644 Dockers/xtream-ppv-bundler/docker-compose.example.yml create mode 100644 Dockers/xtream-ppv-bundler/package.json create mode 100644 Dockers/xtream-ppv-bundler/server.js create mode 100644 Dockers/xtream-ppv-bundler/src/channel-name.js create mode 100644 Dockers/xtream-ppv-bundler/src/epg.js create mode 100644 Dockers/xtream-ppv-bundler/src/events.js create mode 100644 Dockers/xtream-ppv-bundler/src/health.js create mode 100644 Dockers/xtream-ppv-bundler/src/hls.js create mode 100644 Dockers/xtream-ppv-bundler/src/http-utils.js create mode 100644 Dockers/xtream-ppv-bundler/src/logger.js create mode 100644 Dockers/xtream-ppv-bundler/src/playlist.js create mode 100644 Dockers/xtream-ppv-bundler/src/proxy.js create mode 100644 Dockers/xtream-ppv-bundler/src/scheduler.js create mode 100644 Dockers/xtream-ppv-bundler/src/settings.js create mode 100644 Dockers/xtream-ppv-bundler/src/time.js create mode 100644 Dockers/xtream-ppv-bundler/src/xtream-client.js create mode 100644 Dockers/xtream-ppv-bundler/test/channel-name.test.js create mode 100644 Dockers/xtream-ppv-bundler/test/epg.test.js create mode 100644 Dockers/xtream-ppv-bundler/test/events.test.js create mode 100644 Dockers/xtream-ppv-bundler/test/hls.test.js create mode 100644 Dockers/xtream-ppv-bundler/test/playlist.test.js create mode 100644 Dockers/xtream-ppv-bundler/test/scheduler.test.js create mode 100644 Dockers/xtream-ppv-bundler/test/time.test.js create mode 100644 Dockers/xtream-ppv-bundler/test/xtream-client.test.js create mode 100644 Dockers/xtream-ppv-bundler/version diff --git a/.kilo/plans/1793988235237-xtream-ppv-bundler.md b/.kilo/plans/1793988235237-xtream-ppv-bundler.md new file mode 100644 index 0000000..ef9f355 --- /dev/null +++ b/.kilo/plans/1793988235237-xtream-ppv-bundler.md @@ -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/` 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 +... [ | <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. diff --git a/Dockers/xtream-ppv-bundler/.dockerignore b/Dockers/xtream-ppv-bundler/.dockerignore new file mode 100644 index 0000000..9f5ff44 --- /dev/null +++ b/Dockers/xtream-ppv-bundler/.dockerignore @@ -0,0 +1,8 @@ +node_modules +npm-debug.log +test +.git +.gitignore +.dockerignore +docker-compose.example.yml +README.md diff --git a/Dockers/xtream-ppv-bundler/.gitignore b/Dockers/xtream-ppv-bundler/.gitignore new file mode 100644 index 0000000..1ca9571 --- /dev/null +++ b/Dockers/xtream-ppv-bundler/.gitignore @@ -0,0 +1,2 @@ +node_modules/ +npm-debug.log diff --git a/Dockers/xtream-ppv-bundler/Dockerfile b/Dockers/xtream-ppv-bundler/Dockerfile new file mode 100644 index 0000000..76a84eb --- /dev/null +++ b/Dockers/xtream-ppv-bundler/Dockerfile @@ -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"] diff --git a/Dockers/xtream-ppv-bundler/README.md b/Dockers/xtream-ppv-bundler/README.md new file mode 100644 index 0000000..d3a728a --- /dev/null +++ b/Dockers/xtream-ppv-bundler/README.md @@ -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. diff --git a/Dockers/xtream-ppv-bundler/docker-compose.example.yml b/Dockers/xtream-ppv-bundler/docker-compose.example.yml new file mode 100644 index 0000000..a5038bd --- /dev/null +++ b/Dockers/xtream-ppv-bundler/docker-compose.example.yml @@ -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 diff --git a/Dockers/xtream-ppv-bundler/package.json b/Dockers/xtream-ppv-bundler/package.json new file mode 100644 index 0000000..f64af1a --- /dev/null +++ b/Dockers/xtream-ppv-bundler/package.json @@ -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" + } +} diff --git a/Dockers/xtream-ppv-bundler/server.js b/Dockers/xtream-ppv-bundler/server.js new file mode 100644 index 0000000..c19efc5 --- /dev/null +++ b/Dockers/xtream-ppv-bundler/server.js @@ -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); +}); diff --git a/Dockers/xtream-ppv-bundler/src/channel-name.js b/Dockers/xtream-ppv-bundler/src/channel-name.js new file mode 100644 index 0000000..ec081db --- /dev/null +++ b/Dockers/xtream-ppv-bundler/src/channel-name.js @@ -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 }; diff --git a/Dockers/xtream-ppv-bundler/src/epg.js b/Dockers/xtream-ppv-bundler/src/epg.js new file mode 100644 index 0000000..b6292f9 --- /dev/null +++ b/Dockers/xtream-ppv-bundler/src/epg.js @@ -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)}`); + if (event.league) { + lines.push(` ${xmlEscape(event.league)}`); + } + lines.push( + ` ${xmlEscape(`[${event.league || settings.searchTerm}] ${event.title}`)}` + ); + lines.push(` Sport`); + lines.push(' '); + } + } + lines.push(''); + return `${lines.join('\n')}\n`; +} + +module.exports = { buildXmltv, xmlEscape, xmltvDate }; diff --git a/Dockers/xtream-ppv-bundler/src/events.js b/Dockers/xtream-ppv-bundler/src/events.js new file mode 100644 index 0000000..0cea92f --- /dev/null +++ b/Dockers/xtream-ppv-bundler/src/events.js @@ -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 }; diff --git a/Dockers/xtream-ppv-bundler/src/health.js b/Dockers/xtream-ppv-bundler/src/health.js new file mode 100644 index 0000000..dce7cc9 --- /dev/null +++ b/Dockers/xtream-ppv-bundler/src/health.js @@ -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 }; diff --git a/Dockers/xtream-ppv-bundler/src/hls.js b/Dockers/xtream-ppv-bundler/src/hls.js new file mode 100644 index 0000000..adb0cd6 --- /dev/null +++ b/Dockers/xtream-ppv-bundler/src/hls.js @@ -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, +}; diff --git a/Dockers/xtream-ppv-bundler/src/http-utils.js b/Dockers/xtream-ppv-bundler/src/http-utils.js new file mode 100644 index 0000000..900b6e4 --- /dev/null +++ b/Dockers/xtream-ppv-bundler/src/http-utils.js @@ -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, +}; diff --git a/Dockers/xtream-ppv-bundler/src/logger.js b/Dockers/xtream-ppv-bundler/src/logger.js new file mode 100644 index 0000000..eaf385e --- /dev/null +++ b/Dockers/xtream-ppv-bundler/src/logger.js @@ -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, +}; diff --git a/Dockers/xtream-ppv-bundler/src/playlist.js b/Dockers/xtream-ppv-bundler/src/playlist.js new file mode 100644 index 0000000..d3bd0a3 --- /dev/null +++ b/Dockers/xtream-ppv-bundler/src/playlist.js @@ -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 `
  • Jupiler ${slot.slot}: ${escapeHtml(title)}
  • `; + }) + .join(''); + return `Xtream PPV bundler +

    Xtream PPV bundler

    +

    Playlist: ${origin}/playlist.m3u

    +

    EPG (XMLTV): ${origin}/epg.xml

    +

    Status: ${origin}/status

    +

    Kanalen

    +`; +} + +function escapeHtml(value) { + return String(value) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); +} + +module.exports = { buildPlaylist, buildIndex }; diff --git a/Dockers/xtream-ppv-bundler/src/proxy.js b/Dockers/xtream-ppv-bundler/src/proxy.js new file mode 100644 index 0000000..fedf6d3 --- /dev/null +++ b/Dockers/xtream-ppv-bundler/src/proxy.js @@ -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 }; diff --git a/Dockers/xtream-ppv-bundler/src/scheduler.js b/Dockers/xtream-ppv-bundler/src/scheduler.js new file mode 100644 index 0000000..bc26881 --- /dev/null +++ b/Dockers/xtream-ppv-bundler/src/scheduler.js @@ -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 }; diff --git a/Dockers/xtream-ppv-bundler/src/settings.js b/Dockers/xtream-ppv-bundler/src/settings.js new file mode 100644 index 0000000..ecfa52b --- /dev/null +++ b/Dockers/xtream-ppv-bundler/src/settings.js @@ -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 }; diff --git a/Dockers/xtream-ppv-bundler/src/time.js b/Dockers/xtream-ppv-bundler/src/time.js new file mode 100644 index 0000000..ffa3fe8 --- /dev/null +++ b/Dockers/xtream-ppv-bundler/src/time.js @@ -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 }; diff --git a/Dockers/xtream-ppv-bundler/src/xtream-client.js b/Dockers/xtream-ppv-bundler/src/xtream-client.js new file mode 100644 index 0000000..1b9e7fc --- /dev/null +++ b/Dockers/xtream-ppv-bundler/src/xtream-client.js @@ -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, +}; diff --git a/Dockers/xtream-ppv-bundler/test/channel-name.test.js b/Dockers/xtream-ppv-bundler/test/channel-name.test.js new file mode 100644 index 0000000..ddfe615 --- /dev/null +++ b/Dockers/xtream-ppv-bundler/test/channel-name.test.js @@ -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'); +}); diff --git a/Dockers/xtream-ppv-bundler/test/epg.test.js b/Dockers/xtream-ppv-bundler/test/epg.test.js new file mode 100644 index 0000000..e4f26a6 --- /dev/null +++ b/Dockers/xtream-ppv-bundler/test/epg.test.js @@ -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('')); + assert.ok(xml.includes('')); + assert.ok(xml.includes('')); + 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 & "c" \'d\''), 'a & <b> "c" 'd''); + assert.equal(xmltvDate(new Date('2026-11-06T18:25:00Z')), '20261106182500 +0000'); +}); diff --git a/Dockers/xtream-ppv-bundler/test/events.test.js b/Dockers/xtream-ppv-bundler/test/events.test.js new file mode 100644 index 0000000..3543463 --- /dev/null +++ b/Dockers/xtream-ppv-bundler/test/events.test.js @@ -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']); +}); diff --git a/Dockers/xtream-ppv-bundler/test/hls.test.js b/Dockers/xtream-ppv-bundler/test/hls.test.js new file mode 100644 index 0000000..8cecf8e --- /dev/null +++ b/Dockers/xtream-ppv-bundler/test/hls.test.js @@ -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); +}); diff --git a/Dockers/xtream-ppv-bundler/test/playlist.test.js b/Dockers/xtream-ppv-bundler/test/playlist.test.js new file mode 100644 index 0000000..0d5583d --- /dev/null +++ b/Dockers/xtream-ppv-bundler/test/playlist.test.js @@ -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')); +}); diff --git a/Dockers/xtream-ppv-bundler/test/scheduler.test.js b/Dockers/xtream-ppv-bundler/test/scheduler.test.js new file mode 100644 index 0000000..697860a --- /dev/null +++ b/Dockers/xtream-ppv-bundler/test/scheduler.test.js @@ -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']); +}); diff --git a/Dockers/xtream-ppv-bundler/test/time.test.js b/Dockers/xtream-ppv-bundler/test/time.test.js new file mode 100644 index 0000000..5ca8a4e --- /dev/null +++ b/Dockers/xtream-ppv-bundler/test/time.test.js @@ -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); +}); diff --git a/Dockers/xtream-ppv-bundler/test/xtream-client.test.js b/Dockers/xtream-ppv-bundler/test/xtream-client.test.js new file mode 100644 index 0000000..04fcfd9 --- /dev/null +++ b/Dockers/xtream-ppv-bundler/test/xtream-client.test.js @@ -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' + ); +}); diff --git a/Dockers/xtream-ppv-bundler/version b/Dockers/xtream-ppv-bundler/version new file mode 100644 index 0000000..a0f9a4b --- /dev/null +++ b/Dockers/xtream-ppv-bundler/version @@ -0,0 +1 @@ +latest