Add Enigma2/OpenWebif M3U support: program mapping and zap-before-streaming

- Parse #EXTVLCOPT:program=N from M3U playlists (both full and streaming
  parsers, per channel, strict mode) and store it on playlist items
- Transcode multi-program MPEG-TS streams by mapping only the selected
  program's first video and audio stream (DVB teletext/subtitles in the
  program otherwise make FFmpeg fail to produce HLS)
- Optional per-source "zap before streaming" with configurable delay:
  POST /api/openwebif/zap takes sourceId + streamId, requires an enabled,
  zap-enabled M3U source containing that channel, derives the receiver
  from the configured source URL (never from the client), validates the
  Enigma2 service reference, uses a timeout without redirects and does
  not echo the receiver response
- Live TV: Stop button, and stop playback when leaving the page
- Tests for ordinary and program-tagged playlists and the zap endpoint

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Vitor Simoes
2026-10-13 23:01:38 +01:00
parent 035667cfba
commit 700f63da90
17 changed files with 415 additions and 19 deletions

70
test/m3u-parser.test.js Normal file
View File

@ -0,0 +1,70 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const { Readable } = require('node:stream');
const { parse, parseStreaming } = require('../server/services/m3uParser');
const ORDINARY = [
'#EXTM3U',
'#EXTINF:-1 tvg-id="news.one" group-title="News",News One',
'http://provider.test/news1.ts',
'#EXTINF:-1 group-title="Sports",Sports One',
'http://provider.test/sports1.ts',
''
].join('\n');
// Enigma2/OpenWebif bouquet export: program tags, no group-title
const PROGRAM_TAGGED = [
'#EXTM3U',
'#EXTVLCOPT:http-reconnect=true',
'#EXTINF:-1 tvg-id="1:0:1:65:65:53:CE40000:0:0:0:",RTP1',
'#EXTVLCOPT:program=101',
'http://receiver.test:8001/1:0:1:65:65:53:CE40000:0:0:0:',
'#EXTINF:-1 tvg-id="1:0:1:66:65:53:CE40000:0:0:0:",RTP2',
'#EXTVLCOPT:program=102',
'http://receiver.test:8001/1:0:1:66:65:53:CE40000:0:0:0:',
'#EXTINF:-1 tvg-id="untagged",Untagged',
'http://receiver.test:8001/untagged',
''
].join('\n');
async function collect(input, batchSize) {
const batches = [];
for await (const batch of parseStreaming(input, batchSize)) batches.push(batch);
return batches;
}
for (const [label, toInput] of [['string', text => text], ['stream', text => Readable.from([text])]]) {
test(`streaming parser imports ordinary playlists without program tags (${label} input)`, async () => {
const batches = await collect(toInput(ORDINARY));
const channels = batches.flatMap(batch => batch.channels);
assert.equal(batches.at(-1).isLast, true);
assert.deepEqual(channels.map(c => [c.name, c.groupTitle, c.url, c.program]), [
['News One', 'News', 'http://provider.test/news1.ts', null],
['Sports One', 'Sports', 'http://provider.test/sports1.ts', null]
]);
assert.deepEqual([...batches.at(-1).groups], ['News', 'Sports']);
});
test(`streaming parser keeps program tags per channel (${label} input)`, async () => {
const channels = (await collect(toInput(PROGRAM_TAGGED))).flatMap(batch => batch.channels);
assert.deepEqual(channels.map(c => [c.name, c.groupTitle, c.program]), [
['RTP1', 'Uncategorized', '101'],
['RTP2', 'Uncategorized', '102'],
['Untagged', 'Uncategorized', null]
]);
});
}
test('streaming parser does not leak program state between batches or into globals', async () => {
const batches = await collect(PROGRAM_TAGGED, 1);
assert.deepEqual(batches.filter(b => b.channels.length).map(b => b.channels[0].program), ['101', '102', null]);
assert.equal('currentVlcProgram' in globalThis, false);
});
test('full parser matches the streaming parser for ordinary and program-tagged playlists', async () => {
for (const playlist of [ORDINARY, PROGRAM_TAGGED]) {
const { channels } = await parse(playlist);
const streamed = (await collect(playlist)).flatMap(batch => batch.channels);
assert.deepEqual(channels, streamed);
}
});