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:
@ -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) {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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') {
|
||||
|
||||
Reference in New Issue
Block a user