mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-15 17:12:59 -04:00
switchreplay workers to rtsp
This commit is contained in:
@@ -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.
|
||||
- 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.
|
||||
- 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.
|
||||
|
||||
Local verification completed:
|
||||
@@ -594,7 +595,7 @@ Expected externally relevant listeners are:
|
||||
- Rover RTSP publishing on TCP 8554
|
||||
- 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
|
||||
|
||||
|
||||
@@ -40,7 +40,8 @@ case "$PATH_NAME" in
|
||||
esac
|
||||
|
||||
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 \
|
||||
-vf "$FILTER" \
|
||||
-q:v "$QUALITY" \
|
||||
|
||||
@@ -69,13 +69,9 @@ function buildMediaMtxConfig({ config, serverPort, snapshotWriterPath }) {
|
||||
{ url: 'stun:stun.cloudflare.com:3478' },
|
||||
],
|
||||
|
||||
/*
|
||||
Several server-local paths still use SRT: PTZ publishing, replay capture, and the snapshot
|
||||
writer. Rover media moves to RTSP, but removing this listener would break those independent
|
||||
consumers, so both listeners remain deliberately enabled.
|
||||
*/
|
||||
srt: true,
|
||||
srtAddress: ':9000',
|
||||
// Every publisher and server-local reader uses RTSP over TCP. Disable SRT
|
||||
// completely so MediaMTX cannot reintroduce the GoSRT/libSRT ACKACK mismatch.
|
||||
srt: false,
|
||||
|
||||
authMethod: 'http',
|
||||
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.rtspAddress, ':8554');
|
||||
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, 'rtcpAddress'), false);
|
||||
assert.deepEqual(generated.webrtcAdditionalHosts, ['public.example.com', '10.20.30.40']);
|
||||
|
||||
@@ -573,7 +573,7 @@ function schedulePublisherRestart(reason = 'publisher-restart') {
|
||||
function startPublisher() {
|
||||
if (!enabled || !state.rtspUri || publisherProcess) return;
|
||||
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
|
||||
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
|
||||
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.
|
||||
flush_packets/muxdelay/muxpreload are output-side latency knobs; they do not
|
||||
ask the camera or demuxer to discard frames, so they are a safer next step
|
||||
than the stale-frame dropping experiments that made the Reolink feed freeze.
|
||||
The MediaMTX output uses RTSP over TCP, matching every rover publisher and
|
||||
server-local reader. Keeping one media transport avoids the incompatible
|
||||
empty SRT ACKACK packets produced between GoSRT and Fedora's newer libSRT.
|
||||
*/
|
||||
const proc = spawn('ffmpeg', [
|
||||
'-hide_banner',
|
||||
@@ -671,12 +670,10 @@ function startPublisher() {
|
||||
'-2',
|
||||
'-flush_packets',
|
||||
'1',
|
||||
'-muxdelay',
|
||||
'0',
|
||||
'-muxpreload',
|
||||
'0',
|
||||
'-rtsp_transport',
|
||||
'tcp',
|
||||
'-f',
|
||||
'mpegts',
|
||||
'rtsp',
|
||||
output,
|
||||
], { stdio: ['ignore', 'ignore', 'pipe'] });
|
||||
publisherProcess = proc;
|
||||
@@ -1890,7 +1887,7 @@ module.exports = {
|
||||
audio the same way it already mixes rover audio.
|
||||
*/
|
||||
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';
|
||||
return [
|
||||
{
|
||||
|
||||
@@ -15,8 +15,8 @@ function sourceDirForKey(activeSegmentRoot, key) {
|
||||
return path.join(activeSegmentRoot, key);
|
||||
}
|
||||
|
||||
function toSrtReadPath(streamId) {
|
||||
return `srt://127.0.0.1:9000?streamid=read:${encodeURIComponent(streamId)}`;
|
||||
function toRtspReadPath(streamId) {
|
||||
return `rtsp://127.0.0.1:8554/${encodeURIComponent(streamId)}`;
|
||||
}
|
||||
|
||||
function getRoomCameraStream(camera) {
|
||||
@@ -37,9 +37,9 @@ function listDesiredSources() {
|
||||
const sources = [];
|
||||
for (const rover of roverManager.getRoster()) {
|
||||
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)) {
|
||||
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()) {
|
||||
@@ -57,7 +57,12 @@ function listDesiredSources() {
|
||||
function buildWorkerArgs(activeSegmentRoot, source) {
|
||||
const dir = sourceDirForKey(activeSegmentRoot, sourceKey(source));
|
||||
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') {
|
||||
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 isForwardAudioRead = action === 'read' && streamInfo?.id?.endsWith('-fwd');
|
||||
if ((action === 'read' && isSrtLikeProtocol) || isForwardAudioRead) {
|
||||
return res.status(200).end();
|
||||
}
|
||||
if (action === 'publish' && isSrtLikeProtocol) {
|
||||
const isLoopback = ip === '127.0.0.1' || ip === '::1';
|
||||
// Replay and snapshot workers read MediaMTX through loopback RTSP. They do
|
||||
// not represent a browser session, while non-loopback RTSP readers remain
|
||||
// subject to the normal session authorization below.
|
||||
if (action === 'read' && isRtspProtocol && isLoopback) {
|
||||
return res.status(200).end();
|
||||
}
|
||||
/*
|
||||
|
||||
@@ -5,7 +5,7 @@ const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const { registerVideoAuthRoute } = require('./httpRoute');
|
||||
|
||||
function createHarness() {
|
||||
function createHarness({ requestIp = '127.0.0.1' } = {}) {
|
||||
let handler;
|
||||
const app = { post: (_path, fn) => { handler = fn; } };
|
||||
registerVideoAuthRoute({
|
||||
@@ -13,7 +13,7 @@ function createHarness() {
|
||||
io: { sockets: { sockets: new Map() } },
|
||||
logger: { info() {}, warn() {} },
|
||||
videoSessions: { getSession: () => null, revokeSession() {} },
|
||||
getRequestIp: () => '127.0.0.1',
|
||||
getRequestIp: () => requestIp,
|
||||
logAdminEvent() {},
|
||||
extractStreamInfoFromBody: (body) => ({ type: 'rover', id: body.path, baseId: body.path }),
|
||||
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);
|
||||
});
|
||||
|
||||
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', () => {
|
||||
const { request } = createHarness();
|
||||
assert.equal(request({ protocol: 'webrtc', action: 'read', path: 'rover-one' }), 401);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Video Auth Stream Parsing
|
||||
// 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';
|
||||
|
||||
@@ -44,42 +44,10 @@ function extractStreamInfo(path) {
|
||||
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 = {}) {
|
||||
const fromPath = extractStreamInfo((body.path || '').replace(/^\//, ''));
|
||||
if (fromPath) return fromPath;
|
||||
|
||||
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 };
|
||||
// All enabled MediaMTX protocols now report the canonical path directly;
|
||||
// there is no second SRT stream-id syntax to normalize or authorize.
|
||||
return extractStreamInfo((body.path || '').replace(/^\//, ''));
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
|
||||
Reference in New Issue
Block a user