diff --git a/README.md b/README.md
index 24d8941..d6871ed 100644
--- a/README.md
+++ b/README.md
@@ -211,6 +211,12 @@ All transcoding and stream processing settings are found in **Settings → Trans
| **Force Backend Proxy** | Routes streams through the server for CORS headers | When streams fail with CORS errors, or using middleware |
+## Enigma2 / OpenWebif sources
+
+Add the receiver's bouquet export URL (for example, `http://receiver/web/services.m3u?bRef=...`) as an M3U source. Program tags select the matching video/audio service during probing, remux and transcoding. Tagged raw streams are remuxed even when Force Remux is off.
+
+For a single-tuner receiver, enable **Zap before streaming (Enigma2/OpenWebif)** on that source. The player releases its previous stream, tunes the configured receiver, and waits for the source's zap delay before starting playback. The default delay is 1500 ms; values from 0 to 10000 ms are supported. Stop, changing channels, and leaving Live TV cancel pending playback startup. The receiver address comes from the administrator-managed source URL, and only imported channels can be tuned.
+
## Troubleshooting
### Video Won't Play (Black Screen or Loading Forever)
diff --git a/package-lock.json b/package-lock.json
index 2416188..b8d07f4 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "nodecast-tv",
- "version": "2.2.2",
+ "version": "2.2.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "nodecast-tv",
- "version": "2.2.2",
+ "version": "2.2.3",
"license": "GPL-3.0-only",
"dependencies": {
"@ffprobe-installer/ffprobe": "^2.1.2",
diff --git a/package.json b/package.json
index 0e738c0..9e1bd91 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "nodecast-tv",
- "version": "2.2.2",
+ "version": "2.2.3",
"description": "Modern IPTV Player",
"license": "GPL-3.0-only",
"main": "server/index.js",
diff --git a/public/js/components/ChannelList.js b/public/js/components/ChannelList.js
index 7d19d33..8bfeae4 100644
--- a/public/js/components/ChannelList.js
+++ b/public/js/components/ChannelList.js
@@ -846,7 +846,7 @@ class ChannelList {
url: stream.stream_url, // M3U has direct URLs
program: stream.program || null,
zapBeforeStreaming: source?.zapBeforeStreaming || false,
- zapDelay: source?.zapDelay || 1500,
+ zapDelay: source?.zapDelay ?? 1500,
groupId: `m3u_${sourceId}_${stream.category_id}`,
groupTitle: categories.find(c => String(c.category_id) === String(stream.category_id))?.category_name || 'Uncategorized',
sourceId,
diff --git a/public/js/components/SourceManager.js b/public/js/components/SourceManager.js
index af8fdff..cd4ab61 100644
--- a/public/js/components/SourceManager.js
+++ b/public/js/components/SourceManager.js
@@ -266,7 +266,7 @@ class SourceManager {
Zap delay (ms)
+ value="${source.zapDelay ?? 1500}" min="0" max="10000" step="100">
` : '';
@@ -282,7 +282,7 @@ class SourceManager {
const username = document.getElementById('source-username')?.value.trim() || null;
const password = document.getElementById('source-password')?.value.trim() || null;
const zapBeforeStreaming = document.getElementById('source-zap-before-streaming')?.checked || false;
- const zapDelay = parseInt(document.getElementById('source-zap-delay')?.value) || 1500;
+ const zapDelay = Number(document.getElementById('source-zap-delay')?.value ?? 1500);
if (!name || !url) {
alert('Name and URL are required');
@@ -347,7 +347,7 @@ class SourceManager {
}
if (type === 'm3u') {
data.zapBeforeStreaming = document.getElementById('source-zap-before-streaming')?.checked || false;
- data.zapDelay = parseInt(document.getElementById('source-zap-delay')?.value) || 1500;
+ data.zapDelay = Number(document.getElementById('source-zap-delay')?.value ?? 1500);
}
await API.sources.update(id, data);
document.getElementById('modal').classList.remove('active');
diff --git a/public/js/components/VideoPlayer.js b/public/js/components/VideoPlayer.js
index b2efc2c..e587714 100644
--- a/public/js/components/VideoPlayer.js
+++ b/public/js/components/VideoPlayer.js
@@ -646,13 +646,6 @@ class VideoPlayer {
* Play a channel
*/
async play(channel, streamUrl) {
-
- if(this.currentChannel != channel && channel?.zapBeforeStreaming){
- await this.zapBeforeStreaming(channel, streamUrl);
- }
-
- this.currentChannel = channel;
-
try {
// Stop any WatchPage playback (movies/series) before starting Live TV
window.app?.pages?.watch?.stop?.();
@@ -660,6 +653,15 @@ class VideoPlayer {
// Stop current playback
this.stop();
const playbackGeneration = this.playbackGeneration;
+ if (channel?.zapBeforeStreaming) {
+ const controller = this.zapController = new AbortController();
+ await this.zapBeforeStreaming(channel, streamUrl, controller.signal);
+ if (this.playbackGeneration !== playbackGeneration) return;
+ this.zapController = null;
+ }
+ if (this.playbackGeneration !== playbackGeneration) return;
+ this.currentChannel = channel;
+ const programQuery = channel?.program == null ? '' : '&program=' + encodeURIComponent(channel.program);
let probeInfo = null;
this.updateTranscodeStatus('hidden');
@@ -677,7 +679,7 @@ class VideoPlayer {
if (this.settings.autoTranscode) {
console.log('[Player] Auto Transcode enabled. Probing stream...');
try {
- const probeRes = await fetch(`/api/probe?url=${encodeURIComponent(streamUrl)}`);
+ const probeRes = await fetch(`/api/probe?url=${encodeURIComponent(streamUrl)}${programQuery}`);
const info = await probeRes.json();
if (this.playbackGeneration !== playbackGeneration) return;
probeInfo = info;
@@ -723,7 +725,7 @@ class VideoPlayer {
// Raw .ts container - use remux
console.log('[Player] Auto: Using remux (.ts container)');
this.updateTranscodeStatus('remuxing', 'Remux (Auto)');
- const remuxUrl = this.getRemuxUrl(streamUrl, info.audioCodecs || [info.audio]);
+ const remuxUrl = this.getRemuxUrl(streamUrl, info.audioCodecs || [info.audio], channel?.program);
this.currentUrl = remuxUrl;
this.getCaptionTracks().setProbe(probeInfo, streamUrl);
this.getCaptionTracks().useNative();
@@ -769,7 +771,7 @@ class VideoPlayer {
// Probe to get video codec for HEVC tag handling
let videoCodec = 'unknown';
try {
- const probeRes = await fetch(`/api/probe?url=${encodeURIComponent(streamUrl)}`);
+ const probeRes = await fetch(`/api/probe?url=${encodeURIComponent(streamUrl)}${programQuery}`);
const info = await probeRes.json();
if (this.playbackGeneration !== playbackGeneration) return;
videoCodec = info.video;
@@ -815,20 +817,20 @@ class VideoPlayer {
// Force Remux: Route through FFmpeg for container conversion
// Applies to: 1) .ts streams when detected, or 2) ALL non-HLS streams when enabled
- if (this.settings.forceRemux && (isRawTs || isExtensionless)) {
+ if ((this.settings.forceRemux || channel?.program != null) && (isRawTs || isExtensionless)) {
console.log('[Player] Force Remux enabled. Routing through FFmpeg remux...');
console.log('[Player] Stream type:', isRawTs ? 'Raw TS' : 'Extension-less (assumed TS)');
- this.updateTranscodeStatus('remuxing', 'Remux (Force)');
+ this.updateTranscodeStatus('remuxing', this.settings.forceRemux ? 'Remux (Force)' : 'Remux (Program)');
if (!probeInfo) {
try {
- const response = await fetch(`/api/probe?url=${encodeURIComponent(streamUrl)}`);
+ const response = await fetch(`/api/probe?url=${encodeURIComponent(streamUrl)}${programQuery}`);
probeInfo = await response.json();
} catch (error) {
console.warn('[Player] Remux probe failed:', error.message);
}
}
if (this.playbackGeneration !== playbackGeneration) return;
- const remuxUrl = this.getRemuxUrl(streamUrl, probeInfo?.audioCodecs || (probeInfo?.audio ? [probeInfo.audio] : []));
+ const remuxUrl = this.getRemuxUrl(streamUrl, probeInfo?.audioCodecs || (probeInfo?.audio ? [probeInfo.audio] : []), channel?.program);
this.currentUrl = remuxUrl;
this.getCaptionTracks().setProbe(probeInfo, streamUrl);
this.getCaptionTracks().useNative();
@@ -902,6 +904,7 @@ class VideoPlayer {
window.dispatchEvent(new CustomEvent('channelChanged', { detail: channel }));
} catch (err) {
+ if (err.name === 'AbortError') return;
console.error('Error playing channel:', err);
this.showError('Failed to play channel');
}
@@ -1109,8 +1112,9 @@ class VideoPlayer {
* Get remuxed URL for a stream (container conversion only, no re-encoding)
* Used for raw .ts streams that browsers can't play directly
*/
- getRemuxUrl(url, audioCodecs = []) {
- return `/api/remux?url=${encodeURIComponent(url)}&audioCodecs=${encodeURIComponent(audioCodecs.join(','))}`;
+ getRemuxUrl(url, audioCodecs = [], program = null) {
+ const selection = program == null ? '' : '&program=' + encodeURIComponent(program);
+ return `/api/remux?url=${encodeURIComponent(url)}&audioCodecs=${encodeURIComponent(audioCodecs.join(','))}${selection}`;
}
/**
@@ -1129,6 +1133,8 @@ class VideoPlayer {
* Stop playback
*/
stop() {
+ this.zapController?.abort();
+ this.zapController = null;
this.playbackGeneration = (this.playbackGeneration || 0) + 1;
this.audioRecovery?.dispose();
this.audioRecovery = null;
@@ -1311,11 +1317,13 @@ class VideoPlayer {
}
}
- async zapBeforeStreaming(channel, streamUrl) {
+ async zapBeforeStreaming(channel, streamUrl, signal) {
if (!channel?.zapBeforeStreaming) return;
if (!channel?.streamId || !streamUrl) return;
- const delay = parseInt(channel.zapDelay) || 1500;
+ const requestedDelay = Number(channel.zapDelay ?? 1500);
+ const delay = Number.isFinite(requestedDelay) ? Math.max(0, Math.min(10000, requestedDelay)) : 1500;
+ signal?.throwIfAborted();
try {
console.log('[ZAP] before streaming:', {
@@ -1326,6 +1334,7 @@ class VideoPlayer {
// The server resolves the receiver from the configured source
const res = await fetch('/api/openwebif/zap', {
+ signal,
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
@@ -1338,8 +1347,14 @@ class VideoPlayer {
console.warn('[ZAP] backend returned:', res.status);
}
- await new Promise(resolve => setTimeout(resolve, delay));
+ await new Promise((resolve, reject) => {
+ const abort = () => { clearTimeout(timer); reject(Object.assign(new Error('Playback was superseded'), { name: 'AbortError' })); };
+ const timer = setTimeout(() => { signal?.removeEventListener('abort', abort); resolve(); }, delay);
+ signal?.addEventListener('abort', abort, { once: true });
+ if (signal?.aborted) abort();
+ });
} catch (err) {
+ if (signal?.aborted || err.name === 'AbortError') throw err;
console.warn('[ZAP] failed, continuing stream:', err);
}
}
diff --git a/public/js/pages/WatchPage.js b/public/js/pages/WatchPage.js
index 9b7527b..bc027e6 100644
--- a/public/js/pages/WatchPage.js
+++ b/public/js/pages/WatchPage.js
@@ -420,6 +420,8 @@ class WatchPage {
this.stop();
this.setSourceDuration(VodDuration.firstValid(VodDuration.fromContent(this.content || {}), knownDuration));
const playbackGeneration = this.playbackGeneration;
+ const program = this.content?.program ?? this.content?.data?.program ?? null;
+ const programQuery = program == null ? '' : '&program=' + encodeURIComponent(program);
let probeInfo = null;
// Show loading spinner
@@ -444,7 +446,7 @@ class WatchPage {
console.log('[WatchPage] Auto Transcode enabled. Probing stream...');
try {
const ua = settings.userAgentPreset === 'custom' ? settings.userAgentCustom : settings.userAgentPreset;
- const probeRes = await fetch(`/api/probe?url=${encodeURIComponent(url)}&ua=${encodeURIComponent(ua || '')}`);
+ const probeRes = await fetch(`/api/probe?url=${encodeURIComponent(url)}&ua=${encodeURIComponent(ua || '')}${programQuery}`);
const info = await probeRes.json();
if (this.playbackGeneration !== playbackGeneration) return;
probeInfo = info;
@@ -467,7 +469,7 @@ class WatchPage {
this.updateTranscodeStatus(statusMode, statusText);
if (this.playbackGeneration !== playbackGeneration) return;
const playlistUrl = await this.startTranscodeSession(url, {
- program: this.content?.program || this.content?.data?.program || null,
+ program,
videoMode,
seekOffset: this.resumeTime, // Ensure seekOffset is passed
videoCodec: info.video,
@@ -484,7 +486,7 @@ class WatchPage {
// TODO: Move remux to session logic if seeking is needed for TS files
console.log('[WatchPage] Auto: Using remux (.ts container)');
this.updateTranscodeStatus('remuxing', 'Remux (Auto)');
- const finalUrl = this.getRemuxUrl(url, info.audioCodecs || [info.audio]);
+ const finalUrl = this.getRemuxUrl(url, info.audioCodecs || [info.audio], program);
this.video.src = finalUrl;
this.video.play().catch(e => {
if (e.name !== 'AbortError') console.error('[WatchPage] Autoplay error:', e);
@@ -509,7 +511,7 @@ class WatchPage {
this.updateTranscodeStatus(statusMode, statusText);
if (this.playbackGeneration !== playbackGeneration) return;
const playlistUrl = await this.startTranscodeSession(url, {
- program: this.content?.program || this.content?.data?.program || null,
+ program,
videoMode: 'encode',
seekOffset: this.resumeTime
});
@@ -527,7 +529,7 @@ class WatchPage {
let videoCodec = 'unknown';
try {
const ua = settings.userAgentPreset === 'custom' ? settings.userAgentCustom : settings.userAgentPreset;
- const probeRes = await fetch(`/api/probe?url=${encodeURIComponent(url)}&ua=${encodeURIComponent(ua || '')}`);
+ const probeRes = await fetch(`/api/probe?url=${encodeURIComponent(url)}&ua=${encodeURIComponent(ua || '')}${programQuery}`);
const info = await probeRes.json();
if (this.playbackGeneration !== playbackGeneration) return;
videoCodec = info.video;
@@ -535,7 +537,7 @@ class WatchPage {
if (this.playbackGeneration !== playbackGeneration) return;
const playlistUrl = await this.startTranscodeSession(url, {
- program: this.currentChannel?.program || null,
+ program,
videoMode: 'copy',
videoCodec,
seekOffset: this.resumeTime
@@ -547,20 +549,20 @@ class WatchPage {
}
// Priority 2: Force Remux for raw TS streams
- if (settings.forceRemux && isRawTs) {
+ if ((settings.forceRemux && isRawTs) || (program !== null && !looksLikeHls)) {
console.log('[WatchPage] Force Remux enabled');
- this.updateTranscodeStatus('remuxing', 'Remux (Force)');
+ this.updateTranscodeStatus('remuxing', settings.forceRemux ? 'Remux (Force)' : 'Remux (Program)');
if (!probeInfo) {
try {
const ua = settings.userAgentPreset === 'custom' ? settings.userAgentCustom : settings.userAgentPreset;
- const response = await fetch(`/api/probe?url=${encodeURIComponent(url)}&ua=${encodeURIComponent(ua || '')}`);
+ const response = await fetch(`/api/probe?url=${encodeURIComponent(url)}&ua=${encodeURIComponent(ua || '')}${programQuery}`);
probeInfo = await response.json();
} catch (error) {
console.warn('[WatchPage] Remux probe failed:', error.message);
}
}
if (this.playbackGeneration !== playbackGeneration) return;
- const finalUrl = this.getRemuxUrl(url, probeInfo?.audioCodecs || (probeInfo?.audio ? [probeInfo.audio] : []));
+ const finalUrl = this.getRemuxUrl(url, probeInfo?.audioCodecs || (probeInfo?.audio ? [probeInfo.audio] : []), program);
this.video.src = finalUrl;
this.video.play().catch(e => {
if (e.name !== 'AbortError') console.error('[WatchPage] Autoplay error:', e);
@@ -650,8 +652,9 @@ class WatchPage {
if (this.volumeSlider) this.volumeSlider.value = savedVolume;
}
- getRemuxUrl(url, audioCodecs = []) {
- return `/api/remux?url=${encodeURIComponent(url)}&audioCodecs=${encodeURIComponent(audioCodecs.join(','))}`;
+ getRemuxUrl(url, audioCodecs = [], program = null) {
+ const selection = program == null ? '' : '&program=' + encodeURIComponent(program);
+ return `/api/remux?url=${encodeURIComponent(url)}&audioCodecs=${encodeURIComponent(audioCodecs.join(','))}${selection}`;
}
stop() {
diff --git a/server/routes/probe.js b/server/routes/probe.js
index f4a2e33..5e254a1 100644
--- a/server/routes/probe.js
+++ b/server/routes/probe.js
@@ -1,3 +1,4 @@
+const { parseProgram, selectProgramStreams } = require('../services/mpegTsProgram');
const { redactUrl, redactText, validateHttpUrl, FFMPEG_PROTOCOL_WHITELIST } = require('../services/urlSecurity');
const express = require('express');
const router = express.Router();
@@ -41,6 +42,7 @@ function probeStream(url, ffprobePath, userAgent = null, timeout = 15000) {
'-reconnect_delay_max', '10',
'-print_format', 'json',
'-show_streams',
+ '-show_programs',
'-show_format',
'-protocol_whitelist', FFMPEG_PROTOCOL_WHITELIST,
'-probesize', '5000000',
@@ -84,8 +86,8 @@ function probeStream(url, ffprobePath, userAgent = null, timeout = 15000) {
/**
* Analyze probe result and determine compatibility
*/
-function analyzeProbeResult(probeResult, url) {
- const streams = probeResult.streams || [];
+function analyzeProbeResult(probeResult, url, program = null) {
+ const streams = selectProgramStreams(probeResult, program);
const format = probeResult.format || {};
const videoStream = streams.find(s => s.codec_type === 'video');
@@ -152,13 +154,15 @@ function analyzeProbeResult(probeResult, url) {
router.get('/', async (req, res) => {
const { url, ua } = req.query;
+ let program;
+ try { program = parseProgram(req.query.program); } catch (error) { return res.status(400).json({ error: error.message }); }
try { validateHttpUrl(url); } catch (error) { return res.status(400).json({ error: error.message }); }
if (!url) {
return res.status(400).json({ error: 'URL parameter is required' });
}
const ffprobePath = req.app.locals.ffprobePath;
- const cacheKey = `${url}${ua ? `|${ua}` : ''}`;
+ const cacheKey = JSON.stringify([url, ua || '', program]);
if (!ffprobePath) {
// No ffprobe available - assume needs transcoding to be safe
@@ -184,7 +188,7 @@ router.get('/', async (req, res) => {
try {
const probeResult = await probeStream(url, ffprobePath, ua);
- const analysis = analyzeProbeResult(probeResult, url);
+ const analysis = analyzeProbeResult(probeResult, url, program);
// Cache result
probeCache.set(cacheKey, { result: analysis, timestamp: Date.now() });
diff --git a/server/routes/remux.js b/server/routes/remux.js
index 61553bc..395543b 100644
--- a/server/routes/remux.js
+++ b/server/routes/remux.js
@@ -1,3 +1,4 @@
+const { parseProgram } = require('../services/mpegTsProgram');
const { redactUrl, redactText, validateHttpUrl } = require('../services/urlSecurity');
const express = require('express');
const router = express.Router();
@@ -73,8 +74,9 @@ router.get('/', async (req, res) => {
return res.status(400).json({ error: 'URL must be an http:// or https:// stream URL' });
}
- let audioCodecs;
+ let audioCodecs, program;
try {
+ program = parseProgram(req.query.program);
audioCodecs = parseAudioCodecs(req.query.audioCodecs);
} catch (error) {
return res.status(400).json({ error: error.message });
@@ -120,8 +122,8 @@ router.get('/', async (req, res) => {
'-i', safeUrl,
// STRICT MAPPING: Only map video and audio, ignore subtitles/data/attachments
// This prevents remux failure when source container has incompatible subtitle tracks (e.g. MKV -> MP4)
- '-map', '0:v',
- '-map', '0:a',
+ '-map', program === null ? '0:v' : `0:p:${program}:v:0`,
+ '-map', program === null ? '0:a' : `0:p:${program}:a?`,
// Drop subtitles (-sn) and data (-dn) explicitly
'-sn', '-dn',
// Copy streams without re-encoding
diff --git a/server/routes/sources.js b/server/routes/sources.js
index baa128c..753be6a 100644
--- a/server/routes/sources.js
+++ b/server/routes/sources.js
@@ -1,5 +1,9 @@
const express = require('express');
const router = express.Router();
+const zapDelayValue = value => {
+ const delay = Number(value ?? 1500);
+ return Number.isFinite(delay) ? Math.max(0, Math.min(10000, Math.trunc(delay))) : 1500;
+};
const { sources } = require('../db');
const { getDb } = require('../db/sqlite');
const xtreamApi = require('../services/xtreamApi');
@@ -73,7 +77,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, zapBeforeStreaming: zapBeforeStreaming === true, zapDelay: parseInt(zapDelay) || 1500 });
+ const source = await sources.create({ type, name, url, username, password, zapBeforeStreaming: zapBeforeStreaming === true, zapDelay: zapDelayValue(zapDelay) });
// Trigger Sync
syncService.syncSource(source.id).catch(console.error);
res.status(201).json(source);
@@ -100,7 +104,7 @@ router.put('/:id', async (req, res) => {
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;
+ if (zapDelay !== undefined) updates.zapDelay = zapDelayValue(zapDelay);
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)
diff --git a/server/routes/transcode.js b/server/routes/transcode.js
index 87af970..82d3305 100644
--- a/server/routes/transcode.js
+++ b/server/routes/transcode.js
@@ -1,3 +1,4 @@
+const { parseProgram } = require('../services/mpegTsProgram');
const { redactUrl, redactText, validateHttpUrl, FFMPEG_PROTOCOL_WHITELIST } = require('../services/urlSecurity');
const express = require('express');
const router = express.Router();
@@ -34,8 +35,8 @@ transcodeSession.startCleanupInterval();
*/
router.post('/session', startupLimit, async (req, res) => {
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;
+ let programNumber;
+ try { programNumber = parseProgram(program); } catch (error) { return res.status(400).json({ error: error.message }); }
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;
diff --git a/server/services/mpegTsProgram.js b/server/services/mpegTsProgram.js
new file mode 100644
index 0000000..762e8a5
--- /dev/null
+++ b/server/services/mpegTsProgram.js
@@ -0,0 +1,19 @@
+/** Optional MPEG-TS service/program identifier; reject structured or malformed input. */
+function parseProgram(value) {
+ if (value === undefined || value === null) return null;
+ if (!['string', 'number'].includes(typeof value) || !/^\d{1,5}$/.test(String(value))) {
+ throw new Error('Program must be an integer from 1 to 65535');
+ }
+ const program = Number(value);
+ if (program < 1 || program > 65535) throw new Error('Program must be an integer from 1 to 65535');
+ return String(program);
+}
+
+function selectProgramStreams(result, program) {
+ if (program === null) return result.streams || [];
+ const selected = result.programs?.find(item => String(item.program_id ?? item.program_num) === program);
+ if (!selected) throw new Error('Requested program was not found in the stream');
+ return selected.streams || [];
+}
+
+module.exports = { parseProgram, selectProgramStreams };
diff --git a/server/services/transcodeSession.js b/server/services/transcodeSession.js
index fae20b4..e06fe9a 100644
--- a/server/services/transcodeSession.js
+++ b/server/services/transcodeSession.js
@@ -1,3 +1,4 @@
+const { parseProgram } = require('./mpegTsProgram');
const { redactUrl, redactText, validateHttpUrl, FFMPEG_PROTOCOL_WHITELIST } = require('./urlSecurity');
/**
* Transcode Session Service
@@ -103,7 +104,8 @@ class TranscodeSession extends EventEmitter {
upscaleEnabled: options.upscaleEnabled || false,
upscaleMethod: options.upscaleMethod || 'hardware', // 'hardware' or 'software'
upscaleTarget: options.upscaleTarget || '1080p',
- ...options
+ ...options,
+ program: parseProgram(options.program)
};
}
diff --git a/test/enigma2-playback.test.js b/test/enigma2-playback.test.js
new file mode 100644
index 0000000..09a9a7b
--- /dev/null
+++ b/test/enigma2-playback.test.js
@@ -0,0 +1,198 @@
+process.env.ALLOW_LOCAL_MEDIA_URLS = 'true';
+const test = require('node:test');
+const assert = require('node:assert/strict');
+const fs = require('node:fs');
+const os = require('node:os');
+const path = require('node:path');
+const vm = require('node:vm');
+const http = require('node:http');
+const { createRequire } = require('node:module');
+const { spawnSync } = require('node:child_process');
+const express = require('express');
+const { parseProgram } = require('../server/services/mpegTsProgram');
+
+function playerFixture(name, settings, fetcher) {
+ const requests = [], sessions = [];
+ const window = { dispatchEvent() {} };
+ const document = { getElementById() { return null; }, removeEventListener() {} };
+ const probe = { video: 'h264', audio: 'aac', audioChannels: 2, audioCodecs: ['aac'], needsRemux: true };
+ const context = { window, document, AbortController, setTimeout, clearTimeout, CustomEvent: class {},
+ CaptionTracks: require('../public/js/components/CaptionTracks'),
+ VodDuration: require('../public/js/components/VodDuration'), console: { log() {}, warn() {}, error() {} },
+ API: { settings: { get: async () => settings } },
+ fetch: async (url, options) => {
+ requests.push({ url, options });
+ if (fetcher) return fetcher(url, options);
+ return { ok: true, json: async () => url.includes('/zap') ? { success: true } : probe };
+ } };
+ vm.runInNewContext(fs.readFileSync(path.join(__dirname, '../public/js', name === 'VideoPlayer' ? 'components/VideoPlayer.js' : 'pages/WatchPage.js'), 'utf8'), context);
+ const player = Object.create(window[name].prototype);
+ const element = { classList: { add() {}, remove() {} } };
+ Object.assign(player, { settings, content: { data: { program: '102' } }, video: { src: '', play: async () => {}, pause() {}, load() {}, querySelectorAll: () => [] },
+ overlay: element, nowPlaying: element, updateTranscodeStatus() {}, updateQualityBadge() {},
+ updateNowPlaying() {}, showNowPlayingOverlay() {}, fetchEpgData() {}, setVolumeFromStorage() {}, showLoading() {},
+ stopHistoryTracking() {}, saveProgress() {},
+ playHls(url) { this.video.src = url; }, showError(message) { throw Error(message); },
+ startTranscodeSession: async (url, options) => { sessions.push({ url, options }); return '/api/session/stream.m3u8'; }
+ });
+ return { player, requests, sessions, context, start: () => name === 'VideoPlayer'
+ ? player.play({ id: 'service', program: '102' }, 'http://receiver/multi.ts') : player.loadVideo('http://receiver/multi.ts') };
+}
+
+for (const name of ['VideoPlayer', 'WatchPage']) {
+ for (const mode of ['autoTranscode', 'forceRemux', 'default', 'forceTranscode', 'forceVideoTranscode']) {
+ test(`${name} selects the requested program in ${mode} mode`, async () => {
+ const f = playerFixture(name, mode === 'default' ? {} : { [mode]: true });
+ await f.start();
+ for (const { url } of f.requests.filter(request => request.url.startsWith('/api/probe'))) {
+ assert.equal(new URL(url, 'http://app').searchParams.get('program'), '102');
+ }
+ if (mode === 'forceTranscode' || mode === 'forceVideoTranscode') {
+ assert.equal(f.sessions.length, 1);
+ assert.equal(f.sessions[0].options.program, '102');
+ } else {
+ const remux = new URL(f.player.video.src, 'http://app');
+ assert.equal(remux.pathname, '/api/remux');
+ assert.equal(remux.searchParams.get('program'), '102');
+ assert.equal(remux.searchParams.get('audioCodecs'), 'aac');
+ }
+ });
+ }
+}
+
+test('Stop and leaving Live TV cancel the actual zap delay without starting playback', async () => {
+ for (const navigate of [false, true]) {
+ let requested;
+ const request = new Promise(resolve => { requested = resolve; });
+ const f = playerFixture('VideoPlayer', { forceVideoTranscode: true }, async (url, options) => {
+ requested(options.signal);
+ return { ok: true, json: async () => ({ success: true }) };
+ });
+ const playing = f.player.play({ streamId: 'service', sourceId: 1, zapBeforeStreaming: true, zapDelay: 10000 }, 'http://receiver/multi.ts');
+ const signal = await request;
+ if (navigate) {
+ vm.runInNewContext(fs.readFileSync(path.join(__dirname, '../public/js/pages/LivePage.js'), 'utf8'), f.context);
+ new f.context.window.LivePage({ player: f.player }).hide();
+ } else f.player.stop();
+ await playing;
+ assert.equal(signal.aborted, true);
+ assert.equal(f.sessions.length, 0);
+ assert.equal(f.player.video.src, '');
+ }
+});
+
+test('Stop cancels an in-flight zap request and releases the old session before retuning', async () => {
+ let requested, released = false;
+ const ready = new Promise(resolve => { requested = resolve; });
+ const f = playerFixture('VideoPlayer', { forceVideoTranscode: true }, (url, { signal }) => {
+ assert.equal(released, true);
+ requested();
+ return new Promise((resolve, reject) => signal.addEventListener('abort', () => reject(Object.assign(Error('Cancelled'), { name: 'AbortError' })), { once: true }));
+ });
+ f.player.playbackSessions = { stop() { released = true; } };
+ const playing = f.player.play({ streamId: 'service', sourceId: 1, zapBeforeStreaming: true }, 'http://receiver/multi.ts');
+ await ready;
+ f.player.stop();
+ await playing;
+ assert.equal(f.sessions.length, 0);
+});
+
+test('an older zap completing after the latest selection cannot replace its playback', async () => {
+ const f = playerFixture('VideoPlayer', { forceVideoTranscode: true });
+ const releases = {};
+ f.player.zapBeforeStreaming = channel => new Promise(resolve => { releases[channel.id] = resolve; });
+ const old = f.player.play({ id: 'old', zapBeforeStreaming: true }, 'http://receiver/old.ts');
+ const latest = f.player.play({ id: 'latest', zapBeforeStreaming: true }, 'http://receiver/latest.ts');
+ releases.latest(); await latest;
+ releases.old(); await old;
+ assert.deepEqual(f.sessions.map(session => session.url), ['http://receiver/latest.ts']);
+ assert.equal(f.player.currentChannel.id, 'latest');
+});
+
+function routeFixture(file) {
+ const filename = path.join(__dirname, '../server/routes', file);
+ const realRequire = createRequire(filename);
+ const module = { exports: {} };
+ const context = { module, URL, console: { log() {}, error() {} }, setTimeout, clearTimeout,
+ require(id) { return id === '../db' ? { settings: { get: async () => ({}) }, getUserAgent: () => 'fixture' } : realRequire(id); } };
+ vm.runInNewContext(fs.readFileSync(filename, 'utf8'), context, { filename });
+ return { router: module.exports, context };
+}
+
+test('program validation rejects malformed/structured inputs and probing selects only the requested service', () => {
+ for (const value of ['', [], ['102'], {}, true, -1, 0, 65536, '102:a', '1e2', ' 102', 1.5]) assert.throws(() => parseProgram(value));
+ assert.equal(parseProgram(undefined), null);
+ assert.equal(parseProgram(102), '102');
+ const { context } = routeFixture('probe.js');
+ const first = [{ index: 0, codec_type: 'video', codec_name: 'h264', width: 64 }, { index: 1, codec_type: 'audio', codec_name: 'mp3', channels: 1 }];
+ const second = [{ index: 2, codec_type: 'video', codec_name: 'hevc', width: 96 }, { index: 3, codec_type: 'audio', codec_name: 'aac', channels: 2 }];
+ const input = { streams: [...first, ...second], programs: [{ program_id: 101, streams: first }, { program_id: 102, streams: second }], format: { format_name: 'mpegts' } };
+ const info = context.analyzeProbeResult(input, 'http://receiver/multi.ts', '102');
+ assert.equal(info.video, 'hevc'); assert.equal(info.width, 96); assert.equal(info.audioChannels, 2);
+ assert.deepEqual(Array.from(info.audioCodecs), ['aac']);
+ assert.equal(info.needsTranscode, true);
+ assert.throws(() => context.analyzeProbeResult(input, 'http://receiver/multi.ts', '103'), /not found/);
+});
+
+test('real FFmpeg probes, remuxes and transcodes only the requested MPEG-TS program', { timeout: 60000 }, async t => {
+ const binary = spawnSync('ffmpeg', ['-version'], { stdio: 'ignore' }).status === 0 ? 'ffmpeg' : require('ffmpeg-static');
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'nodecast-program-'));
+ t.after(() => fs.rmSync(dir, { recursive: true, force: true }));
+ const source = path.join(dir, 'multi.ts');
+ const run = args => { const result = spawnSync(binary, ['-hide_banner', '-loglevel', 'error', ...args], { timeout: 15000, encoding: 'utf8' }); assert.equal(result.status, 0, result.stderr); return result; };
+ run(['-y', '-f', 'lavfi', '-i', 'color=c=red:s=64x64:r=10', '-f', 'lavfi', '-i', 'sine=frequency=500:sample_rate=48000',
+ '-f', 'lavfi', '-i', 'color=c=blue:s=96x64:r=10', '-f', 'lavfi', '-i', 'sine=frequency=1000:sample_rate=48000', '-t', '12',
+ '-map', '0:v', '-map', '1:a', '-map', '2:v', '-map', '3:a', '-c:v', 'libx264', '-threads', '1', '-preset', 'ultrafast', '-g', '10',
+ '-c:a:0', 'libmp3lame', '-c:a:1', 'aac', '-program', 'program_num=101:st=0:st=1', '-program', 'program_num=102:st=2:st=3', '-f', 'mpegts', source]);
+ const media = fs.readFileSync(source);
+ const probe = routeFixture('probe.js');
+ const app = express();
+ app.locals.ffprobePath = require('@ffprobe-installer/ffprobe').path;
+ app.locals.ffmpegPath = binary;
+ app.get('/multi.ts', (req, res) => res.end(media));
+ app.use('/api/probe', probe.router); app.use('/api/remux', routeFixture('remux.js').router);
+ const server = http.createServer(app);
+ await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
+ t.after(() => new Promise(resolve => { server.close(resolve); server.closeAllConnections(); }));
+ const base = `http://127.0.0.1:${server.address().port}`;
+ const url = encodeURIComponent(base + '/multi.ts');
+ for (const [program, width, audio] of [['102', 96, 'aac'], ['101', 64, 'mp3'], ['102', 96, 'aac']]) {
+ const response = await fetch(`${base}/api/probe?url=${url}&program=${program}`);
+ assert.equal(response.status, 200);
+ const info = await response.json(); assert.equal(info.width, width); assert.deepEqual(info.audioCodecs, [audio]);
+ const remux = await fetch(`${base}/api/remux?url=${url}&program=${program}&audioCodecs=${audio}`);
+ assert.equal(remux.status, 200);
+ const output = path.join(dir, `program${program}.mp4`); fs.writeFileSync(output, Buffer.from(await remux.arrayBuffer()));
+ const details = spawnSync(app.locals.ffprobePath, ['-v', 'error', '-show_streams', '-of', 'json', output], { encoding: 'utf8' });
+ assert.equal(details.status, 0, details.stderr);
+ const streams = JSON.parse(details.stdout).streams;
+ assert.equal(streams.filter(stream => stream.codec_type === 'video').length, 1);
+ assert.equal(streams.find(stream => stream.codec_type === 'video').width, width);
+ assert.deepEqual(streams.filter(stream => stream.codec_type === 'audio').map(stream => stream.codec_name), [audio]);
+ run(['-i', output, '-map', '0:v', '-map', '0:a', '-f', 'null', '-']);
+ }
+ for (const endpoint of ['probe', 'remux']) {
+ for (const query of ['program=102&program=101', 'program[x]=102', 'program=65536', 'program=102:a']) {
+ assert.equal((await fetch(`${base}/api/${endpoint}?url=${url}&${query}`)).status, 400);
+ }
+ }
+ // Encode the requested service using the actual session argument builder.
+ const filename = path.join(__dirname, '../server/services/transcodeSession.js');
+ const module = { exports: {} };
+ vm.runInNewContext(fs.readFileSync(filename, 'utf8'), { module, require: createRequire(filename), process: { env: {}, cwd: () => dir }, console,
+ setTimeout, clearTimeout, setInterval, clearInterval });
+ const session = new module.exports.TranscodeSession(base + '/multi.ts', { program: '102', videoMode: 'encode', videoHeight: 64, audioCodec: 'aac', audioChannels: 2, quality: 'low', audioMixPreset: 'auto' });
+ fs.mkdirSync(session.dir, { recursive: true });
+ // Spawn asynchronously so the fixture HTTP server can deliver input while FFmpeg is running.
+ const { spawn } = require('node:child_process');
+ await new Promise((resolve, reject) => {
+ const child = spawn(binary, session.buildFFmpegArgs()); let stderr = '';
+ const timer = setTimeout(() => { child.kill(); reject(Error('Session encode timed out')); }, 15000);
+ child.stderr.on('data', data => { stderr += data; }); child.on('error', reject);
+ child.on('close', code => { clearTimeout(timer); if (code === 0) resolve(); else reject(Error(stderr)); });
+ });
+ assert.ok(fs.existsSync(session.playlistPath));
+ const output = spawnSync(app.locals.ffprobePath, ['-v', 'error', '-show_streams', '-of', 'json', session.playlistPath], { encoding: 'utf8' });
+ assert.equal(output.status, 0, output.stderr);
+ assert.equal(JSON.parse(output.stdout).streams.find(stream => stream.codec_type === 'video').width, 96);
+});
diff --git a/test/openwebif-zap.test.js b/test/openwebif-zap.test.js
index 751f649..d8ca26f 100644
--- a/test/openwebif-zap.test.js
+++ b/test/openwebif-zap.test.js
@@ -100,9 +100,13 @@ test('zap endpoint requires authentication and only zaps channels of zap-enabled
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 } });
+ type: 'm3u', name: 'Receiver', url: `${receiver.base}/web/services.m3u`, zapBeforeStreaming: true, zapDelay: 0 } });
const plainSource = await request('/api/sources', { headers: adminHeaders, body: {
type: 'm3u', name: 'Plain', url: `${receiver.base}/web/services.m3u` } });
+ assert.equal(zapSource.body.zapDelay, 0);
+ for (const program of [['101'], {}, true, '102:a', '65536', 0]) {
+ assert.equal((await request('/api/transcode/session', { headers: adminHeaders, body: { url: 'http://provider.test/media.ts', program } })).status, 400);
+ }
const [channel] = await waitForChannels(request, adminHeaders, zapSource.body.id);
assert.equal(channel.stream_id, RTP1);
assert.equal(channel.program, '101');