mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-16 09:31:20 -04:00
lets see . . .
This commit is contained in:
@@ -15,8 +15,8 @@ const { subscribe } = require('./eventBus');
|
||||
const { getRoster, lockRover, rovers } = require('./roverManager');
|
||||
const { MODES, getMode, setMode } = require('./modeManager');
|
||||
const { sendExternalMessage } = require('./chatService');
|
||||
const { getRoomCameras, getRoomCamera } = require('./roomCameraService');
|
||||
const { buildRoomCameraReplayVideo } = require('./roomCameraReplayService');
|
||||
const { buildReplayVideo } = require('./replayBuildService');
|
||||
const { getReplaySources, getDefaultDiscordSources, validateSources } = require('./replaySourceService');
|
||||
const { getActiveDrivers } = require('./turnService');
|
||||
const { getNickname } = require('./nicknameService');
|
||||
const { tryTriggerReplay } = require('./replayService');
|
||||
@@ -207,7 +207,7 @@ function formatHelp() {
|
||||
'**Rover Bot Commands**',
|
||||
'`rs help` — show this help',
|
||||
'`rs status [id]` — show rover status (all or one)',
|
||||
'`rs replay [camera]` — send room camera instant replay',
|
||||
'`rs replay [sources]` — send instant replay (room/rover)',
|
||||
'`rs bridge` — show chat bridge status for this server',
|
||||
'`rs bridge here <global|private>` — set chat bridge to this channel',
|
||||
'`rs bridge mode <global|private>` — change chat bridge mode',
|
||||
@@ -263,57 +263,59 @@ function buildDriverCaption() {
|
||||
return `Drivers: ${entries.join(', ')}`;
|
||||
}
|
||||
|
||||
function normalizeCameraQuery(input) {
|
||||
function normalizeReplayQuery(input) {
|
||||
return String(input || '').trim().toLowerCase();
|
||||
}
|
||||
|
||||
function resolveReplayCamera(query) {
|
||||
const cleaned = normalizeCameraQuery(query);
|
||||
if (!cleaned || cleaned === 'all' || cleaned === '*') return { camera: null };
|
||||
const cameras = getRoomCameras();
|
||||
const direct = cameras.find(
|
||||
(camera) =>
|
||||
String(camera.id).toLowerCase() === cleaned ||
|
||||
String(camera.name || '').toLowerCase() === cleaned,
|
||||
);
|
||||
if (direct) return { camera: direct };
|
||||
const starts = cameras.filter(
|
||||
(camera) =>
|
||||
String(camera.id).toLowerCase().startsWith(cleaned) ||
|
||||
String(camera.name || '').toLowerCase().startsWith(cleaned),
|
||||
);
|
||||
if (starts.length === 1) return { camera: starts[0] };
|
||||
if (starts.length > 1) return { error: 'Ambiguous camera name', matches: starts };
|
||||
const includes = cameras.filter(
|
||||
(camera) =>
|
||||
String(camera.id).toLowerCase().includes(cleaned) ||
|
||||
String(camera.name || '').toLowerCase().includes(cleaned),
|
||||
);
|
||||
if (includes.length === 1) return { camera: includes[0] };
|
||||
if (includes.length > 1) return { error: 'Ambiguous camera name', matches: includes };
|
||||
return { error: 'Camera not found', matches: [] };
|
||||
function resolveReplaySources(query) {
|
||||
const cleaned = normalizeReplayQuery(query);
|
||||
if (!cleaned || cleaned === 'all' || cleaned === '*') {
|
||||
return { sources: getDefaultDiscordSources() };
|
||||
}
|
||||
const tokens = cleaned.split(',').map((token) => token.trim()).filter(Boolean);
|
||||
const all = getReplaySources();
|
||||
const matches = [];
|
||||
tokens.forEach((token) => {
|
||||
const [prefix, rest] = token.includes(':') ? token.split(':', 2) : [null, token];
|
||||
const candidates = all.filter((entry) => {
|
||||
const matchId = String(entry.id).toLowerCase() === rest;
|
||||
const matchLabel = String(entry.label || '').toLowerCase() === rest;
|
||||
if (!matchId && !matchLabel) return false;
|
||||
if (!prefix) return true;
|
||||
return entry.type === prefix;
|
||||
});
|
||||
if (candidates.length === 1) {
|
||||
matches.push({ type: candidates[0].type, id: candidates[0].id, label: candidates[0].label });
|
||||
}
|
||||
});
|
||||
const sources = validateSources(matches);
|
||||
if (!sources.length) {
|
||||
return { error: 'No matching sources found', matches: [] };
|
||||
}
|
||||
return { sources };
|
||||
}
|
||||
|
||||
function buildReplayCaption(requester, camera) {
|
||||
function buildReplayCaption(requester, sources = []) {
|
||||
const requesterLabel = requester || 'unknown';
|
||||
const cameraLabel = camera ? `Camera: ${camera.name || camera.id}.` : null;
|
||||
const sourceLabel = sources.length
|
||||
? `Sources: ${sources.map((source) => source.label || `${source.type}:${source.id}`).join(', ')}.`
|
||||
: 'No sources.';
|
||||
return [
|
||||
`Replay requested by ${requesterLabel}.`,
|
||||
cameraLabel,
|
||||
sourceLabel,
|
||||
buildDriverCaption(),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
}
|
||||
|
||||
async function sendReplayToChannel(channelId, requester, cameraId = null) {
|
||||
async function sendReplayToChannel(channelId, requester, sources = []) {
|
||||
if (!channelId) {
|
||||
throw new Error('Replay channel not configured');
|
||||
}
|
||||
const buffer = await buildRoomCameraReplayVideo({ cameraId });
|
||||
const buffer = await buildReplayVideo({ sources });
|
||||
const attachment = new AttachmentBuilder(buffer, { name: 'replay.mp4' });
|
||||
const camera = cameraId ? getRoomCamera(cameraId) : null;
|
||||
const caption = buildReplayCaption(requester, camera);
|
||||
const caption = buildReplayCaption(requester, sources);
|
||||
await sendToChannel(channelId, caption, { files: [attachment] }, { parse: [] });
|
||||
}
|
||||
|
||||
@@ -337,25 +339,21 @@ async function handleReplayCommand(message, query) {
|
||||
});
|
||||
return;
|
||||
}
|
||||
const resolved = resolveReplayCamera(query);
|
||||
const resolved = resolveReplaySources(query);
|
||||
if (resolved?.error) {
|
||||
const matches = resolved.matches || [];
|
||||
const list = matches.length
|
||||
? `Matches: ${matches.map((cam) => cam.name || cam.id).join(', ')}`
|
||||
: 'No matching cameras found.';
|
||||
await message.reply({
|
||||
content: sanitizeMentions(`${resolved.error}. ${list}`),
|
||||
content: sanitizeMentions(resolved.error),
|
||||
allowedMentions: { parse: [], repliedUser: false },
|
||||
});
|
||||
return;
|
||||
}
|
||||
const cameraId = resolved.camera?.id || null;
|
||||
const sources = resolved.sources || [];
|
||||
const requester =
|
||||
message.member?.nickname || message.author?.globalName || message.author?.username || 'Discord';
|
||||
try {
|
||||
const buffer = await buildRoomCameraReplayVideo({ cameraId });
|
||||
const buffer = await buildReplayVideo({ sources });
|
||||
const attachment = new AttachmentBuilder(buffer, { name: 'replay.mp4' });
|
||||
const caption = buildReplayCaption(requester, resolved.camera || null);
|
||||
const caption = buildReplayCaption(requester, sources);
|
||||
await message.reply({
|
||||
content: sanitizeMentions(caption),
|
||||
files: [attachment],
|
||||
@@ -1009,7 +1007,7 @@ function handleBusEvent(event) {
|
||||
updatePresence();
|
||||
break;
|
||||
case 'replay.requested':
|
||||
sendReplayToChannel(payload?.channelId, payload?.requester, payload?.cameraId || null).catch((err) => {
|
||||
sendReplayToChannel(payload?.channelId, payload?.requester, payload?.sources || []).catch((err) => {
|
||||
logger.warn('Replay send failed', err.message);
|
||||
});
|
||||
break;
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
const { execFile } = require('child_process');
|
||||
const fsp = require('fs/promises');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const { promisify } = require('util');
|
||||
const logger = require('../globals/logger').child('replayBuild');
|
||||
const { replaySegmentsDir, segmentSeconds } = require('./replaySegmentManager');
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
const REPLAY_DURATION_MS = 20000;
|
||||
const REPLAY_FPS = 15;
|
||||
const MAX_REPLAY_WIDTH = 1280;
|
||||
const MAX_REPLAY_HEIGHT = 720;
|
||||
const REPLAY_MAX_BYTES = Math.floor(9.5 * 1024 * 1024);
|
||||
|
||||
function buildGridLayout(count) {
|
||||
const cols = Math.ceil(Math.sqrt(count));
|
||||
const rows = Math.ceil(count / cols);
|
||||
return { cols, rows };
|
||||
}
|
||||
|
||||
function clampEven(value) {
|
||||
return Math.max(2, Math.floor(value / 2) * 2);
|
||||
}
|
||||
|
||||
function buildScalePadFilter(tileWidth, tileHeight) {
|
||||
return `scale=${tileWidth}:${tileHeight}:force_original_aspect_ratio=decrease:flags=lanczos,pad=${tileWidth}:${tileHeight}:(ow-iw)/2:(oh-ih)/2:color=black,setsar=1`;
|
||||
}
|
||||
|
||||
async function probeMaxFrameSize(paths) {
|
||||
let maxWidth = 0;
|
||||
let maxHeight = 0;
|
||||
for (const filePath of paths) {
|
||||
try {
|
||||
const { stdout } = await execFileAsync('ffprobe', [
|
||||
'-v',
|
||||
'error',
|
||||
'-select_streams',
|
||||
'v:0',
|
||||
'-show_entries',
|
||||
'stream=width,height',
|
||||
'-of',
|
||||
'csv=p=0',
|
||||
filePath,
|
||||
]);
|
||||
const [widthRaw, heightRaw] = stdout.trim().split(',');
|
||||
const width = Number(widthRaw);
|
||||
const height = Number(heightRaw);
|
||||
if (Number.isFinite(width) && Number.isFinite(height)) {
|
||||
maxWidth = Math.max(maxWidth, width);
|
||||
maxHeight = Math.max(maxHeight, height);
|
||||
}
|
||||
} catch (err) {
|
||||
logger.warn('Failed to probe replay clip size', err.message);
|
||||
}
|
||||
}
|
||||
return { maxWidth, maxHeight };
|
||||
}
|
||||
|
||||
async function listLatestSegments(sourceKey, neededCount) {
|
||||
const dir = path.join(replaySegmentsDir, sourceKey);
|
||||
const entries = await fsp.readdir(dir, { withFileTypes: true });
|
||||
const files = [];
|
||||
for (const entry of entries) {
|
||||
if (!entry.isFile() || !entry.name.endsWith('.mp4')) continue;
|
||||
const filePath = path.join(dir, entry.name);
|
||||
const stat = await fsp.stat(filePath);
|
||||
files.push({ filePath, mtimeMs: stat.mtimeMs });
|
||||
}
|
||||
files.sort((a, b) => a.mtimeMs - b.mtimeMs);
|
||||
return files.slice(-neededCount).map((file) => file.filePath);
|
||||
}
|
||||
|
||||
async function buildReplayVideo({ sources = [] } = {}) {
|
||||
if (!sources.length) {
|
||||
throw new Error('No replay sources selected');
|
||||
}
|
||||
const segmentCount = Math.max(1, Math.ceil(REPLAY_DURATION_MS / (segmentSeconds * 1000)));
|
||||
const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'rover-replay-'));
|
||||
try {
|
||||
const clipPaths = [];
|
||||
for (let i = 0; i < sources.length; i += 1) {
|
||||
const source = sources[i];
|
||||
const key = `${source.type}__${source.id}`;
|
||||
let segmentPaths;
|
||||
try {
|
||||
segmentPaths = await listLatestSegments(key, segmentCount);
|
||||
} catch {
|
||||
throw new Error(`Missing replay segments for ${source.type}:${source.id}`);
|
||||
}
|
||||
if (segmentPaths.length < segmentCount) {
|
||||
throw new Error(`Not enough replay segments for ${source.type}:${source.id}`);
|
||||
}
|
||||
const concatPath = path.join(tmpDir, `concat-${i}.txt`);
|
||||
const concatBody = segmentPaths.map((file) => `file '${file}'`).join('\n');
|
||||
await fsp.writeFile(concatPath, concatBody);
|
||||
const clipPath = path.join(tmpDir, `clip-${i}.mp4`);
|
||||
await execFileAsync('ffmpeg', [
|
||||
'-hide_banner',
|
||||
'-loglevel',
|
||||
'error',
|
||||
'-f',
|
||||
'concat',
|
||||
'-safe',
|
||||
'0',
|
||||
'-i',
|
||||
concatPath,
|
||||
'-c',
|
||||
'copy',
|
||||
clipPath,
|
||||
]);
|
||||
clipPaths.push(clipPath);
|
||||
}
|
||||
|
||||
const { maxWidth, maxHeight } = await probeMaxFrameSize(clipPaths);
|
||||
const layout = buildGridLayout(clipPaths.length);
|
||||
let tileWidth = maxWidth || 640;
|
||||
let tileHeight = maxHeight || 360;
|
||||
let outputWidth = tileWidth * layout.cols;
|
||||
let outputHeight = tileHeight * layout.rows;
|
||||
if (outputWidth > MAX_REPLAY_WIDTH || outputHeight > MAX_REPLAY_HEIGHT) {
|
||||
const scale = Math.min(MAX_REPLAY_WIDTH / outputWidth, MAX_REPLAY_HEIGHT / outputHeight);
|
||||
tileWidth *= scale;
|
||||
tileHeight *= scale;
|
||||
outputWidth = tileWidth * layout.cols;
|
||||
outputHeight = tileHeight * layout.rows;
|
||||
}
|
||||
tileWidth = clampEven(tileWidth);
|
||||
tileHeight = clampEven(tileHeight);
|
||||
outputWidth = clampEven(tileWidth * layout.cols);
|
||||
outputHeight = clampEven(tileHeight * layout.rows);
|
||||
|
||||
const inputArgs = [];
|
||||
const filterParts = [];
|
||||
const layoutParts = [];
|
||||
for (let i = 0; i < clipPaths.length; i += 1) {
|
||||
inputArgs.push('-i', clipPaths[i]);
|
||||
filterParts.push(`[${i}:v]${buildScalePadFilter(tileWidth, tileHeight)}[v${i}]`);
|
||||
const x = (i % layout.cols) * tileWidth;
|
||||
const y = Math.floor(i / layout.cols) * tileHeight;
|
||||
layoutParts.push(`${x}_${y}`);
|
||||
}
|
||||
if (clipPaths.length === 1) {
|
||||
filterParts.push('[v0]null[v]');
|
||||
} else {
|
||||
filterParts.push(
|
||||
`${clipPaths.map((_, i) => `[v${i}]`).join('')}` +
|
||||
`xstack=inputs=${clipPaths.length}:layout=${layoutParts.join('|')}:fill=black[v]`,
|
||||
);
|
||||
}
|
||||
|
||||
const durationSec = Math.max(1, REPLAY_DURATION_MS / 1000);
|
||||
const targetBitrateKbps = Math.max(300, Math.floor((REPLAY_MAX_BYTES * 8) / durationSec / 1000));
|
||||
const maxrateKbps = Math.floor(targetBitrateKbps * 1.1);
|
||||
const bufsizeKbps = Math.floor(targetBitrateKbps * 2);
|
||||
|
||||
const outPath = path.join(tmpDir, 'replay.mp4');
|
||||
await execFileAsync('ffmpeg', [
|
||||
'-y',
|
||||
'-hide_banner',
|
||||
'-loglevel',
|
||||
'error',
|
||||
...inputArgs,
|
||||
'-filter_complex',
|
||||
filterParts.join(';'),
|
||||
'-map',
|
||||
'[v]',
|
||||
'-r',
|
||||
String(REPLAY_FPS),
|
||||
'-c:v',
|
||||
'libx264',
|
||||
'-b:v',
|
||||
`${targetBitrateKbps}k`,
|
||||
'-maxrate',
|
||||
`${maxrateKbps}k`,
|
||||
'-bufsize',
|
||||
`${bufsizeKbps}k`,
|
||||
'-pix_fmt',
|
||||
'yuv420p',
|
||||
outPath,
|
||||
]);
|
||||
const buffer = await fsp.readFile(outPath);
|
||||
return buffer;
|
||||
} finally {
|
||||
try {
|
||||
await fsp.rm(tmpDir, { recursive: true, force: true });
|
||||
} catch (err) {
|
||||
logger.warn('Failed to cleanup replay temp dir', err.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
buildReplayVideo,
|
||||
};
|
||||
@@ -0,0 +1,231 @@
|
||||
const { spawn } = require('child_process');
|
||||
const fsp = require('fs/promises');
|
||||
const path = require('path');
|
||||
const logger = require('../globals/logger').child('replaySegments');
|
||||
const { getRoomCameras, roomCameraEvents } = require('./roomCameraService');
|
||||
const roverManager = require('./roverManager');
|
||||
|
||||
const SEGMENT_DIR = process.env.REPLAY_SEGMENT_DIR || '/var/lib/replay-segments';
|
||||
const SEGMENT_SECONDS = 2;
|
||||
const BUFFER_SECONDS = 40;
|
||||
const CLEANUP_INTERVAL_MS = 20000;
|
||||
const FPS = 15;
|
||||
const SCALE_WIDTH = 640;
|
||||
const MAX_BYTES = Number.parseInt(process.env.REPLAY_SEGMENT_MAX_BYTES || '0', 10);
|
||||
const FFMPEG_BIN = process.env.FFMPEG_BIN || 'ffmpeg';
|
||||
|
||||
const recorders = new Map(); // key -> { proc, source }
|
||||
let cleanupTimer = null;
|
||||
|
||||
function sourceKey(source) {
|
||||
return `${source.type}__${source.id}`;
|
||||
}
|
||||
|
||||
function getRoomCameraStream(camera) {
|
||||
if (camera.streamUrl) return camera.streamUrl;
|
||||
const url = String(camera.url || '');
|
||||
if (url.includes('.mjpg') || url.includes('mjpeg') || url.includes('stream')) {
|
||||
return url;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function listSources() {
|
||||
const rooms = getRoomCameras().map((camera) => ({
|
||||
type: 'room',
|
||||
id: String(camera.id),
|
||||
label: camera.name || camera.id,
|
||||
streamUrl: getRoomCameraStream(camera),
|
||||
}));
|
||||
const rovers = roverManager.getRoster().map((rover) => ({
|
||||
type: 'rover',
|
||||
id: String(rover.id),
|
||||
label: rover.name || rover.id,
|
||||
}));
|
||||
return [...rooms, ...rovers];
|
||||
}
|
||||
|
||||
function buildInputUrl(source) {
|
||||
if (source.type === 'room') {
|
||||
return source.streamUrl || null;
|
||||
}
|
||||
return `srt://127.0.0.1:9000?streamid=read:${encodeURIComponent(source.id)}`;
|
||||
}
|
||||
|
||||
async function ensureDir(dir) {
|
||||
await fsp.mkdir(dir, { recursive: true });
|
||||
}
|
||||
|
||||
function buildOutputPattern(key) {
|
||||
return path.join(SEGMENT_DIR, key, '%Y%m%d-%H%M%S.mp4');
|
||||
}
|
||||
|
||||
function spawnRecorder(source) {
|
||||
const key = sourceKey(source);
|
||||
const inputUrl = buildInputUrl(source);
|
||||
if (!inputUrl) {
|
||||
logger.warn('Replay recorder missing input URL', { key, source });
|
||||
return;
|
||||
}
|
||||
const outPattern = buildOutputPattern(key);
|
||||
ensureDir(path.dirname(outPattern))
|
||||
.then(() => {
|
||||
const args = [
|
||||
'-hide_banner',
|
||||
'-loglevel',
|
||||
'warning',
|
||||
'-fflags',
|
||||
'nobuffer',
|
||||
'-flags',
|
||||
'low_delay',
|
||||
'-i',
|
||||
inputUrl,
|
||||
'-r',
|
||||
String(FPS),
|
||||
'-vf',
|
||||
`fps=${FPS},scale=${SCALE_WIDTH}:-1`,
|
||||
'-an',
|
||||
'-c:v',
|
||||
'libx264',
|
||||
'-preset',
|
||||
'veryfast',
|
||||
'-tune',
|
||||
'zerolatency',
|
||||
'-g',
|
||||
String(FPS),
|
||||
'-keyint_min',
|
||||
String(FPS),
|
||||
'-sc_threshold',
|
||||
'0',
|
||||
'-f',
|
||||
'segment',
|
||||
'-segment_time',
|
||||
String(SEGMENT_SECONDS),
|
||||
'-segment_format',
|
||||
'mp4',
|
||||
'-reset_timestamps',
|
||||
'1',
|
||||
'-strftime',
|
||||
'1',
|
||||
outPattern,
|
||||
];
|
||||
const proc = spawn(FFMPEG_BIN, args, { stdio: 'ignore' });
|
||||
recorders.set(key, { proc, source });
|
||||
proc.on('exit', (code, signal) => {
|
||||
recorders.delete(key);
|
||||
if (!shouldRecord(source)) {
|
||||
return;
|
||||
}
|
||||
const delay = 2000;
|
||||
logger.warn('Replay recorder exited; restarting', { key, code, signal });
|
||||
setTimeout(() => {
|
||||
if (!recorders.has(key) && shouldRecord(source)) {
|
||||
spawnRecorder(source);
|
||||
}
|
||||
}, delay);
|
||||
});
|
||||
})
|
||||
.catch((err) => {
|
||||
logger.warn('Replay recorder setup failed', { key, err: err.message });
|
||||
});
|
||||
}
|
||||
|
||||
function stopRecorder(key) {
|
||||
const entry = recorders.get(key);
|
||||
if (!entry) return;
|
||||
entry.proc.kill('SIGTERM');
|
||||
recorders.delete(key);
|
||||
}
|
||||
|
||||
function shouldRecord(source) {
|
||||
if (source.type === 'room') {
|
||||
return Boolean(source.streamUrl);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function syncRecorders() {
|
||||
const sources = listSources();
|
||||
const desiredKeys = new Set();
|
||||
sources.forEach((source) => {
|
||||
if (!shouldRecord(source)) return;
|
||||
const key = sourceKey(source);
|
||||
desiredKeys.add(key);
|
||||
if (!recorders.has(key)) {
|
||||
spawnRecorder(source);
|
||||
}
|
||||
});
|
||||
Array.from(recorders.keys()).forEach((key) => {
|
||||
if (!desiredKeys.has(key)) {
|
||||
stopRecorder(key);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function cleanupSegments() {
|
||||
try {
|
||||
await ensureDir(SEGMENT_DIR);
|
||||
const cutoff = Date.now() - BUFFER_SECONDS * 1000;
|
||||
const entries = await fsp.readdir(SEGMENT_DIR, { withFileTypes: true });
|
||||
let totalBytes = 0;
|
||||
const files = [];
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
const dirPath = path.join(SEGMENT_DIR, entry.name);
|
||||
const inner = await fsp.readdir(dirPath, { withFileTypes: true });
|
||||
for (const file of inner) {
|
||||
if (!file.isFile() || !file.name.endsWith('.mp4')) continue;
|
||||
const filePath = path.join(dirPath, file.name);
|
||||
const stats = await fsp.stat(filePath);
|
||||
totalBytes += stats.size;
|
||||
files.push({ filePath, mtimeMs: stats.mtimeMs, size: stats.size });
|
||||
if (stats.mtimeMs < cutoff) {
|
||||
await fsp.unlink(filePath);
|
||||
}
|
||||
}
|
||||
const remaining = await fsp.readdir(dirPath);
|
||||
if (!remaining.length) {
|
||||
await fsp.rmdir(dirPath);
|
||||
}
|
||||
}
|
||||
if (MAX_BYTES > 0 && totalBytes > MAX_BYTES) {
|
||||
const overBy = totalBytes - MAX_BYTES;
|
||||
let freed = 0;
|
||||
files.sort((a, b) => a.mtimeMs - b.mtimeMs);
|
||||
for (const file of files) {
|
||||
if (freed >= overBy) break;
|
||||
try {
|
||||
await fsp.unlink(file.filePath);
|
||||
freed += file.size;
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
logger.warn('Replay cleanup failed', err.message);
|
||||
}
|
||||
}
|
||||
|
||||
function start() {
|
||||
syncRecorders();
|
||||
cleanupSegments();
|
||||
if (cleanupTimer) clearInterval(cleanupTimer);
|
||||
cleanupTimer = setInterval(cleanupSegments, CLEANUP_INTERVAL_MS);
|
||||
}
|
||||
|
||||
roomCameraEvents.on('update', () => {
|
||||
syncRecorders();
|
||||
});
|
||||
|
||||
roverManager.managerEvents.on('rover', () => {
|
||||
syncRecorders();
|
||||
});
|
||||
|
||||
start();
|
||||
|
||||
module.exports = {
|
||||
replaySegmentsDir: SEGMENT_DIR,
|
||||
segmentSeconds: SEGMENT_SECONDS,
|
||||
bufferSeconds: BUFFER_SECONDS,
|
||||
};
|
||||
@@ -3,9 +3,10 @@ const logger = require('../globals/logger').child('replaySocket');
|
||||
const { getMode, MODES } = require('./modeManager');
|
||||
const { publishEvent } = require('./eventBus');
|
||||
const { tryTriggerReplay } = require('./replayService');
|
||||
const { validateSources, getDefaultWebSources } = require('./replaySourceService');
|
||||
const assignmentService = require('./assignmentService');
|
||||
const { getNickname } = require('./nicknameService');
|
||||
const { loadConfig } = require('../helpers/configLoader');
|
||||
const { getRoomCamera } = require('./roomCameraService');
|
||||
|
||||
const config = loadConfig();
|
||||
const discordConfig = config.discord || {};
|
||||
@@ -25,9 +26,14 @@ io.on('connection', (socket) => {
|
||||
cb({ error: 'Replay channel not configured', state: null });
|
||||
return;
|
||||
}
|
||||
const requestedCameraId = payload?.cameraId ? String(payload.cameraId) : null;
|
||||
if (requestedCameraId && !getRoomCamera(requestedCameraId)) {
|
||||
cb({ error: 'Unknown camera', state: null });
|
||||
const requestedSources = Array.isArray(payload?.sources) ? payload.sources : null;
|
||||
let sources = requestedSources ? validateSources(requestedSources) : [];
|
||||
if (!sources.length) {
|
||||
const assignment = assignmentService.describeAssignment(socket.id);
|
||||
sources = getDefaultWebSources(assignment);
|
||||
}
|
||||
if (!sources.length) {
|
||||
cb({ error: 'No replay sources selected', state: null });
|
||||
return;
|
||||
}
|
||||
const requester = buildRequesterLabel(socket);
|
||||
@@ -42,7 +48,7 @@ io.on('connection', (socket) => {
|
||||
payload: {
|
||||
channelId,
|
||||
requester,
|
||||
cameraId: requestedCameraId,
|
||||
sources,
|
||||
requestedBy: { socketId: socket.id },
|
||||
},
|
||||
});
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
const roverManager = require('./roverManager');
|
||||
const { getRoomCameras } = require('./roomCameraService');
|
||||
|
||||
function getReplaySources() {
|
||||
const roverSources = roverManager.getRoster().map((rover) => ({
|
||||
type: 'rover',
|
||||
id: String(rover.id),
|
||||
label: rover.name || rover.id,
|
||||
}));
|
||||
const roomSources = getRoomCameras().map((camera) => ({
|
||||
type: 'room',
|
||||
id: String(camera.id),
|
||||
label: camera.name || camera.id,
|
||||
}));
|
||||
return [...roverSources, ...roomSources];
|
||||
}
|
||||
|
||||
function normalizeSource(entry) {
|
||||
if (!entry) return null;
|
||||
if (typeof entry === 'string') {
|
||||
const [type, id] = entry.split(':');
|
||||
if (!type || !id) return null;
|
||||
return { type, id: String(id) };
|
||||
}
|
||||
if (typeof entry === 'object') {
|
||||
if (entry.type && entry.id) {
|
||||
return { type: entry.type, id: String(entry.id) };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function validateSources(list = []) {
|
||||
const allowed = new Map();
|
||||
getReplaySources().forEach((source) => {
|
||||
allowed.set(`${source.type}:${source.id}`, source);
|
||||
});
|
||||
const unique = new Map();
|
||||
(Array.isArray(list) ? list : []).forEach((entry) => {
|
||||
const normalized = normalizeSource(entry);
|
||||
if (!normalized) return;
|
||||
const key = `${normalized.type}:${normalized.id}`;
|
||||
const source = allowed.get(key);
|
||||
if (!source) return;
|
||||
unique.set(key, { type: source.type, id: source.id, label: source.label });
|
||||
});
|
||||
return Array.from(unique.values());
|
||||
}
|
||||
|
||||
function getDefaultWebSources(assignment = {}) {
|
||||
if (assignment?.roverId) {
|
||||
const id = String(assignment.roverId);
|
||||
const match = getReplaySources().find((entry) => entry.type === 'rover' && entry.id === id);
|
||||
return [{ type: 'rover', id, label: match?.label || id }];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function getDefaultDiscordSources() {
|
||||
return getRoomCameras().map((camera) => ({
|
||||
type: 'room',
|
||||
id: String(camera.id),
|
||||
label: camera.name || camera.id,
|
||||
}));
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getReplaySources,
|
||||
validateSources,
|
||||
getDefaultWebSources,
|
||||
getDefaultDiscordSources,
|
||||
};
|
||||
@@ -23,6 +23,7 @@ function normalizeCamera(camera) {
|
||||
name: camera.name || camera.id || String(id),
|
||||
description: camera.description || null,
|
||||
url: camera.url,
|
||||
streamUrl: camera.streamUrl || camera.mjpegUrl || null,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
const EventEmitter = require('events');
|
||||
const logger = require('../globals/logger').child('roomCameraSnapshot');
|
||||
const { getRoomCameras, roomCameraEvents } = require('./roomCameraService');
|
||||
const {
|
||||
recordRoomCameraFrame,
|
||||
clearRoomCameraReplayFrames,
|
||||
} = require('./roomCameraReplayService');
|
||||
|
||||
const POLL_INTERVAL_MS = 67;
|
||||
const FETCH_TIMEOUT_MS = 2000;
|
||||
@@ -36,7 +32,6 @@ async function fetchSnapshot(camera) {
|
||||
const buffer = Buffer.from(arrayBuffer);
|
||||
const ts = Date.now();
|
||||
markState(id, { frame: buffer, ts, error: null, failures: 0 });
|
||||
recordRoomCameraFrame(id, buffer, ts);
|
||||
events.emit('frame', { id, buffer, ts });
|
||||
} catch (err) {
|
||||
const failures = (state?.failures || 0) + 1;
|
||||
@@ -55,7 +50,6 @@ function stopAll() {
|
||||
pollTimer = null;
|
||||
}
|
||||
cameraState.clear();
|
||||
clearRoomCameraReplayFrames();
|
||||
}
|
||||
|
||||
function startAll() {
|
||||
|
||||
@@ -5,7 +5,7 @@ const logger = require('../globals/logger').child('roverSnapshot');
|
||||
const roverManager = require('./roverManager');
|
||||
|
||||
const SNAPSHOT_DIR = process.env.ROVER_SNAPSHOT_DIR || '/var/lib/rover-snapshots';
|
||||
const POLL_INTERVAL_MS = 500;
|
||||
const POLL_INTERVAL_MS = 300;
|
||||
|
||||
const roverState = new Map(); // id -> { frame, ts, error, failures, fetching, mtimeMs }
|
||||
const events = new EventEmitter(); // frame, status
|
||||
|
||||
@@ -7,7 +7,7 @@ const { roverSnapshotEvents, getRoverSnapshotState } = require('./roverSnapshotS
|
||||
|
||||
const SUBSCRIBE_LIMIT = 50;
|
||||
const SUBSCRIBE_WINDOW_MS = 10000;
|
||||
const STREAM_INTERVAL_MS = 800;
|
||||
const STREAM_INTERVAL_MS = 333;
|
||||
|
||||
function passesMode(socket) {
|
||||
const mode = getMode();
|
||||
|
||||
@@ -10,6 +10,7 @@ const { getRoomCameras, roomCameraEvents } = require('./roomCameraService');
|
||||
const { getState: getHomeAssistantState, homeAssistantEvents } = require('./homeAssistantService');
|
||||
const { getNickname, nicknameEvents } = require('./nicknameService');
|
||||
const { getReplayState, replayEvents } = require('./replayService');
|
||||
const { getReplaySources } = require('./replaySourceService');
|
||||
const { loadConfig } = require('../helpers/configLoader');
|
||||
|
||||
const discordInvite = loadConfig().discord?.invite || null;
|
||||
@@ -49,6 +50,7 @@ function buildSession(socket) {
|
||||
roomCameras: getRoomCameras(),
|
||||
homeAssistant: getHomeAssistantState(),
|
||||
replay: getReplayState(),
|
||||
replaySources: getReplaySources(),
|
||||
users,
|
||||
discord: {
|
||||
invite: discordInvite,
|
||||
|
||||
Reference in New Issue
Block a user