switchreplay workers to rtsp

This commit is contained in:
legop3
2026-09-15 16:27:08 -04:00
parent aa3c0c0e7b
commit c3c60fde18
9 changed files with 48 additions and 69 deletions
+2 -1
View File
@@ -476,6 +476,7 @@ Implemented on 2026-09-14:
- Discord command authorization and lockdown moderation recipients now read the live administrator registry, so setup imports and later Discord-ID or role edits take effect without restarting the server. - Discord command authorization and lockdown moderation recipients now read the live administrator registry, so setup imports and later Discord-ID or role edits take effect without restarting the server.
- Full-data restore now leaves `runtime/` untouched, matching its existing exclusion from backup archives and preventing the non-root application from trying to remove lifecycle-controller state owned by the root controller container. - Full-data restore now leaves `runtime/` untouched, matching its existing exclusion from backup archives and preventing the non-root application from trying to remove lifecycle-controller state owned by the root controller container.
- The Users and administrators tab now requests at most 100 lightweight identity summaries through one bounded SQLite query. Search and moderation filters run on the server, while complete signals, permissions, and feature state load only after selecting a user, preventing large identity databases from blocking Socket.IO heartbeats or freezing the browser. - The Users and administrators tab now requests at most 100 lightweight identity summaries through one bounded SQLite query. Search and moderation filters run on the server, while complete signals, permissions, and feature state load only after selecting a user, preventing large identity databases from blocking Socket.IO heartbeats or freezing the browser.
- Removed the remaining server-local SRT hops after Fedora's newer libSRT rejected the zero-payload ACKACK packets emitted by MediaMTX's GoSRT implementation on every acknowledgement cycle. PTZ publishing, replay capture, and snapshot capture now share the existing RTSP/TCP listener, SRT is disabled, and only loopback RTSP readers bypass browser-session authorization.
- Fixed inter-instance public payload generation to read feature flags and social links from the same live configuration revision. Social links enabled through the new configuration system no longer trigger an undefined legacy-config reference and an HTTP 500 response. - Fixed inter-instance public payload generation to read feature flags and social links from the same live configuration revision. Social links enabled through the new configuration system no longer trigger an undefined legacy-config reference and an HTTP 500 response.
Local verification completed: Local verification completed:
@@ -594,7 +595,7 @@ Expected externally relevant listeners are:
- Rover RTSP publishing on TCP 8554 - Rover RTSP publishing on TCP 8554
- WebRTC media on TCP and UDP 8189 - WebRTC media on TCP and UDP 8189
MediaMTX WHEP on 8889, API/metrics listeners, and server-local SRT should stay on loopback unless an identified remote consumer requires otherwise. MediaMTX WHEP on 8889 and API/metrics listeners should stay on loopback unless an identified remote consumer requires otherwise. Publishers and server-local replay/snapshot readers use the single RTSP/TCP listener on 8554; SRT is disabled.
### Compose implementation notes ### Compose implementation notes
+2 -1
View File
@@ -40,7 +40,8 @@ case "$PATH_NAME" in
esac esac
exec ffmpeg -hide_banner -loglevel warning -nostdin -y \ exec ffmpeg -hide_banner -loglevel warning -nostdin -y \
-i "srt://127.0.0.1:9000?streamid=read:${PATH_NAME}" \ -rtsp_transport tcp \
-i "rtsp://127.0.0.1:8554/${PATH_NAME}" \
-an \ -an \
-vf "$FILTER" \ -vf "$FILTER" \
-q:v "$QUALITY" \ -q:v "$QUALITY" \
@@ -69,13 +69,9 @@ function buildMediaMtxConfig({ config, serverPort, snapshotWriterPath }) {
{ url: 'stun:stun.cloudflare.com:3478' }, { url: 'stun:stun.cloudflare.com:3478' },
], ],
/* // Every publisher and server-local reader uses RTSP over TCP. Disable SRT
Several server-local paths still use SRT: PTZ publishing, replay capture, and the snapshot // completely so MediaMTX cannot reintroduce the GoSRT/libSRT ACKACK mismatch.
writer. Rover media moves to RTSP, but removing this listener would break those independent srt: false,
consumers, so both listeners remain deliberately enabled.
*/
srt: true,
srtAddress: ':9000',
authMethod: 'http', authMethod: 'http',
authHTTPAddress: `http://127.0.0.1:${authPort}/mediamtx/auth`, authHTTPAddress: `http://127.0.0.1:${authPort}/mediamtx/auth`,
@@ -20,6 +20,8 @@ test('generates RTSP over TCP without deployment-specific hardcodes', () => {
assert.equal(generated.rtsp, true); assert.equal(generated.rtsp, true);
assert.equal(generated.rtspAddress, ':8554'); assert.equal(generated.rtspAddress, ':8554');
assert.deepEqual(generated.rtspTransports, ['tcp']); assert.deepEqual(generated.rtspTransports, ['tcp']);
assert.equal(generated.srt, false);
assert.equal(Object.hasOwn(generated, 'srtAddress'), false);
assert.equal(Object.hasOwn(generated, 'rtpAddress'), false); assert.equal(Object.hasOwn(generated, 'rtpAddress'), false);
assert.equal(Object.hasOwn(generated, 'rtcpAddress'), false); assert.equal(Object.hasOwn(generated, 'rtcpAddress'), false);
assert.deepEqual(generated.webrtcAdditionalHosts, ['public.example.com', '10.20.30.40']); assert.deepEqual(generated.webrtcAdditionalHosts, ['public.example.com', '10.20.30.40']);
+8 -11
View File
@@ -573,7 +573,7 @@ function schedulePublisherRestart(reason = 'publisher-restart') {
function startPublisher() { function startPublisher() {
if (!enabled || !state.rtspUri || publisherProcess) return; if (!enabled || !state.rtspUri || publisherProcess) return;
const input = addCredentialsToRtsp(state.rtspUri); const input = addCredentialsToRtsp(state.rtspUri);
const output = `srt://127.0.0.1:9000?streamid=publish:${encodeURIComponent(PTZ_STREAM_PATH)}`; const output = `rtsp://127.0.0.1:8554/${encodeURIComponent(PTZ_STREAM_PATH)}`;
/* /*
The full-quality autotrack profile is H265, which is the right camera-side The full-quality autotrack profile is H265, which is the right camera-side
feed but has been unreliable through browser WHEP playback. Re-encoding is feed but has been unreliable through browser WHEP playback. Re-encoding is
@@ -607,10 +607,9 @@ function startPublisher() {
dead session. The existing exit handler then starts a new process, which is dead session. The existing exit handler then starts a new process, which is
the part that creates a fresh RTSP connection after the camera comes back. the part that creates a fresh RTSP connection after the camera comes back.
The mpegts muxer can also hold packets briefly before writing them to SRT. The MediaMTX output uses RTSP over TCP, matching every rover publisher and
flush_packets/muxdelay/muxpreload are output-side latency knobs; they do not server-local reader. Keeping one media transport avoids the incompatible
ask the camera or demuxer to discard frames, so they are a safer next step empty SRT ACKACK packets produced between GoSRT and Fedora's newer libSRT.
than the stale-frame dropping experiments that made the Reolink feed freeze.
*/ */
const proc = spawn('ffmpeg', [ const proc = spawn('ffmpeg', [
'-hide_banner', '-hide_banner',
@@ -671,12 +670,10 @@ function startPublisher() {
'-2', '-2',
'-flush_packets', '-flush_packets',
'1', '1',
'-muxdelay', '-rtsp_transport',
'0', 'tcp',
'-muxpreload',
'0',
'-f', '-f',
'mpegts', 'rtsp',
output, output,
], { stdio: ['ignore', 'ignore', 'pipe'] }); ], { stdio: ['ignore', 'ignore', 'pipe'] });
publisherProcess = proc; publisherProcess = proc;
@@ -1890,7 +1887,7 @@ module.exports = {
audio the same way it already mixes rover audio. audio the same way it already mixes rover audio.
*/ */
if (!enabled || !isReplayEnabled()) return []; if (!enabled || !isReplayEnabled()) return [];
const inputUrl = `srt://127.0.0.1:9000?streamid=read:${encodeURIComponent(PTZ_STREAM_PATH)}`; const inputUrl = `rtsp://127.0.0.1:8554/${encodeURIComponent(PTZ_STREAM_PATH)}`;
const label = cameraConfig.name || 'PTZ Camera'; const label = cameraConfig.name || 'PTZ Camera';
return [ return [
{ {
+10 -5
View File
@@ -15,8 +15,8 @@ function sourceDirForKey(activeSegmentRoot, key) {
return path.join(activeSegmentRoot, key); return path.join(activeSegmentRoot, key);
} }
function toSrtReadPath(streamId) { function toRtspReadPath(streamId) {
return `srt://127.0.0.1:9000?streamid=read:${encodeURIComponent(streamId)}`; return `rtsp://127.0.0.1:8554/${encodeURIComponent(streamId)}`;
} }
function getRoomCameraStream(camera) { function getRoomCameraStream(camera) {
@@ -37,9 +37,9 @@ function listDesiredSources() {
const sources = []; const sources = [];
for (const rover of roverManager.getRoster()) { for (const rover of roverManager.getRoster()) {
const roverId = String(rover.id); const roverId = String(rover.id);
sources.push({ id: roverId, sourceType: 'rover', kind: 'video', label: rover.name || roverId, inputUrl: toSrtReadPath(roverId) }); sources.push({ id: roverId, sourceType: 'rover', kind: 'video', label: rover.name || roverId, inputUrl: toRtspReadPath(roverId) });
if (hasRoverAudioCapture(rover)) { if (hasRoverAudioCapture(rover)) {
sources.push({ id: `${roverId}-audio`, sourceType: 'rover', roverId, kind: 'audio', label: `${rover.name || roverId} audio`, inputUrl: toSrtReadPath(`${roverId}-audio`) }); sources.push({ id: `${roverId}-audio`, sourceType: 'rover', roverId, kind: 'audio', label: `${rover.name || roverId} audio`, inputUrl: toRtspReadPath(`${roverId}-audio`) });
} }
} }
for (const camera of getRoomCameras()) { for (const camera of getRoomCameras()) {
@@ -57,7 +57,12 @@ function listDesiredSources() {
function buildWorkerArgs(activeSegmentRoot, source) { function buildWorkerArgs(activeSegmentRoot, source) {
const dir = sourceDirForKey(activeSegmentRoot, sourceKey(source)); const dir = sourceDirForKey(activeSegmentRoot, sourceKey(source));
const pattern = path.join(dir, 'seg-%06d.mp4'); const pattern = path.join(dir, 'seg-%06d.mp4');
const common = ['-hide_banner', '-loglevel', 'warning', '-y', '-fflags', '+genpts', '-use_wallclock_as_timestamps', '1', '-i', source.inputUrl]; // MediaMTX and RTSP cameras use TCP so replay capture has one reliable
// transport and never falls back to separate RTP/RTCP UDP listeners.
const inputTransport = /^rtsps?:\/\//i.test(source.inputUrl)
? ['-rtsp_transport', 'tcp']
: [];
const common = ['-hide_banner', '-loglevel', 'warning', '-y', '-fflags', '+genpts', '-use_wallclock_as_timestamps', '1', ...inputTransport, '-i', source.inputUrl];
if (source.kind === 'audio') { if (source.kind === 'audio') {
return [ return [
@@ -32,13 +32,12 @@ function registerVideoAuthRoute(deps) {
}); });
} }
const isSrtLikeProtocol = protocol === 'srt' || protocol === 'srtconn' || protocol.startsWith('srt');
const isRtspProtocol = protocol === 'rtsp' || protocol.startsWith('rtsp'); const isRtspProtocol = protocol === 'rtsp' || protocol.startsWith('rtsp');
const isForwardAudioRead = action === 'read' && streamInfo?.id?.endsWith('-fwd'); const isLoopback = ip === '127.0.0.1' || ip === '::1';
if ((action === 'read' && isSrtLikeProtocol) || isForwardAudioRead) { // Replay and snapshot workers read MediaMTX through loopback RTSP. They do
return res.status(200).end(); // not represent a browser session, while non-loopback RTSP readers remain
} // subject to the normal session authorization below.
if (action === 'publish' && isSrtLikeProtocol) { if (action === 'read' && isRtspProtocol && isLoopback) {
return res.status(200).end(); return res.status(200).end();
} }
/* /*
@@ -5,7 +5,7 @@ const test = require('node:test');
const assert = require('node:assert/strict'); const assert = require('node:assert/strict');
const { registerVideoAuthRoute } = require('./httpRoute'); const { registerVideoAuthRoute } = require('./httpRoute');
function createHarness() { function createHarness({ requestIp = '127.0.0.1' } = {}) {
let handler; let handler;
const app = { post: (_path, fn) => { handler = fn; } }; const app = { post: (_path, fn) => { handler = fn; } };
registerVideoAuthRoute({ registerVideoAuthRoute({
@@ -13,7 +13,7 @@ function createHarness() {
io: { sockets: { sockets: new Map() } }, io: { sockets: { sockets: new Map() } },
logger: { info() {}, warn() {} }, logger: { info() {}, warn() {} },
videoSessions: { getSession: () => null, revokeSession() {} }, videoSessions: { getSession: () => null, revokeSession() {} },
getRequestIp: () => '127.0.0.1', getRequestIp: () => requestIp,
logAdminEvent() {}, logAdminEvent() {},
extractStreamInfoFromBody: (body) => ({ type: 'rover', id: body.path, baseId: body.path }), extractStreamInfoFromBody: (body) => ({ type: 'rover', id: body.path, baseId: body.path }),
canAccessStream: () => false, canAccessStream: () => false,
@@ -42,6 +42,16 @@ test('allows an RTSP rover publisher without a browser session', () => {
assert.equal(request({ protocol: 'rtsp', action: 'publish', path: 'rover-one' }), 200); assert.equal(request({ protocol: 'rtsp', action: 'publish', path: 'rover-one' }), 200);
}); });
test('allows server-local RTSP replay and snapshot readers without a browser session', () => {
const { request } = createHarness();
assert.equal(request({ protocol: 'rtsp', action: 'read', path: 'rover-one' }), 200);
});
test('continues rejecting an unauthenticated remote RTSP reader', () => {
const { request } = createHarness({ requestIp: '192.0.2.10' });
assert.equal(request({ protocol: 'rtsp', action: 'read', path: 'rover-one' }), 401);
});
test('continues rejecting an unauthenticated WebRTC read', () => { test('continues rejecting an unauthenticated WebRTC read', () => {
const { request } = createHarness(); const { request } = createHarness();
assert.equal(request({ protocol: 'webrtc', action: 'read', path: 'rover-one' }), 401); assert.equal(request({ protocol: 'webrtc', action: 'read', path: 'rover-one' }), 401);
@@ -1,6 +1,6 @@
// Video Auth Stream Parsing // Video Auth Stream Parsing
// Purpose: Parses MediaMTX path/body payloads into normalized stream targets for rover and room media checks. // Purpose: Parses MediaMTX path/body payloads into normalized stream targets for rover and room media checks.
// Scope: Handles native MediaMTX WHEP/WHIP paths and SRT streamid extraction without performing auth decisions. // Scope: Handles native MediaMTX path forms without performing auth decisions.
const PTZ_STREAM_PATH = 'ptz-camera'; const PTZ_STREAM_PATH = 'ptz-camera';
@@ -44,42 +44,10 @@ function extractStreamInfo(path) {
return null; return null;
} }
function extractSrtStreamId(rawValue) {
const value = decodeURIComponent(String(rawValue || '').trim());
if (!value) return '';
const match = value.match(/(?:^|[?&]|,|#!::)r=([^,&]+)/);
if (match?.[1]) {
return match[1];
}
if (!/[?&=,:]/.test(value)) {
return value;
}
return '';
}
function extractStreamInfoFromBody(body = {}) { function extractStreamInfoFromBody(body = {}) {
const fromPath = extractStreamInfo((body.path || '').replace(/^\//, '')); // All enabled MediaMTX protocols now report the canonical path directly;
if (fromPath) return fromPath; // there is no second SRT stream-id syntax to normalize or authorize.
return extractStreamInfo((body.path || '').replace(/^\//, ''));
const srtId =
extractSrtStreamId(body.streamid) ||
extractSrtStreamId(body.streamId) ||
extractSrtStreamId(body.query);
if (!srtId) return null;
if (srtId === PTZ_STREAM_PATH) {
return { type: 'ptz', id: srtId };
}
if (srtId.endsWith('-fwd')) {
return { type: 'rover', id: srtId, baseId: srtId.slice(0, -4) };
}
const baseId = srtId.endsWith('-audio') ? srtId.slice(0, -6) : srtId;
return { type: 'rover', id: srtId, baseId };
} }
module.exports = { module.exports = {