diff --git a/services/mam-api/src/routes/recorders.js b/services/mam-api/src/routes/recorders.js
index f188912..074f8c1 100644
--- a/services/mam-api/src/routes/recorders.js
+++ b/services/mam-api/src/routes/recorders.js
@@ -65,7 +65,14 @@ function generateClipName(recorderName) {
const hours = String(now.getHours()).padStart(2, '0');
const minutes = String(now.getMinutes()).padStart(2, '0');
const seconds = String(now.getSeconds()).padStart(2, '0');
- return `${recorderName}_${year}${month}${day}_${hours}${minutes}${seconds}`;
+ // Strip filesystem-hostile characters out of the recorder name (spaces
+ // become underscores, anything outside [A-Za-z0-9._-] is dropped) so the
+ // clipName flows cleanly through S3 keys, SMB paths, and ffmpeg args.
+ const safe = String(recorderName || 'rec')
+ .replace(/\s+/g, '_')
+ .replace(/[^A-Za-z0-9._-]/g, '')
+ .slice(0, 40) || 'rec';
+ return `${safe}_${year}${month}${day}_${hours}${minutes}${seconds}`;
}
// Sanitize an operator-provided clip name so it's safe as both an S3 key
diff --git a/services/web-ui/public/screens-asset.jsx b/services/web-ui/public/screens-asset.jsx
index 3d4950a..ea9f7e9 100644
--- a/services/web-ui/public/screens-asset.jsx
+++ b/services/web-ui/public/screens-asset.jsx
@@ -286,12 +286,12 @@ function AssetDetail({ asset, onClose }) {
{msToTimecode(currentMs)}{asset.duration}
- {videoRef.current && (
+ {streamUrl && (
diff --git a/services/web-ui/public/screens-ingest.jsx b/services/web-ui/public/screens-ingest.jsx
index 85ab303..456e50c 100644
--- a/services/web-ui/public/screens-ingest.jsx
+++ b/services/web-ui/public/screens-ingest.jsx
@@ -792,15 +792,24 @@ function NewScheduleModal({ recorders, onClose, onCreated }) {
if (!form.recorder_id) return setErr('Pick a recorder');
if (!form.start_at) return setErr('Start time is required');
if (!form.end_at) return setErr('End time is required');
- if (new Date(form.end_at) <= new Date(form.start_at)) return setErr('End must be after start');
+ const startD = new Date(form.start_at);
+ const endD = new Date(form.end_at);
+ if (endD <= startD) return setErr('End must be after start');
+
+ // Warn (but allow) start times in the past — the scheduler tick will fire
+ // them immediately, which is occasionally what the operator wants
+ // (e.g. "record the next 30 minutes starting now").
+ if (startD < new Date(Date.now() - 60_000)) {
+ if (!confirm('Start time is in the past — recorder will fire immediately when saved.\nContinue?')) return;
+ }
// Datetime-local inputs are in the browser's local zone; ship as ISO so
// Postgres stores them as TIMESTAMPTZ properly.
const body = {
name: form.name.trim(),
recorder_id: form.recorder_id,
- start_at: new Date(form.start_at).toISOString(),
- end_at: new Date(form.end_at).toISOString(),
+ start_at: startD.toISOString(),
+ end_at: endD.toISOString(),
recurrence: form.recurrence,
};