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

@ -145,6 +145,13 @@ function initSchema() {
// Column already exists, ignore
}
try {
db.exec(`ALTER TABLE playlist_items ADD COLUMN program TEXT`);
console.log('[SQLite] Added program column to playlist_items');
} catch (e) {
// Column already exists, ignore
}
console.log('[SQLite] Schema initialized');
}

View File

@ -208,6 +208,7 @@ app.use('/api/probe', require('./routes/probe'));
app.use('/api/subtitle', require('./routes/subtitle'));
app.use('/api/settings', require('./routes/settings'));
app.use('/api/history', require('./routes/history'));
app.use('/api/openwebif', require('./routes/openwebif'));
// SPA fallback - serve index.html for all non-API routes
app.get('*', (req, res) => {

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; }

View File

@ -1,3 +1,5 @@
'use strict';
/**
* M3U Playlist Parser (Streaming)
* Parses EXTM3U format playlists and extracts channel information line-by-line
@ -88,6 +90,7 @@ async function parse(input) {
const groupsSet = new Set();
let currentInfo = null;
let currentGroup = null;
let currentVlcProgram = null;
let lines;
@ -121,6 +124,11 @@ async function parse(input) {
if (currentInfo) {
currentInfo.groupTitle = currentGroup;
}
} else if (trimmed.startsWith('#EXTVLCOPT:program=')) {
currentVlcProgram = trimmed.split('=')[1]?.trim() || null;
if (currentInfo) {
currentInfo.program = currentVlcProgram;
}
} else if (!trimmed.startsWith('#')) {
// This is a stream URL
if (currentInfo) {
@ -132,9 +140,11 @@ async function parse(input) {
...currentInfo,
id: stableId,
url: trimmed,
program: currentVlcProgram,
groupTitle: groupTitle
});
currentInfo = null;
currentVlcProgram = null;
}
}
}
@ -187,6 +197,7 @@ async function* parseStreaming(input, batchSize = 500) {
const groupsSet = new Set();
let currentInfo = null;
let currentGroup = null;
let currentVlcProgram = null;
let batch = [];
let lines;
@ -216,6 +227,11 @@ async function* parseStreaming(input, batchSize = 500) {
if (currentInfo) {
currentInfo.groupTitle = currentGroup;
}
} else if (trimmed.startsWith('#EXTVLCOPT:program=')) {
currentVlcProgram = trimmed.split('=')[1]?.trim() || null;
if (currentInfo) {
currentInfo.program = currentVlcProgram;
}
} else if (!trimmed.startsWith('#')) {
if (currentInfo) {
const groupTitle = currentInfo.groupTitle || currentGroup || 'Uncategorized';
@ -225,9 +241,11 @@ async function* parseStreaming(input, batchSize = 500) {
...currentInfo,
id: stableId,
url: trimmed,
program: currentVlcProgram,
groupTitle: groupTitle
});
currentInfo = null;
currentVlcProgram = null;
// Yield batch when full
if (batch.length >= batchSize) {

View File

@ -58,6 +58,7 @@ class M3uXtreamAdapter {
name,
stream_icon,
stream_url,
program,
category_id,
added_at,
data
@ -92,6 +93,7 @@ class M3uXtreamAdapter {
added: row.added_at,
// M3U-specific: direct stream URL (Xtream builds URLs from credentials)
stream_url: row.stream_url,
program: row.program || extra.program || null,
// Include extra fields from parser (tvgId, etc.)
epg_channel_id: extra.tvgId || null,
...extra

View File

@ -278,14 +278,16 @@ class SyncService {
const stmt = db.prepare(`
INSERT INTO playlist_items (
id, source_id, item_id, type, name, category_id,
stream_icon, stream_url, container_extension,
stream_icon, stream_url, program, container_extension,
rating, year, added_at, data
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
name = excluded.name,
category_id = excluded.category_id,
stream_icon = excluded.stream_icon,
stream_url = excluded.stream_url,
program = excluded.program,
container_extension = excluded.container_extension,
data = excluded.data
`);
@ -332,6 +334,7 @@ class SyncService {
String(catId),
icon,
null, // Direct URL not stored for Xtream usually, built on fly
item.program || null,
container,
rating,
year,
@ -641,6 +644,7 @@ class SyncService {
stream_icon: ch.tvgLogo,
stream_url: ch.url,
tvgId: ch.tvgId || null,
program: ch.program || null,
}));
// Save this batch immediately (skip purge - we'll do it at the end)

View File

@ -230,8 +230,15 @@ class TranscodeSession extends EventEmitter {
}
// Map streams
args.push('-map', '0:v:0');
args.push('-map', '0:a:0?');
// Only map video/audio from the program: mapping the whole program pulls in
// DVB teletext/subtitle streams, which can't be encoded to HLS and kill FFmpeg
if (this.options.program) {
args.push('-map', `0:p:${this.options.program}:v:0`);
args.push('-map', `0:p:${this.options.program}:a:0?`);
} else {
args.push('-map', '0:v:0');
args.push('-map', '0:a:0?');
}
// Add video encoder and filters based on selected encoder OR copy
if (videoMode === 'copy') {