mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-15 17:12:59 -04:00
replay timestamps in filenamess
This commit is contained in:
@@ -12,7 +12,7 @@ const {
|
||||
createReplaySourceResolver,
|
||||
createReplayCaptionBuilder,
|
||||
startDiscordTypingLoop,
|
||||
sanitizeReplayTitleForFilename,
|
||||
buildReplayFilename,
|
||||
firstAttachmentFromMessage,
|
||||
buildDiscordReplayMediaPayload,
|
||||
buildAcceptedMessage,
|
||||
@@ -103,7 +103,9 @@ function createReplayCommand({
|
||||
await progressMessage.edit({ content: sanitizeMentions(buildStatusMessage(job, 'uploading')), allowedMentions: DEFAULT_ALLOWED_MENTIONS });
|
||||
}
|
||||
|
||||
const attachment = new AttachmentBuilder(buffer, { name: `${sanitizeReplayTitleForFilename(job.title)}.mp4` });
|
||||
// Keep direct Discord commands consistent with web-triggered uploads and
|
||||
// with the local hosting fallback used when Discord delivery is unavailable.
|
||||
const attachment = new AttachmentBuilder(buffer, { name: buildReplayFilename(job) });
|
||||
const body = replayCaption.build({ job, usedSources, missingSources });
|
||||
const uploadMessage = await progressMessage.reply({
|
||||
content: body,
|
||||
|
||||
@@ -63,7 +63,7 @@ const {
|
||||
DEFAULT_ALLOWED_MENTIONS,
|
||||
createReplayCaptionBuilder,
|
||||
startDiscordTypingLoop,
|
||||
sanitizeReplayTitleForFilename,
|
||||
buildReplayFilename,
|
||||
firstAttachmentFromMessage,
|
||||
buildDiscordReplayMediaPayload,
|
||||
buildAcceptedMessage,
|
||||
@@ -166,7 +166,9 @@ if (discordConfig?.channels?.replay) {
|
||||
if (progressMessage?.edit) {
|
||||
await progressMessage.edit({ content: buildStatusMessage(job, 'uploading'), allowedMentions: DEFAULT_ALLOWED_MENTIONS });
|
||||
}
|
||||
const attachment = new AttachmentBuilder(buffer, { name: `${sanitizeReplayTitleForFilename(job.title)}.mp4` });
|
||||
// Every delivery path uses the job creation time, so a Discord upload
|
||||
// and a server-hosted fallback always expose the same replay filename.
|
||||
const attachment = new AttachmentBuilder(buffer, { name: buildReplayFilename(job) });
|
||||
const body = replayCaption.build({ job, usedSources, missingSources });
|
||||
const uploadMessage = await channelIO.sendToChannel(context.channelId, body, { files: [attachment] }, DEFAULT_ALLOWED_MENTIONS);
|
||||
if (!uploadMessage) throw new Error('Discord upload did not return a message');
|
||||
|
||||
@@ -45,8 +45,9 @@ function describeSource(source) {
|
||||
}
|
||||
|
||||
function sanitizeReplayTitleForFilename(title) {
|
||||
// Discord attachment names should be readable and filesystem-safe.
|
||||
// The title may originate from chat/web input, so strip separators and cap length before upload.
|
||||
// Replay filenames are exposed by both Discord and the server-hosted fallback.
|
||||
// The title may originate from chat/web input, so strip separators and cap its
|
||||
// length before it is used in an attachment name, URL, or local filesystem path.
|
||||
const cleaned = String(title || '')
|
||||
.replace(/[\\/:*?"<>|]+/g, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
@@ -55,6 +56,21 @@ function sanitizeReplayTitleForFilename(title) {
|
||||
return cleaned || 'replay';
|
||||
}
|
||||
|
||||
function buildReplayFilename(job = {}) {
|
||||
const title = sanitizeReplayTitleForFilename(job.title);
|
||||
const createdAt = Number(job.createdAt);
|
||||
|
||||
// The job creation time is shared by every delivery path. Using it instead
|
||||
// of the later upload/hosting time guarantees that a Discord upload and its
|
||||
// local fallback describe the same replay with the same readable filename.
|
||||
// Date.now() is retained only as a defensive fallback for older callers that
|
||||
// construct a minimal job object without going through createReplayJob().
|
||||
const timestamp = Number.isFinite(createdAt) && createdAt > 0
|
||||
? Math.trunc(createdAt)
|
||||
: Date.now();
|
||||
return `${title} ${timestamp}.mp4`;
|
||||
}
|
||||
|
||||
function buildReplayTitle({ explicitTitle = '', sources = [] } = {}) {
|
||||
const trimmed = normalizeText(explicitTitle).slice(0, 120);
|
||||
if (trimmed) return trimmed;
|
||||
@@ -379,6 +395,7 @@ module.exports = {
|
||||
createReplayCaptionBuilder,
|
||||
startDiscordTypingLoop,
|
||||
sanitizeReplayTitleForFilename,
|
||||
buildReplayFilename,
|
||||
firstAttachmentFromMessage,
|
||||
buildDiscordReplayMediaPayload,
|
||||
buildAcceptedMessage,
|
||||
|
||||
@@ -1,18 +1,29 @@
|
||||
// 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 crypto = require('crypto');
|
||||
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 = /^[a-f0-9]{32}\.mp4$/;
|
||||
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 });
|
||||
@@ -64,7 +75,7 @@ async function cleanup() {
|
||||
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 = `${crypto.randomBytes(16).toString('hex')}.mp4`;
|
||||
const filename = buildReplayFilename(job);
|
||||
const finalPath = path.join(REPLAY_DIR, filename);
|
||||
const temporaryPath = `${finalPath}.${process.pid}.tmp`;
|
||||
|
||||
@@ -102,7 +113,14 @@ app.get('/media/replays/:filename', (req, res, next) => {
|
||||
// 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' } }, (err) => {
|
||||
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);
|
||||
|
||||
Reference in New Issue
Block a user