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`;
|
const thumbnailKey = `thumbnails/${id}.jpg`;
|
||||||
|
|
||||||
if (proxyKey) {
|
if (proxyKey) {
|
||||||
|
// Capture already produced a proxy — queue thumbnail off the proxy.
|
||||||
await thumbnailQueue.add('generate', {
|
await thumbnailQueue.add('generate', {
|
||||||
assetId: id,
|
assetId: id,
|
||||||
proxyKey,
|
proxyKey,
|
||||||
outputKey: thumbnailKey,
|
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 {
|
} 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(
|
await pool.query(
|
||||||
`UPDATE assets SET status = 'ready', updated_at = NOW() WHERE id = $1`,
|
`UPDATE assets SET status = 'ready', updated_at = NOW() WHERE id = $1`,
|
||||||
[id]
|
[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
|
// POST /:id/retry
|
||||||
router.post('/:id/retry', async (req, res, next) => {
|
router.post('/:id/retry', async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
|
|
|
||||||
|
|
@ -68,6 +68,19 @@ function generateClipName(recorderName) {
|
||||||
return `${recorderName}_${year}${month}${day}_${hours}${minutes}${seconds}`;
|
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.
|
* Build Docker PortBindings and ExposedPorts for listener-mode recorders.
|
||||||
*/
|
*/
|
||||||
|
|
@ -292,7 +305,12 @@ router.post('/:id/start', async (req, res, next) => {
|
||||||
const growingEnabled =
|
const growingEnabled =
|
||||||
growingRow.rows[0]?.value === 'true' || growingRow.rows[0]?.value === true;
|
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
|
// live-asset: create the asset row right now (status='live') so the
|
||||||
// library shows the recording while it is happening.
|
// library shows the recording while it is happening.
|
||||||
|
|
|
||||||
|
|
@ -316,6 +316,7 @@ function RecorderRow({ recorder: initialRecorder, onRefresh }) {
|
||||||
const [pending, setPending] = React.useState(false);
|
const [pending, setPending] = React.useState(false);
|
||||||
const [err, setErr] = React.useState(null);
|
const [err, setErr] = React.useState(null);
|
||||||
const [liveStatus, setLiveStatus] = React.useState(null);
|
const [liveStatus, setLiveStatus] = React.useState(null);
|
||||||
|
const [clipName, setClipName] = React.useState('');
|
||||||
const isRec = recorder.status === 'recording';
|
const isRec = recorder.status === 'recording';
|
||||||
|
|
||||||
React.useEffect(() => { setRecorder(initialRecorder); }, [initialRecorder.id, initialRecorder.status]);
|
React.useEffect(() => { setRecorder(initialRecorder); }, [initialRecorder.id, initialRecorder.status]);
|
||||||
|
|
@ -357,8 +358,17 @@ function RecorderRow({ recorder: initialRecorder, onRefresh }) {
|
||||||
setPending(true);
|
setPending(true);
|
||||||
setErr(null);
|
setErr(null);
|
||||||
setRecorder(r => ({ ...r, status: isRec ? 'idle' : 'recording' }));
|
setRecorder(r => ({ ...r, status: isRec ? 'idle' : 'recording' }));
|
||||||
window.ZAMPP_API.fetch('/recorders/' + recorder.id + '/' + action, { method: 'POST' })
|
// Ship the operator-typed clip name on start; stop has no body.
|
||||||
.then(() => { setPending(false); onRefresh(); })
|
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); });
|
.catch(e => { setPending(false); setErr(e.message || 'Failed'); setRecorder(initialRecorder); });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -413,6 +423,19 @@ function RecorderRow({ recorder: initialRecorder, onRefresh }) {
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="recorder-actions">
|
<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
|
{isRec
|
||||||
? <button className="btn danger sm" onClick={toggle} disabled={pending}>
|
? <button className="btn danger sm" onClick={toggle} disabled={pending}>
|
||||||
{pending ? '…' : <><span className="rec-dot" />Stop</>}
|
{pending ? '…' : <><span className="rec-dot" />Stop</>}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue