324 lines
7.9 KiB
JavaScript
324 lines
7.9 KiB
JavaScript
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;
|