Files
projects/.kilo/plans/1793988235237-xtream-ppv-bundler.md
T
Bram d266a8258d
Build and Push Docker Images / build-and-push (push) Successful in 28s
timezone issues
2026-11-07 16:30:47 +01:00

13 KiB

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:

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
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:

... [<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:

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

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.