From 53b65b6a0db5ac39a86013e763d5d4a57eba145a Mon Sep 17 00:00:00 2001 From: Technomancer702 Date: Tue, 13 Oct 2026 17:56:26 -0700 Subject: [PATCH] Cancel superseded Enigma2 startup and select programs throughout playback --- README.md | 6 + package-lock.json | 4 +- package.json | 2 +- public/js/components/ChannelList.js | 2 +- public/js/components/SourceManager.js | 6 +- public/js/components/VideoPlayer.js | 53 ++++--- public/js/pages/WatchPage.js | 27 ++-- server/routes/probe.js | 12 +- server/routes/remux.js | 8 +- server/routes/sources.js | 8 +- server/routes/transcode.js | 5 +- server/services/mpegTsProgram.js | 19 +++ server/services/transcodeSession.js | 4 +- test/enigma2-playback.test.js | 198 ++++++++++++++++++++++++++ test/openwebif-zap.test.js | 6 +- 15 files changed, 309 insertions(+), 51 deletions(-) create mode 100644 server/services/mpegTsProgram.js create mode 100644 test/enigma2-playback.test.js 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 {
+ 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');