mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-15 17:12:59 -04:00
142 lines
5.7 KiB
JavaScript
142 lines
5.7 KiB
JavaScript
// Replay Media Service
|
|
// Purpose: Stores and serves completed replay videos when Discord delivery is unavailable.
|
|
// Scope: Owns only final hosted MP4 files; replay frame caches and active video builds remain outside this service.
|
|
const fsp = require('fs/promises');
|
|
const path = require('path');
|
|
const logger = require('../../globals/logger').child('replayMedia');
|
|
const { app } = require('../../globals/http');
|
|
const { resolveDataDir } = require('../../helpers/dataPaths');
|
|
const { buildReplayFilename } = require('../replayDeliveryService/workflow');
|
|
|
|
const REPLAY_DIR = path.join(resolveDataDir(), 'replays');
|
|
const MAX_AGE_MS = 6 * 60 * 60 * 1000;
|
|
const CLEANUP_INTERVAL_MS = 30 * 60 * 1000;
|
|
const MAX_TOTAL_BYTES = 1024 * 1024 * 1024;
|
|
const PUBLIC_FILE_PATTERN = /^.+ \d{13}\.mp4$/u;
|
|
|
|
function buildContentDisposition(filename) {
|
|
// The ASCII fallback keeps Node's response header valid for titles containing
|
|
// Unicode, while filename* preserves the full readable name in browsers that
|
|
// support the standard UTF-8 Content-Disposition form.
|
|
const asciiFilename = filename.replace(/[^\x20-\x7E]/g, '_');
|
|
const encodedFilename = encodeURIComponent(filename).replace(/[!'()*]/g, (character) => (
|
|
`%${character.charCodeAt(0).toString(16).toUpperCase()}`
|
|
));
|
|
return `inline; filename="${asciiFilename}"; filename*=UTF-8''${encodedFilename}`;
|
|
}
|
|
|
|
async function listCompletedFiles() {
|
|
await fsp.mkdir(REPLAY_DIR, { recursive: true });
|
|
const entries = await fsp.readdir(REPLAY_DIR, { withFileTypes: true });
|
|
const files = [];
|
|
for (const entry of entries) {
|
|
if (!entry.isFile()) continue;
|
|
const filePath = path.join(REPLAY_DIR, entry.name);
|
|
try {
|
|
const stat = await fsp.stat(filePath);
|
|
files.push({ name: entry.name, path: filePath, size: stat.size, mtimeMs: stat.mtimeMs });
|
|
} catch (err) {
|
|
if (err.code !== 'ENOENT') logger.warn('Unable to inspect hosted replay', { file: entry.name, error: err.message });
|
|
}
|
|
}
|
|
return files;
|
|
}
|
|
|
|
async function cleanup() {
|
|
const now = Date.now();
|
|
const files = await listCompletedFiles();
|
|
const completed = files.filter((file) => PUBLIC_FILE_PATTERN.test(file.name)).sort((a, b) => a.mtimeMs - b.mtimeMs);
|
|
const temporary = files.filter((file) => file.name.endsWith('.tmp'));
|
|
|
|
// Temporary files are never served. An old one means a write was interrupted,
|
|
// so it is safe to remove after the same conservative expiry used for media.
|
|
const expiredTemporary = temporary.filter((file) => now - file.mtimeMs > MAX_AGE_MS);
|
|
const expiredCompleted = completed.filter((file) => now - file.mtimeMs > MAX_AGE_MS);
|
|
const toDelete = new Set([...expiredTemporary, ...expiredCompleted].map((file) => file.path));
|
|
|
|
let retainedBytes = completed.reduce((total, file) => total + file.size, 0)
|
|
- expiredCompleted.reduce((total, file) => total + file.size, 0);
|
|
for (const file of completed) {
|
|
if (retainedBytes <= MAX_TOTAL_BYTES) break;
|
|
if (toDelete.has(file.path)) continue;
|
|
toDelete.add(file.path);
|
|
retainedBytes -= file.size;
|
|
}
|
|
|
|
await Promise.all(Array.from(toDelete).map(async (filePath) => {
|
|
try {
|
|
await fsp.unlink(filePath);
|
|
} catch (err) {
|
|
if (err.code !== 'ENOENT') logger.warn('Unable to remove hosted replay', { file: path.basename(filePath), error: err.message });
|
|
}
|
|
}));
|
|
}
|
|
|
|
async function hostReplay({ buffer, job }) {
|
|
if (!Buffer.isBuffer(buffer) || !buffer.length) throw new Error('Replay output was empty');
|
|
await fsp.mkdir(REPLAY_DIR, { recursive: true });
|
|
const filename = buildReplayFilename(job);
|
|
const finalPath = path.join(REPLAY_DIR, filename);
|
|
const temporaryPath = `${finalPath}.${process.pid}.tmp`;
|
|
|
|
// Atomic rename ensures cleanup and HTTP requests can only observe a fully
|
|
// written MP4, never a partially flushed replay.
|
|
try {
|
|
await fsp.writeFile(temporaryPath, buffer, { flag: 'wx' });
|
|
await fsp.rename(temporaryPath, finalPath);
|
|
} catch (err) {
|
|
await fsp.unlink(temporaryPath).catch(() => {});
|
|
throw err;
|
|
}
|
|
|
|
return {
|
|
jobId: job.id,
|
|
status: 'ready',
|
|
title: job.title,
|
|
requester: job.requester,
|
|
requestedBy: job.requestedBy || null,
|
|
url: `/media/replays/${filename}`,
|
|
proxyUrl: null,
|
|
messageUrl: null,
|
|
filename,
|
|
size: buffer.length,
|
|
contentType: 'video/mp4',
|
|
sources: Array.isArray(job.sources) ? job.sources : [],
|
|
ts: Date.now(),
|
|
};
|
|
}
|
|
|
|
app.get('/media/replays/:filename', (req, res, next) => {
|
|
const filename = String(req.params.filename || '');
|
|
if (!PUBLIC_FILE_PATTERN.test(filename)) return res.status(404).end();
|
|
const filePath = path.join(REPLAY_DIR, filename);
|
|
// Express sendFile supports byte-range requests, which preserves seeking in
|
|
// the existing browser video players without implementing a second streamer.
|
|
res.setHeader('Cache-Control', 'private, max-age=3600');
|
|
return res.sendFile(filePath, {
|
|
headers: {
|
|
'Content-Type': 'video/mp4',
|
|
// Supplying the readable filename explicitly ensures that saving a video
|
|
// keeps its replay title even though it was delivered through an HTTP route.
|
|
'Content-Disposition': buildContentDisposition(filename),
|
|
},
|
|
}, (err) => {
|
|
if (!err || res.headersSent) return;
|
|
if (err.code === 'ENOENT') return res.status(404).end();
|
|
return next(err);
|
|
});
|
|
});
|
|
|
|
cleanup().catch((err) => logger.warn('Initial hosted replay cleanup failed', err.message));
|
|
const cleanupTimer = setInterval(() => {
|
|
cleanup().catch((err) => logger.warn('Hosted replay cleanup failed', err.message));
|
|
}, CLEANUP_INTERVAL_MS);
|
|
// Maintenance must never keep a process alive during normal shutdown.
|
|
if (typeof cleanupTimer.unref === 'function') cleanupTimer.unref();
|
|
|
|
module.exports = {
|
|
hostReplay,
|
|
cleanup,
|
|
replayDirectory: REPLAY_DIR,
|
|
};
|