Release 1.5.3
This commit is contained in:
@@ -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
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user