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:
parent
b128c9f5a9
commit
9877ed351f
3 changed files with 107 additions and 3 deletions
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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</>}
|
||||
|
|
|
|||
Loading…
Reference in a new issue