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

View File

@ -0,0 +1,70 @@
const express = require('express');
const router = express.Router();
const { sources } = require('../db');
const { getDb } = require('../db/sqlite');
const { validateHttpUrl } = require('../services/urlSecurity');
const ZAP_TIMEOUT_MS = 5000;
// Enigma2 service reference: ten hex fields, e.g. 1:0:1:65:65:53:CE40000:0:0:0:
const SERVICE_REF = /^[0-9A-Fa-f]{1,8}(?::[0-9A-Fa-f]{1,8}){9}:?$/;
/**
* Zap an Enigma2 receiver to a channel before streaming it.
* POST /api/openwebif/zap
* Body: { sourceId: number, streamId: string }
*
* The receiver address is never taken from the client: it is derived from the
* configured (admin-managed) M3U source URL, and the channel must belong to
* that source.
*/
router.post('/zap', async (req, res) => {
const { sourceId, streamId } = req.body || {};
const id = Number(sourceId);
if (!Number.isInteger(id) || typeof streamId !== 'string' || !SERVICE_REF.test(streamId)) {
return res.status(400).json({ error: 'Valid sourceId and streamId are required' });
}
let source, item;
try {
source = await sources.getById(id);
if (source && source.type === 'm3u' && source.enabled && source.zapBeforeStreaming) {
item = getDb().prepare(`
SELECT item_id FROM playlist_items
WHERE source_id = ? AND type = 'live' AND item_id = ?
`).get(id, streamId);
}
} catch (err) {
console.error('[ZAP] Lookup failed:', err.message);
return res.status(500).json({ error: 'Server error' });
}
if (!source || source.type !== 'm3u' || !source.enabled || !source.zapBeforeStreaming) {
return res.status(404).json({ error: 'Zap is not enabled for this source' });
}
if (!item) {
return res.status(404).json({ error: 'Channel not found' });
}
let zapUrl;
try {
const boxOrigin = new URL(validateHttpUrl(source.url, 'Source URL')).origin;
zapUrl = `${boxOrigin}/web/zap?sRef=${encodeURIComponent(item.item_id)}`;
} catch (err) {
return res.status(400).json({ error: err.message });
}
try {
const response = await fetch(zapUrl, {
redirect: 'manual',
signal: AbortSignal.timeout(ZAP_TIMEOUT_MS)
});
await response.body?.cancel();
res.json({ success: response.ok });
} catch (err) {
console.error('[ZAP] Failed:', err.message);
res.status(502).json({ error: 'Receiver did not respond' });
}
});
module.exports = router;

View File

@ -63,7 +63,7 @@ router.get('/:id', async (req, res) => {
// Create source
router.post('/', async (req, res) => {
try {
const { type, name, url, username, password } = req.body;
const { type, name, url, username, password, zapBeforeStreaming, zapDelay } = req.body;
if (!type || !name || !url) {
return res.status(400).json({ error: 'Type, name, and URL are required' });
@ -73,7 +73,7 @@ router.post('/', async (req, res) => {
return res.status(400).json({ error: 'Invalid source type' });
}
const source = await sources.create({ type, name, url, username, password });
const source = await sources.create({ type, name, url, username, password, zapBeforeStreaming: zapBeforeStreaming === true, zapDelay: parseInt(zapDelay) || 1500 });
// Trigger Sync
syncService.syncSource(source.id).catch(console.error);
res.status(201).json(source);
@ -91,7 +91,7 @@ router.put('/:id', async (req, res) => {
return res.status(404).json({ error: 'Source not found' });
}
const { name, url, username, password } = req.body;
const { name, url, username, password, zapBeforeStreaming, zapDelay } = req.body;
// Omitted fields must be merged against the latest record inside the
// database transaction, not the snapshot read before another edit.
const updates = {};
@ -99,6 +99,8 @@ router.put('/:id', async (req, res) => {
if (url) updates.url = url;
if (username !== undefined) updates.username = username;
if (password !== undefined) updates.password = password;
if (zapBeforeStreaming !== undefined) updates.zapBeforeStreaming = zapBeforeStreaming === true;
if (zapDelay !== undefined) updates.zapDelay = parseInt(zapDelay) || 1500;
const updated = await sources.update(req.params.id, updates);
if (!updated) return res.status(404).json({ error: 'Source not found' });
// Trigger Sync (if critical fields changed? safely just trigger it)

View File

@ -33,7 +33,9 @@ transcodeSession.startCleanupInterval();
* Body: { url: string, seekOffset?: number }
*/
router.post('/session', startupLimit, async (req, res) => {
const { url, seekOffset, videoMode, videoCodec, audioCodec, audioChannels, videoHeight, playbackLeaseId, requestSequence, isLive } = req.body;
const { url, seekOffset, videoMode, videoCodec, audioCodec, audioChannels, videoHeight, playbackLeaseId, requestSequence, isLive, program } = req.body;
// MPEG-TS program number (Enigma2 multi-program streams); digits only
const programNumber = /^\d+$/.test(String(program ?? '')) ? String(program) : null;
const normalizedSeek = Number(seekOffset ?? 0);
if (!Number.isFinite(normalizedSeek) || normalizedSeek < 0) return res.status(400).json({ error: "Invalid seek offset" });
let session = null, disconnected = false;
@ -72,7 +74,8 @@ router.post('/session', startupLimit, async (req, res) => {
videoCodec: videoCodec, // 'h264', 'hevc', etc.
audioCodec: audioCodec, // 'aac', 'ac3', etc.
audioChannels: audioChannels, // number of channels (2=stereo)
videoHeight: videoHeight // source height from probe; used to cap max-resolution in JS
videoHeight: videoHeight, // source height from probe; used to cap max-resolution in JS
program: programNumber
});
if (disconnected) { await transcodeSession.removeSession(session.id, req.user.id); return; }