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:
131
test/openwebif-zap.test.js
Normal file
131
test/openwebif-zap.test.js
Normal 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, []);
|
||||
});
|
||||
Reference in New Issue
Block a user