Merge pull request #165 from vmfasimoes/feature/enigma2-openwebif
Add Enigma2/OpenWebif M3U support: program mapping and zap-before-streaming
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 |
|
| **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 and changing channels cancel pending playback startup. Live TV continues playing when you minimize the player and browse the guide or other pages. 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
4
package-lock.json
generated
@ -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",
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -200,6 +200,12 @@
|
|||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
<button class="watch-btn" id="btn-stop" title="Stop">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="icon">
|
||||||
|
<path d="M6 6h12v12H6z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
<div class="watch-spacer"></div>
|
<div class="watch-spacer"></div>
|
||||||
|
|
||||||
<button class="watch-btn" id="btn-mute" title="Mute">
|
<button class="watch-btn" id="btn-mute" title="Mute">
|
||||||
@ -1101,17 +1107,17 @@
|
|||||||
<script src="/js/components/VodDuration.js?v=1"></script>
|
<script src="/js/components/VodDuration.js?v=1"></script>
|
||||||
<script src="/js/components/CaptionTracks.js?v=2"></script>
|
<script src="/js/components/CaptionTracks.js?v=2"></script>
|
||||||
<script src="/js/components/HlsAudioRecovery.js?v=1"></script>
|
<script src="/js/components/HlsAudioRecovery.js?v=1"></script>
|
||||||
<script src="/js/components/VideoPlayer.js?v=5"></script>
|
<script src="/js/components/VideoPlayer.js?v=6"></script>
|
||||||
<script src="/js/components/ChannelList.js?v=3"></script>
|
<script src="/js/components/ChannelList.js?v=4"></script>
|
||||||
<script src="/js/components/SourceManager.js?v=2"></script>
|
<script src="/js/components/SourceManager.js?v=3"></script>
|
||||||
<script src="/js/components/EpgGuide.js?v=2"></script>
|
<script src="/js/components/EpgGuide.js?v=2"></script>
|
||||||
<script src="/js/pages/HomePage.js?v=2"></script>
|
<script src="/js/pages/HomePage.js?v=2"></script>
|
||||||
<script src="/js/pages/LivePage.js?v=2"></script>
|
<script src="/js/pages/LivePage.js?v=3"></script>
|
||||||
<script src="/js/pages/Guide.js?v=2"></script>
|
<script src="/js/pages/Guide.js?v=2"></script>
|
||||||
<script src="/js/pages/MoviesPage.js?v=4"></script>
|
<script src="/js/pages/MoviesPage.js?v=4"></script>
|
||||||
<script src="/js/pages/SeriesPage.js?v=1"></script>
|
<script src="/js/pages/SeriesPage.js?v=1"></script>
|
||||||
<script src="/js/pages/Settings.js?v=2"></script>
|
<script src="/js/pages/Settings.js?v=2"></script>
|
||||||
<script src="/js/pages/WatchPage.js?v=5"></script>
|
<script src="/js/pages/WatchPage.js?v=6"></script>
|
||||||
<script src="/js/app.js?v=5"></script>
|
<script src="/js/app.js?v=5"></script>
|
||||||
|
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@ -820,6 +820,8 @@ class ChannelList {
|
|||||||
this.groups = [];
|
this.groups = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const source = await API.sources.getById(sourceId);
|
||||||
|
|
||||||
// Use Xtream API endpoints - backend now supports M3U sources too
|
// Use Xtream API endpoints - backend now supports M3U sources too
|
||||||
const categories = await API.proxy.xtream.liveCategories(sourceId);
|
const categories = await API.proxy.xtream.liveCategories(sourceId);
|
||||||
const streams = await API.proxy.xtream.liveStreams(sourceId);
|
const streams = await API.proxy.xtream.liveStreams(sourceId);
|
||||||
@ -842,6 +844,9 @@ class ChannelList {
|
|||||||
tvgId: stream.epg_channel_id,
|
tvgId: stream.epg_channel_id,
|
||||||
tvgLogo: stream.stream_icon,
|
tvgLogo: stream.stream_icon,
|
||||||
url: stream.stream_url, // M3U has direct URLs
|
url: stream.stream_url, // M3U has direct URLs
|
||||||
|
program: stream.program || null,
|
||||||
|
zapBeforeStreaming: source?.zapBeforeStreaming || false,
|
||||||
|
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,
|
||||||
|
|||||||
@ -255,7 +255,22 @@ class SourceManager {
|
|||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
return nameField + urlField;
|
const enigmaOptions = type === 'm3u' ? `
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="checkbox-label">
|
||||||
|
<input type="checkbox" id="source-zap-before-streaming" ${source.zapBeforeStreaming ? 'checked' : ''}>
|
||||||
|
Zap before streaming (Enigma2/OpenWebif)
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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" max="10000" step="100">
|
||||||
|
</div>
|
||||||
|
` : '';
|
||||||
|
|
||||||
|
return nameField + urlField + enigmaOptions;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -266,6 +281,8 @@ class SourceManager {
|
|||||||
const url = document.getElementById('source-url').value.trim();
|
const url = document.getElementById('source-url').value.trim();
|
||||||
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 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');
|
||||||
@ -295,7 +312,7 @@ class SourceManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await API.sources.create({ type, name, url, username, password });
|
await API.sources.create({ type, name, url, username, password, zapBeforeStreaming, zapDelay });
|
||||||
document.getElementById('modal').classList.remove('active');
|
document.getElementById('modal').classList.remove('active');
|
||||||
await this.loadSources();
|
await this.loadSources();
|
||||||
|
|
||||||
@ -322,14 +339,16 @@ class SourceManager {
|
|||||||
alert('Name and URL are required');
|
alert('Name and URL are required');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const data = { name, url };
|
const data = { name, url };
|
||||||
if (type === 'xtream') {
|
if (type === 'xtream') {
|
||||||
data.username = username;
|
data.username = username;
|
||||||
if (password) data.password = password;
|
if (password) data.password = password;
|
||||||
}
|
}
|
||||||
|
if (type === 'm3u') {
|
||||||
|
data.zapBeforeStreaming = document.getElementById('source-zap-before-streaming')?.checked || false;
|
||||||
|
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');
|
||||||
await this.loadSources();
|
await this.loadSources();
|
||||||
|
|||||||
@ -218,6 +218,12 @@ class VideoPlayer {
|
|||||||
togglePlay();
|
togglePlay();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Stop button (ends playback and the server-side transcode session)
|
||||||
|
document.getElementById('btn-stop')?.addEventListener('click', (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
this.stop();
|
||||||
|
});
|
||||||
|
|
||||||
// Center play button (large button shown when paused)
|
// Center play button (large button shown when paused)
|
||||||
const centerPlayBtn = document.getElementById('player-center-play');
|
const centerPlayBtn = document.getElementById('player-center-play');
|
||||||
centerPlayBtn?.addEventListener('click', (e) => {
|
centerPlayBtn?.addEventListener('click', (e) => {
|
||||||
@ -640,8 +646,6 @@ class VideoPlayer {
|
|||||||
* Play a channel
|
* Play a channel
|
||||||
*/
|
*/
|
||||||
async play(channel, streamUrl) {
|
async play(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?.();
|
||||||
@ -649,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');
|
||||||
|
|
||||||
@ -666,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;
|
||||||
@ -691,6 +704,7 @@ class VideoPlayer {
|
|||||||
this.updateTranscodeStatus(statusMode, statusText);
|
this.updateTranscodeStatus(statusMode, statusText);
|
||||||
if (this.playbackGeneration !== playbackGeneration) return;
|
if (this.playbackGeneration !== playbackGeneration) return;
|
||||||
const playlistUrl = await this.startTranscodeSession(streamUrl, {
|
const playlistUrl = await this.startTranscodeSession(streamUrl, {
|
||||||
|
program: channel?.program || null,
|
||||||
videoMode,
|
videoMode,
|
||||||
videoCodec: info.video,
|
videoCodec: info.video,
|
||||||
audioCodec: info.audio,
|
audioCodec: info.audio,
|
||||||
@ -711,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();
|
||||||
@ -741,7 +755,7 @@ class VideoPlayer {
|
|||||||
console.log(`[Player] ${statusText} enabled. Starting session (encode)...`);
|
console.log(`[Player] ${statusText} enabled. Starting session (encode)...`);
|
||||||
this.updateTranscodeStatus(statusMode, statusText);
|
this.updateTranscodeStatus(statusMode, statusText);
|
||||||
if (this.playbackGeneration !== playbackGeneration) return;
|
if (this.playbackGeneration !== playbackGeneration) return;
|
||||||
const playlistUrl = await this.startTranscodeSession(streamUrl, { videoMode: 'encode' });
|
const playlistUrl = await this.startTranscodeSession(streamUrl, { program: channel?.program || null, videoMode: 'encode' });
|
||||||
if (!playlistUrl || this.playbackGeneration !== playbackGeneration) return;
|
if (!playlistUrl || this.playbackGeneration !== playbackGeneration) return;
|
||||||
this.currentUrl = playlistUrl;
|
this.currentUrl = playlistUrl;
|
||||||
|
|
||||||
@ -757,14 +771,14 @@ 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;
|
||||||
} catch (e) { console.warn('Probe failed for force audio, assuming h264'); }
|
} catch (e) { console.warn('Probe failed for force audio, assuming h264'); }
|
||||||
|
|
||||||
if (this.playbackGeneration !== playbackGeneration) return;
|
if (this.playbackGeneration !== playbackGeneration) return;
|
||||||
const playlistUrl = await this.startTranscodeSession(streamUrl, { videoMode: 'copy', videoCodec });
|
const playlistUrl = await this.startTranscodeSession(streamUrl, { program: channel?.program || null, videoMode: 'copy', videoCodec });
|
||||||
if (!playlistUrl || this.playbackGeneration !== playbackGeneration) return;
|
if (!playlistUrl || this.playbackGeneration !== playbackGeneration) return;
|
||||||
this.currentUrl = playlistUrl;
|
this.currentUrl = playlistUrl;
|
||||||
|
|
||||||
@ -803,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();
|
||||||
@ -890,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');
|
||||||
}
|
}
|
||||||
@ -1097,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}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -1117,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;
|
||||||
@ -1298,6 +1316,49 @@ class VideoPlayer {
|
|||||||
this.container.requestFullscreen();
|
this.container.requestFullscreen();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async zapBeforeStreaming(channel, streamUrl, signal) {
|
||||||
|
if (!channel?.zapBeforeStreaming) return;
|
||||||
|
if (!channel?.streamId || !streamUrl) return;
|
||||||
|
|
||||||
|
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:', {
|
||||||
|
channel: channel.name,
|
||||||
|
serviceRef: channel.streamId,
|
||||||
|
delay
|
||||||
|
});
|
||||||
|
|
||||||
|
// 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({
|
||||||
|
sourceId: channel.sourceId,
|
||||||
|
streamId: channel.streamId
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
console.warn('[ZAP] backend returned:', res.status);
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Export
|
// Export
|
||||||
|
|||||||
@ -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,6 +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,
|
||||||
videoMode,
|
videoMode,
|
||||||
seekOffset: this.resumeTime, // Ensure seekOffset is passed
|
seekOffset: this.resumeTime, // Ensure seekOffset is passed
|
||||||
videoCodec: info.video,
|
videoCodec: info.video,
|
||||||
@ -483,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);
|
||||||
@ -508,6 +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,
|
||||||
videoMode: 'encode',
|
videoMode: 'encode',
|
||||||
seekOffset: this.resumeTime
|
seekOffset: this.resumeTime
|
||||||
});
|
});
|
||||||
@ -525,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;
|
||||||
@ -533,6 +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,
|
||||||
videoMode: 'copy',
|
videoMode: 'copy',
|
||||||
videoCodec,
|
videoCodec,
|
||||||
seekOffset: this.resumeTime
|
seekOffset: this.resumeTime
|
||||||
@ -544,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);
|
||||||
@ -647,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() {
|
||||||
|
|||||||
@ -145,6 +145,13 @@ function initSchema() {
|
|||||||
// Column already exists, ignore
|
// Column already exists, ignore
|
||||||
}
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
db.exec(`ALTER TABLE playlist_items ADD COLUMN program TEXT`);
|
||||||
|
console.log('[SQLite] Added program column to playlist_items');
|
||||||
|
} catch (e) {
|
||||||
|
// Column already exists, ignore
|
||||||
|
}
|
||||||
|
|
||||||
console.log('[SQLite] Schema initialized');
|
console.log('[SQLite] Schema initialized');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -208,6 +208,7 @@ app.use('/api/probe', require('./routes/probe'));
|
|||||||
app.use('/api/subtitle', require('./routes/subtitle'));
|
app.use('/api/subtitle', require('./routes/subtitle'));
|
||||||
app.use('/api/settings', require('./routes/settings'));
|
app.use('/api/settings', require('./routes/settings'));
|
||||||
app.use('/api/history', require('./routes/history'));
|
app.use('/api/history', require('./routes/history'));
|
||||||
|
app.use('/api/openwebif', require('./routes/openwebif'));
|
||||||
|
|
||||||
// SPA fallback - serve index.html for all non-API routes
|
// SPA fallback - serve index.html for all non-API routes
|
||||||
app.get('*', (req, res) => {
|
app.get('*', (req, res) => {
|
||||||
|
|||||||
70
server/routes/openwebif.js
Normal file
70
server/routes/openwebif.js
Normal file
@ -0,0 +1,70 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const router = express.Router();
|
||||||
|
const { sources } = require('../db');
|
||||||
|
const { getDb } = require('../db/sqlite');
|
||||||
|
const { validateHttpUrl } = require('../services/urlSecurity');
|
||||||
|
|
||||||
|
const ZAP_TIMEOUT_MS = 5000;
|
||||||
|
// Enigma2 service reference: ten hex fields, e.g. 1:0:1:65:65:53:CE40000:0:0:0:
|
||||||
|
const SERVICE_REF = /^[0-9A-Fa-f]{1,8}(?::[0-9A-Fa-f]{1,8}){9}:?$/;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zap an Enigma2 receiver to a channel before streaming it.
|
||||||
|
* POST /api/openwebif/zap
|
||||||
|
* Body: { sourceId: number, streamId: string }
|
||||||
|
*
|
||||||
|
* The receiver address is never taken from the client: it is derived from the
|
||||||
|
* configured (admin-managed) M3U source URL, and the channel must belong to
|
||||||
|
* that source.
|
||||||
|
*/
|
||||||
|
router.post('/zap', async (req, res) => {
|
||||||
|
const { sourceId, streamId } = req.body || {};
|
||||||
|
const id = Number(sourceId);
|
||||||
|
|
||||||
|
if (!Number.isInteger(id) || typeof streamId !== 'string' || !SERVICE_REF.test(streamId)) {
|
||||||
|
return res.status(400).json({ error: 'Valid sourceId and streamId are required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
let source, item;
|
||||||
|
try {
|
||||||
|
source = await sources.getById(id);
|
||||||
|
if (source && source.type === 'm3u' && source.enabled && source.zapBeforeStreaming) {
|
||||||
|
item = getDb().prepare(`
|
||||||
|
SELECT item_id FROM playlist_items
|
||||||
|
WHERE source_id = ? AND type = 'live' AND item_id = ?
|
||||||
|
`).get(id, streamId);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[ZAP] Lookup failed:', err.message);
|
||||||
|
return res.status(500).json({ error: 'Server error' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!source || source.type !== 'm3u' || !source.enabled || !source.zapBeforeStreaming) {
|
||||||
|
return res.status(404).json({ error: 'Zap is not enabled for this source' });
|
||||||
|
}
|
||||||
|
if (!item) {
|
||||||
|
return res.status(404).json({ error: 'Channel not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
let zapUrl;
|
||||||
|
try {
|
||||||
|
const boxOrigin = new URL(validateHttpUrl(source.url, 'Source URL')).origin;
|
||||||
|
zapUrl = `${boxOrigin}/web/zap?sRef=${encodeURIComponent(item.item_id)}`;
|
||||||
|
} catch (err) {
|
||||||
|
return res.status(400).json({ error: err.message });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(zapUrl, {
|
||||||
|
redirect: 'manual',
|
||||||
|
signal: AbortSignal.timeout(ZAP_TIMEOUT_MS)
|
||||||
|
});
|
||||||
|
await response.body?.cancel();
|
||||||
|
res.json({ success: response.ok });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[ZAP] Failed:', err.message);
|
||||||
|
res.status(502).json({ error: 'Receiver did not respond' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
@ -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() });
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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');
|
||||||
@ -63,7 +67,7 @@ router.get('/:id', async (req, res) => {
|
|||||||
// Create source
|
// Create source
|
||||||
router.post('/', async (req, res) => {
|
router.post('/', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { type, name, url, username, password } = req.body;
|
const { type, name, url, username, password, zapBeforeStreaming, zapDelay } = req.body;
|
||||||
|
|
||||||
if (!type || !name || !url) {
|
if (!type || !name || !url) {
|
||||||
return res.status(400).json({ error: 'Type, name, and URL are required' });
|
return res.status(400).json({ error: 'Type, name, and URL are required' });
|
||||||
@ -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 });
|
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);
|
||||||
@ -91,7 +95,7 @@ router.put('/:id', async (req, res) => {
|
|||||||
return res.status(404).json({ error: 'Source not found' });
|
return res.status(404).json({ error: 'Source not found' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const { name, url, username, password } = req.body;
|
const { name, url, username, password, zapBeforeStreaming, zapDelay } = req.body;
|
||||||
// Omitted fields must be merged against the latest record inside the
|
// Omitted fields must be merged against the latest record inside the
|
||||||
// database transaction, not the snapshot read before another edit.
|
// database transaction, not the snapshot read before another edit.
|
||||||
const updates = {};
|
const updates = {};
|
||||||
@ -99,6 +103,8 @@ router.put('/:id', async (req, res) => {
|
|||||||
if (url) updates.url = url;
|
if (url) updates.url = url;
|
||||||
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 (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)
|
||||||
|
|||||||
@ -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();
|
||||||
@ -33,7 +34,9 @@ transcodeSession.startCleanupInterval();
|
|||||||
* Body: { url: string, seekOffset?: number }
|
* Body: { url: string, seekOffset?: number }
|
||||||
*/
|
*/
|
||||||
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 } = req.body;
|
const { url, seekOffset, videoMode, videoCodec, audioCodec, audioChannels, videoHeight, playbackLeaseId, requestSequence, isLive, program } = req.body;
|
||||||
|
let programNumber;
|
||||||
|
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;
|
||||||
@ -72,7 +75,8 @@ router.post('/session', startupLimit, async (req, res) => {
|
|||||||
videoCodec: videoCodec, // 'h264', 'hevc', etc.
|
videoCodec: videoCodec, // 'h264', 'hevc', etc.
|
||||||
audioCodec: audioCodec, // 'aac', 'ac3', etc.
|
audioCodec: audioCodec, // 'aac', 'ac3', etc.
|
||||||
audioChannels: audioChannels, // number of channels (2=stereo)
|
audioChannels: audioChannels, // number of channels (2=stereo)
|
||||||
videoHeight: videoHeight // source height from probe; used to cap max-resolution in JS
|
videoHeight: videoHeight, // source height from probe; used to cap max-resolution in JS
|
||||||
|
program: programNumber
|
||||||
});
|
});
|
||||||
|
|
||||||
if (disconnected) { await transcodeSession.removeSession(session.id, req.user.id); return; }
|
if (disconnected) { await transcodeSession.removeSession(session.id, req.user.id); return; }
|
||||||
|
|||||||
@ -1,3 +1,5 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* M3U Playlist Parser (Streaming)
|
* M3U Playlist Parser (Streaming)
|
||||||
* Parses EXTM3U format playlists and extracts channel information line-by-line
|
* Parses EXTM3U format playlists and extracts channel information line-by-line
|
||||||
@ -88,6 +90,7 @@ async function parse(input) {
|
|||||||
const groupsSet = new Set();
|
const groupsSet = new Set();
|
||||||
let currentInfo = null;
|
let currentInfo = null;
|
||||||
let currentGroup = null;
|
let currentGroup = null;
|
||||||
|
let currentVlcProgram = null;
|
||||||
|
|
||||||
let lines;
|
let lines;
|
||||||
|
|
||||||
@ -121,6 +124,11 @@ async function parse(input) {
|
|||||||
if (currentInfo) {
|
if (currentInfo) {
|
||||||
currentInfo.groupTitle = currentGroup;
|
currentInfo.groupTitle = currentGroup;
|
||||||
}
|
}
|
||||||
|
} else if (trimmed.startsWith('#EXTVLCOPT:program=')) {
|
||||||
|
currentVlcProgram = trimmed.split('=')[1]?.trim() || null;
|
||||||
|
if (currentInfo) {
|
||||||
|
currentInfo.program = currentVlcProgram;
|
||||||
|
}
|
||||||
} else if (!trimmed.startsWith('#')) {
|
} else if (!trimmed.startsWith('#')) {
|
||||||
// This is a stream URL
|
// This is a stream URL
|
||||||
if (currentInfo) {
|
if (currentInfo) {
|
||||||
@ -132,9 +140,11 @@ async function parse(input) {
|
|||||||
...currentInfo,
|
...currentInfo,
|
||||||
id: stableId,
|
id: stableId,
|
||||||
url: trimmed,
|
url: trimmed,
|
||||||
|
program: currentVlcProgram,
|
||||||
groupTitle: groupTitle
|
groupTitle: groupTitle
|
||||||
});
|
});
|
||||||
currentInfo = null;
|
currentInfo = null;
|
||||||
|
currentVlcProgram = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -187,6 +197,7 @@ async function* parseStreaming(input, batchSize = 500) {
|
|||||||
const groupsSet = new Set();
|
const groupsSet = new Set();
|
||||||
let currentInfo = null;
|
let currentInfo = null;
|
||||||
let currentGroup = null;
|
let currentGroup = null;
|
||||||
|
let currentVlcProgram = null;
|
||||||
let batch = [];
|
let batch = [];
|
||||||
|
|
||||||
let lines;
|
let lines;
|
||||||
@ -216,6 +227,11 @@ async function* parseStreaming(input, batchSize = 500) {
|
|||||||
if (currentInfo) {
|
if (currentInfo) {
|
||||||
currentInfo.groupTitle = currentGroup;
|
currentInfo.groupTitle = currentGroup;
|
||||||
}
|
}
|
||||||
|
} else if (trimmed.startsWith('#EXTVLCOPT:program=')) {
|
||||||
|
currentVlcProgram = trimmed.split('=')[1]?.trim() || null;
|
||||||
|
if (currentInfo) {
|
||||||
|
currentInfo.program = currentVlcProgram;
|
||||||
|
}
|
||||||
} else if (!trimmed.startsWith('#')) {
|
} else if (!trimmed.startsWith('#')) {
|
||||||
if (currentInfo) {
|
if (currentInfo) {
|
||||||
const groupTitle = currentInfo.groupTitle || currentGroup || 'Uncategorized';
|
const groupTitle = currentInfo.groupTitle || currentGroup || 'Uncategorized';
|
||||||
@ -225,9 +241,11 @@ async function* parseStreaming(input, batchSize = 500) {
|
|||||||
...currentInfo,
|
...currentInfo,
|
||||||
id: stableId,
|
id: stableId,
|
||||||
url: trimmed,
|
url: trimmed,
|
||||||
|
program: currentVlcProgram,
|
||||||
groupTitle: groupTitle
|
groupTitle: groupTitle
|
||||||
});
|
});
|
||||||
currentInfo = null;
|
currentInfo = null;
|
||||||
|
currentVlcProgram = null;
|
||||||
|
|
||||||
// Yield batch when full
|
// Yield batch when full
|
||||||
if (batch.length >= batchSize) {
|
if (batch.length >= batchSize) {
|
||||||
|
|||||||
@ -58,6 +58,7 @@ class M3uXtreamAdapter {
|
|||||||
name,
|
name,
|
||||||
stream_icon,
|
stream_icon,
|
||||||
stream_url,
|
stream_url,
|
||||||
|
program,
|
||||||
category_id,
|
category_id,
|
||||||
added_at,
|
added_at,
|
||||||
data
|
data
|
||||||
@ -92,6 +93,7 @@ class M3uXtreamAdapter {
|
|||||||
added: row.added_at,
|
added: row.added_at,
|
||||||
// M3U-specific: direct stream URL (Xtream builds URLs from credentials)
|
// M3U-specific: direct stream URL (Xtream builds URLs from credentials)
|
||||||
stream_url: row.stream_url,
|
stream_url: row.stream_url,
|
||||||
|
program: row.program || extra.program || null,
|
||||||
// Include extra fields from parser (tvgId, etc.)
|
// Include extra fields from parser (tvgId, etc.)
|
||||||
epg_channel_id: extra.tvgId || null,
|
epg_channel_id: extra.tvgId || null,
|
||||||
...extra
|
...extra
|
||||||
|
|||||||
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 };
|
||||||
@ -278,14 +278,16 @@ class SyncService {
|
|||||||
const stmt = db.prepare(`
|
const stmt = db.prepare(`
|
||||||
INSERT INTO playlist_items (
|
INSERT INTO playlist_items (
|
||||||
id, source_id, item_id, type, name, category_id,
|
id, source_id, item_id, type, name, category_id,
|
||||||
stream_icon, stream_url, container_extension,
|
stream_icon, stream_url, program, container_extension,
|
||||||
rating, year, added_at, data
|
rating, year, added_at, data
|
||||||
)
|
)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
ON CONFLICT(id) DO UPDATE SET
|
ON CONFLICT(id) DO UPDATE SET
|
||||||
name = excluded.name,
|
name = excluded.name,
|
||||||
category_id = excluded.category_id,
|
category_id = excluded.category_id,
|
||||||
stream_icon = excluded.stream_icon,
|
stream_icon = excluded.stream_icon,
|
||||||
|
stream_url = excluded.stream_url,
|
||||||
|
program = excluded.program,
|
||||||
container_extension = excluded.container_extension,
|
container_extension = excluded.container_extension,
|
||||||
data = excluded.data
|
data = excluded.data
|
||||||
`);
|
`);
|
||||||
@ -332,6 +334,7 @@ class SyncService {
|
|||||||
String(catId),
|
String(catId),
|
||||||
icon,
|
icon,
|
||||||
null, // Direct URL not stored for Xtream usually, built on fly
|
null, // Direct URL not stored for Xtream usually, built on fly
|
||||||
|
item.program || null,
|
||||||
container,
|
container,
|
||||||
rating,
|
rating,
|
||||||
year,
|
year,
|
||||||
@ -641,6 +644,7 @@ class SyncService {
|
|||||||
stream_icon: ch.tvgLogo,
|
stream_icon: ch.tvgLogo,
|
||||||
stream_url: ch.url,
|
stream_url: ch.url,
|
||||||
tvgId: ch.tvgId || null,
|
tvgId: ch.tvgId || null,
|
||||||
|
program: ch.program || null,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Save this batch immediately (skip purge - we'll do it at the end)
|
// Save this batch immediately (skip purge - we'll do it at the end)
|
||||||
|
|||||||
@ -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)
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -230,8 +232,15 @@ class TranscodeSession extends EventEmitter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Map streams
|
// Map streams
|
||||||
args.push('-map', '0:v:0');
|
// Only map video/audio from the program: mapping the whole program pulls in
|
||||||
args.push('-map', '0:a:0?');
|
// DVB teletext/subtitle streams, which can't be encoded to HLS and kill FFmpeg
|
||||||
|
if (this.options.program) {
|
||||||
|
args.push('-map', `0:p:${this.options.program}:v:0`);
|
||||||
|
args.push('-map', `0:p:${this.options.program}:a:0?`);
|
||||||
|
} else {
|
||||||
|
args.push('-map', '0:v:0');
|
||||||
|
args.push('-map', '0:a:0?');
|
||||||
|
}
|
||||||
|
|
||||||
// Add video encoder and filters based on selected encoder OR copy
|
// Add video encoder and filters based on selected encoder OR copy
|
||||||
if (videoMode === 'copy') {
|
if (videoMode === 'copy') {
|
||||||
|
|||||||
241
test/enigma2-playback.test.js
Normal file
241
test/enigma2-playback.test.js
Normal file
@ -0,0 +1,241 @@
|
|||||||
|
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 cancels the actual zap delay without starting playback', async () => {
|
||||||
|
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;
|
||||||
|
f.player.stop();
|
||||||
|
await playing;
|
||||||
|
assert.equal(signal.aborted, true);
|
||||||
|
assert.equal(f.sessions.length, 0);
|
||||||
|
assert.equal(f.player.video.src, '');
|
||||||
|
});
|
||||||
|
|
||||||
|
function navigationFixture(f) {
|
||||||
|
Object.assign(f.context.document, { addEventListener() {}, querySelectorAll: () => [] });
|
||||||
|
f.context.history = { pushState() {}, replaceState() {} };
|
||||||
|
vm.runInNewContext(fs.readFileSync(path.join(__dirname, '../public/js/app.js'), 'utf8') + ';window.TestApp = App;', f.context);
|
||||||
|
vm.runInNewContext(fs.readFileSync(path.join(__dirname, '../public/js/pages/LivePage.js'), 'utf8'), f.context);
|
||||||
|
const app = Object.create(f.context.window.TestApp.prototype);
|
||||||
|
app.player = f.player;
|
||||||
|
app.channelList = { channels: [{}] };
|
||||||
|
app.pages = { live: new f.context.window.LivePage(app) };
|
||||||
|
app.currentPage = 'live';
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
|
||||||
|
test('browsing the guide and catalogs preserves Live TV playback and its session', async () => {
|
||||||
|
const f = playerFixture('VideoPlayer', { forceVideoTranscode: true });
|
||||||
|
await f.start();
|
||||||
|
let released = 0;
|
||||||
|
f.player.playbackSessions = { stop() { released++; } };
|
||||||
|
f.player.currentSessionId = 'live-session';
|
||||||
|
const source = f.player.video.src, generation = f.player.playbackGeneration;
|
||||||
|
const app = navigationFixture(f);
|
||||||
|
for (const page of ['guide', 'movies', 'series']) {
|
||||||
|
app.navigateTo('live');
|
||||||
|
app.navigateTo(page);
|
||||||
|
assert.equal(f.player.video.src, source);
|
||||||
|
assert.equal(f.player.currentSessionId, 'live-session');
|
||||||
|
assert.equal(f.player.playbackGeneration, generation);
|
||||||
|
}
|
||||||
|
assert.equal(released, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('browsing during a zap delay lets the selected live channel start normally', async () => {
|
||||||
|
let requested;
|
||||||
|
const ready = 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: 0 }, 'http://receiver/multi.ts');
|
||||||
|
const signal = await ready;
|
||||||
|
navigationFixture(f).navigateTo('guide');
|
||||||
|
assert.equal(signal.aborted, false);
|
||||||
|
await playing;
|
||||||
|
assert.equal(f.sessions.length, 1);
|
||||||
|
assert.equal(f.player.video.src, '/api/session/stream.m3u8');
|
||||||
|
});
|
||||||
|
|
||||||
|
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 = spawnSync('ffprobe', ['-version'], { stdio: 'ignore' }).status === 0
|
||||||
|
? 'ffprobe' : 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, JSON.stringify(info)); 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);
|
||||||
|
});
|
||||||
70
test/m3u-parser.test.js
Normal file
70
test/m3u-parser.test.js
Normal file
@ -0,0 +1,70 @@
|
|||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const { Readable } = require('node:stream');
|
||||||
|
const { parse, parseStreaming } = require('../server/services/m3uParser');
|
||||||
|
|
||||||
|
const ORDINARY = [
|
||||||
|
'#EXTM3U',
|
||||||
|
'#EXTINF:-1 tvg-id="news.one" group-title="News",News One',
|
||||||
|
'http://provider.test/news1.ts',
|
||||||
|
'#EXTINF:-1 group-title="Sports",Sports One',
|
||||||
|
'http://provider.test/sports1.ts',
|
||||||
|
''
|
||||||
|
].join('\n');
|
||||||
|
|
||||||
|
// Enigma2/OpenWebif bouquet export: program tags, no group-title
|
||||||
|
const PROGRAM_TAGGED = [
|
||||||
|
'#EXTM3U',
|
||||||
|
'#EXTVLCOPT:http-reconnect=true',
|
||||||
|
'#EXTINF:-1 tvg-id="1:0:1:65:65:53:CE40000:0:0:0:",RTP1',
|
||||||
|
'#EXTVLCOPT:program=101',
|
||||||
|
'http://receiver.test:8001/1:0:1:65:65:53:CE40000:0:0:0:',
|
||||||
|
'#EXTINF:-1 tvg-id="1:0:1:66:65:53:CE40000:0:0:0:",RTP2',
|
||||||
|
'#EXTVLCOPT:program=102',
|
||||||
|
'http://receiver.test:8001/1:0:1:66:65:53:CE40000:0:0:0:',
|
||||||
|
'#EXTINF:-1 tvg-id="untagged",Untagged',
|
||||||
|
'http://receiver.test:8001/untagged',
|
||||||
|
''
|
||||||
|
].join('\n');
|
||||||
|
|
||||||
|
async function collect(input, batchSize) {
|
||||||
|
const batches = [];
|
||||||
|
for await (const batch of parseStreaming(input, batchSize)) batches.push(batch);
|
||||||
|
return batches;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [label, toInput] of [['string', text => text], ['stream', text => Readable.from([text])]]) {
|
||||||
|
test(`streaming parser imports ordinary playlists without program tags (${label} input)`, async () => {
|
||||||
|
const batches = await collect(toInput(ORDINARY));
|
||||||
|
const channels = batches.flatMap(batch => batch.channels);
|
||||||
|
assert.equal(batches.at(-1).isLast, true);
|
||||||
|
assert.deepEqual(channels.map(c => [c.name, c.groupTitle, c.url, c.program]), [
|
||||||
|
['News One', 'News', 'http://provider.test/news1.ts', null],
|
||||||
|
['Sports One', 'Sports', 'http://provider.test/sports1.ts', null]
|
||||||
|
]);
|
||||||
|
assert.deepEqual([...batches.at(-1).groups], ['News', 'Sports']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test(`streaming parser keeps program tags per channel (${label} input)`, async () => {
|
||||||
|
const channels = (await collect(toInput(PROGRAM_TAGGED))).flatMap(batch => batch.channels);
|
||||||
|
assert.deepEqual(channels.map(c => [c.name, c.groupTitle, c.program]), [
|
||||||
|
['RTP1', 'Uncategorized', '101'],
|
||||||
|
['RTP2', 'Uncategorized', '102'],
|
||||||
|
['Untagged', 'Uncategorized', null]
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test('streaming parser does not leak program state between batches or into globals', async () => {
|
||||||
|
const batches = await collect(PROGRAM_TAGGED, 1);
|
||||||
|
assert.deepEqual(batches.filter(b => b.channels.length).map(b => b.channels[0].program), ['101', '102', null]);
|
||||||
|
assert.equal('currentVlcProgram' in globalThis, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('full parser matches the streaming parser for ordinary and program-tagged playlists', async () => {
|
||||||
|
for (const playlist of [ORDINARY, PROGRAM_TAGGED]) {
|
||||||
|
const { channels } = await parse(playlist);
|
||||||
|
const streamed = (await collect(playlist)).flatMap(batch => batch.channels);
|
||||||
|
assert.deepEqual(channels, streamed);
|
||||||
|
}
|
||||||
|
});
|
||||||
135
test/openwebif-zap.test.js
Normal file
135
test/openwebif-zap.test.js
Normal file
@ -0,0 +1,135 @@
|
|||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const fs = require('node:fs');
|
||||||
|
const path = require('node:path');
|
||||||
|
const os = require('node:os');
|
||||||
|
const http = require('node:http');
|
||||||
|
const { spawn } = require('node:child_process');
|
||||||
|
|
||||||
|
const RTP1 = '1:0:1:65:65:53:CE40000:0:0:0:';
|
||||||
|
|
||||||
|
async function listen(t, handler) {
|
||||||
|
const server = http.createServer(handler);
|
||||||
|
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
|
||||||
|
t.after(() => new Promise(resolve => server.close(resolve)));
|
||||||
|
return `http://127.0.0.1:${server.address().port}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Minimal OpenWebif: bouquet export plus a zap endpoint that records calls
|
||||||
|
async function fakeReceiver(t) {
|
||||||
|
const zaps = [];
|
||||||
|
const base = await listen(t, (req, res) => {
|
||||||
|
const url = new URL(req.url, 'http://receiver');
|
||||||
|
if (url.pathname === '/web/services.m3u') {
|
||||||
|
res.end([
|
||||||
|
'#EXTM3U',
|
||||||
|
`#EXTINF:-1 tvg-id="${RTP1}",RTP1`,
|
||||||
|
'#EXTVLCOPT:program=101',
|
||||||
|
`http://127.0.0.1:1/${RTP1}`,
|
||||||
|
''
|
||||||
|
].join('\n'));
|
||||||
|
} else if (url.pathname === '/web/zap') {
|
||||||
|
zaps.push(url.searchParams.get('sRef'));
|
||||||
|
res.setHeader('Content-Type', 'text/xml');
|
||||||
|
res.end('<e2simplexmlresult><e2state>True</e2state></e2simplexmlresult>');
|
||||||
|
} else {
|
||||||
|
res.statusCode = 404;
|
||||||
|
res.end();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return { base, zaps };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startApp(t) {
|
||||||
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'nodecast-zap-test-'));
|
||||||
|
const child = spawn(process.execPath, ['server/index.js'], { cwd: path.join(__dirname, '..'),
|
||||||
|
env: { ...process.env, PORT: '0', NODECAST_DATA_DIR: dir,
|
||||||
|
NODECAST_DISABLE_BACKGROUND_JOBS: 'true', JWT_SECRET: '', SESSION_SECRET: '',
|
||||||
|
OIDC_ISSUER_URL: '', ALLOW_LOCAL_MEDIA_URLS: 'true', AUTH_COOKIE_SECURE: 'false' },
|
||||||
|
stdio: ['ignore', 'pipe', 'pipe'] });
|
||||||
|
t.after(async () => {
|
||||||
|
if (child.exitCode === null) {
|
||||||
|
const closed = new Promise(resolve => child.once('close', resolve));
|
||||||
|
child.kill();
|
||||||
|
await closed;
|
||||||
|
}
|
||||||
|
fs.rmSync(dir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
let output = '';
|
||||||
|
const base = await new Promise((resolve, reject) => {
|
||||||
|
const timer = setTimeout(() => reject(new Error(`Server startup timed out: ${output}`)), 15000);
|
||||||
|
child.stdout.on('data', chunk => {
|
||||||
|
output += chunk;
|
||||||
|
const match = output.match(/server running on http:\/\/localhost:(\d+)/);
|
||||||
|
if (match) { clearTimeout(timer); resolve(`http://127.0.0.1:${match[1]}`); }
|
||||||
|
});
|
||||||
|
child.stderr.on('data', chunk => { output += chunk; });
|
||||||
|
child.once('exit', code => { clearTimeout(timer); reject(new Error(`Server exited ${code}: ${output}`)); });
|
||||||
|
});
|
||||||
|
const request = async (endpoint, { body, headers, method } = {}) => {
|
||||||
|
const response = await fetch(`${base}${endpoint}`, { method: method || (body ? 'POST' : 'GET'),
|
||||||
|
headers: { 'Content-Type': 'application/json', ...headers }, body: body && JSON.stringify(body) });
|
||||||
|
const text = await response.text();
|
||||||
|
return { status: response.status, body: text ? JSON.parse(text) : null,
|
||||||
|
cookie: response.headers.getSetCookie().find(value => value.startsWith('nodecast_auth_')) };
|
||||||
|
};
|
||||||
|
return { request };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForChannels(request, headers, sourceId) {
|
||||||
|
for (let attempt = 0; attempt < 50; attempt++) {
|
||||||
|
const { body } = await request(`/api/proxy/xtream/${sourceId}/live_streams`, { headers });
|
||||||
|
if (Array.isArray(body) && body.length) return body;
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 100));
|
||||||
|
}
|
||||||
|
throw new Error('M3U source did not sync');
|
||||||
|
}
|
||||||
|
|
||||||
|
test('zap endpoint requires authentication and only zaps channels of zap-enabled sources', async t => {
|
||||||
|
const receiver = await fakeReceiver(t);
|
||||||
|
const elsewhere = [];
|
||||||
|
const attacker = await listen(t, (req, res) => { elsewhere.push(req.url); res.end(); });
|
||||||
|
const { request } = await startApp(t);
|
||||||
|
|
||||||
|
assert.equal((await request('/api/openwebif/zap', { body: { sourceId: 1, streamId: RTP1 } })).status, 401);
|
||||||
|
|
||||||
|
const admin = await request('/api/auth/setup', { body: { username: 'Admin', password: 'password123' } });
|
||||||
|
const adminHeaders = { Cookie: admin.cookie.split(';')[0] };
|
||||||
|
await request('/api/auth/users', { headers: adminHeaders, body: { username: 'Viewer', password: 'password123', role: 'viewer' } });
|
||||||
|
const login = await request('/api/auth/login', { body: { username: 'Viewer', password: 'password123' } });
|
||||||
|
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, 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');
|
||||||
|
await waitForChannels(request, adminHeaders, plainSource.body.id);
|
||||||
|
|
||||||
|
const zap = body => request('/api/openwebif/zap', { headers, body });
|
||||||
|
|
||||||
|
// Client-supplied receiver addresses are ignored; the configured source decides
|
||||||
|
const ok = await zap({ sourceId: zapSource.body.id, streamId: RTP1, boxUrl: attacker });
|
||||||
|
assert.equal(ok.status, 200);
|
||||||
|
assert.deepEqual(ok.body, { success: true });
|
||||||
|
assert.deepEqual(receiver.zaps, [RTP1]);
|
||||||
|
assert.deepEqual(elsewhere, []);
|
||||||
|
|
||||||
|
for (const [body, status] of [
|
||||||
|
[{ boxUrl: attacker, serviceRef: RTP1 }, 400],
|
||||||
|
[{ sourceId: zapSource.body.id, streamId: 'http://127.0.0.1/admin' }, 400],
|
||||||
|
[{ sourceId: zapSource.body.id, streamId: '1:0:1:66:65:53:CE40000:0:0:0:' }, 404],
|
||||||
|
[{ sourceId: plainSource.body.id, streamId: RTP1 }, 404],
|
||||||
|
[{ sourceId: 999, streamId: RTP1 }, 404]
|
||||||
|
]) {
|
||||||
|
assert.equal((await zap(body)).status, status, JSON.stringify(body));
|
||||||
|
}
|
||||||
|
assert.deepEqual(receiver.zaps, [RTP1]);
|
||||||
|
assert.deepEqual(elsewhere, []);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user