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:
@ -200,6 +200,12 @@
|
||||
</svg>
|
||||
</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>
|
||||
|
||||
<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/VodDuration.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/ChannelList.js?v=3"></script>
|
||||
<script src="/js/components/SourceManager.js?v=2"></script>
|
||||
<script src="/js/components/VideoPlayer.js?v=5"></script>
|
||||
<script src="/js/components/ChannelList.js?v=4"></script>
|
||||
<script src="/js/components/SourceManager.js?v=3"></script>
|
||||
<script src="/js/components/EpgGuide.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/MoviesPage.js?v=4"></script>
|
||||
<script src="/js/pages/SeriesPage.js?v=1"></script>
|
||||
|
||||
@ -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
|
||||
|
||||
@ -145,6 +145,13 @@ function initSchema() {
|
||||
// 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');
|
||||
}
|
||||
|
||||
|
||||
@ -208,6 +208,7 @@ app.use('/api/probe', require('./routes/probe'));
|
||||
app.use('/api/subtitle', require('./routes/subtitle'));
|
||||
app.use('/api/settings', require('./routes/settings'));
|
||||
app.use('/api/history', require('./routes/history'));
|
||||
app.use('/api/openwebif', require('./routes/openwebif'));
|
||||
|
||||
// SPA fallback - serve index.html for all non-API routes
|
||||
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;
|
||||
@ -63,7 +63,7 @@ router.get('/:id', async (req, res) => {
|
||||
// Create source
|
||||
router.post('/', async (req, res) => {
|
||||
try {
|
||||
const { type, name, url, username, password } = req.body;
|
||||
const { type, name, url, username, password, zapBeforeStreaming, zapDelay } = req.body;
|
||||
|
||||
if (!type || !name || !url) {
|
||||
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' });
|
||||
}
|
||||
|
||||
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
|
||||
syncService.syncSource(source.id).catch(console.error);
|
||||
res.status(201).json(source);
|
||||
@ -91,7 +91,7 @@ router.put('/:id', async (req, res) => {
|
||||
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
|
||||
// database transaction, not the snapshot read before another edit.
|
||||
const updates = {};
|
||||
@ -99,6 +99,8 @@ router.put('/:id', async (req, res) => {
|
||||
if (url) updates.url = url;
|
||||
if (username !== undefined) updates.username = username;
|
||||
if (password !== undefined) updates.password = password;
|
||||
if (zapBeforeStreaming !== undefined) updates.zapBeforeStreaming = zapBeforeStreaming === true;
|
||||
if (zapDelay !== undefined) updates.zapDelay = parseInt(zapDelay) || 1500;
|
||||
const updated = await sources.update(req.params.id, updates);
|
||||
if (!updated) return res.status(404).json({ error: 'Source not found' });
|
||||
// Trigger Sync (if critical fields changed? safely just trigger it)
|
||||
|
||||
@ -33,7 +33,9 @@ transcodeSession.startCleanupInterval();
|
||||
* Body: { url: string, seekOffset?: number }
|
||||
*/
|
||||
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);
|
||||
if (!Number.isFinite(normalizedSeek) || normalizedSeek < 0) return res.status(400).json({ error: "Invalid seek offset" });
|
||||
let session = null, disconnected = false;
|
||||
@ -72,7 +74,8 @@ router.post('/session', startupLimit, async (req, res) => {
|
||||
videoCodec: videoCodec, // 'h264', 'hevc', etc.
|
||||
audioCodec: audioCodec, // 'aac', 'ac3', etc.
|
||||
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; }
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* M3U Playlist Parser (Streaming)
|
||||
* Parses EXTM3U format playlists and extracts channel information line-by-line
|
||||
@ -88,6 +90,7 @@ async function parse(input) {
|
||||
const groupsSet = new Set();
|
||||
let currentInfo = null;
|
||||
let currentGroup = null;
|
||||
let currentVlcProgram = null;
|
||||
|
||||
let lines;
|
||||
|
||||
@ -121,6 +124,11 @@ async function parse(input) {
|
||||
if (currentInfo) {
|
||||
currentInfo.groupTitle = currentGroup;
|
||||
}
|
||||
} else if (trimmed.startsWith('#EXTVLCOPT:program=')) {
|
||||
currentVlcProgram = trimmed.split('=')[1]?.trim() || null;
|
||||
if (currentInfo) {
|
||||
currentInfo.program = currentVlcProgram;
|
||||
}
|
||||
} else if (!trimmed.startsWith('#')) {
|
||||
// This is a stream URL
|
||||
if (currentInfo) {
|
||||
@ -132,9 +140,11 @@ async function parse(input) {
|
||||
...currentInfo,
|
||||
id: stableId,
|
||||
url: trimmed,
|
||||
program: currentVlcProgram,
|
||||
groupTitle: groupTitle
|
||||
});
|
||||
currentInfo = null;
|
||||
currentVlcProgram = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -187,6 +197,7 @@ async function* parseStreaming(input, batchSize = 500) {
|
||||
const groupsSet = new Set();
|
||||
let currentInfo = null;
|
||||
let currentGroup = null;
|
||||
let currentVlcProgram = null;
|
||||
let batch = [];
|
||||
|
||||
let lines;
|
||||
@ -216,6 +227,11 @@ async function* parseStreaming(input, batchSize = 500) {
|
||||
if (currentInfo) {
|
||||
currentInfo.groupTitle = currentGroup;
|
||||
}
|
||||
} else if (trimmed.startsWith('#EXTVLCOPT:program=')) {
|
||||
currentVlcProgram = trimmed.split('=')[1]?.trim() || null;
|
||||
if (currentInfo) {
|
||||
currentInfo.program = currentVlcProgram;
|
||||
}
|
||||
} else if (!trimmed.startsWith('#')) {
|
||||
if (currentInfo) {
|
||||
const groupTitle = currentInfo.groupTitle || currentGroup || 'Uncategorized';
|
||||
@ -225,9 +241,11 @@ async function* parseStreaming(input, batchSize = 500) {
|
||||
...currentInfo,
|
||||
id: stableId,
|
||||
url: trimmed,
|
||||
program: currentVlcProgram,
|
||||
groupTitle: groupTitle
|
||||
});
|
||||
currentInfo = null;
|
||||
currentVlcProgram = null;
|
||||
|
||||
// Yield batch when full
|
||||
if (batch.length >= batchSize) {
|
||||
|
||||
@ -58,6 +58,7 @@ class M3uXtreamAdapter {
|
||||
name,
|
||||
stream_icon,
|
||||
stream_url,
|
||||
program,
|
||||
category_id,
|
||||
added_at,
|
||||
data
|
||||
@ -92,6 +93,7 @@ class M3uXtreamAdapter {
|
||||
added: row.added_at,
|
||||
// M3U-specific: direct stream URL (Xtream builds URLs from credentials)
|
||||
stream_url: row.stream_url,
|
||||
program: row.program || extra.program || null,
|
||||
// Include extra fields from parser (tvgId, etc.)
|
||||
epg_channel_id: extra.tvgId || null,
|
||||
...extra
|
||||
|
||||
@ -278,14 +278,16 @@ class SyncService {
|
||||
const stmt = db.prepare(`
|
||||
INSERT INTO playlist_items (
|
||||
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
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
name = excluded.name,
|
||||
category_id = excluded.category_id,
|
||||
stream_icon = excluded.stream_icon,
|
||||
stream_url = excluded.stream_url,
|
||||
program = excluded.program,
|
||||
container_extension = excluded.container_extension,
|
||||
data = excluded.data
|
||||
`);
|
||||
@ -332,6 +334,7 @@ class SyncService {
|
||||
String(catId),
|
||||
icon,
|
||||
null, // Direct URL not stored for Xtream usually, built on fly
|
||||
item.program || null,
|
||||
container,
|
||||
rating,
|
||||
year,
|
||||
@ -641,6 +644,7 @@ class SyncService {
|
||||
stream_icon: ch.tvgLogo,
|
||||
stream_url: ch.url,
|
||||
tvgId: ch.tvgId || null,
|
||||
program: ch.program || null,
|
||||
}));
|
||||
|
||||
// Save this batch immediately (skip purge - we'll do it at the end)
|
||||
|
||||
@ -230,8 +230,15 @@ class TranscodeSession extends EventEmitter {
|
||||
}
|
||||
|
||||
// Map streams
|
||||
args.push('-map', '0:v:0');
|
||||
args.push('-map', '0:a:0?');
|
||||
// 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:a:0?');
|
||||
}
|
||||
|
||||
// Add video encoder and filters based on selected encoder OR copy
|
||||
if (videoMode === 'copy') {
|
||||
|
||||
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);
|
||||
}
|
||||
});
|
||||
131
test/openwebif-zap.test.js
Normal file
131
test/openwebif-zap.test.js
Normal 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, []);
|
||||
});
|
||||
Reference in New Issue
Block a user