# 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.