diff --git a/services/mam-api/src/routes/assets.js b/services/mam-api/src/routes/assets.js index 7e37407..841d00d 100644 --- a/services/mam-api/src/routes/assets.js +++ b/services/mam-api/src/routes/assets.js @@ -53,7 +53,8 @@ router.get('/', async (req, res, next) => { const params = []; 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'`; } @@ -116,7 +117,11 @@ router.post('/', async (req, res, next) => { 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( `SELECT * FROM assets @@ -176,30 +181,15 @@ 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, - }); + 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, + 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] - ); + await pool.query(`UPDATE assets SET status = 'ready', updated_at = NOW() WHERE id = $1`, [id]); asset.status = 'ready'; } @@ -214,23 +204,16 @@ router.post('/cleanup-live', async (req, res, next) => { try { const maxAgeHours = Math.max(1, parseInt(req.query.max_age_hours || '4', 10)); const result = await pool.query( - `UPDATE assets - SET status = 'error', updated_at = NOW() - WHERE status = 'live' - AND created_at < NOW() - ($1 * INTERVAL '1 hour') + `UPDATE assets SET status = 'error', updated_at = NOW() + WHERE status = 'live' AND created_at < NOW() - ($1 * INTERVAL '1 hour') RETURNING id, display_name, project_id, created_at`, [maxAgeHours] ); res.json({ cleaned: result.rowCount, assets: result.rows }); - } catch (err) { - next(err); - } + } catch (err) { next(err); } }); // POST /cleanup-live-orphans -// Reaps /live// 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) => { try { const liveRoot = process.env.LIVE_DIR || '/live'; @@ -242,24 +225,11 @@ router.post('/cleanup-live-orphans', async (_req, res, next) => { 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 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: [] }); - - // 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 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 reaped = []; - const kept = []; + const reaped = [], kept = []; for (const id of dirIds) { if (keep.has(id)) { kept.push(id); continue; } 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}`); } } - res.json({ reaped: reaped.length, kept: kept.length, dirs: reaped }); - } catch (err) { - next(err); - } + } catch (err) { next(err); } }); // 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]); if (result.rows.length === 0) return res.status(404).json({ error: 'Asset not found' }); res.json(result.rows[0]); - } catch (err) { - next(err); - } + } catch (err) { next(err); } }); // PATCH /:id @@ -295,30 +260,21 @@ router.patch('/:id', async (req, res, next) => { try { const { id } = req.params; const { display_name, tags, notes, bin_id } = req.body; - - const updates = []; - const params = []; + const updates = [], params = []; let paramCount = 1; - if (display_name !== undefined) { updates.push(`display_name = $${paramCount++}`); params.push(display_name); } if (tags !== undefined) { updates.push(`tags = $${paramCount++}`); params.push(tags); } 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 (updates.length === 0) return res.status(400).json({ error: 'No fields to update' }); - updates.push(`updated_at = NOW()`); params.push(id); - const result = await pool.query( - `UPDATE assets SET ${updates.join(', ')} WHERE id = $${paramCount} RETURNING *`, - params + `UPDATE assets SET ${updates.join(', ')} WHERE id = $${paramCount} RETURNING *`, params ); if (result.rows.length === 0) return res.status(404).json({ error: 'Asset not found' }); res.json(result.rows[0]); - } catch (err) { - next(err); - } + } catch (err) { next(err); } }); // 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, created_at, updated_at ) VALUES ( - $1, $2, $3, $4, $5, - $6, $7, $8, $9, $10, - $11, $12, $13, $14, $15, $16, $17, $18, - NOW(), NOW() + $1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,NOW(),NOW() ) RETURNING *`, [ - newId, - projectId || src.project_id, + newId, projectId || src.project_id, binId === undefined ? src.bin_id : (binId || null), - src.filename, src.display_name, - src.status, src.media_type, + src.filename, src.display_name, src.status, src.media_type, 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.file_size, src.tags, src.notes, ] ); res.status(201).json(ins.rows[0]); - } catch (err) { - next(err); - } + } catch (err) { next(err); } }); -// POST /:id/mark-empty — flag a pre-created live asset as 'error' because -// the recorder finished a session without any frames (bad source URL, dead -// SDI signal, etc.). Called by the capture sidecar's shutdown handler. +// POST /:id/mark-empty router.post('/:id/mark-empty', async (req, res, next) => { try { const { id } = req.params; @@ -370,8 +317,7 @@ router.post('/:id/mark-empty', async (req, res, next) => { SET status = 'error', notes = COALESCE(notes || E'\\n', '') || 'Recording produced no frames — source never connected.', updated_at = NOW() - WHERE id = $1 AND status = 'live' - RETURNING id`, + WHERE id = $1 AND status = 'live' RETURNING id`, [id] ); 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); } }); -// 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. +// POST /:id/generate-proxy router.post('/:id/generate-proxy', async (req, res, next) => { try { 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`; 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] + `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. +// POST /backfill-proxies 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 + 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, - }); + 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)`, + `UPDATE assets SET status = 'processing', updated_at = NOW() WHERE id = ANY($1)`, [targets.rows.map(r => r.id)] ); } @@ -428,40 +365,23 @@ router.post('/backfill-proxies', async (_req, res, next) => { } catch (err) { next(err); } }); -// POST /:id/retry — re-queue the proxy job. -// -// 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. +// POST /:id/retry router.post('/:id/retry', 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 source file to reprocess' }); - } - 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.`, - }); - } + if (!asset.original_s3_key) 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 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] + `UPDATE assets SET status = 'processing', updated_at = NOW() WHERE id = $1 RETURNING *`, [id] ); res.json(updated.rows[0]); - } catch (err) { - next(err); - } + } catch (err) { next(err); } }); // 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]); if (assetResult.rows.length === 0) return res.status(404).json({ error: 'Asset not found' }); const asset = assetResult.rows[0]; - if (asset.proxy_s3_key) await deleteObject(asset.proxy_s3_key); - if (asset.thumbnail_s3_key) await deleteObject(asset.thumbnail_s3_key); - if (asset.original_s3_key) await deleteObject(asset.original_s3_key); + const s3Errors = []; + for (const key of [asset.proxy_s3_key, asset.thumbnail_s3_key, 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]); - res.json({ message: 'Asset deleted permanently' }); + res.json({ message: 'Asset deleted permanently', ...(s3Errors.length ? { s3Errors } : {}) }); } else { const result = await pool.query( - `UPDATE assets SET status = 'archived', updated_at = NOW() WHERE id = $1 RETURNING *`, - [id] + `UPDATE assets SET status = 'archived', updated_at = NOW() WHERE id = $1 RETURNING *`, [id] ); if (result.rows.length === 0) return res.status(404).json({ error: 'Asset not found' }); res.json(result.rows[0]); } - } catch (err) { - next(err); - } + } catch (err) { next(err); } }); // 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' }); const orig = a.original_s3_key; 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" - // CTA. has_source tells the UI whether retry is even possible. - return res.json({ - url: null, - type: null, - reason: 'no_proxy', - has_source: !!a.original_s3_key, - }); - } 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 -// 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) => { try { const { id } = req.params; - const a = await pool.query( - 'SELECT id, project_id, display_name, status FROM assets WHERE id = $1', - [id] - ); + const a = await pool.query('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' }); const asset = a.rows[0]; - - if (asset.status !== 'live') { - 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')` - ); + if (asset.status !== 'live') 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 = {}; for (const { key, value } of s.rows) cfg[key] = value; - if (cfg.growing_enabled !== 'true') { - 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' }); - } - - // Find the recorder driving this asset so we know the right file extension. + if (cfg.growing_enabled !== 'true') 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' }); const rec = await pool.query( - `SELECT recording_container FROM recorders - WHERE current_session_id = $1 - ORDER BY updated_at DESC LIMIT 1`, - [asset.display_name] + `SELECT recording_container FROM recorders WHERE current_session_id = $1 ORDER BY updated_at DESC LIMIT 1`, + [asset.id] ); const ext = rec.rows[0]?.recording_container || 'mov'; - const smbRoot = cfg.growing_smb_url.replace(/\/+$/, ''); - const winPath = smbRoot.replace(/^smb:\/\//, '\\\\').replace(/\//g, '\\') + - `\\${asset.project_id}\\${asset.display_name}.${ext}`; - const posix = smbRoot.replace(/^smb:\/\//, '//') + - `/${asset.project_id}/${asset.display_name}.${ext}`; - 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); - } + const winPath = smbRoot.replace(/^smb:\/\//, '\\\\').replace(/\//g, '\\') + `\\${asset.project_id}\\${asset.display_name}.${ext}`; + const posix = smbRoot.replace(/^smb:\/\//, '//') + `/${asset.project_id}/${asset.display_name}.${ext}`; + 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 @@ -599,17 +477,14 @@ router.get('/:id/video', async (req, res, next) => { router.get('/:id/hires', async (req, res, next) => { try { const { id } = req.params; - const r = await pool.query( - 'SELECT original_s3_key, filename, display_name, file_size FROM assets WHERE id = $1', - [id] - ); + const r = await pool.query('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' }); const a = r.rows[0]; 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 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 ext = (parts.length > 1 ? parts[parts.length - 1] : 'mxf').toLowerCase(); + 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' }); } catch (err) { next(err); } }); @@ -625,127 +500,60 @@ router.get('/:id/thumbnail', async (req, res, next) => { const url = await getSignedUrlForObject(thumbnail_s3_key); if (req.query.redirect === '1') return res.redirect(302, url); res.json({ url }); - } catch (err) { - next(err); - } + } catch (err) { next(err); } }); -// POST /batch-trim — Queue hi-res auto-relink trim jobs for a batch of clips. -// Each clip gets a BullMQ job in the 'trim' queue and a temp_segments row. +// POST /batch-trim router.post('/batch-trim', async (req, res, next) => { try { 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) { if (!c.assetId || !c.filename || - !Number.isFinite(Number(c.sourceInFrames)) || - !Number.isFinite(Number(c.sourceOutFrames)) || - !Number.isFinite(Number(c.timelineInFrames)) || - !Number.isFinite(Number(c.timelineOutFrames)) || + !Number.isFinite(Number(c.sourceInFrames)) || !Number.isFinite(Number(c.sourceOutFrames)) || + !Number.isFinite(Number(c.timelineInFrames)) || !Number.isFinite(Number(c.timelineOutFrames)) || !Number.isInteger(Number(c.trackIndex)) || Number(c.trackIndex) < 0) { - return res.status(400).json({ - error: 'Each clip must have assetId, filename, sourceInFrames, sourceOutFrames, timelineInFrames, timelineOutFrames, and a non-negative integer trackIndex', - }); + return res.status(400).json({ error: 'Each clip must have assetId, filename, sourceInFrames, sourceOutFrames, timelineInFrames, timelineOutFrames, and a non-negative integer trackIndex' }); } } - const jobId = uuidv4(); const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000); - - // 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 })] - ); - + await pool.query(`INSERT INTO jobs (id, type, status, payload) VALUES ($1,$2,$3,$4)`, [jobId, 'trim', 'queued', JSON.stringify({ clips })]); const clipResults = []; for (const c of clips) { const clipInstanceId = uuidv4(); - - // Add BullMQ job to trim queue - 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] - ); - + 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 }); + 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' }); } - res.status(201).json({ jobId, clips: clipResults }); - } catch (err) { - next(err); - } + } catch (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) => { try { const { jobId } = req.params; - const jobResult = await pool.query('SELECT * FROM jobs WHERE id = $1', [jobId]); - if (jobResult.rows.length === 0) { - return res.status(404).json({ error: 'Trim job not found' }); - } + if (jobResult.rows.length === 0) return res.status(404).json({ error: 'Trim job not found' }); 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 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, - })); - + 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 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 }); - } catch (err) { - next(err); - } + } catch (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) => { try { const { clipInstanceId } = req.params; - const result = await pool.query( - 'SELECT s3_key FROM temp_segments WHERE clip_instance_id = $1 AND expires_at > NOW()', - [clipInstanceId] - ); - if (result.rows.length === 0) { - return res.status(404).json({ error: 'Temp segment not found or expired' }); - } + const result = await pool.query('SELECT s3_key FROM temp_segments WHERE clip_instance_id = $1 AND expires_at > NOW()', [clipInstanceId]); + if (result.rows.length === 0) return res.status(404).json({ error: 'Temp segment not found or expired' }); const { s3_key } = result.rows[0]; - if (!s3_key) { - return res.status(404).json({ error: 'Segment not yet processed' }); - } + if (!s3_key) return res.status(404).json({ error: 'Segment not yet processed' }); const url = await getSignedUrlForObject(s3_key); res.json({ url, s3Key: s3_key }); - } catch (err) { - next(err); - } + } catch (err) { next(err); } }); export default router;