mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-16 01:21:20 -04:00
167 lines
5.9 KiB
JavaScript
167 lines
5.9 KiB
JavaScript
// Replay Segment Store
|
|
// Purpose: Maintains in-memory segment index and performs file-system refresh/cleanup for replay windows.
|
|
// Scope: Handles segment discovery, overlap queries, and retention cleanup.
|
|
const fsp = require('fs/promises');
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
const logger = require('../../globals/logger').child('replayEngineV2');
|
|
const { BUFFER_SECONDS, SEGMENT_SECONDS } = require('./constants');
|
|
const { workers, segmentIndex } = require('./state');
|
|
const { sourceKey, sourceDirForKey } = require('./sources');
|
|
const ptzCameraService = require('../ptzCameraService');
|
|
|
|
async function ensureDir(dir) {
|
|
await fsp.mkdir(dir, { recursive: true });
|
|
}
|
|
|
|
function createSegmentStore({ getActiveSegmentRoot }) {
|
|
async function refreshIndexForWorker(worker) {
|
|
const key = worker.key;
|
|
const dir = sourceDirForKey(getActiveSegmentRoot(), key);
|
|
let files;
|
|
try {
|
|
files = await fsp.readdir(dir, { withFileTypes: true });
|
|
} catch {
|
|
return;
|
|
}
|
|
|
|
const segmentFiles = files.filter((entry) => entry.isFile() && /^seg-\d{6}\.mp4$/.test(entry.name)).map((entry) => entry.name).sort();
|
|
if (!segmentFiles.length) {
|
|
segmentIndex.set(key, []);
|
|
return;
|
|
}
|
|
|
|
const cutoffMs = Date.now() - BUFFER_SECONDS * 1000 - 5000;
|
|
const entries = [];
|
|
for (const filename of segmentFiles) {
|
|
const filePath = path.join(dir, filename);
|
|
let stat;
|
|
try { stat = await fsp.stat(filePath); } catch { continue; }
|
|
if (!stat.isFile() || stat.size < 4096) continue;
|
|
const endMs = Math.round(stat.mtimeMs);
|
|
const startMs = Math.round(endMs - SEGMENT_SECONDS * 1000);
|
|
if (endMs < cutoffMs) continue;
|
|
entries.push({
|
|
filePath,
|
|
startMs,
|
|
endMs,
|
|
mtimeMs: stat.mtimeMs,
|
|
size: stat.size,
|
|
kind: worker.source.kind,
|
|
sourceType: worker.source.sourceType,
|
|
sourceId: worker.source.id,
|
|
roverId: worker.source.roverId || null,
|
|
});
|
|
}
|
|
segmentIndex.set(key, entries.sort((a, b) => a.startMs - b.startMs));
|
|
}
|
|
|
|
async function refreshSegmentIndex() {
|
|
for (const worker of Array.from(workers.values())) {
|
|
await refreshIndexForWorker(worker);
|
|
}
|
|
}
|
|
|
|
async function cleanupOldFiles() {
|
|
const cutoff = Date.now() - BUFFER_SECONDS * 1000;
|
|
const root = getActiveSegmentRoot();
|
|
try {
|
|
await ensureDir(root);
|
|
const dirs = await fsp.readdir(root, { withFileTypes: true });
|
|
for (const dirent of dirs) {
|
|
if (!dirent.isDirectory()) continue;
|
|
const dirPath = path.join(root, dirent.name);
|
|
let files;
|
|
try { files = await fsp.readdir(dirPath, { withFileTypes: true }); } catch { continue; }
|
|
for (const file of files) {
|
|
if (!file.isFile() || !file.name.endsWith('.mp4')) continue;
|
|
const filePath = path.join(dirPath, file.name);
|
|
try {
|
|
const stat = await fsp.stat(filePath);
|
|
if (stat.mtimeMs < cutoff) await fsp.unlink(filePath);
|
|
} catch {}
|
|
}
|
|
}
|
|
} catch (err) {
|
|
logger.warn('cleanup failed', err.message);
|
|
}
|
|
}
|
|
|
|
function getVideoEntriesForSource(source) {
|
|
const key = sourceKey({ sourceType: String(source.type), kind: 'video', id: String(source.id) });
|
|
return segmentIndex.get(key) || [];
|
|
}
|
|
|
|
function getAudioEntriesForRover(roverId) {
|
|
const key = sourceKey({ sourceType: 'rover', kind: 'audio', id: `${String(roverId)}-audio` });
|
|
return segmentIndex.get(key) || [];
|
|
}
|
|
|
|
function overlapping(entries, startMs, endMs) {
|
|
return entries.filter((entry) => entry.endMs > startMs && entry.startMs < endMs);
|
|
}
|
|
|
|
async function bootstrapIndexFromDisk() {
|
|
const root = getActiveSegmentRoot();
|
|
await ensureDir(root);
|
|
const dirs = await fsp.readdir(root, { withFileTypes: true });
|
|
for (const dirent of dirs) {
|
|
if (!dirent.isDirectory()) continue;
|
|
segmentIndex.set(dirent.name, []);
|
|
}
|
|
}
|
|
|
|
function getReplayHealthSnapshot({ BUILD_DURATION_MS, roverManager, getRoomCameras }) {
|
|
const now = Date.now();
|
|
const neededCount = Math.max(1, Math.ceil(BUILD_DURATION_MS / 1000));
|
|
const sources = [];
|
|
let readyCount = 0;
|
|
|
|
const replaySources = [];
|
|
for (const rover of roverManager.getRoster().filter((entry) => roverManager.canReplayRoverId(entry.id))) {
|
|
replaySources.push({ type: 'rover', id: String(rover.id), label: rover.name || rover.id });
|
|
}
|
|
for (const camera of getRoomCameras()) {
|
|
replaySources.push({ type: 'room', id: String(camera.id), label: camera.name || camera.id });
|
|
}
|
|
const ptzSource = ptzCameraService.getReplaySource();
|
|
if (ptzSource) replaySources.push(ptzSource);
|
|
|
|
for (const source of replaySources) {
|
|
const key = sourceKey({ sourceType: source.type, kind: 'video', id: String(source.id) });
|
|
const dir = sourceDirForKey(getActiveSegmentRoot(), key);
|
|
let recentCount = 0;
|
|
let lastSegmentAt = null;
|
|
try {
|
|
const files = fs.readdirSync(dir);
|
|
for (const name of files) {
|
|
if (!/^seg-\d{6}\.mp4$/.test(name)) continue;
|
|
const full = path.join(dir, name);
|
|
let stat;
|
|
try { stat = fs.statSync(full); } catch { continue; }
|
|
if (!stat.isFile() || stat.size < 4096) continue;
|
|
if (stat.mtimeMs > (lastSegmentAt || 0)) lastSegmentAt = stat.mtimeMs;
|
|
if (now - stat.mtimeMs <= BUFFER_SECONDS * 1000) recentCount += 1;
|
|
}
|
|
} catch {}
|
|
const ready = recentCount >= neededCount;
|
|
if (ready) readyCount += 1;
|
|
sources.push({ type: source.type, id: source.id, label: source.label, recentCount, neededCount, lastSegmentAt, ready });
|
|
}
|
|
|
|
return { sources, readyCount, totalCount: sources.length };
|
|
}
|
|
|
|
return {
|
|
refreshSegmentIndex,
|
|
cleanupOldFiles,
|
|
getVideoEntriesForSource,
|
|
getAudioEntriesForRover,
|
|
overlapping,
|
|
bootstrapIndexFromDisk,
|
|
getReplayHealthSnapshot,
|
|
};
|
|
}
|
|
|
|
module.exports = { createSegmentStore };
|