Cancel superseded Enigma2 startup and select programs throughout playback
This commit is contained in:
@ -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)
|
||||
|
||||
4
package-lock.json
generated
4
package-lock.json
generated
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -266,7 +266,7 @@ class SourceManager {
|
||||
<div class="form-group">
|
||||
<label for="source-zap-delay">Zap delay (ms)</label>
|
||||
<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>
|
||||
` : '';
|
||||
|
||||
@ -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');
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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() {
|
||||
|
||||
@ -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() });
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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;
|
||||
|
||||
19
server/services/mpegTsProgram.js
Normal file
19
server/services/mpegTsProgram.js
Normal 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 };
|
||||
@ -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)
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
198
test/enigma2-playback.test.js
Normal file
198
test/enigma2-playback.test.js
Normal 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);
|
||||
});
|
||||
@ -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');
|
||||
|
||||
Reference in New Issue
Block a user