Add Enigma2/OpenWebif M3U support: program mapping and zap-before-streaming
- Parse #EXTVLCOPT:program=N from M3U playlists (both full and streaming parsers, per channel, strict mode) and store it on playlist items - Transcode multi-program MPEG-TS streams by mapping only the selected program's first video and audio stream (DVB teletext/subtitles in the program otherwise make FFmpeg fail to produce HLS) - Optional per-source "zap before streaming" with configurable delay: POST /api/openwebif/zap takes sourceId + streamId, requires an enabled, zap-enabled M3U source containing that channel, derives the receiver from the configured source URL (never from the client), validates the Enigma2 service reference, uses a timeout without redirects and does not echo the receiver response - Live TV: Stop button, and stop playback when leaving the page - Tests for ordinary and program-tagged playlists and the zap endpoint Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@ -820,6 +820,8 @@ class ChannelList {
|
||||
this.groups = [];
|
||||
}
|
||||
|
||||
const source = await API.sources.getById(sourceId);
|
||||
|
||||
// Use Xtream API endpoints - backend now supports M3U sources too
|
||||
const categories = await API.proxy.xtream.liveCategories(sourceId);
|
||||
const streams = await API.proxy.xtream.liveStreams(sourceId);
|
||||
@ -842,6 +844,9 @@ class ChannelList {
|
||||
tvgId: stream.epg_channel_id,
|
||||
tvgLogo: stream.stream_icon,
|
||||
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}`,
|
||||
groupTitle: categories.find(c => String(c.category_id) === String(stream.category_id))?.category_name || 'Uncategorized',
|
||||
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" step="100">
|
||||
</div>
|
||||
` : '';
|
||||
|
||||
return nameField + urlField + enigmaOptions;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -266,6 +281,8 @@ class SourceManager {
|
||||
const url = document.getElementById('source-url').value.trim();
|
||||
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;
|
||||
|
||||
if (!name || !url) {
|
||||
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');
|
||||
await this.loadSources();
|
||||
|
||||
@ -322,14 +339,16 @@ class SourceManager {
|
||||
alert('Name and URL are required');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const data = { name, url };
|
||||
if (type === 'xtream') {
|
||||
data.username = username;
|
||||
if (password) data.password = password;
|
||||
}
|
||||
|
||||
if (type === 'm3u') {
|
||||
data.zapBeforeStreaming = document.getElementById('source-zap-before-streaming')?.checked || false;
|
||||
data.zapDelay = parseInt(document.getElementById('source-zap-delay')?.value) || 1500;
|
||||
}
|
||||
await API.sources.update(id, data);
|
||||
document.getElementById('modal').classList.remove('active');
|
||||
await this.loadSources();
|
||||
|
||||
@ -218,6 +218,12 @@ class VideoPlayer {
|
||||
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)
|
||||
const centerPlayBtn = document.getElementById('player-center-play');
|
||||
centerPlayBtn?.addEventListener('click', (e) => {
|
||||
@ -779,6 +785,11 @@ class VideoPlayer {
|
||||
* Play a channel
|
||||
*/
|
||||
async play(channel, streamUrl) {
|
||||
|
||||
if(this.currentChannel != channel && channel?.zapBeforeStreaming){
|
||||
await this.zapBeforeStreaming(channel, streamUrl);
|
||||
}
|
||||
|
||||
this.currentChannel = channel;
|
||||
|
||||
try {
|
||||
@ -830,6 +841,7 @@ class VideoPlayer {
|
||||
this.updateTranscodeStatus(statusMode, statusText);
|
||||
if (this.playbackGeneration !== playbackGeneration) return;
|
||||
const playlistUrl = await this.startTranscodeSession(streamUrl, {
|
||||
program: channel?.program || null,
|
||||
videoMode,
|
||||
videoCodec: info.video,
|
||||
audioCodec: info.audio,
|
||||
@ -880,7 +892,7 @@ class VideoPlayer {
|
||||
console.log(`[Player] ${statusText} enabled. Starting session (encode)...`);
|
||||
this.updateTranscodeStatus(statusMode, statusText);
|
||||
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;
|
||||
this.currentUrl = playlistUrl;
|
||||
|
||||
@ -903,7 +915,7 @@ class VideoPlayer {
|
||||
} catch (e) { console.warn('Probe failed for force audio, assuming h264'); }
|
||||
|
||||
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;
|
||||
this.currentUrl = playlistUrl;
|
||||
|
||||
@ -1479,6 +1491,40 @@ class VideoPlayer {
|
||||
this.container.requestFullscreen();
|
||||
}
|
||||
}
|
||||
|
||||
async zapBeforeStreaming(channel, streamUrl) {
|
||||
if (!channel?.zapBeforeStreaming) return;
|
||||
if (!channel?.streamId || !streamUrl) return;
|
||||
|
||||
const delay = parseInt(channel.zapDelay) || 1500;
|
||||
|
||||
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', {
|
||||
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 => setTimeout(resolve, delay));
|
||||
} catch (err) {
|
||||
console.warn('[ZAP] failed, continuing stream:', err);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// Export
|
||||
|
||||
@ -103,6 +103,8 @@ class LivePage {
|
||||
|
||||
hide() {
|
||||
document.removeEventListener('keydown', this.handleKeydown);
|
||||
// Stop the stream when leaving Live TV so the transcode session is released
|
||||
this.app.player?.stop();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -467,6 +467,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,
|
||||
videoMode,
|
||||
seekOffset: this.resumeTime, // Ensure seekOffset is passed
|
||||
videoCodec: info.video,
|
||||
@ -508,6 +509,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,
|
||||
videoMode: 'encode',
|
||||
seekOffset: this.resumeTime
|
||||
});
|
||||
@ -533,6 +535,7 @@ class WatchPage {
|
||||
|
||||
if (this.playbackGeneration !== playbackGeneration) return;
|
||||
const playlistUrl = await this.startTranscodeSession(url, {
|
||||
program: this.currentChannel?.program || null,
|
||||
videoMode: 'copy',
|
||||
videoCodec,
|
||||
seekOffset: this.resumeTime
|
||||
|
||||
Reference in New Issue
Block a user