Cancel superseded Enigma2 startup and select programs throughout playback

This commit is contained in:
Technomancer702
2026-10-13 17:56:26 -07:00
parent f2effe0832
commit 53b65b6a0d
15 changed files with 309 additions and 51 deletions

View File

@ -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 | | **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 ## Troubleshooting
### Video Won't Play (Black Screen or Loading Forever) ### Video Won't Play (Black Screen or Loading Forever)

4
package-lock.json generated
View File

@ -1,12 +1,12 @@
{ {
"name": "nodecast-tv", "name": "nodecast-tv",
"version": "2.2.2", "version": "2.2.3",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "nodecast-tv", "name": "nodecast-tv",
"version": "2.2.2", "version": "2.2.3",
"license": "GPL-3.0-only", "license": "GPL-3.0-only",
"dependencies": { "dependencies": {
"@ffprobe-installer/ffprobe": "^2.1.2", "@ffprobe-installer/ffprobe": "^2.1.2",

View File

@ -1,6 +1,6 @@
{ {
"name": "nodecast-tv", "name": "nodecast-tv",
"version": "2.2.2", "version": "2.2.3",
"description": "Modern IPTV Player", "description": "Modern IPTV Player",
"license": "GPL-3.0-only", "license": "GPL-3.0-only",
"main": "server/index.js", "main": "server/index.js",

View File

@ -846,7 +846,7 @@ class ChannelList {
url: stream.stream_url, // M3U has direct URLs url: stream.stream_url, // M3U has direct URLs
program: stream.program || null, program: stream.program || null,
zapBeforeStreaming: source?.zapBeforeStreaming || false, zapBeforeStreaming: source?.zapBeforeStreaming || false,
zapDelay: source?.zapDelay || 1500, zapDelay: source?.zapDelay ?? 1500,
groupId: `m3u_${sourceId}_${stream.category_id}`, groupId: `m3u_${sourceId}_${stream.category_id}`,
groupTitle: categories.find(c => String(c.category_id) === String(stream.category_id))?.category_name || 'Uncategorized', groupTitle: categories.find(c => String(c.category_id) === String(stream.category_id))?.category_name || 'Uncategorized',
sourceId, sourceId,

View File

@ -266,7 +266,7 @@ class SourceManager {
<div class="form-group"> <div class="form-group">
<label for="source-zap-delay">Zap delay (ms)</label> <label for="source-zap-delay">Zap delay (ms)</label>
<input type="number" id="source-zap-delay" class="form-input" <input type="number" id="source-zap-delay" class="form-input"
value="${source.zapDelay || 1500}" min="0" step="100"> value="${source.zapDelay ?? 1500}" min="0" max="10000" step="100">
</div> </div>
` : ''; ` : '';
@ -282,7 +282,7 @@ class SourceManager {
const username = document.getElementById('source-username')?.value.trim() || null; const username = document.getElementById('source-username')?.value.trim() || null;
const password = document.getElementById('source-password')?.value.trim() || null; const password = document.getElementById('source-password')?.value.trim() || null;
const zapBeforeStreaming = document.getElementById('source-zap-before-streaming')?.checked || false; 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) { if (!name || !url) {
alert('Name and URL are required'); alert('Name and URL are required');
@ -347,7 +347,7 @@ class SourceManager {
} }
if (type === 'm3u') { if (type === 'm3u') {
data.zapBeforeStreaming = document.getElementById('source-zap-before-streaming')?.checked || false; 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); await API.sources.update(id, data);
document.getElementById('modal').classList.remove('active'); document.getElementById('modal').classList.remove('active');

View File

@ -646,13 +646,6 @@ class VideoPlayer {
* Play a channel * Play a channel
*/ */
async play(channel, streamUrl) { async play(channel, streamUrl) {
if(this.currentChannel != channel && channel?.zapBeforeStreaming){
await this.zapBeforeStreaming(channel, streamUrl);
}
this.currentChannel = channel;
try { try {
// Stop any WatchPage playback (movies/series) before starting Live TV // Stop any WatchPage playback (movies/series) before starting Live TV
window.app?.pages?.watch?.stop?.(); window.app?.pages?.watch?.stop?.();
@ -660,6 +653,15 @@ class VideoPlayer {
// Stop current playback // Stop current playback
this.stop(); this.stop();
const playbackGeneration = this.playbackGeneration; 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; let probeInfo = null;
this.updateTranscodeStatus('hidden'); this.updateTranscodeStatus('hidden');
@ -677,7 +679,7 @@ class VideoPlayer {
if (this.settings.autoTranscode) { if (this.settings.autoTranscode) {
console.log('[Player] Auto Transcode enabled. Probing stream...'); console.log('[Player] Auto Transcode enabled. Probing stream...');
try { 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(); const info = await probeRes.json();
if (this.playbackGeneration !== playbackGeneration) return; if (this.playbackGeneration !== playbackGeneration) return;
probeInfo = info; probeInfo = info;
@ -723,7 +725,7 @@ class VideoPlayer {
// Raw .ts container - use remux // Raw .ts container - use remux
console.log('[Player] Auto: Using remux (.ts container)'); console.log('[Player] Auto: Using remux (.ts container)');
this.updateTranscodeStatus('remuxing', 'Remux (Auto)'); 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.currentUrl = remuxUrl;
this.getCaptionTracks().setProbe(probeInfo, streamUrl); this.getCaptionTracks().setProbe(probeInfo, streamUrl);
this.getCaptionTracks().useNative(); this.getCaptionTracks().useNative();
@ -769,7 +771,7 @@ class VideoPlayer {
// Probe to get video codec for HEVC tag handling // Probe to get video codec for HEVC tag handling
let videoCodec = 'unknown'; let videoCodec = 'unknown';
try { 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(); const info = await probeRes.json();
if (this.playbackGeneration !== playbackGeneration) return; if (this.playbackGeneration !== playbackGeneration) return;
videoCodec = info.video; videoCodec = info.video;
@ -815,20 +817,20 @@ class VideoPlayer {
// Force Remux: Route through FFmpeg for container conversion // Force Remux: Route through FFmpeg for container conversion
// Applies to: 1) .ts streams when detected, or 2) ALL non-HLS streams when enabled // 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] Force Remux enabled. Routing through FFmpeg remux...');
console.log('[Player] Stream type:', isRawTs ? 'Raw TS' : 'Extension-less (assumed TS)'); 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) { if (!probeInfo) {
try { try {
const response = await fetch(`/api/probe?url=${encodeURIComponent(streamUrl)}`); const response = await fetch(`/api/probe?url=${encodeURIComponent(streamUrl)}${programQuery}`);
probeInfo = await response.json(); probeInfo = await response.json();
} catch (error) { } catch (error) {
console.warn('[Player] Remux probe failed:', error.message); console.warn('[Player] Remux probe failed:', error.message);
} }
} }
if (this.playbackGeneration !== playbackGeneration) return; 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.currentUrl = remuxUrl;
this.getCaptionTracks().setProbe(probeInfo, streamUrl); this.getCaptionTracks().setProbe(probeInfo, streamUrl);
this.getCaptionTracks().useNative(); this.getCaptionTracks().useNative();
@ -902,6 +904,7 @@ class VideoPlayer {
window.dispatchEvent(new CustomEvent('channelChanged', { detail: channel })); window.dispatchEvent(new CustomEvent('channelChanged', { detail: channel }));
} catch (err) { } catch (err) {
if (err.name === 'AbortError') return;
console.error('Error playing channel:', err); console.error('Error playing channel:', err);
this.showError('Failed to play channel'); this.showError('Failed to play channel');
} }
@ -1109,8 +1112,9 @@ class VideoPlayer {
* Get remuxed URL for a stream (container conversion only, no re-encoding) * Get remuxed URL for a stream (container conversion only, no re-encoding)
* Used for raw .ts streams that browsers can't play directly * Used for raw .ts streams that browsers can't play directly
*/ */
getRemuxUrl(url, audioCodecs = []) { getRemuxUrl(url, audioCodecs = [], program = null) {
return `/api/remux?url=${encodeURIComponent(url)}&audioCodecs=${encodeURIComponent(audioCodecs.join(','))}`; 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 playback
*/ */
stop() { stop() {
this.zapController?.abort();
this.zapController = null;
this.playbackGeneration = (this.playbackGeneration || 0) + 1; this.playbackGeneration = (this.playbackGeneration || 0) + 1;
this.audioRecovery?.dispose(); this.audioRecovery?.dispose();
this.audioRecovery = null; this.audioRecovery = null;
@ -1311,11 +1317,13 @@ class VideoPlayer {
} }
} }
async zapBeforeStreaming(channel, streamUrl) { async zapBeforeStreaming(channel, streamUrl, signal) {
if (!channel?.zapBeforeStreaming) return; if (!channel?.zapBeforeStreaming) return;
if (!channel?.streamId || !streamUrl) 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 { try {
console.log('[ZAP] before streaming:', { console.log('[ZAP] before streaming:', {
@ -1326,6 +1334,7 @@ class VideoPlayer {
// The server resolves the receiver from the configured source // The server resolves the receiver from the configured source
const res = await fetch('/api/openwebif/zap', { const res = await fetch('/api/openwebif/zap', {
signal,
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
@ -1338,8 +1347,14 @@ class VideoPlayer {
console.warn('[ZAP] backend returned:', res.status); 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) { } catch (err) {
if (signal?.aborted || err.name === 'AbortError') throw err;
console.warn('[ZAP] failed, continuing stream:', err); console.warn('[ZAP] failed, continuing stream:', err);
} }
} }

View File

@ -420,6 +420,8 @@ class WatchPage {
this.stop(); this.stop();
this.setSourceDuration(VodDuration.firstValid(VodDuration.fromContent(this.content || {}), knownDuration)); this.setSourceDuration(VodDuration.firstValid(VodDuration.fromContent(this.content || {}), knownDuration));
const playbackGeneration = this.playbackGeneration; const playbackGeneration = this.playbackGeneration;
const program = this.content?.program ?? this.content?.data?.program ?? null;
const programQuery = program == null ? '' : '&program=' + encodeURIComponent(program);
let probeInfo = null; let probeInfo = null;
// Show loading spinner // Show loading spinner
@ -444,7 +446,7 @@ class WatchPage {
console.log('[WatchPage] Auto Transcode enabled. Probing stream...'); console.log('[WatchPage] Auto Transcode enabled. Probing stream...');
try { try {
const ua = settings.userAgentPreset === 'custom' ? settings.userAgentCustom : settings.userAgentPreset; 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(); const info = await probeRes.json();
if (this.playbackGeneration !== playbackGeneration) return; if (this.playbackGeneration !== playbackGeneration) return;
probeInfo = info; probeInfo = info;
@ -467,7 +469,7 @@ class WatchPage {
this.updateTranscodeStatus(statusMode, statusText); this.updateTranscodeStatus(statusMode, statusText);
if (this.playbackGeneration !== playbackGeneration) return; if (this.playbackGeneration !== playbackGeneration) return;
const playlistUrl = await this.startTranscodeSession(url, { const playlistUrl = await this.startTranscodeSession(url, {
program: this.content?.program || this.content?.data?.program || null, program,
videoMode, videoMode,
seekOffset: this.resumeTime, // Ensure seekOffset is passed seekOffset: this.resumeTime, // Ensure seekOffset is passed
videoCodec: info.video, videoCodec: info.video,
@ -484,7 +486,7 @@ class WatchPage {
// TODO: Move remux to session logic if seeking is needed for TS files // TODO: Move remux to session logic if seeking is needed for TS files
console.log('[WatchPage] Auto: Using remux (.ts container)'); console.log('[WatchPage] Auto: Using remux (.ts container)');
this.updateTranscodeStatus('remuxing', 'Remux (Auto)'); 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.src = finalUrl;
this.video.play().catch(e => { this.video.play().catch(e => {
if (e.name !== 'AbortError') console.error('[WatchPage] Autoplay error:', e); if (e.name !== 'AbortError') console.error('[WatchPage] Autoplay error:', e);
@ -509,7 +511,7 @@ class WatchPage {
this.updateTranscodeStatus(statusMode, statusText); this.updateTranscodeStatus(statusMode, statusText);
if (this.playbackGeneration !== playbackGeneration) return; if (this.playbackGeneration !== playbackGeneration) return;
const playlistUrl = await this.startTranscodeSession(url, { const playlistUrl = await this.startTranscodeSession(url, {
program: this.content?.program || this.content?.data?.program || null, program,
videoMode: 'encode', videoMode: 'encode',
seekOffset: this.resumeTime seekOffset: this.resumeTime
}); });
@ -527,7 +529,7 @@ class WatchPage {
let videoCodec = 'unknown'; let videoCodec = 'unknown';
try { try {
const ua = settings.userAgentPreset === 'custom' ? settings.userAgentCustom : settings.userAgentPreset; 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(); const info = await probeRes.json();
if (this.playbackGeneration !== playbackGeneration) return; if (this.playbackGeneration !== playbackGeneration) return;
videoCodec = info.video; videoCodec = info.video;
@ -535,7 +537,7 @@ class WatchPage {
if (this.playbackGeneration !== playbackGeneration) return; if (this.playbackGeneration !== playbackGeneration) return;
const playlistUrl = await this.startTranscodeSession(url, { const playlistUrl = await this.startTranscodeSession(url, {
program: this.currentChannel?.program || null, program,
videoMode: 'copy', videoMode: 'copy',
videoCodec, videoCodec,
seekOffset: this.resumeTime seekOffset: this.resumeTime
@ -547,20 +549,20 @@ class WatchPage {
} }
// Priority 2: Force Remux for raw TS streams // 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'); console.log('[WatchPage] Force Remux enabled');
this.updateTranscodeStatus('remuxing', 'Remux (Force)'); this.updateTranscodeStatus('remuxing', settings.forceRemux ? 'Remux (Force)' : 'Remux (Program)');
if (!probeInfo) { if (!probeInfo) {
try { try {
const ua = settings.userAgentPreset === 'custom' ? settings.userAgentCustom : settings.userAgentPreset; 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(); probeInfo = await response.json();
} catch (error) { } catch (error) {
console.warn('[WatchPage] Remux probe failed:', error.message); console.warn('[WatchPage] Remux probe failed:', error.message);
} }
} }
if (this.playbackGeneration !== playbackGeneration) return; 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.src = finalUrl;
this.video.play().catch(e => { this.video.play().catch(e => {
if (e.name !== 'AbortError') console.error('[WatchPage] Autoplay error:', e); if (e.name !== 'AbortError') console.error('[WatchPage] Autoplay error:', e);
@ -650,8 +652,9 @@ class WatchPage {
if (this.volumeSlider) this.volumeSlider.value = savedVolume; if (this.volumeSlider) this.volumeSlider.value = savedVolume;
} }
getRemuxUrl(url, audioCodecs = []) { getRemuxUrl(url, audioCodecs = [], program = null) {
return `/api/remux?url=${encodeURIComponent(url)}&audioCodecs=${encodeURIComponent(audioCodecs.join(','))}`; const selection = program == null ? '' : '&program=' + encodeURIComponent(program);
return `/api/remux?url=${encodeURIComponent(url)}&audioCodecs=${encodeURIComponent(audioCodecs.join(','))}${selection}`;
} }
stop() { stop() {

View File

@ -1,3 +1,4 @@
const { parseProgram, selectProgramStreams } = require('../services/mpegTsProgram');
const { redactUrl, redactText, validateHttpUrl, FFMPEG_PROTOCOL_WHITELIST } = require('../services/urlSecurity'); const { redactUrl, redactText, validateHttpUrl, FFMPEG_PROTOCOL_WHITELIST } = require('../services/urlSecurity');
const express = require('express'); const express = require('express');
const router = express.Router(); const router = express.Router();
@ -41,6 +42,7 @@ function probeStream(url, ffprobePath, userAgent = null, timeout = 15000) {
'-reconnect_delay_max', '10', '-reconnect_delay_max', '10',
'-print_format', 'json', '-print_format', 'json',
'-show_streams', '-show_streams',
'-show_programs',
'-show_format', '-show_format',
'-protocol_whitelist', FFMPEG_PROTOCOL_WHITELIST, '-protocol_whitelist', FFMPEG_PROTOCOL_WHITELIST,
'-probesize', '5000000', '-probesize', '5000000',
@ -84,8 +86,8 @@ function probeStream(url, ffprobePath, userAgent = null, timeout = 15000) {
/** /**
* Analyze probe result and determine compatibility * Analyze probe result and determine compatibility
*/ */
function analyzeProbeResult(probeResult, url) { function analyzeProbeResult(probeResult, url, program = null) {
const streams = probeResult.streams || []; const streams = selectProgramStreams(probeResult, program);
const format = probeResult.format || {}; const format = probeResult.format || {};
const videoStream = streams.find(s => s.codec_type === 'video'); const videoStream = streams.find(s => s.codec_type === 'video');
@ -152,13 +154,15 @@ function analyzeProbeResult(probeResult, url) {
router.get('/', async (req, res) => { router.get('/', async (req, res) => {
const { url, ua } = req.query; 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 }); } try { validateHttpUrl(url); } catch (error) { return res.status(400).json({ error: error.message }); }
if (!url) { if (!url) {
return res.status(400).json({ error: 'URL parameter is required' }); return res.status(400).json({ error: 'URL parameter is required' });
} }
const ffprobePath = req.app.locals.ffprobePath; const ffprobePath = req.app.locals.ffprobePath;
const cacheKey = `${url}${ua ? `|${ua}` : ''}`; const cacheKey = JSON.stringify([url, ua || '', program]);
if (!ffprobePath) { if (!ffprobePath) {
// No ffprobe available - assume needs transcoding to be safe // No ffprobe available - assume needs transcoding to be safe
@ -184,7 +188,7 @@ router.get('/', async (req, res) => {
try { try {
const probeResult = await probeStream(url, ffprobePath, ua); const probeResult = await probeStream(url, ffprobePath, ua);
const analysis = analyzeProbeResult(probeResult, url); const analysis = analyzeProbeResult(probeResult, url, program);
// Cache result // Cache result
probeCache.set(cacheKey, { result: analysis, timestamp: Date.now() }); probeCache.set(cacheKey, { result: analysis, timestamp: Date.now() });

View File

@ -1,3 +1,4 @@
const { parseProgram } = require('../services/mpegTsProgram');
const { redactUrl, redactText, validateHttpUrl } = require('../services/urlSecurity'); const { redactUrl, redactText, validateHttpUrl } = require('../services/urlSecurity');
const express = require('express'); const express = require('express');
const router = express.Router(); 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' }); return res.status(400).json({ error: 'URL must be an http:// or https:// stream URL' });
} }
let audioCodecs; let audioCodecs, program;
try { try {
program = parseProgram(req.query.program);
audioCodecs = parseAudioCodecs(req.query.audioCodecs); audioCodecs = parseAudioCodecs(req.query.audioCodecs);
} catch (error) { } catch (error) {
return res.status(400).json({ error: error.message }); return res.status(400).json({ error: error.message });
@ -120,8 +122,8 @@ router.get('/', async (req, res) => {
'-i', safeUrl, '-i', safeUrl,
// STRICT MAPPING: Only map video and audio, ignore subtitles/data/attachments // 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) // This prevents remux failure when source container has incompatible subtitle tracks (e.g. MKV -> MP4)
'-map', '0:v', '-map', program === null ? '0:v' : `0:p:${program}:v:0`,
'-map', '0:a', '-map', program === null ? '0:a' : `0:p:${program}:a?`,
// Drop subtitles (-sn) and data (-dn) explicitly // Drop subtitles (-sn) and data (-dn) explicitly
'-sn', '-dn', '-sn', '-dn',
// Copy streams without re-encoding // Copy streams without re-encoding

View File

@ -1,5 +1,9 @@
const express = require('express'); const express = require('express');
const router = express.Router(); 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 { sources } = require('../db');
const { getDb } = require('../db/sqlite'); const { getDb } = require('../db/sqlite');
const xtreamApi = require('../services/xtreamApi'); const xtreamApi = require('../services/xtreamApi');
@ -73,7 +77,7 @@ router.post('/', async (req, res) => {
return res.status(400).json({ error: 'Invalid source type' }); 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 // Trigger Sync
syncService.syncSource(source.id).catch(console.error); syncService.syncSource(source.id).catch(console.error);
res.status(201).json(source); res.status(201).json(source);
@ -100,7 +104,7 @@ router.put('/:id', async (req, res) => {
if (username !== undefined) updates.username = username; if (username !== undefined) updates.username = username;
if (password !== undefined) updates.password = password; if (password !== undefined) updates.password = password;
if (zapBeforeStreaming !== undefined) updates.zapBeforeStreaming = zapBeforeStreaming === true; 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); const updated = await sources.update(req.params.id, updates);
if (!updated) return res.status(404).json({ error: 'Source not found' }); if (!updated) return res.status(404).json({ error: 'Source not found' });
// Trigger Sync (if critical fields changed? safely just trigger it) // Trigger Sync (if critical fields changed? safely just trigger it)

View File

@ -1,3 +1,4 @@
const { parseProgram } = require('../services/mpegTsProgram');
const { redactUrl, redactText, validateHttpUrl, FFMPEG_PROTOCOL_WHITELIST } = require('../services/urlSecurity'); const { redactUrl, redactText, validateHttpUrl, FFMPEG_PROTOCOL_WHITELIST } = require('../services/urlSecurity');
const express = require('express'); const express = require('express');
const router = express.Router(); const router = express.Router();
@ -34,8 +35,8 @@ transcodeSession.startCleanupInterval();
*/ */
router.post('/session', startupLimit, async (req, res) => { router.post('/session', startupLimit, async (req, res) => {
const { url, seekOffset, videoMode, videoCodec, audioCodec, audioChannels, videoHeight, playbackLeaseId, requestSequence, isLive, program } = 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 let programNumber;
const programNumber = /^\d+$/.test(String(program ?? '')) ? String(program) : null; try { programNumber = parseProgram(program); } catch (error) { return res.status(400).json({ error: error.message }); }
const normalizedSeek = Number(seekOffset ?? 0); const normalizedSeek = Number(seekOffset ?? 0);
if (!Number.isFinite(normalizedSeek) || normalizedSeek < 0) return res.status(400).json({ error: "Invalid seek offset" }); if (!Number.isFinite(normalizedSeek) || normalizedSeek < 0) return res.status(400).json({ error: "Invalid seek offset" });
let session = null, disconnected = false; let session = null, disconnected = false;

View File

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

View File

@ -1,3 +1,4 @@
const { parseProgram } = require('./mpegTsProgram');
const { redactUrl, redactText, validateHttpUrl, FFMPEG_PROTOCOL_WHITELIST } = require('./urlSecurity'); const { redactUrl, redactText, validateHttpUrl, FFMPEG_PROTOCOL_WHITELIST } = require('./urlSecurity');
/** /**
* Transcode Session Service * Transcode Session Service
@ -103,7 +104,8 @@ class TranscodeSession extends EventEmitter {
upscaleEnabled: options.upscaleEnabled || false, upscaleEnabled: options.upscaleEnabled || false,
upscaleMethod: options.upscaleMethod || 'hardware', // 'hardware' or 'software' upscaleMethod: options.upscaleMethod || 'hardware', // 'hardware' or 'software'
upscaleTarget: options.upscaleTarget || '1080p', upscaleTarget: options.upscaleTarget || '1080p',
...options ...options,
program: parseProgram(options.program)
}; };
} }

View File

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

View File

@ -100,9 +100,13 @@ test('zap endpoint requires authentication and only zaps channels of zap-enabled
const headers = { Cookie: login.cookie.split(';')[0] }; const headers = { Cookie: login.cookie.split(';')[0] };
const zapSource = await request('/api/sources', { headers: adminHeaders, body: { 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: { const plainSource = await request('/api/sources', { headers: adminHeaders, body: {
type: 'm3u', name: 'Plain', url: `${receiver.base}/web/services.m3u` } }); 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); const [channel] = await waitForChannels(request, adminHeaders, zapSource.body.id);
assert.equal(channel.stream_id, RTP1); assert.equal(channel.stream_id, RTP1);
assert.equal(channel.program, '101'); assert.equal(channel.program, '101');