import express from 'express'; import { Queue } from 'bullmq'; import { v4 as uuidv4 } from 'uuid'; import pool from '../db/pool.js'; import { getSignedUrlForObject, deleteObject } from '../s3/client.js'; import { requireAuth } from '../middleware/auth.js'; const router = express.Router(); router.use(requireAuth); // BullMQ queue connection (mirrors worker/src/index.js) const parseRedisUrl = (url) => { const parsed = new URL(url); return { host: parsed.hostname, port: parseInt(parsed.port, 10) }; }; const thumbnailQueue = new Queue('thumbnail', { connection: parseRedisUrl(process.env.REDIS_URL || 'redis://queue:6379'), }); // GET / - List assets with filtering router.get('/', async (req, res, next) => { try { const { project_id, bin_id, status, search, media_type, limit = 50, offset = 0, } = req.query; let query = ` SELECT a.*, COUNT(*) OVER() AS full_count FROM assets a WHERE 1=1 `; const params = []; let paramCount = 1; if (project_id) { query += ` AND a.project_id = $${paramCount++}`; params.push(project_id); } if (bin_id) { query += ` AND a.bin_id = $${paramCount++}`; params.push(bin_id); } if (status) { query += ` AND a.status = $${paramCount++}`; params.push(status); } if (media_type) { query += ` AND a.media_type = $${paramCount++}`; params.push(media_type); } if (search) { query += ` AND (a.display_name ILIKE $${paramCount} OR a.notes ILIKE $${paramCount})`; params.push(`%${search}%`); paramCount++; } query += ` ORDER BY a.created_at DESC`; query += ` LIMIT $${paramCount++} OFFSET $${paramCount++}`; params.push(limit, offset); const result = await pool.query(query, params); const total = result.rows.length > 0 ? parseInt(result.rows[0].full_count, 10) : 0; res.json({ assets: result.rows, total, }); } catch (err) { next(err); } }); // 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 { projectId, binId, clipName, hiresKey, proxyKey, duration, // seconds (integer) capturedAt, // ISO 8601 string } = req.body; if (!projectId || !clipName) { return res.status(400).json({ error: 'projectId and clipName are required' }); } const id = uuidv4(); const thumbnailKey = `thumbnails/${id}.jpg`; const durationMs = duration ? Math.round(duration * 1000) : null; const result = await pool.query( `INSERT INTO assets ( id, project_id, bin_id, filename, display_name, status, media_type, original_s3_key, proxy_s3_key, duration_ms, created_at, updated_at ) VALUES ( $1, $2, $3, $4, $4, 'processing', 'video', $5, $6, $7, COALESCE($8::timestamptz, NOW()), NOW() ) RETURNING *`, [ id, projectId, binId || null, clipName, hiresKey || null, proxyKey || null, durationMs, capturedAt || null, ] ); const asset = result.rows[0]; // Dispatch thumbnail job — proxy already in S3 from capture if (proxyKey) { await thumbnailQueue.add('generate', { assetId: id, proxyKey, 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] ); asset.status = 'ready'; } res.status(201).json(asset); } catch (err) { next(err); } }); // GET /:id - Single asset 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' }); } res.json(result.rows[0]); } catch (err) { next(err); } }); // PATCH /:id - Update asset metadata router.patch('/:id', async (req, res, next) => { try { const { id } = req.params; const { display_name, tags, notes } = req.body; const updates = []; const 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 (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' }); } res.json(result.rows[0]); } catch (err) { next(err); } }); // DELETE /:id - Soft or hard delete 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 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); 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 *`, [id] ); if (result.rows.length === 0) { return res.status(404).json({ error: 'Asset not found' }); } res.json(result.rows[0]); } } catch (err) { next(err); } }); // GET /:id/stream - Signed URL for proxy playback router.get('/:id/stream', async (req, res, next) => { try { const { id } = req.params; const result = await pool.query( 'SELECT proxy_s3_key FROM assets WHERE id = $1', [id] ); if (result.rows.length === 0) { return res.status(404).json({ error: 'Asset not found' }); } const { proxy_s3_key } = result.rows[0]; if (!proxy_s3_key) { return res.status(400).json({ error: 'No proxy available for this asset' }); } const url = await getSignedUrlForObject(proxy_s3_key); res.json({ url }); } catch (err) { next(err); } }); // GET /:id/thumbnail - Signed URL for thumbnail image 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 { thumbnail_s3_key } = result.rows[0]; if (!thumbnail_s3_key) { return res.status(404).json({ error: 'Thumbnail not yet available' }); } const url = await getSignedUrlForObject(thumbnail_s3_key); res.json({ url }); } catch (err) { next(err); } }); export default router;