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

131
test/openwebif-zap.test.js Normal file
View File

@ -0,0 +1,131 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const os = require('node:os');
const http = require('node:http');
const { spawn } = require('node:child_process');
const RTP1 = '1:0:1:65:65:53:CE40000:0:0:0:';
async function listen(t, handler) {
const server = http.createServer(handler);
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
t.after(() => new Promise(resolve => server.close(resolve)));
return `http://127.0.0.1:${server.address().port}`;
}
// Minimal OpenWebif: bouquet export plus a zap endpoint that records calls
async function fakeReceiver(t) {
const zaps = [];
const base = await listen(t, (req, res) => {
const url = new URL(req.url, 'http://receiver');
if (url.pathname === '/web/services.m3u') {
res.end([
'#EXTM3U',
`#EXTINF:-1 tvg-id="${RTP1}",RTP1`,
'#EXTVLCOPT:program=101',
`http://127.0.0.1:1/${RTP1}`,
''
].join('\n'));
} else if (url.pathname === '/web/zap') {
zaps.push(url.searchParams.get('sRef'));
res.setHeader('Content-Type', 'text/xml');
res.end('<e2simplexmlresult><e2state>True</e2state></e2simplexmlresult>');
} else {
res.statusCode = 404;
res.end();
}
});
return { base, zaps };
}
async function startApp(t) {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'nodecast-zap-test-'));
const child = spawn(process.execPath, ['server/index.js'], { cwd: path.join(__dirname, '..'),
env: { ...process.env, PORT: '0', NODECAST_DATA_DIR: dir,
NODECAST_DISABLE_BACKGROUND_JOBS: 'true', JWT_SECRET: '', SESSION_SECRET: '',
OIDC_ISSUER_URL: '', ALLOW_LOCAL_MEDIA_URLS: 'true', AUTH_COOKIE_SECURE: 'false' },
stdio: ['ignore', 'pipe', 'pipe'] });
t.after(async () => {
if (child.exitCode === null) {
const closed = new Promise(resolve => child.once('close', resolve));
child.kill();
await closed;
}
fs.rmSync(dir, { recursive: true, force: true });
});
let output = '';
const base = await new Promise((resolve, reject) => {
const timer = setTimeout(() => reject(new Error(`Server startup timed out: ${output}`)), 15000);
child.stdout.on('data', chunk => {
output += chunk;
const match = output.match(/server running on http:\/\/localhost:(\d+)/);
if (match) { clearTimeout(timer); resolve(`http://127.0.0.1:${match[1]}`); }
});
child.stderr.on('data', chunk => { output += chunk; });
child.once('exit', code => { clearTimeout(timer); reject(new Error(`Server exited ${code}: ${output}`)); });
});
const request = async (endpoint, { body, headers, method } = {}) => {
const response = await fetch(`${base}${endpoint}`, { method: method || (body ? 'POST' : 'GET'),
headers: { 'Content-Type': 'application/json', ...headers }, body: body && JSON.stringify(body) });
const text = await response.text();
return { status: response.status, body: text ? JSON.parse(text) : null,
cookie: response.headers.getSetCookie().find(value => value.startsWith('nodecast_auth_')) };
};
return { request };
}
async function waitForChannels(request, headers, sourceId) {
for (let attempt = 0; attempt < 50; attempt++) {
const { body } = await request(`/api/proxy/xtream/${sourceId}/live_streams`, { headers });
if (Array.isArray(body) && body.length) return body;
await new Promise(resolve => setTimeout(resolve, 100));
}
throw new Error('M3U source did not sync');
}
test('zap endpoint requires authentication and only zaps channels of zap-enabled sources', async t => {
const receiver = await fakeReceiver(t);
const elsewhere = [];
const attacker = await listen(t, (req, res) => { elsewhere.push(req.url); res.end(); });
const { request } = await startApp(t);
assert.equal((await request('/api/openwebif/zap', { body: { sourceId: 1, streamId: RTP1 } })).status, 401);
const admin = await request('/api/auth/setup', { body: { username: 'Admin', password: 'password123' } });
const adminHeaders = { Cookie: admin.cookie.split(';')[0] };
await request('/api/auth/users', { headers: adminHeaders, body: { username: 'Viewer', password: 'password123', role: 'viewer' } });
const login = await request('/api/auth/login', { body: { username: 'Viewer', password: 'password123' } });
const headers = { Cookie: login.cookie.split(';')[0] };
const zapSource = await request('/api/sources', { headers: adminHeaders, body: {
type: 'm3u', name: 'Receiver', url: `${receiver.base}/web/services.m3u`, zapBeforeStreaming: true } });
const plainSource = await request('/api/sources', { headers: adminHeaders, body: {
type: 'm3u', name: 'Plain', url: `${receiver.base}/web/services.m3u` } });
const [channel] = await waitForChannels(request, adminHeaders, zapSource.body.id);
assert.equal(channel.stream_id, RTP1);
assert.equal(channel.program, '101');
await waitForChannels(request, adminHeaders, plainSource.body.id);
const zap = body => request('/api/openwebif/zap', { headers, body });
// Client-supplied receiver addresses are ignored; the configured source decides
const ok = await zap({ sourceId: zapSource.body.id, streamId: RTP1, boxUrl: attacker });
assert.equal(ok.status, 200);
assert.deepEqual(ok.body, { success: true });
assert.deepEqual(receiver.zaps, [RTP1]);
assert.deepEqual(elsewhere, []);
for (const [body, status] of [
[{ boxUrl: attacker, serviceRef: RTP1 }, 400],
[{ sourceId: zapSource.body.id, streamId: 'http://127.0.0.1/admin' }, 400],
[{ sourceId: zapSource.body.id, streamId: '1:0:1:66:65:53:CE40000:0:0:0:' }, 404],
[{ sourceId: plainSource.body.id, streamId: RTP1 }, 404],
[{ sourceId: 999, streamId: RTP1 }, 404]
]) {
assert.equal((await zap(body)).status, status, JSON.stringify(body));
}
assert.deepEqual(receiver.zaps, [RTP1]);
assert.deepEqual(elsewhere, []);
});