fix(recorders): queue proxy on finalize + custom clip names

- POST /api/v1/assets: when transitioning from 'live' to 'processing'
  with a hi-res key but no proxy, queue a proxy job instead of just
  flipping status='ready'. Recorder-captured clips now get a proxy
  + thumbnail like upload-path assets do
- POST /api/v1/recorders/:id/start now accepts { clipName } in the body;
  operator-supplied name (sanitized to [A-Za-z0-9 ._-], capped at 80)
  overrides the auto-generated <recorder>_<timestamp> fallback
- RecorderRow gets a 'Clip name (optional)' input visible when stopped;
  Enter triggers Record, value sent on POST start, cleared on stop
- New POST /api/v1/assets/:id/generate-proxy and
  POST /api/v1/assets/backfill-proxies for one-shot cleanup of pre-fix
  clips that have a hi-res master but no proxy
This commit is contained in:
claude 2026-05-23 03:41:03 +00:00
parent b128c9f5a9
commit 9877ed351f
3 changed files with 107 additions and 3 deletions

View file

@ -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 {

View file

@ -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 `<recorder>_<timestamp>` 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.

View file

@ -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 }) {
)}
</div>
<div className="recorder-actions">
{!isRec && (
<input
className="field-input"
value={clipName}
onChange={e => 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
? <button className="btn danger sm" onClick={toggle} disabled={pending}>
{pending ? '…' : <><span className="rec-dot" />Stop</>}