diff --git a/services/mam-api/src/routes/assets.js b/services/mam-api/src/routes/assets.js index 9bf1dfd..0459e72 100644 --- a/services/mam-api/src/routes/assets.js +++ b/services/mam-api/src/routes/assets.js @@ -1,4 +1,6 @@ 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'; @@ -7,6 +9,16 @@ 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 { @@ -60,7 +72,7 @@ router.get('/', async (req, res, next) => { params.push(limit, offset); const result = await pool.query(query, params); - const total = result.rows.length > 0 ? result.rows[0].full_count : 0; + const total = result.rows.length > 0 ? parseInt(result.rows[0].full_count, 10) : 0; res.json({ assets: result.rows, @@ -71,6 +83,83 @@ 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 { + 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 { @@ -87,7 +176,7 @@ router.get('/:id', async (req, res, next) => { } }); -// PATCH /:id - Update asset +// PATCH /:id - Update asset metadata router.patch('/:id', async (req, res, next) => { try { const { id } = req.params; @@ -145,7 +234,6 @@ router.delete('/:id', async (req, res, next) => { const { hard } = req.query; if (hard === 'true') { - // Hard delete: get asset info, delete from S3, delete from DB const assetResult = await pool.query( 'SELECT * FROM assets WHERE id = $1', [id] @@ -157,26 +245,17 @@ router.delete('/:id', async (req, res, next) => { const asset = assetResult.rows[0]; - // Delete from S3 - 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.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); - // Delete from database await pool.query('DELETE FROM assets WHERE id = $1', [id]); - res.json({ message: 'Asset deleted permanently' }); } else { - // Soft delete: set status to archived const result = await pool.query( - 'UPDATE assets SET status = $1, updated_at = NOW() WHERE id = $2 RETURNING *', - ['archived', id] + `UPDATE assets SET status = 'archived', updated_at = NOW() + WHERE id = $1 RETURNING *`, + [id] ); if (result.rows.length === 0) { @@ -206,7 +285,7 @@ router.get('/:id/stream', async (req, res, next) => { const { proxy_s3_key } = result.rows[0]; if (!proxy_s3_key) { - return res.status(400).json({ error: 'No proxy available' }); + return res.status(400).json({ error: 'No proxy available for this asset' }); } const url = await getSignedUrlForObject(proxy_s3_key); @@ -216,7 +295,7 @@ router.get('/:id/stream', async (req, res, next) => { } }); -// GET /:id/thumbnail - Signed URL for thumbnail +// GET /:id/thumbnail - Signed URL for thumbnail image router.get('/:id/thumbnail', async (req, res, next) => { try { const { id } = req.params; @@ -232,7 +311,7 @@ router.get('/:id/thumbnail', async (req, res, next) => { const { thumbnail_s3_key } = result.rows[0]; if (!thumbnail_s3_key) { - return res.status(400).json({ error: 'No thumbnail available' }); + return res.status(404).json({ error: 'Thumbnail not yet available' }); } const url = await getSignedUrlForObject(thumbnail_s3_key);