2026-04-07 21:58:27 -04:00
|
|
|
|
import express from 'express';
|
|
|
|
|
|
import pool from '../db/pool.js';
|
|
|
|
|
|
import { requireAuth } from '../middleware/auth.js';
|
|
|
|
|
|
import { Queue } from 'bullmq';
|
|
|
|
|
|
|
|
|
|
|
|
const router = express.Router();
|
|
|
|
|
|
router.use(requireAuth);
|
|
|
|
|
|
|
2026-05-16 17:38:53 -04:00
|
|
|
|
// ── Redis connection ──────────────────────────────────────────────────────────
|
2026-05-16 00:30:26 -04:00
|
|
|
|
const parseRedisUrl = (url) => {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const parsed = new URL(url);
|
|
|
|
|
|
return { host: parsed.hostname, port: parseInt(parsed.port, 10) || 6379 };
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
return { host: 'localhost', port: 6379 };
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-05-16 17:38:53 -04:00
|
|
|
|
const redisConn = parseRedisUrl(process.env.REDIS_URL || 'redis://queue:6379');
|
|
|
|
|
|
|
|
|
|
|
|
const proxyQueue = new Queue('proxy', { connection: redisConn });
|
|
|
|
|
|
const thumbnailQueue = new Queue('thumbnail', { connection: redisConn });
|
|
|
|
|
|
const conformQueue = new Queue('conform', { connection: redisConn });
|
2026-05-23 16:05:41 -04:00
|
|
|
|
const importQueue = new Queue('import', { connection: redisConn });
|
2026-05-16 17:38:53 -04:00
|
|
|
|
|
|
|
|
|
|
const QUEUES = [
|
|
|
|
|
|
{ queue: proxyQueue, type: 'proxy' },
|
|
|
|
|
|
{ queue: thumbnailQueue, type: 'thumbnail' },
|
|
|
|
|
|
{ queue: conformQueue, type: 'conform' },
|
2026-05-23 16:05:41 -04:00
|
|
|
|
{ queue: importQueue, type: 'import' },
|
2026-05-16 17:38:53 -04:00
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
|
|
// BullMQ state → API status mapping
|
|
|
|
|
|
const STATE_MAP = {
|
2026-05-19 23:09:47 -04:00
|
|
|
|
waiting: 'waiting',
|
|
|
|
|
|
active: 'active',
|
|
|
|
|
|
completed: 'completed',
|
|
|
|
|
|
failed: 'failed',
|
|
|
|
|
|
delayed: 'waiting',
|
|
|
|
|
|
paused: 'waiting',
|
2026-05-16 17:38:53 -04:00
|
|
|
|
};
|
2026-04-07 21:58:27 -04:00
|
|
|
|
|
2026-05-19 23:09:47 -04:00
|
|
|
|
// Ordered state buckets used for bulk fetch — avoids N+1 getState() calls.
|
|
|
|
|
|
const STATE_BUCKETS = ['active', 'waiting', 'completed', 'failed', 'delayed', 'paused'];
|
|
|
|
|
|
|
2026-05-16 17:38:53 -04:00
|
|
|
|
function normalizeJob(bullJob, type, apiStatus) {
|
|
|
|
|
|
const isCompleted = apiStatus === 'completed';
|
|
|
|
|
|
const isFailed = apiStatus === 'failed';
|
|
|
|
|
|
return {
|
|
|
|
|
|
id: `${type}:${bullJob.id}`,
|
|
|
|
|
|
type,
|
|
|
|
|
|
status: apiStatus,
|
|
|
|
|
|
progress: typeof bullJob.progress === 'number' ? bullJob.progress : 0,
|
|
|
|
|
|
asset_id: bullJob.data?.assetId || null,
|
|
|
|
|
|
asset_name: bullJob.data?.assetName || null,
|
|
|
|
|
|
created_at: bullJob.timestamp ? new Date(bullJob.timestamp).toISOString() : null,
|
|
|
|
|
|
started_at: bullJob.processedOn ? new Date(bullJob.processedOn).toISOString() : null,
|
|
|
|
|
|
completed_at: isCompleted && bullJob.finishedOn ? new Date(bullJob.finishedOn).toISOString() : null,
|
|
|
|
|
|
failed_at: isFailed && bullJob.finishedOn ? new Date(bullJob.finishedOn).toISOString() : null,
|
|
|
|
|
|
error: bullJob.failedReason || null,
|
|
|
|
|
|
metadata: bullJob.data || {},
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-19 23:09:47 -04:00
|
|
|
|
// Fetch all jobs from all queues in bulk by state bucket (no per-job getState() calls).
|
2026-05-16 17:38:53 -04:00
|
|
|
|
async function getAllBullMQJobs() {
|
|
|
|
|
|
const results = [];
|
|
|
|
|
|
for (const { queue, type } of QUEUES) {
|
2026-05-19 23:09:47 -04:00
|
|
|
|
for (const bucket of STATE_BUCKETS) {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const apiStatus = STATE_MAP[bucket] || bucket;
|
|
|
|
|
|
const jobs = await queue.getJobs([bucket], 0, 200);
|
|
|
|
|
|
for (const job of jobs) {
|
|
|
|
|
|
results.push(normalizeJob(job, type, apiStatus));
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
// queue or bucket unavailable — skip
|
2026-05-16 17:38:53 -04:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return results;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-23 16:23:23 -04:00
|
|
|
|
// Mutate `jobs` in place to fill in asset_name from the assets table for any
|
|
|
|
|
|
// job that has an assetId but no inline assetName in its payload. One bulk
|
|
|
|
|
|
// SQL query per refresh — cheap, and means we don't have to remember to pass
|
|
|
|
|
|
// assetName at every enqueue site (upload.js, capture stop, scheduler, etc.).
|
|
|
|
|
|
async function attachAssetNames(jobs) {
|
|
|
|
|
|
const idsNeedingLookup = [...new Set(
|
|
|
|
|
|
jobs.filter(j => j.asset_id && !j.asset_name).map(j => j.asset_id)
|
|
|
|
|
|
)];
|
|
|
|
|
|
if (idsNeedingLookup.length === 0) return;
|
|
|
|
|
|
|
|
|
|
|
|
let rows = [];
|
|
|
|
|
|
try {
|
|
|
|
|
|
const result = await pool.query(
|
|
|
|
|
|
'SELECT id, display_name, filename FROM assets WHERE id = ANY($1::uuid[])',
|
|
|
|
|
|
[idsNeedingLookup]
|
|
|
|
|
|
);
|
|
|
|
|
|
rows = result.rows;
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
// If the lookup fails (DB down, bad UUID in a stale BullMQ payload), keep
|
|
|
|
|
|
// serving jobs without names rather than 500-ing the whole list.
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
const byId = new Map(rows.map(r => [r.id, r.display_name || r.filename]));
|
|
|
|
|
|
for (const j of jobs) {
|
|
|
|
|
|
if (j.asset_id && !j.asset_name) {
|
|
|
|
|
|
const name = byId.get(j.asset_id);
|
|
|
|
|
|
if (name) j.asset_name = name;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-19 23:09:47 -04:00
|
|
|
|
// ── GET /events – Server-Sent Events stream of live job updates ───────────────
|
|
|
|
|
|
router.get('/events', async (req, res) => {
|
|
|
|
|
|
res.setHeader('Content-Type', 'text/event-stream');
|
|
|
|
|
|
res.setHeader('Cache-Control', 'no-cache');
|
|
|
|
|
|
res.setHeader('Connection', 'keep-alive');
|
2026-05-22 12:18:53 -04:00
|
|
|
|
res.setHeader('X-Accel-Buffering', 'no');
|
2026-05-19 23:09:47 -04:00
|
|
|
|
res.flushHeaders();
|
|
|
|
|
|
|
|
|
|
|
|
let closed = false;
|
|
|
|
|
|
req.on('close', () => { closed = true; });
|
|
|
|
|
|
|
|
|
|
|
|
const push = async () => {
|
|
|
|
|
|
if (closed) return;
|
|
|
|
|
|
try {
|
|
|
|
|
|
const jobs = await getAllBullMQJobs();
|
2026-05-23 16:23:23 -04:00
|
|
|
|
await attachAssetNames(jobs);
|
2026-05-19 23:09:47 -04:00
|
|
|
|
if (!closed) res.write(`data: ${JSON.stringify({ type: 'jobs', jobs })}\n\n`);
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
if (!closed) res.write(`data: ${JSON.stringify({ type: 'error', message: err.message })}\n\n`);
|
|
|
|
|
|
}
|
|
|
|
|
|
if (!closed) setTimeout(push, 2000);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
await push();
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-05-16 17:38:53 -04:00
|
|
|
|
// ── GET / - List jobs (BullMQ queues) ────────────────────────────────────────
|
2026-04-07 21:58:27 -04:00
|
|
|
|
router.get('/', async (req, res, next) => {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const { type, status, asset_id } = req.query;
|
2026-05-16 17:38:53 -04:00
|
|
|
|
let jobs = await getAllBullMQJobs();
|
2026-05-23 16:23:23 -04:00
|
|
|
|
await attachAssetNames(jobs);
|
2026-04-07 21:58:27 -04:00
|
|
|
|
|
2026-05-16 17:38:53 -04:00
|
|
|
|
if (type) jobs = jobs.filter(j => j.type === type);
|
|
|
|
|
|
if (status) jobs = jobs.filter(j => j.status === status);
|
|
|
|
|
|
if (asset_id) jobs = jobs.filter(j => j.asset_id === asset_id);
|
2026-04-07 21:58:27 -04:00
|
|
|
|
|
2026-05-16 17:38:53 -04:00
|
|
|
|
jobs.sort((a, b) => new Date(b.created_at || 0) - new Date(a.created_at || 0));
|
|
|
|
|
|
res.json(jobs);
|
2026-04-07 21:58:27 -04:00
|
|
|
|
} catch (err) {
|
|
|
|
|
|
next(err);
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-05-16 17:38:53 -04:00
|
|
|
|
// ── GET /:id - Single job ─────────────────────────────────────────────────────
|
2026-04-07 21:58:27 -04:00
|
|
|
|
router.get('/:id', async (req, res, next) => {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const { id } = req.params;
|
2026-05-16 17:38:53 -04:00
|
|
|
|
const colonIdx = id.indexOf(':');
|
|
|
|
|
|
const qType = colonIdx > -1 ? id.slice(0, colonIdx) : null;
|
|
|
|
|
|
const bullId = colonIdx > -1 ? id.slice(colonIdx + 1) : id;
|
|
|
|
|
|
|
|
|
|
|
|
for (const { queue, type } of QUEUES) {
|
|
|
|
|
|
if (qType && type !== qType) continue;
|
|
|
|
|
|
try {
|
|
|
|
|
|
const job = await queue.getJob(bullId);
|
|
|
|
|
|
if (job) {
|
|
|
|
|
|
const state = await job.getState();
|
|
|
|
|
|
const apiStatus = STATE_MAP[state] || state;
|
2026-05-23 16:23:23 -04:00
|
|
|
|
const normalized = normalizeJob(job, type, apiStatus);
|
|
|
|
|
|
await attachAssetNames([normalized]);
|
|
|
|
|
|
return res.json(normalized);
|
2026-05-16 17:38:53 -04:00
|
|
|
|
}
|
|
|
|
|
|
} catch { /* try next queue */ }
|
2026-04-07 21:58:27 -04:00
|
|
|
|
}
|
2026-05-16 17:38:53 -04:00
|
|
|
|
res.status(404).json({ error: 'Job not found' });
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
next(err);
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
2026-04-07 21:58:27 -04:00
|
|
|
|
|
2026-05-22 12:18:53 -04:00
|
|
|
|
// ── POST /:id/retry - Retry a failed job ──────────────────────────────────────
|
|
|
|
|
|
router.post('/:id/retry', async (req, res, next) => {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const { id } = req.params;
|
|
|
|
|
|
const colonIdx = id.indexOf(':');
|
|
|
|
|
|
const qType = colonIdx > -1 ? id.slice(0, colonIdx) : null;
|
|
|
|
|
|
const bullId = colonIdx > -1 ? id.slice(colonIdx + 1) : id;
|
|
|
|
|
|
|
|
|
|
|
|
for (const { queue, type } of QUEUES) {
|
|
|
|
|
|
if (qType && type !== qType) continue;
|
|
|
|
|
|
try {
|
|
|
|
|
|
const job = await queue.getJob(bullId);
|
|
|
|
|
|
if (job) {
|
|
|
|
|
|
await job.retry();
|
|
|
|
|
|
return res.json({ id, status: 'queued' });
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch { /* try next queue */ }
|
|
|
|
|
|
}
|
|
|
|
|
|
res.status(404).json({ error: 'Job not found' });
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
next(err);
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-05-16 17:38:53 -04:00
|
|
|
|
// ── DELETE /:id - Remove a job ────────────────────────────────────────────────
|
|
|
|
|
|
router.delete('/:id', async (req, res, next) => {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const { id } = req.params;
|
|
|
|
|
|
const colonIdx = id.indexOf(':');
|
|
|
|
|
|
const qType = colonIdx > -1 ? id.slice(0, colonIdx) : null;
|
|
|
|
|
|
const bullId = colonIdx > -1 ? id.slice(colonIdx + 1) : id;
|
|
|
|
|
|
|
|
|
|
|
|
for (const { queue, type } of QUEUES) {
|
|
|
|
|
|
if (qType && type !== qType) continue;
|
|
|
|
|
|
try {
|
|
|
|
|
|
const job = await queue.getJob(bullId);
|
|
|
|
|
|
if (job) {
|
|
|
|
|
|
await job.remove();
|
|
|
|
|
|
return res.json({ success: true });
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch { /* try next queue */ }
|
|
|
|
|
|
}
|
|
|
|
|
|
res.status(404).json({ error: 'Job not found' });
|
2026-04-07 21:58:27 -04:00
|
|
|
|
} catch (err) {
|
|
|
|
|
|
next(err);
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-05-18 23:22:14 -04:00
|
|
|
|
// ── POST /conform - Submit a conform (EDL export) job ────────────────────────
|
2026-04-07 21:58:27 -04:00
|
|
|
|
router.post('/conform', async (req, res, next) => {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const { edl, project_id, output_format } = req.body;
|
|
|
|
|
|
|
|
|
|
|
|
if (!edl || !project_id || !output_format) {
|
|
|
|
|
|
return res.status(400).json({
|
|
|
|
|
|
error: 'edl, project_id, and output_format are required',
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-18 23:22:14 -04:00
|
|
|
|
const bullJob = await conformQueue.add('conform-task', {
|
2026-04-07 21:58:27 -04:00
|
|
|
|
edl,
|
2026-05-18 23:22:14 -04:00
|
|
|
|
projectId: project_id,
|
2026-05-16 00:46:45 -04:00
|
|
|
|
outputFormat: output_format,
|
2026-04-07 21:58:27 -04:00
|
|
|
|
});
|
|
|
|
|
|
|
2026-05-18 23:22:14 -04:00
|
|
|
|
res.status(202).json({ id: `conform:${bullJob.id}`, status: 'queued' });
|
2026-04-07 21:58:27 -04:00
|
|
|
|
} catch (err) {
|
|
|
|
|
|
next(err);
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
export default router;
|