fix: add POST /assets handler for capture registration + thumbnail job dispatch

This commit is contained in:
Zac Gaetano 2026-05-15 21:24:02 -04:00
parent cd0c724bdd
commit db73235149

View file

@ -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);