fix: add POST /assets handler for capture registration + thumbnail job dispatch
This commit is contained in:
parent
cd0c724bdd
commit
db73235149
1 changed files with 100 additions and 21 deletions
|
|
@ -1,4 +1,6 @@
|
||||||
import express from 'express';
|
import express from 'express';
|
||||||
|
import { Queue } from 'bullmq';
|
||||||
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
import pool from '../db/pool.js';
|
import pool from '../db/pool.js';
|
||||||
import { getSignedUrlForObject, deleteObject } from '../s3/client.js';
|
import { getSignedUrlForObject, deleteObject } from '../s3/client.js';
|
||||||
import { requireAuth } from '../middleware/auth.js';
|
import { requireAuth } from '../middleware/auth.js';
|
||||||
|
|
@ -7,6 +9,16 @@ const router = express.Router();
|
||||||
|
|
||||||
router.use(requireAuth);
|
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
|
// GET / - List assets with filtering
|
||||||
router.get('/', async (req, res, next) => {
|
router.get('/', async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
|
|
@ -60,7 +72,7 @@ router.get('/', async (req, res, next) => {
|
||||||
params.push(limit, offset);
|
params.push(limit, offset);
|
||||||
|
|
||||||
const result = await pool.query(query, params);
|
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({
|
res.json({
|
||||||
assets: result.rows,
|
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
|
// GET /:id - Single asset
|
||||||
router.get('/:id', async (req, res, next) => {
|
router.get('/:id', async (req, res, next) => {
|
||||||
try {
|
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) => {
|
router.patch('/:id', async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
|
|
@ -145,7 +234,6 @@ router.delete('/:id', async (req, res, next) => {
|
||||||
const { hard } = req.query;
|
const { hard } = req.query;
|
||||||
|
|
||||||
if (hard === 'true') {
|
if (hard === 'true') {
|
||||||
// Hard delete: get asset info, delete from S3, delete from DB
|
|
||||||
const assetResult = await pool.query(
|
const assetResult = await pool.query(
|
||||||
'SELECT * FROM assets WHERE id = $1',
|
'SELECT * FROM assets WHERE id = $1',
|
||||||
[id]
|
[id]
|
||||||
|
|
@ -157,26 +245,17 @@ router.delete('/:id', async (req, res, next) => {
|
||||||
|
|
||||||
const asset = assetResult.rows[0];
|
const asset = assetResult.rows[0];
|
||||||
|
|
||||||
// Delete from S3
|
if (asset.proxy_s3_key) await deleteObject(asset.proxy_s3_key);
|
||||||
if (asset.proxy_s3_key) {
|
if (asset.thumbnail_s3_key) await deleteObject(asset.thumbnail_s3_key);
|
||||||
await deleteObject(asset.proxy_s3_key);
|
if (asset.original_s3_key) await deleteObject(asset.original_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]);
|
await pool.query('DELETE FROM assets WHERE id = $1', [id]);
|
||||||
|
|
||||||
res.json({ message: 'Asset deleted permanently' });
|
res.json({ message: 'Asset deleted permanently' });
|
||||||
} else {
|
} else {
|
||||||
// Soft delete: set status to archived
|
|
||||||
const result = await pool.query(
|
const result = await pool.query(
|
||||||
'UPDATE assets SET status = $1, updated_at = NOW() WHERE id = $2 RETURNING *',
|
`UPDATE assets SET status = 'archived', updated_at = NOW()
|
||||||
['archived', id]
|
WHERE id = $1 RETURNING *`,
|
||||||
|
[id]
|
||||||
);
|
);
|
||||||
|
|
||||||
if (result.rows.length === 0) {
|
if (result.rows.length === 0) {
|
||||||
|
|
@ -206,7 +285,7 @@ router.get('/:id/stream', async (req, res, next) => {
|
||||||
const { proxy_s3_key } = result.rows[0];
|
const { proxy_s3_key } = result.rows[0];
|
||||||
|
|
||||||
if (!proxy_s3_key) {
|
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);
|
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) => {
|
router.get('/:id/thumbnail', async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
|
|
@ -232,7 +311,7 @@ router.get('/:id/thumbnail', async (req, res, next) => {
|
||||||
const { thumbnail_s3_key } = result.rows[0];
|
const { thumbnail_s3_key } = result.rows[0];
|
||||||
|
|
||||||
if (!thumbnail_s3_key) {
|
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);
|
const url = await getSignedUrlForObject(thumbnail_s3_key);
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue