Files
projects/.kilo/plans/1793988235237-xtream-ppv-bundler.md
T
Bram 91f22b54b4
Build and Push Docker Images / build-and-push (push) Successful in 36s
xtream bundler
2026-11-06 19:24:39 +01:00

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