Release 1.5.3

This commit is contained in:
2026-07-25 15:11:41 +01:00
parent 9ee938fd2f
commit 0e89892c94
89 changed files with 7660 additions and 542 deletions
+287 -8
View File
@@ -6,7 +6,13 @@ const { spawn } = require('child_process');
function createRtmpStreamService(options) {
const mediaDir = options && options.mediaDir ? options.mediaDir : null;
const ffmpegPath = options && options.ffmpegPath ? options.ffmpegPath : 'ffmpeg';
const ffprobePath = options && options.ffprobePath ? options.ffprobePath : 'ffprobe';
const probeIntervalMs = options && options.probeIntervalMs ? Number(options.probeIntervalMs) : 2000;
const sessions = new Map();
const watchedSources = new Set();
const sourceStatusCache = new Map();
const sourceProbePromises = new Map();
let probeTimer = null;
if (!mediaDir) {
throw new Error('mediaDir is required');
@@ -48,6 +54,207 @@ function createRtmpStreamService(options) {
return false;
}
function runCommand(command, args, timeoutMs) {
return new Promise(function (resolve) {
var child = spawn(command, args, {
stdio: ['ignore', 'pipe', 'pipe']
});
var stdout = '';
var stderr = '';
var finished = false;
var timer = null;
function done(result) {
if (finished) {
return;
}
finished = true;
if (timer) {
clearTimeout(timer);
timer = null;
}
resolve(result);
}
child.stdout.on('data', function (chunk) {
stdout += String(chunk || '');
});
child.stderr.on('data', function (chunk) {
stderr += String(chunk || '');
});
child.on('error', function (error) {
done({ ok: false, error: error, stdout: stdout, stderr: stderr, timedOut: false });
});
child.on('exit', function (code, signal) {
done({ ok: code === 0, code: code, signal: signal, stdout: stdout, stderr: stderr, timedOut: false });
});
timer = setTimeout(function () {
try {
child.kill('SIGKILL');
} catch (_error) {
// ignore timeout cleanup errors
}
done({ ok: false, code: null, signal: 'SIGKILL', stdout: stdout, stderr: stderr, timedOut: true });
}, Math.max(1000, Number(timeoutMs || 0) || 4000));
});
}
async function probeSourceUrl(sourceUrl) {
const normalizedSource = normalizeSourceUrl(sourceUrl);
const result = await runCommand(ffprobePath, [
'-hide_banner',
'-loglevel', 'error',
'-rw_timeout', '3000000',
'-show_streams',
'-of', 'json',
normalizedSource
], 4000);
if (!result.ok) {
return {
live: false,
timedOut: Boolean(result.timedOut),
stderr: String(result.stderr || '').trim()
};
}
try {
const parsed = JSON.parse(String(result.stdout || '{}'));
const streams = Array.isArray(parsed && parsed.streams) ? parsed.streams : [];
return {
live: streams.length > 0,
timedOut: false,
stderr: String(result.stderr || '').trim()
};
} catch (_error) {
return {
live: false,
timedOut: false,
stderr: String(result.stderr || '').trim()
};
}
}
function getSourceStatus(sourceUrl) {
return sourceStatusCache.get(String(sourceUrl || '').trim()) || null;
}
function setSourceStatus(sourceUrl, nextStatus) {
const normalizedSource = String(sourceUrl || '').trim();
if (!normalizedSource) {
return null;
}
const status = Object.assign({
live: false,
probing: false,
checkedAt: Date.now(),
timedOut: false,
stderr: ''
}, nextStatus || {});
status.live = Boolean(status.live);
status.probing = Boolean(status.probing);
status.checkedAt = Number(status.checkedAt || Date.now());
sourceStatusCache.set(normalizedSource, status);
return status;
}
function registerSourceWatch(sourceUrl) {
const normalizedSource = normalizeSourceUrl(sourceUrl);
watchedSources.add(normalizedSource);
return normalizedSource;
}
async function refreshSourceStatus(sourceUrl) {
const normalizedSource = registerSourceWatch(sourceUrl);
const existingPromise = sourceProbePromises.get(normalizedSource) || null;
if (existingPromise) {
return existingPromise;
}
setSourceStatus(normalizedSource, Object.assign({}, getSourceStatus(normalizedSource) || {}, {
probing: true
}));
const probePromise = probeSourceUrl(normalizedSource).then(function (probe) {
return setSourceStatus(normalizedSource, {
live: Boolean(probe.live),
probing: false,
checkedAt: Date.now(),
timedOut: Boolean(probe.timedOut),
stderr: String(probe.stderr || '')
});
}).catch(function (error) {
return setSourceStatus(normalizedSource, {
live: false,
probing: false,
checkedAt: Date.now(),
timedOut: false,
stderr: String(error && error.message ? error.message : '')
});
}).finally(function () {
sourceProbePromises.delete(normalizedSource);
});
sourceProbePromises.set(normalizedSource, probePromise);
return probePromise;
}
function scheduleSourceRefresh(sourceUrl) {
const normalizedSource = registerSourceWatch(sourceUrl);
const currentStatus = getSourceStatus(normalizedSource);
if (currentStatus && currentStatus.probing) {
return sourceProbePromises.get(normalizedSource) || Promise.resolve(currentStatus);
}
return refreshSourceStatus(normalizedSource);
}
function isSourceStatusFresh(status) {
if (!status || !status.checkedAt) {
return false;
}
return Date.now() - Number(status.checkedAt || 0) < probeIntervalMs;
}
function sweepWatchedSources() {
watchedSources.forEach(function (sourceUrl) {
const status = getSourceStatus(sourceUrl);
if (!status || !isSourceStatusFresh(status) || status.live === false) {
scheduleSourceRefresh(sourceUrl);
}
});
}
if (probeIntervalMs > 0) {
probeTimer = setInterval(function () {
sweepWatchedSources();
}, probeIntervalMs);
if (probeTimer && typeof probeTimer.unref === 'function') {
probeTimer.unref();
}
}
async function isSessionLive(session) {
if (!session || !session.process) {
return false;
}
if (session.exitCode !== undefined && session.exitCode !== null) {
return false;
}
if (session.exitSignal !== undefined && session.exitSignal !== null) {
return false;
}
try {
const stat = await fs.promises.stat(session.manifestPath);
return stat.isFile() && stat.size > 0;
} catch (_error) {
return false;
}
}
function buildPlaylistUrl(key) {
return '/api/rtmp/streams/' + encodeURIComponent(key) + '/index.m3u8';
}
@@ -56,14 +263,30 @@ function createRtmpStreamService(options) {
const normalizedSource = normalizeSourceUrl(sourceUrl);
const normalizedDisableAudio = Boolean(disableAudio);
const key = getSessionKey(normalizedSource, normalizedDisableAudio);
if (sessions.has(key)) {
return sessions.get(key);
}
const directory = path.join(cacheRoot, key);
const manifestPath = path.join(directory, 'index.m3u8');
const existingSession = sessions.get(key) || null;
if (existingSession && existingSession.process && existingSession.exitCode === null && existingSession.exitSignal === null) {
return existingSession;
}
if (existingSession) {
sessions.delete(key);
try {
if (existingSession.process && typeof existingSession.process.kill === 'function') {
existingSession.process.kill('SIGKILL');
}
} catch (_error) {
// ignore cleanup errors
}
try {
await fs.promises.rm(directory, { recursive: true, force: true });
} catch (_error2) {
// ignore cleanup errors
}
}
await ensureDirectory(directory);
const args = [
@@ -117,6 +340,8 @@ function createRtmpStreamService(options) {
manifestPath: manifestPath,
playlistUrl: buildPlaylistUrl(key),
process: child,
exitCode: null,
exitSignal: null,
ready: waitForFile(manifestPath, 5000)
};
@@ -132,6 +357,51 @@ function createRtmpStreamService(options) {
return session.playlistUrl + '?t=' + Date.now();
}
async function getSessionStatus(sourceUrl, disableAudio) {
const normalizedSource = normalizeSourceUrl(sourceUrl);
const normalizedDisableAudio = Boolean(disableAudio);
registerSourceWatch(normalizedSource);
let sourceStatus = getSourceStatus(normalizedSource);
if (!sourceStatus) {
scheduleSourceRefresh(normalizedSource).catch(function (_error) {
return null;
});
sourceStatus = getSourceStatus(normalizedSource);
} else if (!isSourceStatusFresh(sourceStatus) && !sourceStatus.probing) {
scheduleSourceRefresh(normalizedSource).catch(function (_error) {
return null;
});
}
if (!sourceStatus || !sourceStatus.live) {
return {
session: null,
ready: false,
live: false,
probing: Boolean(sourceStatus && sourceStatus.probing),
checkedAt: sourceStatus ? sourceStatus.checkedAt : null,
timedOut: Boolean(sourceStatus && sourceStatus.timedOut),
stderr: String(sourceStatus && sourceStatus.stderr || '')
};
}
const session = await ensureSession(normalizedSource, normalizedDisableAudio);
const ready = await session.ready.catch(function () {
return false;
});
const live = ready && Boolean(session && session.process && session.exitCode === null && session.exitSignal === null);
return {
session: session,
ready: ready,
live: live,
probing: Boolean(sourceStatus && sourceStatus.probing),
checkedAt: sourceStatus ? sourceStatus.checkedAt : null,
timedOut: false,
stderr: ''
};
}
function getSessionByKey(key) {
return sessions.get(String(key || '').trim()) || null;
}
@@ -141,9 +411,13 @@ function createRtmpStreamService(options) {
if (!session) {
return null;
}
await session.ready.catch(function () {
return false;
});
if (!session.process || session.exitCode !== null || session.exitSignal !== null) {
return null;
}
const live = await isSessionLive(session);
if (!live) {
return null;
}
return session.manifestPath;
}
@@ -163,6 +437,11 @@ function createRtmpStreamService(options) {
ensureSession: ensureSession,
getPlaylistUrl: getPlaylistUrl,
getSessionByKey: getSessionByKey,
isSessionLive: isSessionLive,
getSessionStatus: getSessionStatus,
refreshSourceStatus: refreshSourceStatus,
scheduleSourceRefresh: scheduleSourceRefresh,
getSourceStatus: getSourceStatus,
getManifestFilePath: getManifestFilePath,
getSegmentFilePath: getSegmentFilePath
};