mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-16 09:31:20 -04:00
replay timestamps in filenamess
This commit is contained in:
@@ -12,7 +12,7 @@ const {
|
|||||||
createReplaySourceResolver,
|
createReplaySourceResolver,
|
||||||
createReplayCaptionBuilder,
|
createReplayCaptionBuilder,
|
||||||
startDiscordTypingLoop,
|
startDiscordTypingLoop,
|
||||||
sanitizeReplayTitleForFilename,
|
buildReplayFilename,
|
||||||
firstAttachmentFromMessage,
|
firstAttachmentFromMessage,
|
||||||
buildDiscordReplayMediaPayload,
|
buildDiscordReplayMediaPayload,
|
||||||
buildAcceptedMessage,
|
buildAcceptedMessage,
|
||||||
@@ -103,7 +103,9 @@ function createReplayCommand({
|
|||||||
await progressMessage.edit({ content: sanitizeMentions(buildStatusMessage(job, 'uploading')), allowedMentions: DEFAULT_ALLOWED_MENTIONS });
|
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 body = replayCaption.build({ job, usedSources, missingSources });
|
||||||
const uploadMessage = await progressMessage.reply({
|
const uploadMessage = await progressMessage.reply({
|
||||||
content: body,
|
content: body,
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ const {
|
|||||||
DEFAULT_ALLOWED_MENTIONS,
|
DEFAULT_ALLOWED_MENTIONS,
|
||||||
createReplayCaptionBuilder,
|
createReplayCaptionBuilder,
|
||||||
startDiscordTypingLoop,
|
startDiscordTypingLoop,
|
||||||
sanitizeReplayTitleForFilename,
|
buildReplayFilename,
|
||||||
firstAttachmentFromMessage,
|
firstAttachmentFromMessage,
|
||||||
buildDiscordReplayMediaPayload,
|
buildDiscordReplayMediaPayload,
|
||||||
buildAcceptedMessage,
|
buildAcceptedMessage,
|
||||||
@@ -166,7 +166,9 @@ if (discordConfig?.channels?.replay) {
|
|||||||
if (progressMessage?.edit) {
|
if (progressMessage?.edit) {
|
||||||
await progressMessage.edit({ content: buildStatusMessage(job, 'uploading'), allowedMentions: DEFAULT_ALLOWED_MENTIONS });
|
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 body = replayCaption.build({ job, usedSources, missingSources });
|
||||||
const uploadMessage = await channelIO.sendToChannel(context.channelId, body, { files: [attachment] }, DEFAULT_ALLOWED_MENTIONS);
|
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');
|
if (!uploadMessage) throw new Error('Discord upload did not return a message');
|
||||||
|
|||||||
@@ -45,8 +45,9 @@ function describeSource(source) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function sanitizeReplayTitleForFilename(title) {
|
function sanitizeReplayTitleForFilename(title) {
|
||||||
// Discord attachment names should be readable and filesystem-safe.
|
// 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 length before upload.
|
// 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 || '')
|
const cleaned = String(title || '')
|
||||||
.replace(/[\\/:*?"<>|]+/g, ' ')
|
.replace(/[\\/:*?"<>|]+/g, ' ')
|
||||||
.replace(/\s+/g, ' ')
|
.replace(/\s+/g, ' ')
|
||||||
@@ -55,6 +56,21 @@ function sanitizeReplayTitleForFilename(title) {
|
|||||||
return cleaned || 'replay';
|
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 = [] } = {}) {
|
function buildReplayTitle({ explicitTitle = '', sources = [] } = {}) {
|
||||||
const trimmed = normalizeText(explicitTitle).slice(0, 120);
|
const trimmed = normalizeText(explicitTitle).slice(0, 120);
|
||||||
if (trimmed) return trimmed;
|
if (trimmed) return trimmed;
|
||||||
@@ -379,6 +395,7 @@ module.exports = {
|
|||||||
createReplayCaptionBuilder,
|
createReplayCaptionBuilder,
|
||||||
startDiscordTypingLoop,
|
startDiscordTypingLoop,
|
||||||
sanitizeReplayTitleForFilename,
|
sanitizeReplayTitleForFilename,
|
||||||
|
buildReplayFilename,
|
||||||
firstAttachmentFromMessage,
|
firstAttachmentFromMessage,
|
||||||
buildDiscordReplayMediaPayload,
|
buildDiscordReplayMediaPayload,
|
||||||
buildAcceptedMessage,
|
buildAcceptedMessage,
|
||||||
|
|||||||
@@ -1,18 +1,29 @@
|
|||||||
// Replay Media Service
|
// Replay Media Service
|
||||||
// Purpose: Stores and serves completed replay videos when Discord delivery is unavailable.
|
// 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.
|
// 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 fsp = require('fs/promises');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const logger = require('../../globals/logger').child('replayMedia');
|
const logger = require('../../globals/logger').child('replayMedia');
|
||||||
const { app } = require('../../globals/http');
|
const { app } = require('../../globals/http');
|
||||||
const { resolveDataDir } = require('../../helpers/dataPaths');
|
const { resolveDataDir } = require('../../helpers/dataPaths');
|
||||||
|
const { buildReplayFilename } = require('../replayDeliveryService/workflow');
|
||||||
|
|
||||||
const REPLAY_DIR = path.join(resolveDataDir(), 'replays');
|
const REPLAY_DIR = path.join(resolveDataDir(), 'replays');
|
||||||
const MAX_AGE_MS = 6 * 60 * 60 * 1000;
|
const MAX_AGE_MS = 6 * 60 * 60 * 1000;
|
||||||
const CLEANUP_INTERVAL_MS = 30 * 60 * 1000;
|
const CLEANUP_INTERVAL_MS = 30 * 60 * 1000;
|
||||||
const MAX_TOTAL_BYTES = 1024 * 1024 * 1024;
|
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() {
|
async function listCompletedFiles() {
|
||||||
await fsp.mkdir(REPLAY_DIR, { recursive: true });
|
await fsp.mkdir(REPLAY_DIR, { recursive: true });
|
||||||
@@ -64,7 +75,7 @@ async function cleanup() {
|
|||||||
async function hostReplay({ buffer, job }) {
|
async function hostReplay({ buffer, job }) {
|
||||||
if (!Buffer.isBuffer(buffer) || !buffer.length) throw new Error('Replay output was empty');
|
if (!Buffer.isBuffer(buffer) || !buffer.length) throw new Error('Replay output was empty');
|
||||||
await fsp.mkdir(REPLAY_DIR, { recursive: true });
|
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 finalPath = path.join(REPLAY_DIR, filename);
|
||||||
const temporaryPath = `${finalPath}.${process.pid}.tmp`;
|
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
|
// Express sendFile supports byte-range requests, which preserves seeking in
|
||||||
// the existing browser video players without implementing a second streamer.
|
// the existing browser video players without implementing a second streamer.
|
||||||
res.setHeader('Cache-Control', 'private, max-age=3600');
|
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 || res.headersSent) return;
|
||||||
if (err.code === 'ENOENT') return res.status(404).end();
|
if (err.code === 'ENOENT') return res.status(404).end();
|
||||||
return next(err);
|
return next(err);
|
||||||
|
|||||||
Reference in New Issue
Block a user