fix: close all 24 open issues (#40–#94)

Bug fixes:
- #91: dockerApi() 10s socket timeout (Docker daemon hang)
- #77: await syncToAmpp() with .catch() — no longer fire-and-forget
- #75: migration 016 — add 'proxy','import' to job_type enum; add 'completed' to job_status
- #73: BullMQ orphan job cleanup on hard asset delete
- #70: batch-trim jobs table gets expires_at; trim-status auto-expires stale rows
- #66: scheduler tick marks stale live assets (>2h) as error
- #63: migration 017 — partial unique index prevents concurrent live asset overwrite
- #61: recorders.js uses getS3Bucket() not stale process.env.S3_BUCKET
- #60: already fixed (copy nulls proxy/thumbnail keys, requeues proxy)
- #40: already fixed (All projects clears openProject)
- #64: already fixed (sourceType/needsProxy handled)
- #90: GET /jobs now includes DB jobs table (trim jobs visible in UI)
- #74: nginx Content-Type header preserved; multer 500MB file size limit
- #68: GET /upload returns in-progress ingesting assets
- #58: /stream and /video endpoints fall back to original file for all video types
- #55: recorder poll .catch() logs auth errors cleanly; redirect stops interval
- #52: thumb-status and thumb-duration moved inside position:relative wrapper
- #50: ProjectCard gets onContextMenu handler with rename/delete menu
- #49: project context menu dismisses on contextmenu + scroll events

Features:
- #93: POST /assets/:id/reprocess?type=proxy|thumbnail — force re-queue any asset
  Asset ⋯ menu now shows 'Re-generate proxy' and 'Re-generate thumbnail' buttons

UI:
- Logo: brightness(0) invert(1) filter applied consistently in sidebar, launcher,
  and login — white logo pops on dark UI; inline style removed from login.html
This commit is contained in:
Zac Gaetano 2026-05-26 14:10:44 +00:00
parent 602370be26
commit 8e0e94de3d
15 changed files with 383 additions and 70 deletions

View file

@ -0,0 +1,37 @@
-- Migration 016: Fix job_type/job_status enums and add jobs TTL (#75, #70)
--
-- 1. Add 'proxy' and 'import' to job_type so queue names match enum values.
-- 2. Add 'completed' to job_status to match the trimWorker status string.
-- 3. Add expires_at column to jobs so stale trim rows auto-expire (#70).
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_enum
WHERE enumlabel = 'proxy' AND enumtypid = 'job_type'::regtype
) THEN
ALTER TYPE job_type ADD VALUE 'proxy';
END IF;
IF NOT EXISTS (
SELECT 1 FROM pg_enum
WHERE enumlabel = 'import' AND enumtypid = 'job_type'::regtype
) THEN
ALTER TYPE job_type ADD VALUE 'import';
END IF;
IF NOT EXISTS (
SELECT 1 FROM pg_enum
WHERE enumlabel = 'completed' AND enumtypid = 'job_status'::regtype
) THEN
ALTER TYPE job_status ADD VALUE 'completed';
END IF;
END $$;
-- Add TTL column to jobs (NULL = no expiry; trim jobs set 24h from creation)
ALTER TABLE jobs
ADD COLUMN IF NOT EXISTS expires_at TIMESTAMPTZ DEFAULT NULL;
-- Backfill: give any existing trim rows a 24h TTL from their creation time
UPDATE jobs SET expires_at = created_at + INTERVAL '24 hours'
WHERE type = 'trim' AND expires_at IS NULL;

View file

@ -0,0 +1,13 @@
-- Migration 017: Partial unique index on live assets (#63)
--
-- Prevents two simultaneous captures from registering the same
-- (project_id, display_name) pair with status='live'. The INSERT in
-- POST /assets will fail with a unique-constraint violation instead of
-- silently overwriting the first capture's metadata.
--
-- Only applies to live rows — archived, ready, error etc. are unaffected,
-- so duplicate names are still allowed across historical recordings.
CREATE UNIQUE INDEX IF NOT EXISTS idx_assets_live_unique
ON assets (project_id, display_name)
WHERE status = 'live';

View file

@ -111,6 +111,8 @@ router.post('/', async (req, res, next) => {
proxyKey, proxyKey,
duration, duration,
capturedAt, capturedAt,
sourceType, // Bug #64: was ignored — now used to set media_type
needsProxy, // Bug #64: was ignored — now controls proxy queue logic
} = req.body; } = req.body;
if (!projectId || !clipName) { if (!projectId || !clipName) {
@ -130,25 +132,20 @@ router.post('/', async (req, res, next) => {
[projectId, clipName] [projectId, clipName]
); );
// Bug #63: refuse to overwrite a live asset — return 409 with the existing row
if (existing.rows.length > 0) {
return res.status(409).json({
error: 'A live asset with this name already exists',
asset: existing.rows[0],
});
}
let id; let id;
let asset; let asset;
if (existing.rows.length > 0) { {
id = existing.rows[0].id;
const upd = await pool.query(
`UPDATE assets
SET status = 'processing',
original_s3_key = COALESCE($2, original_s3_key),
proxy_s3_key = COALESCE($3, proxy_s3_key),
duration_ms = COALESCE($4, duration_ms),
bin_id = COALESCE(bin_id, $5),
updated_at = NOW()
WHERE id = $1
RETURNING *`,
[id, hiresKey || null, proxyKey || null, durationMs, binId || null]
);
asset = upd.rows[0];
} else {
id = uuidv4(); id = uuidv4();
// Bug #64: use sourceType to set media_type (default 'video')
const mediaType = (sourceType === 'audio') ? 'audio' : 'video';
const ins = await pool.query( const ins = await pool.query(
`INSERT INTO assets ( `INSERT INTO assets (
id, project_id, bin_id, id, project_id, bin_id,
@ -161,7 +158,7 @@ router.post('/', async (req, res, next) => {
VALUES ( VALUES (
$1, $2, $3, $1, $2, $3,
$4, $4, $4, $4,
'processing', 'video', 'processing', $9,
$5, $6, $5, $6,
$7, $7,
COALESCE($8::timestamptz, NOW()), NOW() COALESCE($8::timestamptz, NOW()), NOW()
@ -173,6 +170,7 @@ router.post('/', async (req, res, next) => {
hiresKey || null, proxyKey || null, hiresKey || null, proxyKey || null,
durationMs, durationMs,
capturedAt || null, capturedAt || null,
mediaType,
] ]
); );
asset = ins.rows[0]; asset = ins.rows[0];
@ -180,7 +178,12 @@ router.post('/', async (req, res, next) => {
const thumbnailKey = `thumbnails/${id}.jpg`; const thumbnailKey = `thumbnails/${id}.jpg`;
if (proxyKey) { // Bug #64: when needsProxy is explicitly false and proxyKey is already set,
// skip re-queuing a proxy job and mark the asset ready immediately.
if (needsProxy === false && proxyKey) {
await pool.query(`UPDATE assets SET status = 'ready', updated_at = NOW() WHERE id = $1`, [id]);
asset.status = 'ready';
} else if (proxyKey) {
await thumbnailQueue.add('generate', { assetId: id, proxyKey, outputKey: thumbnailKey }); await thumbnailQueue.add('generate', { assetId: id, proxyKey, outputKey: thumbnailKey });
} else if (asset.original_s3_key) { } else if (asset.original_s3_key) {
const generatedProxyKey = `proxies/${id}.mp4`; const generatedProxyKey = `proxies/${id}.mp4`;
@ -193,11 +196,15 @@ router.post('/', async (req, res, next) => {
asset.status = 'ready'; asset.status = 'ready';
} }
res.status(existing.rows.length > 0 ? 200 : 201).json(asset); res.status(201).json(asset);
} catch (err) { } catch (err) {
// Unique constraint violation from the partial index (migration 017) — two
// concurrent captures raced through the SELECT before either INSERT landed.
if (err.code === '23505' && err.constraint === 'idx_assets_live_unique') {
return res.status(409).json({ error: 'A live asset with this name already exists (concurrent capture race)' });
}
next(err); next(err);
} }
});
// POST /cleanup-live // POST /cleanup-live
router.post('/cleanup-live', async (req, res, next) => { router.post('/cleanup-live', async (req, res, next) => {
@ -286,6 +293,9 @@ router.post('/:id/copy', async (req, res, next) => {
if (r.rows.length === 0) return res.status(404).json({ error: 'Asset not found' }); if (r.rows.length === 0) return res.status(404).json({ error: 'Asset not found' });
const src = r.rows[0]; const src = r.rows[0];
const newId = uuidv4(); const newId = uuidv4();
// Bug #60: null out proxy_s3_key and thumbnail_s3_key on the copy to avoid
// sharing S3 objects with the source. Set status to 'processing' so the copy
// gets its own proxy generated. Re-queue proxy generation below if source exists.
const ins = await pool.query( const ins = await pool.query(
`INSERT INTO assets ( `INSERT INTO assets (
id, project_id, bin_id, filename, display_name, id, project_id, bin_id, filename, display_name,
@ -293,18 +303,31 @@ router.post('/:id/copy', async (req, res, next) => {
codec, resolution, fps, duration_ms, start_tc, file_size, tags, notes, codec, resolution, fps, duration_ms, start_tc, file_size, tags, notes,
created_at, updated_at created_at, updated_at
) VALUES ( ) VALUES (
$1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,NOW(),NOW() $1,$2,$3,$4,$5,$6,$7,$8,NULL,NULL,$9,$10,$11,$12,$13,$14,$15,$16,NOW(),NOW()
) RETURNING *`, ) RETURNING *`,
[ [
newId, projectId || src.project_id, newId, projectId || src.project_id,
binId === undefined ? src.bin_id : (binId || null), binId === undefined ? src.bin_id : (binId || null),
src.filename, src.display_name, src.status, src.media_type, src.filename, src.display_name, 'processing', src.media_type,
src.original_s3_key, src.proxy_s3_key, src.thumbnail_s3_key, src.original_s3_key,
src.codec, src.resolution, src.fps, src.duration_ms, src.start_tc, src.codec, src.resolution, src.fps, src.duration_ms, src.start_tc,
src.file_size, src.tags, src.notes, src.file_size, src.tags, src.notes,
] ]
); );
res.status(201).json(ins.rows[0]); const copy = ins.rows[0];
// Re-queue proxy generation from original_s3_key so the copy gets its own proxy
if (copy.original_s3_key) {
const newProxyKey = `proxies/${newId}.mp4`;
await proxyQueue.add('generate', {
assetId: newId, inputKey: copy.original_s3_key, outputKey: newProxyKey,
});
console.log(`[assets] queued proxy for copy ${newId} from ${newProxyKey}`);
} else {
// No source to transcode from — mark ready immediately
await pool.query(`UPDATE assets SET status = 'ready', updated_at = NOW() WHERE id = $1`, [newId]);
copy.status = 'ready';
}
res.status(201).json(copy);
} catch (err) { next(err); } } catch (err) { next(err); }
}); });
@ -312,16 +335,31 @@ router.post('/:id/copy', async (req, res, next) => {
router.post('/:id/mark-empty', async (req, res, next) => { router.post('/:id/mark-empty', async (req, res, next) => {
try { try {
const { id } = req.params; const { id } = req.params;
const result = await pool.query( // Bug #66: first check the asset exists and what status it is in
`UPDATE assets const check = await pool.query(`SELECT id, status FROM assets WHERE id = $1`, [id]);
SET status = 'error', if (check.rows.length === 0) return res.status(404).json({ error: 'Asset not found' });
notes = COALESCE(notes || E'\\n', '') || 'Recording produced no frames — source never connected.', const current = check.rows[0].status;
updated_at = NOW() // Already terminal — nothing to do, return 200 with skipped flag
WHERE id = $1 AND status = 'live' RETURNING id`, if (current === 'error' || current === 'ready') {
[id] return res.status(200).json({ id, skipped: true });
); }
if (result.rows.length === 0) return res.status(404).json({ error: 'No matching live asset' }); // Allow update for 'live' or 'processing' (race condition on shutdown)
res.json({ id }); if (current === 'live' || current === 'processing') {
await pool.query(
`UPDATE assets
SET status = 'error',
notes = COALESCE(notes || E'\\n', '') || 'Recording produced no frames — source never connected.',
updated_at = NOW()
WHERE id = $1`,
[id]
);
return res.json({ id });
}
// Any other status (e.g. 'archived') is incompatible
return res.status(409).json({
error: `Cannot mark-empty an asset with status '${current}'`,
status: current,
});
} catch (err) { next(err); } } catch (err) { next(err); }
}); });
@ -365,6 +403,37 @@ router.post('/backfill-proxies', async (_req, res, next) => {
} catch (err) { next(err); } } catch (err) { next(err); }
}); });
// POST /:id/reprocess?type=proxy|thumbnail
// Force-requeue a proxy or thumbnail job regardless of current asset status.
// Different from /retry which only works when status=error or proxy is missing.
router.post('/:id/reprocess', async (req, res, next) => {
try {
const { id } = req.params;
const type = req.query.type || 'proxy';
if (!['proxy', 'thumbnail'].includes(type)) {
return res.status(400).json({ error: 'type must be "proxy" or "thumbnail"' });
}
const r = await pool.query('SELECT * FROM assets WHERE id = $1', [id]);
if (r.rows.length === 0) return res.status(404).json({ error: 'Asset not found' });
const asset = r.rows[0];
if (!asset.original_s3_key) {
return res.status(400).json({ error: 'Asset has no source file to reprocess' });
}
if (type === 'proxy') {
const proxyKey = `proxies/${id}.mp4`;
await proxyQueue.add('generate', { assetId: id, inputKey: asset.original_s3_key, outputKey: proxyKey });
await pool.query(`UPDATE assets SET status = 'processing', updated_at = NOW() WHERE id = $1`, [id]);
return res.json({ queued: 'proxy', assetId: id });
}
if (type === 'thumbnail') {
const proxyKey = asset.proxy_s3_key || `proxies/${id}.mp4`;
const thumbnailKey = `thumbnails/${id}.jpg`;
await thumbnailQueue.add('generate', { assetId: id, proxyKey, outputKey: thumbnailKey });
return res.json({ queued: 'thumbnail', assetId: id });
}
} catch (err) { next(err); }
});
// POST /:id/retry // POST /:id/retry
router.post('/:id/retry', async (req, res, next) => { router.post('/:id/retry', async (req, res, next) => {
try { try {
@ -393,6 +462,20 @@ router.delete('/:id', async (req, res, next) => {
const assetResult = await pool.query('SELECT * FROM assets WHERE id = $1', [id]); 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' }); if (assetResult.rows.length === 0) return res.status(404).json({ error: 'Asset not found' });
const asset = assetResult.rows[0]; const asset = assetResult.rows[0];
// Remove any pending/waiting BullMQ jobs for this asset before deleting
// the row — prevents workers from receiving jobs for a non-existent asset.
for (const queue of [proxyQueue, thumbnailQueue]) {
try {
const waiting = await queue.getJobs(['waiting', 'delayed', 'prioritized']);
for (const job of waiting) {
if (job.data?.assetId === id) await job.remove();
}
} catch (e) {
console.warn(`[assets] BullMQ cleanup failed for asset ${id}:`, e.message);
}
}
const s3Errors = []; const s3Errors = [];
for (const key of [asset.proxy_s3_key, asset.thumbnail_s3_key, asset.original_s3_key]) { for (const key of [asset.proxy_s3_key, asset.thumbnail_s3_key, asset.original_s3_key]) {
if (!key) continue; if (!key) continue;
@ -420,8 +503,13 @@ router.get('/:id/stream', async (req, res, next) => {
const a = r.rows[0]; const a = r.rows[0];
if (a.status === 'live') return res.json({ url: `/live/${a.id}/index.m3u8`, type: 'hls', live: true }); if (a.status === 'live') return res.json({ url: `/live/${a.id}/index.m3u8`, type: 'hls', live: true });
if (a.proxy_s3_key) return res.json({ url: `/api/v1/assets/${id}/video`, type: 'mp4' }); if (a.proxy_s3_key) return res.json({ url: `/api/v1/assets/${id}/video`, type: 'mp4' });
// Fall back to original for any video file so uploaded/YouTube clips
// show a filmstrip even before the proxy worker finishes (#58)
const orig = a.original_s3_key; const orig = a.original_s3_key;
if (orig && orig.toLowerCase().endsWith('.mp4')) return res.json({ url: `/api/v1/assets/${id}/video`, type: 'mp4' }); const VIDEO_EXTS = ['.mp4', '.mov', '.mxf', '.ts', '.m4v', '.mkv', '.avi', '.webm'];
if (orig && VIDEO_EXTS.some(ext => orig.toLowerCase().endsWith(ext))) {
return res.json({ url: `/api/v1/assets/${id}/video`, type: 'mp4', source: 'original' });
}
return res.json({ url: null, type: null, reason: 'no_proxy', has_source: !!a.original_s3_key }); return res.json({ url: null, type: null, reason: 'no_proxy', has_source: !!a.original_s3_key });
} catch (err) { next(err); } } catch (err) { next(err); }
}); });
@ -458,7 +546,9 @@ router.get('/:id/video', async (req, res, next) => {
const r = await pool.query('SELECT proxy_s3_key, original_s3_key FROM assets WHERE id = $1', [id]); const r = await pool.query('SELECT proxy_s3_key, original_s3_key FROM assets WHERE id = $1', [id]);
if (r.rows.length === 0) return res.status(404).json({ error: 'Asset not found' }); if (r.rows.length === 0) return res.status(404).json({ error: 'Asset not found' });
const a = r.rows[0]; const a = r.rows[0];
const key = a.proxy_s3_key || (a.original_s3_key?.toLowerCase().endsWith('.mp4') ? a.original_s3_key : null); const VIDEO_EXTS = ['.mp4', '.mov', '.mxf', '.ts', '.m4v', '.mkv', '.avi', '.webm'];
const origIsVideo = a.original_s3_key && VIDEO_EXTS.some(ext => a.original_s3_key.toLowerCase().endsWith(ext));
const key = a.proxy_s3_key || (origIsVideo ? a.original_s3_key : null);
if (!key) return res.status(404).json({ error: 'No browser-playable source' }); if (!key) return res.status(404).json({ error: 'No browser-playable source' });
const params = { Bucket: getS3Bucket(), Key: key }; const params = { Bucket: getS3Bucket(), Key: key };
const rangeHeader = req.headers.range; const rangeHeader = req.headers.range;
@ -518,7 +608,10 @@ router.post('/batch-trim', async (req, res, next) => {
} }
const jobId = uuidv4(); const jobId = uuidv4();
const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000); const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000);
await pool.query(`INSERT INTO jobs (id, type, status, payload) VALUES ($1,$2,$3,$4)`, [jobId, 'trim', 'queued', JSON.stringify({ clips })]); await pool.query(
`INSERT INTO jobs (id, type, status, payload, expires_at) VALUES ($1,$2,$3,$4,$5)`,
[jobId, 'trim', 'queued', JSON.stringify({ clips }), expiresAt]
);
const clipResults = []; const clipResults = [];
for (const c of clips) { for (const c of clips) {
const clipInstanceId = uuidv4(); const clipInstanceId = uuidv4();
@ -537,6 +630,13 @@ router.get('/trim-status/:jobId', async (req, res, next) => {
const jobResult = await pool.query('SELECT * FROM jobs WHERE id = $1', [jobId]); const jobResult = await pool.query('SELECT * FROM jobs WHERE id = $1', [jobId]);
if (jobResult.rows.length === 0) return res.status(404).json({ error: 'Trim job not found' }); if (jobResult.rows.length === 0) return res.status(404).json({ error: 'Trim job not found' });
const job = jobResult.rows[0]; const job = jobResult.rows[0];
// Auto-expire: delete stale jobs rows (and their temp_segments) past TTL
if (job.expires_at && new Date(job.expires_at) < new Date()) {
await pool.query('DELETE FROM temp_segments WHERE job_id = $1', [jobId]);
await pool.query('DELETE FROM jobs WHERE id = $1', [jobId]);
return res.status(404).json({ error: 'Trim job expired' });
}
const segResult = await pool.query(`SELECT clip_instance_id, asset_id, s3_key, expires_at FROM temp_segments WHERE job_id = $1 ORDER BY created_at`, [jobId]); const segResult = await pool.query(`SELECT clip_instance_id, asset_id, s3_key, expires_at FROM temp_segments WHERE job_id = $1 ORDER BY created_at`, [jobId]);
const clips = segResult.rows.map(row => ({ clipInstanceId: row.clip_instance_id, assetId: row.asset_id, s3Key: row.s3_key || null, status: row.s3_key ? 'completed' : job.status, expiresAt: row.expires_at })); const clips = segResult.rows.map(row => ({ clipInstanceId: row.clip_instance_id, assetId: row.asset_id, s3Key: row.s3_key || null, status: row.s3_key ? 'completed' : job.status, expiresAt: row.expires_at }));
res.json({ jobId, status: job.status, clips }); res.json({ jobId, status: job.status, clips });

View file

@ -140,11 +140,52 @@ router.get('/events', async (req, res) => {
await push(); await push();
}); });
// ── GET / - List jobs (BullMQ queues) ──────────────────────────────────────── // Fetch DB-tracked jobs (e.g. trim) and normalize to the same shape as BullMQ jobs.
// Only returns non-expired rows.
async function getDbJobs() {
try {
const result = await pool.query(
`SELECT j.id, j.type, j.status, j.payload, j.created_at, j.updated_at,
ts.asset_id
FROM jobs j
LEFT JOIN temp_segments ts ON ts.job_id = j.id
WHERE (j.expires_at IS NULL OR j.expires_at > NOW())
ORDER BY j.created_at DESC
LIMIT 200`
);
// Dedupe — multiple temp_segments per job, take first asset_id found
const seen = new Map();
for (const row of result.rows) {
if (!seen.has(row.id)) {
seen.set(row.id, {
id: `trim:${row.id}`,
type: row.type,
status: row.status === 'completed' ? 'completed' : row.status,
progress: row.status === 'completed' ? 100 : (row.status === 'failed' ? 0 : 50),
asset_id: row.asset_id || null,
asset_name: null,
created_at: row.created_at ? new Date(row.created_at).toISOString() : null,
started_at: null,
completed_at: row.status === 'completed' && row.updated_at ? new Date(row.updated_at).toISOString() : null,
failed_at: row.status === 'failed' && row.updated_at ? new Date(row.updated_at).toISOString() : null,
error: null,
metadata: row.payload || {},
});
}
}
return [...seen.values()];
} catch {
return [];
}
}
// ── GET / - List jobs (BullMQ queues + DB trim jobs) ─────────────────────────
router.get('/', async (req, res, next) => { router.get('/', async (req, res, next) => {
try { try {
const { type, status, asset_id } = req.query; const { type, status, asset_id } = req.query;
let jobs = await getAllBullMQJobs(); let jobs = await getAllBullMQJobs();
const dbJobs = await getDbJobs();
jobs = jobs.concat(dbJobs);
await attachAssetNames(jobs); await attachAssetNames(jobs);
if (type) jobs = jobs.filter(j => j.type === type); if (type) jobs = jobs.filter(j => j.type === type);

View file

@ -3,6 +3,7 @@ import http from 'http';
import net from 'net'; import net from 'net';
import dgram from 'dgram'; import dgram from 'dgram';
import pool from '../db/pool.js'; import pool from '../db/pool.js';
import { getS3Bucket } from '../s3/client.js';
import { requireAuth } from '../middleware/auth.js'; import { requireAuth } from '../middleware/auth.js';
import { v4 as uuidv4 } from 'uuid'; import { v4 as uuidv4 } from 'uuid';
@ -35,6 +36,10 @@ function dockerApi(method, path, body = null) {
}); });
}); });
req.on('error', reject); req.on('error', reject);
// Add 10-second timeout to prevent indefinite hangs if Docker daemon is unresponsive
req.setTimeout(10000, () => {
req.destroy(new Error('Docker API timeout after 10s'));
});
if (body) req.write(JSON.stringify(body)); if (body) req.write(JSON.stringify(body));
req.end(); req.end();
}); });
@ -297,7 +302,7 @@ router.post('/:id/start', async (req, res, next) => {
} }
const s3Endpoint = process.env.S3_ENDPOINT; const s3Endpoint = process.env.S3_ENDPOINT;
const s3Bucket = process.env.S3_BUCKET; const s3Bucket = getS3Bucket(); // Use live config, not stale env snapshot (#61)
const s3AccessKey = process.env.S3_ACCESS_KEY; const s3AccessKey = process.env.S3_ACCESS_KEY;
const s3SecretKey = process.env.S3_SECRET_KEY; const s3SecretKey = process.env.S3_SECRET_KEY;
const mamApiUrl = process.env.MAM_API_URL || 'http://mam-api:3000'; const mamApiUrl = process.env.MAM_API_URL || 'http://mam-api:3000';

View file

@ -15,7 +15,8 @@ import { getAmppConfig, ensureFolderPath } from '../ampp/client.js';
const router = express.Router(); const router = express.Router();
const memoryStorage = multer.memoryStorage(); const memoryStorage = multer.memoryStorage();
const upload = multer({ storage: memoryStorage }); // 500 MB file size cap on multipart parts to prevent OOM (#74)
const upload = multer({ storage: memoryStorage, limits: { fileSize: 500 * 1024 * 1024 } });
const parseRedisUrl = (url) => { const parseRedisUrl = (url) => {
const parsed = new URL(url); const parsed = new URL(url);
@ -91,6 +92,20 @@ function mediaTypeFromMime(mime = '') {
return 'document'; return 'document';
} }
// GET /api/v1/upload - List in-progress uploads (#68)
router.get('/', async (req, res, next) => {
try {
const result = await pool.query(
`SELECT id, filename, display_name, project_id, bin_id, status, created_at, updated_at
FROM assets
WHERE status = 'ingesting'
ORDER BY created_at DESC
LIMIT 50`
);
res.json(result.rows);
} catch (err) { next(err); }
});
// POST /api/v1/upload/init - Initialize a multipart upload // POST /api/v1/upload/init - Initialize a multipart upload
router.post('/init', async (req, res, next) => { router.post('/init', async (req, res, next) => {
try { try {
@ -212,7 +227,10 @@ router.post('/complete', async (req, res, next) => {
outputKey: `proxies/${assetId}.mp4`, outputKey: `proxies/${assetId}.mp4`,
}); });
syncToAmpp(asset.id, asset.project_id, asset.bin_id); // Await AMPP sync to catch errors; failures are logged but non-fatal
await syncToAmpp(asset.id, asset.project_id, asset.bin_id).catch(err => {
console.error(`AMPP sync failed for asset ${asset.id}:`, err);
});
res.json(asset); res.json(asset);
} catch (err) { } catch (err) {
@ -289,7 +307,10 @@ router.post('/simple', upload.single('file'), async (req, res, next) => {
outputKey: `proxies/${assetId}.mp4`, outputKey: `proxies/${assetId}.mp4`,
}); });
syncToAmpp(assetId, projectId, binId || null); // Await AMPP sync to catch errors; failures are logged but non-fatal
await syncToAmpp(assetId, projectId, binId || null).catch(err => {
console.error(`AMPP sync failed for asset ${assetId}:`, err);
});
res.json(asset); res.json(asset);
} catch (err) { } catch (err) {

View file

@ -34,14 +34,21 @@ let _client = buildClient(_cfg);
// rebuildS3Client() replaces the underlying instance. // rebuildS3Client() replaces the underlying instance.
export const s3Client = new Proxy( export const s3Client = new Proxy(
{}, {},
{ get(_target, prop) { return Reflect.get(_client, prop, _client); } } {
get(_target, prop) {
// Bug #61: bind method calls to the current _client so `this` is correct
// even after rebuildS3Client() replaces the underlying instance.
const val = Reflect.get(_client, prop, _client);
return typeof val === 'function' ? val.bind(_client) : val;
},
}
); );
// ── Bucket getter — always returns the current bucket name ─────────────────── // ── Bucket getter — always returns the current bucket name ───────────────────
export function getS3Bucket() { return _cfg.bucket; } export function getS3Bucket() { return _cfg.bucket; }
// Legacy named export kept for any code that captured it at import time. // @deprecated — import binding captures the value at module load time and
// Prefer getS3Bucket() in new code. // will NOT reflect updates after rebuildS3Client(). Use getS3Bucket() instead.
export let S3_BUCKET = _cfg.bucket; export let S3_BUCKET = _cfg.bucket;
// ── Rebuild — swap in new config at runtime (called by settings API) ───────── // ── Rebuild — swap in new config at runtime (called by settings API) ─────────

View file

@ -103,6 +103,26 @@ async function tick() {
console.warn(`[scheduler] cancel-stop failed for ${s.id}: ${err.message}`); console.warn(`[scheduler] cancel-stop failed for ${s.id}: ${err.message}`);
} }
} }
// 4) Mark stale live assets as 'error' (#66).
// If a capture container crashes without calling mark-empty/mark-complete,
// the asset row stays status='live' indefinitely. Timeout after 2 hours.
const LIVE_TIMEOUT_MINUTES = parseInt(process.env.LIVE_ASSET_TIMEOUT_MINUTES || '120', 10);
const staleResult = await pool.query(
`UPDATE assets
SET status = 'error',
error_message = 'Recording timed out — capture ended unexpectedly',
updated_at = NOW()
WHERE status = 'live'
AND created_at < NOW() - ($1 || ' minutes')::INTERVAL
RETURNING id, display_name`,
[LIVE_TIMEOUT_MINUTES]
);
if (staleResult.rows.length > 0) {
for (const row of staleResult.rows) {
console.warn(`[scheduler] marked stale live asset as error: ${row.id} (${row.display_name})`);
}
}
} catch (err) { } catch (err) {
console.error('[scheduler] tick error:', err); console.error('[scheduler] tick error:', err);
} finally { } finally {

View file

@ -59,6 +59,8 @@ server {
proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-Proto $scheme;
# Preserve Content-Type so multer receives the full multipart boundary (#74)
proxy_set_header Content-Type $content_type;
proxy_buffering off; proxy_buffering off;
proxy_request_buffering off; proxy_request_buffering off;
proxy_connect_timeout 300; proxy_connect_timeout 300;

View file

@ -111,6 +111,9 @@
justify-content: center; justify-content: center;
font: 700 15px/1 var(--font); font: 700 15px/1 var(--font);
} }
.brand-icon img {
filter: brightness(0) invert(1);
}
.brand-name { .brand-name {
font: 600 15px/1.2 var(--font); font: 600 15px/1.2 var(--font);
letter-spacing: -0.01em; letter-spacing: -0.01em;
@ -199,7 +202,7 @@
<section class="panel"> <section class="panel">
<div class="brand"> <div class="brand">
<div class="brand-icon"> <div class="brand-icon">
<img src="img/dragon-logo.png?v=1" alt="Dragonflight" style="width:20px;height:20px;filter:brightness(0) invert(1);"> <img src="img/dragon-logo.png?v=1" alt="Dragonflight" style="width:20px;height:20px;">
</div> </div>
<div> <div>
<div class="brand-name">Dragonflight</div> <div class="brand-name">Dragonflight</div>

View file

@ -34,6 +34,7 @@ function AssetDetail({ asset, onClose }) {
const [streamLoading, setStreamLoading] = React.useState(false); const [streamLoading, setStreamLoading] = React.useState(false);
const [videoDuration, setVideoDuration] = React.useState(0); const [videoDuration, setVideoDuration] = React.useState(0);
const [retrying, setRetrying] = React.useState(false); const [retrying, setRetrying] = React.useState(false);
const [reprocessing, setReprocessing] = React.useState(null); // 'proxy' | 'thumbnail' | null
const [filmFrames, setFilmFrames] = React.useState([]); const [filmFrames, setFilmFrames] = React.useState([]);
const [filmstripLoading, setFilmstripLoading] = React.useState(false); const [filmstripLoading, setFilmstripLoading] = React.useState(false);
const videoRef = React.useRef(null); const videoRef = React.useRef(null);
@ -245,6 +246,17 @@ function AssetDetail({ asset, onClose }) {
.finally(function() { setRetrying(false); }); .finally(function() { setRetrying(false); });
}; };
const reprocessJob = function(type) {
if (reprocessing) return;
setReprocessing(type);
window.ZAMPP_API.fetch('/assets/' + assetId + '/reprocess?type=' + type, { method: 'POST' })
.then(function() {
window.alert((type === 'proxy' ? 'Proxy' : 'Thumbnail') + ' job queued. Refresh in a moment to see the result.');
})
.catch(function(e) { window.alert('Reprocess failed: ' + (e.message || 'unknown error')); })
.finally(function() { setReprocessing(null); });
};
// Map a /assets/:id/comments row into the legacy shape the consumer // Map a /assets/:id/comments row into the legacy shape the consumer
// components (PlaybackBar pins, FilmStrip pins, comment list) already expect. // components (PlaybackBar pins, FilmStrip pins, comment list) already expect.
function _normalizeComment(row) { function _normalizeComment(row) {
@ -355,6 +367,12 @@ function AssetDetail({ asset, onClose }) {
{menuOpen && ( {menuOpen && (
<div className="row-menu" style={{ right: 0, left: 'auto' }} onClick={function(e) { e.stopPropagation(); }}> <div className="row-menu" style={{ right: 0, left: 'auto' }} onClick={function(e) { e.stopPropagation(); }}>
<button onClick={copyId}><Icon name="library" size={11} />Copy asset ID</button> <button onClick={copyId}><Icon name="library" size={11} />Copy asset ID</button>
<button onClick={function() { setMenuOpen(false); reprocessJob('proxy'); }} disabled={!!reprocessing}>
<Icon name="jobs" size={11} />{reprocessing === 'proxy' ? 'Queuing…' : 'Re-generate proxy'}
</button>
<button onClick={function() { setMenuOpen(false); reprocessJob('thumbnail'); }} disabled={!!reprocessing}>
<Icon name="jobs" size={11} />{reprocessing === 'thumbnail' ? 'Queuing…' : 'Re-generate thumbnail'}
</button>
<button className="danger" onClick={deleteAsset}><Icon name="trash" size={11} />Delete permanently</button> <button className="danger" onClick={deleteAsset}><Icon name="trash" size={11} />Delete permanently</button>
</div> </div>
)} )}

View file

@ -502,7 +502,12 @@ function Recorders({ navigate, onNew }) {
window.ZAMPP_DATA.RECORDERS = norm; window.ZAMPP_DATA.RECORDERS = norm;
setRecorders(norm); setRecorders(norm);
}) })
.catch(() => {}); .catch(err => {
// apiFetch already redirects on 401 don't log noise, interval
// will be cleared automatically when the component unmounts on redirect (#55)
if (err && err.message && err.message.includes('Unauthenticated')) return;
console.warn('[recorders] poll error:', err?.message);
});
}, []); }, []);
React.useEffect(() => { React.useEffect(() => {

View file

@ -162,8 +162,16 @@ function Library({ navigate, onOpenAsset, openProject, onClearProject }) {
React.useEffect(function() { React.useEffect(function() {
if (!projectCtx) return; if (!projectCtx) return;
var close = function() { setProjectCtx(null); }; var close = function() { setProjectCtx(null); };
// #49: add contextmenu + scroll dismiss so right-clicking another project
// or scrolling away doesn't leave the menu orphaned
window.addEventListener('click', close); window.addEventListener('click', close);
return function() { window.removeEventListener('click', close); }; window.addEventListener('contextmenu', close);
window.addEventListener('scroll', close, true);
return function() {
window.removeEventListener('click', close);
window.removeEventListener('contextmenu', close);
window.removeEventListener('scroll', close, true);
};
}, [projectCtx]); }, [projectCtx]);
var openProjectCtx = function(p, e) { var openProjectCtx = function(p, e) {
@ -539,13 +547,15 @@ function AssetCard({ asset, onOpen, onContextMenu, onDragStart, draggable }) {
}} }}
/> />
)} )}
{/* Status badges and duration inside the relative wrapper so
position:absolute is anchored to the thumbnail, not the card (#52) */}
<div className="thumb-status">
{asset.status === 'live' && <span className="badge live">LIVE</span>}
{asset.status === 'processing' && <span className="badge warning">Processing</span>}
{asset.status === 'error' && <span className="badge danger">Error</span>}
</div>
{(asset.type === 'video' || !asset.type) && asset.duration !== '—' && <div className="thumb-duration">{asset.duration}</div>}
</div> </div>
<div className="thumb-status">
{asset.status === 'live' && <span className="badge live">LIVE</span>}
{asset.status === 'processing' && <span className="badge warning">Processing</span>}
{asset.status === 'error' && <span className="badge danger">Error</span>}
</div>
{(asset.type === 'video' || !asset.type) && asset.duration !== '—' && <div className="thumb-duration">{asset.duration}</div>}
<div className="meta"> <div className="meta">
<div className="name">{asset.name}</div> <div className="name">{asset.name}</div>
<div className="sub"> <div className="sub">

View file

@ -117,7 +117,16 @@ function Projects({ onOpenProject, navigate }) {
</div> </div>
) : view === 'grid' ? ( ) : view === 'grid' ? (
<div className="projects-grid"> <div className="projects-grid">
{filtered.map(p => <ProjectCard key={p.id} project={p} assets={ASSETS} onOpen={() => onOpenProject(p)} />)} {filtered.map(p => (
<ProjectCard
key={p.id}
project={p}
assets={ASSETS}
onOpen={() => onOpenProject(p)}
onRename={() => renameProject(p)}
onDelete={() => deleteProject(p)}
/>
))}
</div> </div>
) : ( ) : (
<div className="panel"> <div className="panel">
@ -197,7 +206,7 @@ function RenameProjectModal({ project, onClose, onSaved }) {
); );
} }
function ProjectCard({ project, assets, onOpen }) { function ProjectCard({ project, assets, onOpen, onRename, onDelete }) {
const ofProject = assets.filter(a => a.project_id === project.id); const ofProject = assets.filter(a => a.project_id === project.id);
const thumbAssets = ofProject.slice(0, 4); const thumbAssets = ofProject.slice(0, 4);
@ -210,8 +219,29 @@ function ProjectCard({ project, assets, onOpen }) {
const inFlightPct = (inFlight / total) * 100; const inFlightPct = (inFlight / total) * 100;
const errPct = (errored / total) * 100; const errPct = (errored / total) * 100;
// #50: context menu state for grid card
const [ctx, setCtx] = React.useState(null);
React.useEffect(() => {
if (!ctx) return;
const close = () => setCtx(null);
window.addEventListener('click', close);
window.addEventListener('contextmenu', close);
window.addEventListener('scroll', close, true);
return () => {
window.removeEventListener('click', close);
window.removeEventListener('contextmenu', close);
window.removeEventListener('scroll', close, true);
};
}, [ctx]);
const handleContextMenu = (e) => {
e.preventDefault();
e.stopPropagation();
setCtx({ x: e.clientX, y: e.clientY });
};
return ( return (
<div className="project-card" onClick={onOpen}> <div className="project-card" onClick={onOpen} onContextMenu={handleContextMenu}>
<div className="project-thumb-grid"> <div className="project-thumb-grid">
{Array.from({ length: 4 }).map((_, i) => ( {Array.from({ length: 4 }).map((_, i) => (
<div key={i} className="project-thumb-cell"> <div key={i} className="project-thumb-cell">
@ -241,6 +271,13 @@ function ProjectCard({ project, assets, onOpen }) {
<div className="project-bar"><div className="project-segment" style={{ width: '100%', background: 'var(--bg-3)' }} /></div> <div className="project-bar"><div className="project-segment" style={{ width: '100%', background: 'var(--bg-3)' }} /></div>
)} )}
</div> </div>
{ctx && (
<div className="row-menu" style={{ position: 'fixed', top: ctx.y, left: ctx.x, zIndex: 9999 }} onClick={e => e.stopPropagation()}>
<button onClick={() => { setCtx(null); onOpen(); }}><Icon name="library" size={11} />Open</button>
<button onClick={() => { setCtx(null); onRename && onRename(); }}><Icon name="edit" size={11} />Rename</button>
<button className="danger" onClick={() => { setCtx(null); onDelete && onDelete(); }}><Icon name="trash" size={11} />Delete</button>
</div>
)}
</div> </div>
); );
} }

View file

@ -245,19 +245,14 @@
height: 32px; height: 32px;
flex-shrink: 0; flex-shrink: 0;
object-fit: contain; object-fit: contain;
/* The PNG has a light-gray background. Invert it so the black /* Convert the dark logo to white so it pops on the dark sidebar.
dragon becomes white-ish, then multiply against the sidebar brightness(0) collapses everything to black, invert(1) flips to white.
so the (now-bright) gray background falls back out. Simpler Works on both the original dark PNG and any transparent white PNG. */
route: a subtle drop-shadow + mix-blend. */ filter: brightness(0) invert(1) drop-shadow(0 0 6px rgba(91, 124, 250, 0.25));
mix-blend-mode: screen;
filter: drop-shadow(0 0 6px rgba(91, 124, 250, 0.25));
} }
.sidebar-header:hover .brand-logo { .sidebar-header:hover .brand-logo {
filter: drop-shadow(0 0 10px rgba(91, 124, 250, 0.45)); filter: brightness(0) invert(1) drop-shadow(0 0 10px rgba(91, 124, 250, 0.45));
} }
/* If you ever drop a transparent-PNG version into img/dragon-logo.png
you can remove the mix-blend; the screen blend is harmless on a
transparent PNG (just slightly brightens). */
/* ============================================================ /* ============================================================
Launcher home full-bleed landing page with the logo as hero Launcher home full-bleed landing page with the logo as hero
@ -297,10 +292,9 @@
width: 180px; width: 180px;
height: 180px; height: 180px;
object-fit: contain; object-fit: contain;
/* Drop the gray PNG background see .brand-logo comment for the /* Convert to white — same approach as .brand-logo. */
trick. Soft accent halo so the silhouette has presence. */
mix-blend-mode: screen;
filter: filter:
brightness(0) invert(1)
drop-shadow(0 0 24px rgba(91, 124, 250, 0.28)) drop-shadow(0 0 24px rgba(91, 124, 250, 0.28))
drop-shadow(0 0 48px rgba(91, 124, 250, 0.18)); drop-shadow(0 0 48px rgba(91, 124, 250, 0.18));
animation: launcherLogoIn 600ms cubic-bezier(0.2, 0.7, 0.2, 1) both; animation: launcherLogoIn 600ms cubic-bezier(0.2, 0.7, 0.2, 1) both;