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

@ -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') {