fix(#78): GET /assets — include_archived filter now independent of status filter
This commit is contained in:
parent
2e1ac72585
commit
7fc502513e
1 changed files with 85 additions and 277 deletions
|
|
@ -53,7 +53,8 @@ router.get('/', async (req, res, next) => {
|
||||||
const params = [];
|
const params = [];
|
||||||
let paramCount = 1;
|
let paramCount = 1;
|
||||||
|
|
||||||
if (!status && include_archived !== 'true') {
|
// Exclude archived unless explicitly requested — independent of status filter
|
||||||
|
if (include_archived !== 'true') {
|
||||||
query += ` AND a.status <> 'archived'`;
|
query += ` AND a.status <> 'archived'`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -116,7 +117,11 @@ router.post('/', async (req, res, next) => {
|
||||||
return res.status(400).json({ error: 'projectId and clipName are required' });
|
return res.status(400).json({ error: 'projectId and clipName are required' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const durationMs = duration ? Math.round(duration * 1000) : null;
|
const durationNum = duration !== undefined && duration !== null ? Number(duration) : null;
|
||||||
|
if (durationNum !== null && !Number.isFinite(durationNum)) {
|
||||||
|
return res.status(400).json({ error: 'duration must be a finite number (seconds)' });
|
||||||
|
}
|
||||||
|
const durationMs = durationNum !== null ? Math.round(durationNum * 1000) : null;
|
||||||
|
|
||||||
const existing = await pool.query(
|
const existing = await pool.query(
|
||||||
`SELECT * FROM assets
|
`SELECT * FROM assets
|
||||||
|
|
@ -176,30 +181,15 @@ 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', { assetId: id, proxyKey, outputKey: thumbnailKey });
|
||||||
await thumbnailQueue.add('generate', {
|
|
||||||
assetId: id,
|
|
||||||
proxyKey,
|
|
||||||
outputKey: thumbnailKey,
|
|
||||||
});
|
|
||||||
} else if (asset.original_s3_key) {
|
} 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`;
|
const generatedProxyKey = `proxies/${id}.mp4`;
|
||||||
await proxyQueue.add('generate', {
|
await proxyQueue.add('generate', {
|
||||||
assetId: id,
|
assetId: id, inputKey: asset.original_s3_key, outputKey: generatedProxyKey,
|
||||||
inputKey: asset.original_s3_key,
|
|
||||||
outputKey: generatedProxyKey,
|
|
||||||
});
|
});
|
||||||
console.log(`[assets] queued proxy for ${id} (${asset.display_name})`);
|
console.log(`[assets] queued proxy for ${id} (${asset.display_name})`);
|
||||||
} else {
|
} else {
|
||||||
// Nothing to transcode — mark ready so it isn't stuck at 'processing'.
|
await pool.query(`UPDATE assets SET status = 'ready', updated_at = NOW() WHERE id = $1`, [id]);
|
||||||
// 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]
|
|
||||||
);
|
|
||||||
asset.status = 'ready';
|
asset.status = 'ready';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -214,23 +204,16 @@ router.post('/cleanup-live', async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const maxAgeHours = Math.max(1, parseInt(req.query.max_age_hours || '4', 10));
|
const maxAgeHours = Math.max(1, parseInt(req.query.max_age_hours || '4', 10));
|
||||||
const result = await pool.query(
|
const result = await pool.query(
|
||||||
`UPDATE assets
|
`UPDATE assets SET status = 'error', updated_at = NOW()
|
||||||
SET status = 'error', updated_at = NOW()
|
WHERE status = 'live' AND created_at < NOW() - ($1 * INTERVAL '1 hour')
|
||||||
WHERE status = 'live'
|
|
||||||
AND created_at < NOW() - ($1 * INTERVAL '1 hour')
|
|
||||||
RETURNING id, display_name, project_id, created_at`,
|
RETURNING id, display_name, project_id, created_at`,
|
||||||
[maxAgeHours]
|
[maxAgeHours]
|
||||||
);
|
);
|
||||||
res.json({ cleaned: result.rowCount, assets: result.rows });
|
res.json({ cleaned: result.rowCount, assets: result.rows });
|
||||||
} catch (err) {
|
} catch (err) { next(err); }
|
||||||
next(err);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// POST /cleanup-live-orphans
|
// POST /cleanup-live-orphans
|
||||||
// Reaps /live/<uuid>/ directories that have no matching asset row in the DB.
|
|
||||||
// These accumulate when a recorder crashes mid-capture or when an asset row
|
|
||||||
// is deleted after the HLS dir was created. Closes part of #7.
|
|
||||||
router.post('/cleanup-live-orphans', async (_req, res, next) => {
|
router.post('/cleanup-live-orphans', async (_req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const liveRoot = process.env.LIVE_DIR || '/live';
|
const liveRoot = process.env.LIVE_DIR || '/live';
|
||||||
|
|
@ -242,24 +225,11 @@ router.post('/cleanup-live-orphans', async (_req, res, next) => {
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
const uuidRe = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
const uuidRe = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||||
|
const dirIds = entries.filter(e => e.isDirectory() && uuidRe.test(e.name)).map(e => e.name);
|
||||||
const dirIds = entries
|
|
||||||
.filter(e => e.isDirectory() && uuidRe.test(e.name))
|
|
||||||
.map(e => e.name);
|
|
||||||
|
|
||||||
if (dirIds.length === 0) return res.json({ reaped: 0, kept: 0, dirs: [] });
|
if (dirIds.length === 0) return res.json({ reaped: 0, kept: 0, dirs: [] });
|
||||||
|
const known = await pool.query('SELECT id FROM assets WHERE id = ANY($1::uuid[])', [dirIds]);
|
||||||
// Find which of those UUIDs correspond to live or in-flight assets.
|
|
||||||
// We keep dirs that match ANY asset row (regardless of status) so that
|
|
||||||
// an archived asset's HLS scrubber isn't yanked out from under it.
|
|
||||||
const known = await pool.query(
|
|
||||||
'SELECT id FROM assets WHERE id = ANY($1::uuid[])',
|
|
||||||
[dirIds]
|
|
||||||
);
|
|
||||||
const keep = new Set(known.rows.map(r => r.id));
|
const keep = new Set(known.rows.map(r => r.id));
|
||||||
|
const reaped = [], kept = [];
|
||||||
const reaped = [];
|
|
||||||
const kept = [];
|
|
||||||
for (const id of dirIds) {
|
for (const id of dirIds) {
|
||||||
if (keep.has(id)) { kept.push(id); continue; }
|
if (keep.has(id)) { kept.push(id); continue; }
|
||||||
const fullPath = path.join(liveRoot, id);
|
const fullPath = path.join(liveRoot, id);
|
||||||
|
|
@ -271,11 +241,8 @@ router.post('/cleanup-live-orphans', async (_req, res, next) => {
|
||||||
console.warn(`[assets] failed to reap ${fullPath}: ${err.message}`);
|
console.warn(`[assets] failed to reap ${fullPath}: ${err.message}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
res.json({ reaped: reaped.length, kept: kept.length, dirs: reaped });
|
res.json({ reaped: reaped.length, kept: kept.length, dirs: reaped });
|
||||||
} catch (err) {
|
} catch (err) { next(err); }
|
||||||
next(err);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// GET /:id
|
// GET /:id
|
||||||
|
|
@ -285,9 +252,7 @@ router.get('/:id', async (req, res, next) => {
|
||||||
const result = await pool.query('SELECT * FROM assets WHERE id = $1', [id]);
|
const result = await pool.query('SELECT * FROM assets WHERE id = $1', [id]);
|
||||||
if (result.rows.length === 0) return res.status(404).json({ error: 'Asset not found' });
|
if (result.rows.length === 0) return res.status(404).json({ error: 'Asset not found' });
|
||||||
res.json(result.rows[0]);
|
res.json(result.rows[0]);
|
||||||
} catch (err) {
|
} catch (err) { next(err); }
|
||||||
next(err);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// PATCH /:id
|
// PATCH /:id
|
||||||
|
|
@ -295,30 +260,21 @@ router.patch('/:id', async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
const { display_name, tags, notes, bin_id } = req.body;
|
const { display_name, tags, notes, bin_id } = req.body;
|
||||||
|
const updates = [], params = [];
|
||||||
const updates = [];
|
|
||||||
const params = [];
|
|
||||||
let paramCount = 1;
|
let paramCount = 1;
|
||||||
|
|
||||||
if (display_name !== undefined) { updates.push(`display_name = $${paramCount++}`); params.push(display_name); }
|
if (display_name !== undefined) { updates.push(`display_name = $${paramCount++}`); params.push(display_name); }
|
||||||
if (tags !== undefined) { updates.push(`tags = $${paramCount++}`); params.push(tags); }
|
if (tags !== undefined) { updates.push(`tags = $${paramCount++}`); params.push(tags); }
|
||||||
if (notes !== undefined) { updates.push(`notes = $${paramCount++}`); params.push(notes); }
|
if (notes !== undefined) { updates.push(`notes = $${paramCount++}`); params.push(notes); }
|
||||||
if (bin_id !== undefined) { updates.push(`bin_id = $${paramCount++}`); params.push(bin_id || null); }
|
if (bin_id !== undefined) { updates.push(`bin_id = $${paramCount++}`); params.push(bin_id || null); }
|
||||||
|
|
||||||
if (updates.length === 0) return res.status(400).json({ error: 'No fields to update' });
|
if (updates.length === 0) return res.status(400).json({ error: 'No fields to update' });
|
||||||
|
|
||||||
updates.push(`updated_at = NOW()`);
|
updates.push(`updated_at = NOW()`);
|
||||||
params.push(id);
|
params.push(id);
|
||||||
|
|
||||||
const result = await pool.query(
|
const result = await pool.query(
|
||||||
`UPDATE assets SET ${updates.join(', ')} WHERE id = $${paramCount} RETURNING *`,
|
`UPDATE assets SET ${updates.join(', ')} WHERE id = $${paramCount} RETURNING *`, params
|
||||||
params
|
|
||||||
);
|
);
|
||||||
if (result.rows.length === 0) return res.status(404).json({ error: 'Asset not found' });
|
if (result.rows.length === 0) return res.status(404).json({ error: 'Asset not found' });
|
||||||
res.json(result.rows[0]);
|
res.json(result.rows[0]);
|
||||||
} catch (err) {
|
} catch (err) { next(err); }
|
||||||
next(err);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// POST /:id/copy
|
// POST /:id/copy
|
||||||
|
|
@ -337,31 +293,22 @@ router.post('/:id/copy', async (req, res, next) => {
|
||||||
codec, resolution, fps, duration_ms, start_tc, file_size, tags, notes,
|
codec, resolution, fps, duration_ms, start_tc, file_size, tags, notes,
|
||||||
created_at, updated_at
|
created_at, updated_at
|
||||||
) VALUES (
|
) VALUES (
|
||||||
$1, $2, $3, $4, $5,
|
$1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,NOW(),NOW()
|
||||||
$6, $7, $8, $9, $10,
|
|
||||||
$11, $12, $13, $14, $15, $16, $17, $18,
|
|
||||||
NOW(), NOW()
|
|
||||||
) RETURNING *`,
|
) RETURNING *`,
|
||||||
[
|
[
|
||||||
newId,
|
newId, projectId || src.project_id,
|
||||||
projectId || src.project_id,
|
|
||||||
binId === undefined ? src.bin_id : (binId || null),
|
binId === undefined ? src.bin_id : (binId || null),
|
||||||
src.filename, src.display_name,
|
src.filename, src.display_name, src.status, src.media_type,
|
||||||
src.status, src.media_type,
|
|
||||||
src.original_s3_key, src.proxy_s3_key, src.thumbnail_s3_key,
|
src.original_s3_key, src.proxy_s3_key, src.thumbnail_s3_key,
|
||||||
src.codec, src.resolution, src.fps, src.duration_ms, src.start_tc,
|
src.codec, src.resolution, src.fps, src.duration_ms, src.start_tc,
|
||||||
src.file_size, src.tags, src.notes,
|
src.file_size, src.tags, src.notes,
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
res.status(201).json(ins.rows[0]);
|
res.status(201).json(ins.rows[0]);
|
||||||
} catch (err) {
|
} catch (err) { next(err); }
|
||||||
next(err);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// POST /:id/mark-empty — flag a pre-created live asset as 'error' because
|
// POST /:id/mark-empty
|
||||||
// the recorder finished a session without any frames (bad source URL, dead
|
|
||||||
// SDI signal, etc.). Called by the capture sidecar's shutdown handler.
|
|
||||||
router.post('/:id/mark-empty', async (req, res, next) => {
|
router.post('/:id/mark-empty', async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
|
|
@ -370,8 +317,7 @@ router.post('/:id/mark-empty', async (req, res, next) => {
|
||||||
SET status = 'error',
|
SET status = 'error',
|
||||||
notes = COALESCE(notes || E'\\n', '') || 'Recording produced no frames — source never connected.',
|
notes = COALESCE(notes || E'\\n', '') || 'Recording produced no frames — source never connected.',
|
||||||
updated_at = NOW()
|
updated_at = NOW()
|
||||||
WHERE id = $1 AND status = 'live'
|
WHERE id = $1 AND status = 'live' RETURNING id`,
|
||||||
RETURNING id`,
|
|
||||||
[id]
|
[id]
|
||||||
);
|
);
|
||||||
if (result.rows.length === 0) return res.status(404).json({ error: 'No matching live asset' });
|
if (result.rows.length === 0) return res.status(404).json({ error: 'No matching live asset' });
|
||||||
|
|
@ -379,9 +325,7 @@ router.post('/:id/mark-empty', async (req, res, next) => {
|
||||||
} catch (err) { next(err); }
|
} catch (err) { next(err); }
|
||||||
});
|
});
|
||||||
|
|
||||||
// POST /:id/generate-proxy — re-queue proxy for an asset that has a hi-res
|
// POST /:id/generate-proxy
|
||||||
// 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) => {
|
router.post('/:id/generate-proxy', async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
|
|
@ -392,35 +336,28 @@ router.post('/:id/generate-proxy', async (req, res, next) => {
|
||||||
const proxyKey = asset.proxy_s3_key || `proxies/${id}.mp4`;
|
const proxyKey = asset.proxy_s3_key || `proxies/${id}.mp4`;
|
||||||
await proxyQueue.add('generate', { assetId: id, inputKey: asset.original_s3_key, outputKey: proxyKey });
|
await proxyQueue.add('generate', { assetId: id, inputKey: asset.original_s3_key, outputKey: proxyKey });
|
||||||
const updated = await pool.query(
|
const updated = await pool.query(
|
||||||
`UPDATE assets SET status = 'processing', updated_at = NOW() WHERE id = $1 RETURNING *`,
|
`UPDATE assets SET status = 'processing', updated_at = NOW() WHERE id = $1 RETURNING *`, [id]
|
||||||
[id]
|
|
||||||
);
|
);
|
||||||
res.json(updated.rows[0]);
|
res.json(updated.rows[0]);
|
||||||
} catch (err) { next(err); }
|
} catch (err) { next(err); }
|
||||||
});
|
});
|
||||||
|
|
||||||
// POST /backfill-proxies — admin: queue proxy generation for every 'ready'
|
// POST /backfill-proxies
|
||||||
// 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) => {
|
router.post('/backfill-proxies', async (_req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const targets = await pool.query(
|
const targets = await pool.query(
|
||||||
`SELECT id, original_s3_key FROM assets
|
`SELECT id, original_s3_key FROM assets
|
||||||
WHERE status = 'ready'
|
WHERE status = 'ready' AND original_s3_key IS NOT NULL
|
||||||
AND original_s3_key IS NOT NULL
|
|
||||||
AND (proxy_s3_key IS NULL OR proxy_s3_key = '')
|
AND (proxy_s3_key IS NULL OR proxy_s3_key = '')
|
||||||
ORDER BY created_at DESC`
|
ORDER BY created_at DESC`
|
||||||
);
|
);
|
||||||
for (const asset of targets.rows) {
|
for (const asset of targets.rows) {
|
||||||
const proxyKey = `proxies/${asset.id}.mp4`;
|
const proxyKey = `proxies/${asset.id}.mp4`;
|
||||||
await proxyQueue.add('generate', {
|
await proxyQueue.add('generate', { assetId: asset.id, inputKey: asset.original_s3_key, outputKey: proxyKey });
|
||||||
assetId: asset.id, inputKey: asset.original_s3_key, outputKey: proxyKey,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
if (targets.rows.length > 0) {
|
if (targets.rows.length > 0) {
|
||||||
await pool.query(
|
await pool.query(
|
||||||
`UPDATE assets SET status = 'processing', updated_at = NOW()
|
`UPDATE assets SET status = 'processing', updated_at = NOW() WHERE id = ANY($1)`,
|
||||||
WHERE id = ANY($1)`,
|
|
||||||
[targets.rows.map(r => r.id)]
|
[targets.rows.map(r => r.id)]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -428,40 +365,23 @@ router.post('/backfill-proxies', async (_req, res, next) => {
|
||||||
} catch (err) { next(err); }
|
} catch (err) { next(err); }
|
||||||
});
|
});
|
||||||
|
|
||||||
// POST /:id/retry — re-queue the proxy job.
|
// POST /:id/retry
|
||||||
//
|
|
||||||
// Originally this only fired for status='error', which meant an archived or
|
|
||||||
// 'ready'-but-proxy-less asset (e.g. an old recorder capture that never got
|
|
||||||
// a browser-playable proxy) had no way back. Now we also accept assets that
|
|
||||||
// have a hi-res source but no proxy_s3_key, regardless of status — the UI
|
|
||||||
// uses this as the user-facing "Generate proxy" action.
|
|
||||||
router.post('/:id/retry', async (req, res, next) => {
|
router.post('/:id/retry', async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
const r = await pool.query('SELECT * FROM assets WHERE id = $1', [id]);
|
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' });
|
if (r.rows.length === 0) return res.status(404).json({ error: 'Asset not found' });
|
||||||
const asset = r.rows[0];
|
const asset = r.rows[0];
|
||||||
if (!asset.original_s3_key) {
|
if (!asset.original_s3_key) return res.status(400).json({ error: 'Asset has no source file to reprocess' });
|
||||||
return res.status(400).json({ error: 'Asset has no source file to reprocess' });
|
const canRetry = asset.status === 'error' || !asset.proxy_s3_key;
|
||||||
}
|
if (!canRetry) return res.status(400).json({ error: `Nothing to retry — asset is ${asset.status} and already has a proxy.` });
|
||||||
const canRetry =
|
|
||||||
asset.status === 'error' ||
|
|
||||||
!asset.proxy_s3_key; // works for 'ready' or 'archived' that lost / never had a proxy
|
|
||||||
if (!canRetry) {
|
|
||||||
return res.status(400).json({
|
|
||||||
error: `Nothing to retry — asset is ${asset.status} and already has a proxy.`,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
const proxyKey = asset.proxy_s3_key || `proxies/${id}.mp4`;
|
const proxyKey = asset.proxy_s3_key || `proxies/${id}.mp4`;
|
||||||
await proxyQueue.add('generate', { assetId: id, inputKey: asset.original_s3_key, outputKey: proxyKey });
|
await proxyQueue.add('generate', { assetId: id, inputKey: asset.original_s3_key, outputKey: proxyKey });
|
||||||
const updated = await pool.query(
|
const updated = await pool.query(
|
||||||
`UPDATE assets SET status = 'processing', updated_at = NOW() WHERE id = $1 RETURNING *`,
|
`UPDATE assets SET status = 'processing', updated_at = NOW() WHERE id = $1 RETURNING *`, [id]
|
||||||
[id]
|
|
||||||
);
|
);
|
||||||
res.json(updated.rows[0]);
|
res.json(updated.rows[0]);
|
||||||
} catch (err) {
|
} catch (err) { next(err); }
|
||||||
next(err);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// DELETE /:id
|
// DELETE /:id
|
||||||
|
|
@ -473,22 +393,22 @@ router.delete('/:id', async (req, res, next) => {
|
||||||
const assetResult = await pool.query('SELECT * FROM assets WHERE id = $1', [id]);
|
const assetResult = await pool.query('SELECT * FROM assets WHERE id = $1', [id]);
|
||||||
if (assetResult.rows.length === 0) return res.status(404).json({ error: 'Asset not found' });
|
if (assetResult.rows.length === 0) return res.status(404).json({ error: 'Asset not found' });
|
||||||
const asset = assetResult.rows[0];
|
const asset = assetResult.rows[0];
|
||||||
if (asset.proxy_s3_key) await deleteObject(asset.proxy_s3_key);
|
const s3Errors = [];
|
||||||
if (asset.thumbnail_s3_key) await deleteObject(asset.thumbnail_s3_key);
|
for (const key of [asset.proxy_s3_key, asset.thumbnail_s3_key, asset.original_s3_key]) {
|
||||||
if (asset.original_s3_key) await deleteObject(asset.original_s3_key);
|
if (!key) continue;
|
||||||
|
try { await deleteObject(key); }
|
||||||
|
catch (e) { s3Errors.push({ key, error: e.message }); console.warn(`[assets] s3 delete failed for ${key}:`, e.message); }
|
||||||
|
}
|
||||||
await pool.query('DELETE FROM assets WHERE id = $1', [id]);
|
await pool.query('DELETE FROM assets WHERE id = $1', [id]);
|
||||||
res.json({ message: 'Asset deleted permanently' });
|
res.json({ message: 'Asset deleted permanently', ...(s3Errors.length ? { s3Errors } : {}) });
|
||||||
} else {
|
} else {
|
||||||
const result = await pool.query(
|
const result = await pool.query(
|
||||||
`UPDATE assets SET status = 'archived', updated_at = NOW() WHERE id = $1 RETURNING *`,
|
`UPDATE assets SET status = 'archived', updated_at = NOW() WHERE id = $1 RETURNING *`, [id]
|
||||||
[id]
|
|
||||||
);
|
);
|
||||||
if (result.rows.length === 0) return res.status(404).json({ error: 'Asset not found' });
|
if (result.rows.length === 0) return res.status(404).json({ error: 'Asset not found' });
|
||||||
res.json(result.rows[0]);
|
res.json(result.rows[0]);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) { next(err); }
|
||||||
next(err);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// GET /:id/stream
|
// GET /:id/stream
|
||||||
|
|
@ -502,75 +422,33 @@ router.get('/:id/stream', async (req, res, next) => {
|
||||||
if (a.proxy_s3_key) return res.json({ url: `/api/v1/assets/${id}/video`, type: 'mp4' });
|
if (a.proxy_s3_key) return res.json({ url: `/api/v1/assets/${id}/video`, type: 'mp4' });
|
||||||
const orig = a.original_s3_key;
|
const orig = a.original_s3_key;
|
||||||
if (orig && orig.toLowerCase().endsWith('.mp4')) return res.json({ url: `/api/v1/assets/${id}/video`, type: 'mp4' });
|
if (orig && orig.toLowerCase().endsWith('.mp4')) return res.json({ url: `/api/v1/assets/${id}/video`, type: 'mp4' });
|
||||||
// No browser-playable source — let the UI surface a "Generate proxy"
|
return res.json({ url: null, type: null, reason: 'no_proxy', has_source: !!a.original_s3_key });
|
||||||
// CTA. has_source tells the UI whether retry is even possible.
|
} catch (err) { next(err); }
|
||||||
return res.json({
|
|
||||||
url: null,
|
|
||||||
type: null,
|
|
||||||
reason: 'no_proxy',
|
|
||||||
has_source: !!a.original_s3_key,
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
next(err);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// GET /:id/live-path
|
// GET /:id/live-path
|
||||||
// Returns the SMB UNC path of a live (growing) asset so the Premiere panel
|
|
||||||
// can mount the file in place rather than downloading a proxy first.
|
|
||||||
// Editors mount the SMB share once; the panel passes individual file paths.
|
|
||||||
router.get('/:id/live-path', async (req, res, next) => {
|
router.get('/:id/live-path', async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
const a = await pool.query(
|
const a = await pool.query('SELECT id, project_id, display_name, status FROM assets WHERE id = $1', [id]);
|
||||||
'SELECT id, project_id, display_name, status FROM assets WHERE id = $1',
|
|
||||||
[id]
|
|
||||||
);
|
|
||||||
if (a.rows.length === 0) return res.status(404).json({ error: 'Asset not found' });
|
if (a.rows.length === 0) return res.status(404).json({ error: 'Asset not found' });
|
||||||
const asset = a.rows[0];
|
const asset = a.rows[0];
|
||||||
|
if (asset.status !== 'live') return res.status(409).json({ error: 'Asset is not currently growing', status: asset.status });
|
||||||
if (asset.status !== 'live') {
|
const s = await pool.query(`SELECT key, value FROM settings WHERE key IN ('growing_enabled','growing_smb_url')`);
|
||||||
return res.status(409).json({ error: 'Asset is not currently growing', status: asset.status });
|
|
||||||
}
|
|
||||||
|
|
||||||
const s = await pool.query(
|
|
||||||
`SELECT key, value FROM settings
|
|
||||||
WHERE key IN ('growing_enabled', 'growing_smb_url')`
|
|
||||||
);
|
|
||||||
const cfg = {};
|
const cfg = {};
|
||||||
for (const { key, value } of s.rows) cfg[key] = value;
|
for (const { key, value } of s.rows) cfg[key] = value;
|
||||||
if (cfg.growing_enabled !== 'true') {
|
if (cfg.growing_enabled !== 'true') return res.status(409).json({ error: 'Growing-files mode is disabled' });
|
||||||
return res.status(409).json({ error: 'Growing-files mode is disabled' });
|
if (!cfg.growing_smb_url) return res.status(409).json({ error: 'No SMB URL configured — set growing_smb_url in Settings' });
|
||||||
}
|
|
||||||
if (!cfg.growing_smb_url) {
|
|
||||||
return res.status(409).json({ error: 'No SMB URL configured — set growing_smb_url in Settings' });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Find the recorder driving this asset so we know the right file extension.
|
|
||||||
const rec = await pool.query(
|
const rec = await pool.query(
|
||||||
`SELECT recording_container FROM recorders
|
`SELECT recording_container FROM recorders WHERE current_session_id = $1 ORDER BY updated_at DESC LIMIT 1`,
|
||||||
WHERE current_session_id = $1
|
[asset.id]
|
||||||
ORDER BY updated_at DESC LIMIT 1`,
|
|
||||||
[asset.display_name]
|
|
||||||
);
|
);
|
||||||
const ext = rec.rows[0]?.recording_container || 'mov';
|
const ext = rec.rows[0]?.recording_container || 'mov';
|
||||||
|
|
||||||
const smbRoot = cfg.growing_smb_url.replace(/\/+$/, '');
|
const smbRoot = cfg.growing_smb_url.replace(/\/+$/, '');
|
||||||
const winPath = smbRoot.replace(/^smb:\/\//, '\\\\').replace(/\//g, '\\') +
|
const winPath = smbRoot.replace(/^smb:\/\//, '\\\\').replace(/\//g, '\\') + `\\${asset.project_id}\\${asset.display_name}.${ext}`;
|
||||||
`\\${asset.project_id}\\${asset.display_name}.${ext}`;
|
const posix = smbRoot.replace(/^smb:\/\//, '//') + `/${asset.project_id}/${asset.display_name}.${ext}`;
|
||||||
const posix = smbRoot.replace(/^smb:\/\//, '//') +
|
res.json({ smb_url: `${smbRoot}/${asset.project_id}/${asset.display_name}.${ext}`, win_path: winPath, posix_path: posix, project_id: asset.project_id, display_name: asset.display_name, ext });
|
||||||
`/${asset.project_id}/${asset.display_name}.${ext}`;
|
} catch (err) { next(err); }
|
||||||
res.json({
|
|
||||||
smb_url: `${smbRoot}/${asset.project_id}/${asset.display_name}.${ext}`,
|
|
||||||
win_path: winPath,
|
|
||||||
posix_path: posix,
|
|
||||||
project_id: asset.project_id,
|
|
||||||
display_name: asset.display_name,
|
|
||||||
ext,
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
next(err);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// GET /:id/video
|
// GET /:id/video
|
||||||
|
|
@ -599,17 +477,14 @@ router.get('/:id/video', async (req, res, next) => {
|
||||||
router.get('/:id/hires', async (req, res, next) => {
|
router.get('/:id/hires', async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
const r = await pool.query(
|
const r = await pool.query('SELECT original_s3_key, filename, display_name, file_size FROM assets WHERE id = $1', [id]);
|
||||||
'SELECT original_s3_key, filename, display_name, file_size FROM assets WHERE id = $1',
|
|
||||||
[id]
|
|
||||||
);
|
|
||||||
if (r.rows.length === 0) return res.status(404).json({ error: 'Asset not found' });
|
if (r.rows.length === 0) return res.status(404).json({ error: 'Asset not found' });
|
||||||
const a = r.rows[0];
|
const a = r.rows[0];
|
||||||
if (!a.original_s3_key) return res.status(404).json({ error: 'No hi-res source available' });
|
if (!a.original_s3_key) return res.status(404).json({ error: 'No hi-res source available' });
|
||||||
const url = await getSignedUrlForObject(a.original_s3_key);
|
const url = await getSignedUrlForObject(a.original_s3_key);
|
||||||
const parts = a.original_s3_key.split('.');
|
const parts = a.original_s3_key.split('.');
|
||||||
const ext = (parts.length > 1 ? parts[parts.length - 1] : 'mxf').toLowerCase();
|
const ext = (parts.length > 1 ? parts[parts.length - 1] : 'mxf').toLowerCase();
|
||||||
const base = (a.display_name || a.filename || id).replace(/[^\w.-]/g, '_').substring(0, 100);
|
const base = (a.display_name || a.filename || id).replace(/[^\w.-]/g, '_').substring(0, 100);
|
||||||
res.json({ url, filename: `${base}.${ext}`, ext, file_size: a.file_size || null, type: 'hires' });
|
res.json({ url, filename: `${base}.${ext}`, ext, file_size: a.file_size || null, type: 'hires' });
|
||||||
} catch (err) { next(err); }
|
} catch (err) { next(err); }
|
||||||
});
|
});
|
||||||
|
|
@ -625,127 +500,60 @@ router.get('/:id/thumbnail', async (req, res, next) => {
|
||||||
const url = await getSignedUrlForObject(thumbnail_s3_key);
|
const url = await getSignedUrlForObject(thumbnail_s3_key);
|
||||||
if (req.query.redirect === '1') return res.redirect(302, url);
|
if (req.query.redirect === '1') return res.redirect(302, url);
|
||||||
res.json({ url });
|
res.json({ url });
|
||||||
} catch (err) {
|
} catch (err) { next(err); }
|
||||||
next(err);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// POST /batch-trim — Queue hi-res auto-relink trim jobs for a batch of clips.
|
// POST /batch-trim
|
||||||
// Each clip gets a BullMQ job in the 'trim' queue and a temp_segments row.
|
|
||||||
router.post('/batch-trim', async (req, res, next) => {
|
router.post('/batch-trim', async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const { clips } = req.body;
|
const { clips } = req.body;
|
||||||
|
if (!Array.isArray(clips) || clips.length === 0) return res.status(400).json({ error: 'clips array is required and must be non-empty' });
|
||||||
if (!Array.isArray(clips) || clips.length === 0) {
|
|
||||||
return res.status(400).json({ error: 'clips array is required and must be non-empty' });
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const c of clips) {
|
for (const c of clips) {
|
||||||
if (!c.assetId || !c.filename ||
|
if (!c.assetId || !c.filename ||
|
||||||
!Number.isFinite(Number(c.sourceInFrames)) ||
|
!Number.isFinite(Number(c.sourceInFrames)) || !Number.isFinite(Number(c.sourceOutFrames)) ||
|
||||||
!Number.isFinite(Number(c.sourceOutFrames)) ||
|
!Number.isFinite(Number(c.timelineInFrames)) || !Number.isFinite(Number(c.timelineOutFrames)) ||
|
||||||
!Number.isFinite(Number(c.timelineInFrames)) ||
|
|
||||||
!Number.isFinite(Number(c.timelineOutFrames)) ||
|
|
||||||
!Number.isInteger(Number(c.trackIndex)) || Number(c.trackIndex) < 0) {
|
!Number.isInteger(Number(c.trackIndex)) || Number(c.trackIndex) < 0) {
|
||||||
return res.status(400).json({
|
return res.status(400).json({ error: 'Each clip must have assetId, filename, sourceInFrames, sourceOutFrames, timelineInFrames, timelineOutFrames, and a non-negative integer trackIndex' });
|
||||||
error: 'Each clip must have assetId, filename, sourceInFrames, sourceOutFrames, timelineInFrames, timelineOutFrames, and a non-negative integer trackIndex',
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const jobId = uuidv4();
|
const jobId = uuidv4();
|
||||||
const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000);
|
const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000);
|
||||||
|
await pool.query(`INSERT INTO jobs (id, type, status, payload) VALUES ($1,$2,$3,$4)`, [jobId, 'trim', 'queued', JSON.stringify({ clips })]);
|
||||||
// Create job record in the jobs table
|
|
||||||
await pool.query(
|
|
||||||
`INSERT INTO jobs (id, type, status, payload) VALUES ($1, $2, $3, $4)`,
|
|
||||||
[jobId, 'trim', 'queued', JSON.stringify({ clips })]
|
|
||||||
);
|
|
||||||
|
|
||||||
const clipResults = [];
|
const clipResults = [];
|
||||||
for (const c of clips) {
|
for (const c of clips) {
|
||||||
const clipInstanceId = uuidv4();
|
const clipInstanceId = uuidv4();
|
||||||
|
await trimQueue.add('trim-clip', { jobId, clipInstanceId, assetId: c.assetId, filename: c.filename, sourceInFrames: c.sourceInFrames, sourceOutFrames: c.sourceOutFrames, timelineInFrames: c.timelineInFrames, timelineOutFrames: c.timelineOutFrames, trackIndex: c.trackIndex });
|
||||||
// Add BullMQ job to trim queue
|
await pool.query(`INSERT INTO temp_segments (job_id, clip_instance_id, asset_id, s3_key, expires_at) VALUES ($1,$2,$3,'',$4)`, [jobId, clipInstanceId, c.assetId, expiresAt]);
|
||||||
await trimQueue.add('trim-clip', {
|
|
||||||
jobId,
|
|
||||||
clipInstanceId,
|
|
||||||
assetId: c.assetId,
|
|
||||||
filename: c.filename,
|
|
||||||
sourceInFrames: c.sourceInFrames,
|
|
||||||
sourceOutFrames: c.sourceOutFrames,
|
|
||||||
timelineInFrames: c.timelineInFrames,
|
|
||||||
timelineOutFrames: c.timelineOutFrames,
|
|
||||||
trackIndex: c.trackIndex,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Create temp_segment record (s3_key will be set by the worker)
|
|
||||||
await pool.query(
|
|
||||||
`INSERT INTO temp_segments (job_id, clip_instance_id, asset_id, s3_key, expires_at)
|
|
||||||
VALUES ($1, $2, $3, '', $4)`,
|
|
||||||
[jobId, clipInstanceId, c.assetId, expiresAt]
|
|
||||||
);
|
|
||||||
|
|
||||||
clipResults.push({ clipInstanceId, status: 'queued' });
|
clipResults.push({ clipInstanceId, status: 'queued' });
|
||||||
}
|
}
|
||||||
|
|
||||||
res.status(201).json({ jobId, clips: clipResults });
|
res.status(201).json({ jobId, clips: clipResults });
|
||||||
} catch (err) {
|
} catch (err) { next(err); }
|
||||||
next(err);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// GET /trim-status/:jobId — Get the status of all clips in a batch trim job.
|
// GET /trim-status/:jobId
|
||||||
router.get('/trim-status/:jobId', async (req, res, next) => {
|
router.get('/trim-status/:jobId', async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const { jobId } = req.params;
|
const { jobId } = req.params;
|
||||||
|
|
||||||
const jobResult = await pool.query('SELECT * FROM jobs WHERE id = $1', [jobId]);
|
const jobResult = await pool.query('SELECT * FROM jobs WHERE id = $1', [jobId]);
|
||||||
if (jobResult.rows.length === 0) {
|
if (jobResult.rows.length === 0) return res.status(404).json({ error: 'Trim job not found' });
|
||||||
return res.status(404).json({ error: 'Trim job not found' });
|
|
||||||
}
|
|
||||||
const job = jobResult.rows[0];
|
const job = jobResult.rows[0];
|
||||||
|
const segResult = await pool.query(`SELECT clip_instance_id, asset_id, s3_key, expires_at FROM temp_segments WHERE job_id = $1 ORDER BY created_at`, [jobId]);
|
||||||
const segResult = await pool.query(
|
const clips = segResult.rows.map(row => ({ clipInstanceId: row.clip_instance_id, assetId: row.asset_id, s3Key: row.s3_key || null, status: row.s3_key ? 'completed' : job.status, expiresAt: row.expires_at }));
|
||||||
`SELECT clip_instance_id, asset_id, s3_key, expires_at
|
|
||||||
FROM temp_segments WHERE job_id = $1 ORDER BY created_at`,
|
|
||||||
[jobId]
|
|
||||||
);
|
|
||||||
|
|
||||||
const clips = segResult.rows.map(row => ({
|
|
||||||
clipInstanceId: row.clip_instance_id,
|
|
||||||
assetId: row.asset_id,
|
|
||||||
s3Key: row.s3_key || null,
|
|
||||||
status: row.s3_key ? 'completed' : job.status,
|
|
||||||
expiresAt: row.expires_at,
|
|
||||||
}));
|
|
||||||
|
|
||||||
res.json({ jobId, status: job.status, clips });
|
res.json({ jobId, status: job.status, clips });
|
||||||
} catch (err) {
|
} catch (err) { next(err); }
|
||||||
next(err);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// GET /temp-segment-url/:clipInstanceId - Get signed URL for a temp segment
|
// GET /temp-segment-url/:clipInstanceId
|
||||||
router.get('/temp-segment-url/:clipInstanceId', async (req, res, next) => {
|
router.get('/temp-segment-url/:clipInstanceId', async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const { clipInstanceId } = req.params;
|
const { clipInstanceId } = req.params;
|
||||||
const result = await pool.query(
|
const result = await pool.query('SELECT s3_key FROM temp_segments WHERE clip_instance_id = $1 AND expires_at > NOW()', [clipInstanceId]);
|
||||||
'SELECT s3_key FROM temp_segments WHERE clip_instance_id = $1 AND expires_at > NOW()',
|
if (result.rows.length === 0) return res.status(404).json({ error: 'Temp segment not found or expired' });
|
||||||
[clipInstanceId]
|
|
||||||
);
|
|
||||||
if (result.rows.length === 0) {
|
|
||||||
return res.status(404).json({ error: 'Temp segment not found or expired' });
|
|
||||||
}
|
|
||||||
const { s3_key } = result.rows[0];
|
const { s3_key } = result.rows[0];
|
||||||
if (!s3_key) {
|
if (!s3_key) return res.status(404).json({ error: 'Segment not yet processed' });
|
||||||
return res.status(404).json({ error: 'Segment not yet processed' });
|
|
||||||
}
|
|
||||||
const url = await getSignedUrlForObject(s3_key);
|
const url = await getSignedUrlForObject(s3_key);
|
||||||
res.json({ url, s3Key: s3_key });
|
res.json({ url, s3Key: s3_key });
|
||||||
} catch (err) {
|
} catch (err) { next(err); }
|
||||||
next(err);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue