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

@ -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">
@ -1100,12 +1106,12 @@
<script src="/js/components/PlaybackSessions.js?v=1"></script> <script src="/js/components/PlaybackSessions.js?v=1"></script>
<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=1"></script> <script src="/js/components/CaptionTracks.js?v=1"></script>
<script src="/js/components/VideoPlayer.js?v=4"></script> <script src="/js/components/VideoPlayer.js?v=5"></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>

View File

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

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 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 = parseInt(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 = parseInt(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();

View File

@ -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) => {
@ -779,6 +785,11 @@ class VideoPlayer {
* Play a channel * Play a channel
*/ */
async play(channel, streamUrl) { async play(channel, streamUrl) {
if(this.currentChannel != channel && channel?.zapBeforeStreaming){
await this.zapBeforeStreaming(channel, streamUrl);
}
this.currentChannel = channel; this.currentChannel = channel;
try { try {
@ -830,6 +841,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,
@ -880,7 +892,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;
@ -903,7 +915,7 @@ class VideoPlayer {
} 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;
@ -1479,6 +1491,40 @@ class VideoPlayer {
this.container.requestFullscreen(); 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 // Export

View File

@ -103,6 +103,8 @@ class LivePage {
hide() { hide() {
document.removeEventListener('keydown', this.handleKeydown); document.removeEventListener('keydown', this.handleKeydown);
// Stop the stream when leaving Live TV so the transcode session is released
this.app.player?.stop();
} }
} }

View File

@ -467,6 +467,7 @@ class WatchPage {
this.updateTranscodeStatus(statusMode, statusText); this.updateTranscodeStatus(statusMode, statusText);
if (this.playbackGeneration !== playbackGeneration) return; if (this.playbackGeneration !== playbackGeneration) return;
const playlistUrl = await this.startTranscodeSession(url, { const playlistUrl = await this.startTranscodeSession(url, {
program: this.content?.program || this.content?.data?.program || null,
videoMode, videoMode,
seekOffset: this.resumeTime, // Ensure seekOffset is passed seekOffset: this.resumeTime, // Ensure seekOffset is passed
videoCodec: info.video, videoCodec: info.video,
@ -508,6 +509,7 @@ class WatchPage {
this.updateTranscodeStatus(statusMode, statusText); this.updateTranscodeStatus(statusMode, statusText);
if (this.playbackGeneration !== playbackGeneration) return; if (this.playbackGeneration !== playbackGeneration) return;
const playlistUrl = await this.startTranscodeSession(url, { const playlistUrl = await this.startTranscodeSession(url, {
program: this.content?.program || this.content?.data?.program || null,
videoMode: 'encode', videoMode: 'encode',
seekOffset: this.resumeTime seekOffset: this.resumeTime
}); });
@ -533,6 +535,7 @@ class WatchPage {
if (this.playbackGeneration !== playbackGeneration) return; if (this.playbackGeneration !== playbackGeneration) return;
const playlistUrl = await this.startTranscodeSession(url, { const playlistUrl = await this.startTranscodeSession(url, {
program: this.currentChannel?.program || null,
videoMode: 'copy', videoMode: 'copy',
videoCodec, videoCodec,
seekOffset: this.resumeTime seekOffset: this.resumeTime

View File

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

View File

@ -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) => {

View 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;

View File

@ -63,7 +63,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 +73,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: parseInt(zapDelay) || 1500 });
// 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 +91,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 +99,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 = parseInt(zapDelay) || 1500;
const updated = await sources.update(req.params.id, updates); const updated = await sources.update(req.params.id, updates);
if (!updated) return res.status(404).json({ error: 'Source not found' }); if (!updated) return res.status(404).json({ error: 'Source not found' });
// Trigger Sync (if critical fields changed? safely just trigger it) // Trigger Sync (if critical fields changed? safely just trigger it)

View File

@ -33,7 +33,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;
// MPEG-TS program number (Enigma2 multi-program streams); digits only
const programNumber = /^\d+$/.test(String(program ?? '')) ? String(program) : null;
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 +74,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; }

View File

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

View File

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

View File

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

View File

@ -230,8 +230,15 @@ class TranscodeSession extends EventEmitter {
} }
// Map streams // Map streams
// Only map video/audio from the program: mapping the whole program pulls in
// 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:v:0');
args.push('-map', '0:a: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') {

70
test/m3u-parser.test.js Normal file
View 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);
}
});

131
test/openwebif-zap.test.js Normal file
View File

@ -0,0 +1,131 @@
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 } });
const plainSource = await request('/api/sources', { headers: adminHeaders, body: {
type: 'm3u', name: 'Plain', url: `${receiver.base}/web/services.m3u` } });
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, []);
});