From 358aa0b1d6de5790594bc79ede4bbbda9e9ff615 Mon Sep 17 00:00:00 2001 From: legop3 Date: Tue, 21 Jul 2026 16:29:10 -0400 Subject: [PATCH] replay timestamps in filenamess --- .../discordBotService/commands/replay.js | 6 +++-- .../src/services/discordBotService/index.js | 6 +++-- .../replayDeliveryService/workflow.js | 21 +++++++++++++-- .../src/services/replayMediaService/index.js | 26 ++++++++++++++++--- 4 files changed, 49 insertions(+), 10 deletions(-) diff --git a/server/src/services/discordBotService/commands/replay.js b/server/src/services/discordBotService/commands/replay.js index 4b9f19aa..b8779e69 100644 --- a/server/src/services/discordBotService/commands/replay.js +++ b/server/src/services/discordBotService/commands/replay.js @@ -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, diff --git a/server/src/services/discordBotService/index.js b/server/src/services/discordBotService/index.js index 23496d74..205bc152 100644 --- a/server/src/services/discordBotService/index.js +++ b/server/src/services/discordBotService/index.js @@ -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'); diff --git a/server/src/services/replayDeliveryService/workflow.js b/server/src/services/replayDeliveryService/workflow.js index 00839cb4..e737a34e 100644 --- a/server/src/services/replayDeliveryService/workflow.js +++ b/server/src/services/replayDeliveryService/workflow.js @@ -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, diff --git a/server/src/services/replayMediaService/index.js b/server/src/services/replayMediaService/index.js index 2a2669bd..8c314b9c 100644 --- a/server/src/services/replayMediaService/index.js +++ b/server/src/services/replayMediaService/index.js @@ -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);