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:
Vitor Simoes
2026-10-13 23:01:38 +01:00
parent 035667cfba
commit 700f63da90
17 changed files with 415 additions and 19 deletions

View File

@ -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,

View File

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

View File

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