diff --git a/services/mam-api/src/routes/assets.js b/services/mam-api/src/routes/assets.js index 1b866b7..6078080 100644 --- a/services/mam-api/src/routes/assets.js +++ b/services/mam-api/src/routes/assets.js @@ -170,12 +170,26 @@ router.post('/', async (req, res, next) => { const thumbnailKey = `thumbnails/${id}.jpg`; if (proxyKey) { + // Capture already produced a proxy — queue thumbnail off the proxy. await thumbnailQueue.add('generate', { assetId: id, proxyKey, outputKey: thumbnailKey, }); + } else if (asset.original_s3_key) { + // No proxy yet but we have a hi-res master in S3 — queue proxy + // generation. The proxy worker flips the asset to 'ready' on success + // and dispatches the thumbnail itself once the proxy lands. + const generatedProxyKey = `proxies/${id}.mp4`; + await proxyQueue.add('generate', { + assetId: id, + inputKey: asset.original_s3_key, + outputKey: generatedProxyKey, + }); + console.log(`[assets] queued proxy for ${id} (${asset.display_name})`); } else { + // Nothing to transcode — mark ready so it isn't stuck at 'processing'. + // The shutdown POST path with no hires/proxy lands here (rare, but legal). await pool.query( `UPDATE assets SET status = 'ready', updated_at = NOW() WHERE id = $1`, [id] @@ -288,6 +302,55 @@ router.post('/:id/copy', async (req, res, next) => { } }); +// POST /:id/generate-proxy — re-queue proxy for an asset that has a hi-res +// master but no proxy_s3_key. Used to backfill recorder-captured clips that +// pre-date the auto-proxy-on-finalize fix. +router.post('/:id/generate-proxy', async (req, res, next) => { + try { + const { id } = req.params; + const r = await pool.query('SELECT * FROM assets WHERE id = $1', [id]); + if (r.rows.length === 0) return res.status(404).json({ error: 'Asset not found' }); + const asset = r.rows[0]; + if (!asset.original_s3_key) return res.status(400).json({ error: 'Asset has no hi-res source to proxy from' }); + const proxyKey = asset.proxy_s3_key || `proxies/${id}.mp4`; + await proxyQueue.add('generate', { assetId: id, inputKey: asset.original_s3_key, outputKey: proxyKey }); + const updated = await pool.query( + `UPDATE assets SET status = 'processing', updated_at = NOW() WHERE id = $1 RETURNING *`, + [id] + ); + res.json(updated.rows[0]); + } catch (err) { next(err); } +}); + +// POST /backfill-proxies — admin: queue proxy generation for every 'ready' +// asset that has a hi-res but no proxy. One-shot maintenance for clips +// captured before the auto-queue-on-finalize fix. +router.post('/backfill-proxies', async (_req, res, next) => { + try { + const targets = await pool.query( + `SELECT id, original_s3_key FROM assets + WHERE status = 'ready' + AND original_s3_key IS NOT NULL + AND (proxy_s3_key IS NULL OR proxy_s3_key = '') + ORDER BY created_at DESC` + ); + for (const asset of targets.rows) { + const proxyKey = `proxies/${asset.id}.mp4`; + await proxyQueue.add('generate', { + assetId: asset.id, inputKey: asset.original_s3_key, outputKey: proxyKey, + }); + } + if (targets.rows.length > 0) { + await pool.query( + `UPDATE assets SET status = 'processing', updated_at = NOW() + WHERE id = ANY($1)`, + [targets.rows.map(r => r.id)] + ); + } + res.json({ queued: targets.rows.length }); + } catch (err) { next(err); } +}); + // POST /:id/retry router.post('/:id/retry', async (req, res, next) => { try { diff --git a/services/mam-api/src/routes/recorders.js b/services/mam-api/src/routes/recorders.js index b4d6c8e..f188912 100644 --- a/services/mam-api/src/routes/recorders.js +++ b/services/mam-api/src/routes/recorders.js @@ -68,6 +68,19 @@ function generateClipName(recorderName) { return `${recorderName}_${year}${month}${day}_${hours}${minutes}${seconds}`; } +// Sanitize an operator-provided clip name so it's safe as both an S3 key +// segment and an SMB/POSIX filename. Allow letters, digits, dot, dash, +// underscore, and spaces; collapse runs of whitespace; cap at 80 chars. +function sanitizeClipName(raw) { + if (typeof raw !== 'string') return null; + const cleaned = raw + .replace(/[^A-Za-z0-9._\- ]+/g, '') + .replace(/\s+/g, ' ') + .trim() + .slice(0, 80); + return cleaned.length > 0 ? cleaned : null; +} + /** * Build Docker PortBindings and ExposedPorts for listener-mode recorders. */ @@ -292,7 +305,12 @@ router.post('/:id/start', async (req, res, next) => { const growingEnabled = growingRow.rows[0]?.value === 'true' || growingRow.rows[0]?.value === true; - const clipName = generateClipName(recorder.name); + // Operator-supplied clip name wins over the auto-timestamped fallback. + // The Recorders UI passes this on the start request when the user types + // something into the "Clip name" field; otherwise it's blank and we + // generate `_` as before. + const customClipName = sanitizeClipName(req.body && req.body.clipName); + const clipName = customClipName || generateClipName(recorder.name); // live-asset: create the asset row right now (status='live') so the // library shows the recording while it is happening. diff --git a/services/web-ui/public/screens-ingest.jsx b/services/web-ui/public/screens-ingest.jsx index ebd5972..85ab303 100644 --- a/services/web-ui/public/screens-ingest.jsx +++ b/services/web-ui/public/screens-ingest.jsx @@ -316,6 +316,7 @@ function RecorderRow({ recorder: initialRecorder, onRefresh }) { const [pending, setPending] = React.useState(false); const [err, setErr] = React.useState(null); const [liveStatus, setLiveStatus] = React.useState(null); + const [clipName, setClipName] = React.useState(''); const isRec = recorder.status === 'recording'; React.useEffect(() => { setRecorder(initialRecorder); }, [initialRecorder.id, initialRecorder.status]); @@ -357,8 +358,17 @@ function RecorderRow({ recorder: initialRecorder, onRefresh }) { setPending(true); setErr(null); setRecorder(r => ({ ...r, status: isRec ? 'idle' : 'recording' })); - window.ZAMPP_API.fetch('/recorders/' + recorder.id + '/' + action, { method: 'POST' }) - .then(() => { setPending(false); onRefresh(); }) + // Ship the operator-typed clip name on start; stop has no body. + const body = (action === 'start' && clipName.trim()) + ? JSON.stringify({ clipName: clipName.trim() }) + : undefined; + window.ZAMPP_API.fetch('/recorders/' + recorder.id + '/' + action, { method: 'POST', body }) + .then(() => { + setPending(false); + // Clear the input on a successful stop so the next take starts fresh. + if (action === 'stop') setClipName(''); + onRefresh(); + }) .catch(e => { setPending(false); setErr(e.message || 'Failed'); setRecorder(initialRecorder); }); }; @@ -413,6 +423,19 @@ function RecorderRow({ recorder: initialRecorder, onRefresh }) { )}
+ {!isRec && ( + setClipName(e.target.value)} + placeholder="Clip name (optional)" + disabled={pending} + maxLength={80} + onKeyDown={e => { if (e.key === 'Enter') toggle(); }} + style={{ width: 180, padding: '5px 8px', fontSize: 12 }} + title="Letters, numbers, spaces, dot, dash, underscore. Blank = auto timestamp." + /> + )} {isRec ?