271 lines
14 KiB
Markdown
271 lines
14 KiB
Markdown
# 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 met volledig vaste id's, namen en `tvg-chno`. De actuele wedstrijd
|
|
staat in de EPG en `/status`, niet in de kanaalnaam. Een leeg 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 |
|
|
| `SOURCE_TIMEZONE` | `UTC` | nee | Tijdzone van de tijdstempels in kanaalnamen |
|
|
| `TIMEZONE` | `Europe/Brussels` | nee | Tijdzone voor weergave in playlist en EPG |
|
|
| `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`.
|
|
- Tijdstempels worden standaard als UTC gelezen (`SOURCE_TIMEZONE`), correct omgaan met
|
|
zomertijd (Intl-offsetberekening, geen afhankelijkheid van de proces-TZ). Weergave in
|
|
playlist/EPG gebeurt in `TIMEZONE`.
|
|
- 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 met vaste metadata:
|
|
`#EXTINF:-1 tvg-id="jupiler-1" tvg-name="Jupiler 1" tvg-chno="1",Jupiler 1`
|
|
en daaronder `http://<host>/stream/1`. Wedstrijdinformatie staat in de EPG.
|
|
- `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.
|