diff --git a/services/mam-api/src/routes/assets.js b/services/mam-api/src/routes/assets.js index 768ca53..e476ee4 100644 --- a/services/mam-api/src/routes/assets.js +++ b/services/mam-api/src/routes/assets.js @@ -2,7 +2,7 @@ import express from 'express'; import { Queue } from 'bullmq'; import { v4 as uuidv4 } from 'uuid'; import pool from '../db/pool.js'; -import { getSignedUrlForObject, deleteObject, s3Client, S3_BUCKET } from '../s3/client.js'; +import { getSignedUrlForObject, deleteObject, s3Client, getS3Bucket } from '../s3/client.js'; import { GetObjectCommand } from '@aws-sdk/client-s3'; import { requireAuth } from '../middleware/auth.js'; @@ -47,8 +47,6 @@ router.get('/', async (req, res, next) => { const params = []; let paramCount = 1; - // Hide archived rows unless explicitly asked for, or unless the caller is - // already filtering by status=archived. if (!status && include_archived !== 'true') { query += ` AND a.status <> 'archived'`; } @@ -74,7 +72,6 @@ router.get('/', async (req, res, next) => { } if (search) { - // Search display_name, filename, and notes — filename was previously omitted query += ` AND (a.display_name ILIKE $${paramCount} OR a.filename ILIKE $${paramCount} OR a.notes ILIKE $${paramCount})`; params.push(`%${search}%`); paramCount++; @@ -97,11 +94,6 @@ router.get('/', async (req, res, next) => { }); // POST / - Register a new asset from a completed capture session -// -// Called by the capture service immediately after stop() completes. -// At this point both the HiRes and Proxy files already exist in S3 -// (written by the dual FFmpeg stream), so we set status='processing' -// and immediately dispatch a thumbnail job — no proxy_gen needed. router.post('/', async (req, res, next) => { try { const { @@ -110,8 +102,8 @@ router.post('/', async (req, res, next) => { clipName, hiresKey, proxyKey, - duration, // seconds (integer) - capturedAt, // ISO 8601 string + duration, + capturedAt, } = req.body; if (!projectId || !clipName) { @@ -120,10 +112,6 @@ router.post('/', async (req, res, next) => { const durationMs = duration ? Math.round(duration * 1000) : null; - // Phase 1 growing-files: an asset row may already exist in status='live' - // (pre-created at recorder start so the library shows the recording while - // it is happening). If so we UPDATE that row instead of inserting a new - // one -- otherwise we would have two rows per recording. const existing = await pool.query( `SELECT * FROM assets WHERE project_id = $1 AND display_name = $2 AND status = 'live' @@ -181,8 +169,6 @@ router.post('/', async (req, res, next) => { const thumbnailKey = `thumbnails/${id}.jpg`; - - // Dispatch thumbnail job — proxy already in S3 from capture if (proxyKey) { await thumbnailQueue.add('generate', { assetId: id, @@ -190,7 +176,6 @@ router.post('/', async (req, res, next) => { outputKey: thumbnailKey, }); } else { - // No proxy yet — mark ready immediately (e.g. audio-only or test mode) await pool.query( `UPDATE assets SET status = 'ready', updated_at = NOW() WHERE id = $1`, [id] @@ -204,11 +189,7 @@ router.post('/', async (req, res, next) => { } }); -// POST /cleanup-live – mark stuck 'live' assets as 'error' -// -// Recorder containers that crash without calling the stop callback leave -// assets permanently in 'live' status. This endpoint recovers them. -// Default age threshold: 4 hours. Accepts ?max_age_hours=N override. +// POST /cleanup-live router.post('/cleanup-live', async (req, res, next) => { try { const maxAgeHours = Math.max(1, parseInt(req.query.max_age_hours || '4', 10)); @@ -226,23 +207,19 @@ router.post('/cleanup-live', async (req, res, next) => { } }); -// GET /:id - Single asset +// GET /:id router.get('/:id', async (req, res, next) => { try { const { id } = req.params; 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]); } catch (err) { next(err); } }); -// PATCH /:id - Update asset metadata +// PATCH /:id router.patch('/:id', async (req, res, next) => { try { const { id } = req.params; @@ -252,66 +229,35 @@ router.patch('/:id', async (req, res, next) => { const params = []; 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 (notes !== undefined) { updates.push(`notes = $${paramCount++}`); params.push(notes); } + if (bin_id !== undefined) { updates.push(`bin_id = $${paramCount++}`); params.push(bin_id || null); } - if (tags !== undefined) { - updates.push(`tags = $${paramCount++}`); - params.push(tags); - } - - if (notes !== undefined) { - updates.push(`notes = $${paramCount++}`); - params.push(notes); - } - - if (bin_id !== undefined) { - // Accept null to move the asset back to the project root - 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()`); params.push(id); - const query = ` - UPDATE assets - SET ${updates.join(', ')} - WHERE id = $${paramCount} - RETURNING * - `; - - const result = await pool.query(query, params); - - if (result.rows.length === 0) { - return res.status(404).json({ error: 'Asset not found' }); - } - + const result = await pool.query( + `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); } }); -// POST /:id/copy - Reference-copy an asset into another bin (or project) -// -// Same S3 keys, new asset row. Mirrors filename + metadata. Useful for -// multi-binning a single piece of media without duplicating storage. +// POST /:id/copy router.post('/:id/copy', async (req, res, next) => { try { const { id } = req.params; const { binId, projectId } = req.body; - 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 src = r.rows[0]; - const newId = uuidv4(); const ins = await pool.query( `INSERT INTO assets ( @@ -329,21 +275,11 @@ router.post('/:id/copy', async (req, res, next) => { newId, projectId || src.project_id, binId === undefined ? src.bin_id : (binId || null), - 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, + 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]); @@ -352,81 +288,47 @@ router.post('/:id/copy', async (req, res, next) => { } }); -// POST /:id/retry - Re-queue proxy generation for an asset stuck in error state -// -// Proxy failures leave assets at status='error' with no recovery path from the -// UI. This endpoint re-dispatches the proxy job so the worker chain -// (proxy → thumbnail) runs again without manual DB edits. +// 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.status !== 'error') { - return res.status(400).json({ error: `Asset is not in error state (current: ${asset.status})` }); - } - if (!asset.original_s3_key) { - return res.status(400).json({ error: 'Asset has no source file to reprocess' }); - } - - // Re-use the existing proxy key if one was partially written; otherwise - // construct the canonical key so the worker chain writes to the right place. + if (asset.status !== 'error') return res.status(400).json({ error: `Asset is not in error state (current: ${asset.status})` }); + if (!asset.original_s3_key) return res.status(400).json({ error: 'Asset has no source file to reprocess' }); 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( `UPDATE assets SET status = 'processing', updated_at = NOW() WHERE id = $1 RETURNING *`, [id] ); - res.json(updated.rows[0]); } catch (err) { next(err); } }); -// DELETE /:id - Soft or hard delete +// DELETE /:id router.delete('/:id', async (req, res, next) => { try { const { id } = req.params; const { hard } = req.query; - if (hard === 'true') { - 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 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.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); - + if (asset.original_s3_key) await deleteObject(asset.original_s3_key); await pool.query('DELETE FROM assets WHERE id = $1', [id]); res.json({ message: 'Asset deleted permanently' }); } else { 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] ); - - 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]); } } catch (err) { @@ -434,30 +336,24 @@ router.delete('/:id', async (req, res, next) => { } }); -// GET /:id/stream - URL for proxy playback (relative path, for browser use) +// GET /:id/stream router.get('/:id/stream', 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 a = r.rows[0]; - if (a.status === 'live') { - return res.json({ url: `/live/${a.id}/index.m3u8`, type: 'hls', live: true }); - } - if (a.proxy_s3_key) { - return res.json({ url: `/api/v1/assets/${id}/video`, type: 'mp4' }); - } + if (a.status === 'live') return res.json({ url: `/live/${a.id}/index.m3u8`, type: 'hls', live: true }); + 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' }); - } + if (orig && orig.toLowerCase().endsWith('.mp4')) return res.json({ url: `/api/v1/assets/${id}/video`, type: 'mp4' }); return res.json({ url: null, type: null, reason: 'no_proxy' }); } catch (err) { next(err); } }); -// GET /:id/video - Stream proxy for browser video playback (bypasses S3 direct) +// GET /:id/video router.get('/:id/video', async (req, res, next) => { try { const { id } = req.params; @@ -466,28 +362,20 @@ router.get('/:id/video', async (req, res, next) => { const a = r.rows[0]; const key = a.proxy_s3_key || (a.original_s3_key?.toLowerCase().endsWith('.mp4') ? a.original_s3_key : null); if (!key) return res.status(404).json({ error: 'No browser-playable source' }); - const params = { Bucket: S3_BUCKET, Key: key }; + const params = { Bucket: getS3Bucket(), Key: key }; const rangeHeader = req.headers.range; if (rangeHeader) params.Range = rangeHeader; const s3Res = await s3Client.send(new GetObjectCommand(params)); const status = rangeHeader ? 206 : 200; - const headers = { - 'Content-Type': 'video/mp4', - 'Accept-Ranges': 'bytes', - 'Cache-Control': 'no-store' - }; + const headers = { 'Content-Type': 'video/mp4', 'Accept-Ranges': 'bytes', 'Cache-Control': 'no-store' }; if (s3Res.ContentLength) headers['Content-Length'] = String(s3Res.ContentLength); - if (s3Res.ContentRange) headers['Content-Range'] = s3Res.ContentRange; + if (s3Res.ContentRange) headers['Content-Range'] = s3Res.ContentRange; res.writeHead(status, headers); s3Res.Body.pipe(res); } catch (err) { next(err); } }); -// GET /:id/hires - Presigned S3 URL for the original hi-res source file -// -// Returns a short-lived presigned URL that the Premiere plugin can use to -// download the full-resolution original directly from S3. Also includes -// the derived filename and file_size so the client can show a size warning. +// GET /:id/hires router.get('/:id/hires', async (req, res, next) => { try { const { id } = req.params; @@ -497,9 +385,7 @@ router.get('/:id/hires', async (req, res, next) => { ); 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 for this asset' }); - } + 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 parts = a.original_s3_key.split('.'); const ext = (parts.length > 1 ? parts[parts.length - 1] : 'mxf').toLowerCase(); @@ -508,28 +394,15 @@ router.get('/:id/hires', async (req, res, next) => { } catch (err) { next(err); } }); -// GET /:id/thumbnail - Signed URL (JSON) or 302 redirect (?redirect=1) for thumbnail +// GET /:id/thumbnail router.get('/:id/thumbnail', async (req, res, next) => { try { const { id } = req.params; - const result = await pool.query( - 'SELECT thumbnail_s3_key FROM assets WHERE id = $1', - [id] - ); - - if (result.rows.length === 0) { - return res.status(404).json({ error: 'Asset not found' }); - } - + const result = await pool.query('SELECT thumbnail_s3_key FROM assets WHERE id = $1', [id]); + if (result.rows.length === 0) return res.status(404).json({ error: 'Asset not found' }); const { thumbnail_s3_key } = result.rows[0]; - - if (!thumbnail_s3_key) { - return res.status(404).json({ error: 'Thumbnail not yet available' }); - } - + if (!thumbnail_s3_key) return res.status(404).json({ error: 'Thumbnail not yet available' }); const url = await getSignedUrlForObject(thumbnail_s3_key); - - // ?redirect=1 lets work directly in the browser if (req.query.redirect === '1') return res.redirect(302, url); res.json({ url }); } catch (err) {