replay timestamps in filenamess

This commit is contained in:
legop3
2026-07-21 16:29:10 -04:00
parent 0ecee03f64
commit 358aa0b1d6
4 changed files with 49 additions and 10 deletions
@@ -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);