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:
parent
602370be26
commit
8e0e94de3d
15 changed files with 383 additions and 70 deletions
37
services/mam-api/src/db/migrations/016-fix-job-type-enum.sql
Normal file
37
services/mam-api/src/db/migrations/016-fix-job-type-enum.sql
Normal 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;
|
||||
|
|
@ -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';
|
||||
|
|
@ -111,6 +111,8 @@ router.post('/', async (req, res, next) => {
|
|||
proxyKey,
|
||||
duration,
|
||||
capturedAt,
|
||||
sourceType, // Bug #64: was ignored — now used to set media_type
|
||||
needsProxy, // Bug #64: was ignored — now controls proxy queue logic
|
||||
} = req.body;
|
||||
|
||||
if (!projectId || !clipName) {
|
||||
|
|
@ -130,25 +132,20 @@ router.post('/', async (req, res, next) => {
|
|||
[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 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();
|
||||
// Bug #64: use sourceType to set media_type (default 'video')
|
||||
const mediaType = (sourceType === 'audio') ? 'audio' : 'video';
|
||||
const ins = await pool.query(
|
||||
`INSERT INTO assets (
|
||||
id, project_id, bin_id,
|
||||
|
|
@ -161,7 +158,7 @@ router.post('/', async (req, res, next) => {
|
|||
VALUES (
|
||||
$1, $2, $3,
|
||||
$4, $4,
|
||||
'processing', 'video',
|
||||
'processing', $9,
|
||||
$5, $6,
|
||||
$7,
|
||||
COALESCE($8::timestamptz, NOW()), NOW()
|
||||
|
|
@ -173,6 +170,7 @@ router.post('/', async (req, res, next) => {
|
|||
hiresKey || null, proxyKey || null,
|
||||
durationMs,
|
||||
capturedAt || null,
|
||||
mediaType,
|
||||
]
|
||||
);
|
||||
asset = ins.rows[0];
|
||||
|
|
@ -180,7 +178,12 @@ router.post('/', async (req, res, next) => {
|
|||
|
||||
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 });
|
||||
} else if (asset.original_s3_key) {
|
||||
const generatedProxyKey = `proxies/${id}.mp4`;
|
||||
|
|
@ -193,11 +196,15 @@ router.post('/', async (req, res, next) => {
|
|||
asset.status = 'ready';
|
||||
}
|
||||
|
||||
res.status(existing.rows.length > 0 ? 200 : 201).json(asset);
|
||||
res.status(201).json(asset);
|
||||
} 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);
|
||||
}
|
||||
});
|
||||
|
||||
// POST /cleanup-live
|
||||
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' });
|
||||
const src = r.rows[0];
|
||||
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(
|
||||
`INSERT INTO assets (
|
||||
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,
|
||||
created_at, updated_at
|
||||
) 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 *`,
|
||||
[
|
||||
newId, projectId || src.project_id,
|
||||
binId === undefined ? src.bin_id : (binId || null),
|
||||
src.filename, src.display_name, src.status, src.media_type,
|
||||
src.original_s3_key, src.proxy_s3_key, src.thumbnail_s3_key,
|
||||
src.filename, src.display_name, 'processing', src.media_type,
|
||||
src.original_s3_key,
|
||||
src.codec, src.resolution, src.fps, src.duration_ms, src.start_tc,
|
||||
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); }
|
||||
});
|
||||
|
||||
|
|
@ -312,16 +335,31 @@ router.post('/:id/copy', async (req, res, next) => {
|
|||
router.post('/:id/mark-empty', async (req, res, next) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const result = await pool.query(
|
||||
// Bug #66: first check the asset exists and what status it is in
|
||||
const check = await pool.query(`SELECT id, status FROM assets WHERE id = $1`, [id]);
|
||||
if (check.rows.length === 0) return res.status(404).json({ error: 'Asset not found' });
|
||||
const current = check.rows[0].status;
|
||||
// Already terminal — nothing to do, return 200 with skipped flag
|
||||
if (current === 'error' || current === 'ready') {
|
||||
return res.status(200).json({ id, skipped: true });
|
||||
}
|
||||
// Allow update for 'live' or 'processing' (race condition on shutdown)
|
||||
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 AND status = 'live' RETURNING id`,
|
||||
WHERE id = $1`,
|
||||
[id]
|
||||
);
|
||||
if (result.rows.length === 0) return res.status(404).json({ error: 'No matching live asset' });
|
||||
res.json({ 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); }
|
||||
});
|
||||
|
||||
|
|
@ -365,6 +403,37 @@ router.post('/backfill-proxies', async (_req, res, next) => {
|
|||
} 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
|
||||
router.post('/:id/retry', async (req, res, next) => {
|
||||
try {
|
||||
|
|
@ -393,6 +462,20 @@ router.delete('/:id', async (req, res, next) => {
|
|||
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];
|
||||
|
||||
// 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 = [];
|
||||
for (const key of [asset.proxy_s3_key, asset.thumbnail_s3_key, asset.original_s3_key]) {
|
||||
if (!key) continue;
|
||||
|
|
@ -420,8 +503,13 @@ router.get('/:id/stream', async (req, res, next) => {
|
|||
const a = r.rows[0];
|
||||
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' });
|
||||
// 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;
|
||||
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 });
|
||||
} 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]);
|
||||
if (r.rows.length === 0) return res.status(404).json({ error: 'Asset not found' });
|
||||
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' });
|
||||
const params = { Bucket: getS3Bucket(), Key: key };
|
||||
const rangeHeader = req.headers.range;
|
||||
|
|
@ -518,7 +608,10 @@ router.post('/batch-trim', async (req, res, next) => {
|
|||
}
|
||||
const jobId = uuidv4();
|
||||
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 = [];
|
||||
for (const c of clips) {
|
||||
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]);
|
||||
if (jobResult.rows.length === 0) return res.status(404).json({ error: 'Trim job not found' });
|
||||
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 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 });
|
||||
|
|
|
|||
|
|
@ -140,11 +140,52 @@ router.get('/events', async (req, res) => {
|
|||
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) => {
|
||||
try {
|
||||
const { type, status, asset_id } = req.query;
|
||||
let jobs = await getAllBullMQJobs();
|
||||
const dbJobs = await getDbJobs();
|
||||
jobs = jobs.concat(dbJobs);
|
||||
await attachAssetNames(jobs);
|
||||
|
||||
if (type) jobs = jobs.filter(j => j.type === type);
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import http from 'http';
|
|||
import net from 'net';
|
||||
import dgram from 'dgram';
|
||||
import pool from '../db/pool.js';
|
||||
import { getS3Bucket } from '../s3/client.js';
|
||||
import { requireAuth } from '../middleware/auth.js';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
|
|
@ -35,6 +36,10 @@ function dockerApi(method, path, body = null) {
|
|||
});
|
||||
});
|
||||
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));
|
||||
req.end();
|
||||
});
|
||||
|
|
@ -297,7 +302,7 @@ router.post('/:id/start', async (req, res, next) => {
|
|||
}
|
||||
|
||||
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 s3SecretKey = process.env.S3_SECRET_KEY;
|
||||
const mamApiUrl = process.env.MAM_API_URL || 'http://mam-api:3000';
|
||||
|
|
|
|||
|
|
@ -15,7 +15,8 @@ import { getAmppConfig, ensureFolderPath } from '../ampp/client.js';
|
|||
const router = express.Router();
|
||||
|
||||
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 parsed = new URL(url);
|
||||
|
|
@ -91,6 +92,20 @@ function mediaTypeFromMime(mime = '') {
|
|||
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
|
||||
router.post('/init', async (req, res, next) => {
|
||||
try {
|
||||
|
|
@ -212,7 +227,10 @@ router.post('/complete', async (req, res, next) => {
|
|||
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);
|
||||
} catch (err) {
|
||||
|
|
@ -289,7 +307,10 @@ router.post('/simple', upload.single('file'), async (req, res, next) => {
|
|||
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);
|
||||
} catch (err) {
|
||||
|
|
|
|||
|
|
@ -34,14 +34,21 @@ let _client = buildClient(_cfg);
|
|||
// rebuildS3Client() replaces the underlying instance.
|
||||
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 ───────────────────
|
||||
export function getS3Bucket() { return _cfg.bucket; }
|
||||
|
||||
// Legacy named export kept for any code that captured it at import time.
|
||||
// Prefer getS3Bucket() in new code.
|
||||
// @deprecated — import binding captures the value at module load time and
|
||||
// will NOT reflect updates after rebuildS3Client(). Use getS3Bucket() instead.
|
||||
export let S3_BUCKET = _cfg.bucket;
|
||||
|
||||
// ── Rebuild — swap in new config at runtime (called by settings API) ─────────
|
||||
|
|
|
|||
|
|
@ -103,6 +103,26 @@ async function tick() {
|
|||
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) {
|
||||
console.error('[scheduler] tick error:', err);
|
||||
} finally {
|
||||
|
|
|
|||
|
|
@ -59,6 +59,8 @@ server {
|
|||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
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_request_buffering off;
|
||||
proxy_connect_timeout 300;
|
||||
|
|
|
|||
|
|
@ -111,6 +111,9 @@
|
|||
justify-content: center;
|
||||
font: 700 15px/1 var(--font);
|
||||
}
|
||||
.brand-icon img {
|
||||
filter: brightness(0) invert(1);
|
||||
}
|
||||
.brand-name {
|
||||
font: 600 15px/1.2 var(--font);
|
||||
letter-spacing: -0.01em;
|
||||
|
|
@ -199,7 +202,7 @@
|
|||
<section class="panel">
|
||||
<div class="brand">
|
||||
<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 class="brand-name">Dragonflight</div>
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@ function AssetDetail({ asset, onClose }) {
|
|||
const [streamLoading, setStreamLoading] = React.useState(false);
|
||||
const [videoDuration, setVideoDuration] = React.useState(0);
|
||||
const [retrying, setRetrying] = React.useState(false);
|
||||
const [reprocessing, setReprocessing] = React.useState(null); // 'proxy' | 'thumbnail' | null
|
||||
const [filmFrames, setFilmFrames] = React.useState([]);
|
||||
const [filmstripLoading, setFilmstripLoading] = React.useState(false);
|
||||
const videoRef = React.useRef(null);
|
||||
|
|
@ -245,6 +246,17 @@ function AssetDetail({ asset, onClose }) {
|
|||
.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
|
||||
// components (PlaybackBar pins, FilmStrip pins, comment list) already expect.
|
||||
function _normalizeComment(row) {
|
||||
|
|
@ -355,6 +367,12 @@ function AssetDetail({ asset, onClose }) {
|
|||
{menuOpen && (
|
||||
<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={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>
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -502,7 +502,12 @@ function Recorders({ navigate, onNew }) {
|
|||
window.ZAMPP_DATA.RECORDERS = 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(() => {
|
||||
|
|
|
|||
|
|
@ -162,8 +162,16 @@ function Library({ navigate, onOpenAsset, openProject, onClearProject }) {
|
|||
React.useEffect(function() {
|
||||
if (!projectCtx) return;
|
||||
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);
|
||||
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]);
|
||||
|
||||
var openProjectCtx = function(p, e) {
|
||||
|
|
@ -539,13 +547,15 @@ function AssetCard({ asset, onOpen, onContextMenu, onDragStart, draggable }) {
|
|||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{/* 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 className="meta">
|
||||
<div className="name">{asset.name}</div>
|
||||
<div className="sub">
|
||||
|
|
|
|||
|
|
@ -117,7 +117,16 @@ function Projects({ onOpenProject, navigate }) {
|
|||
</div>
|
||||
) : view === '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 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 thumbAssets = ofProject.slice(0, 4);
|
||||
|
||||
|
|
@ -210,8 +219,29 @@ function ProjectCard({ project, assets, onOpen }) {
|
|||
const inFlightPct = (inFlight / 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 (
|
||||
<div className="project-card" onClick={onOpen}>
|
||||
<div className="project-card" onClick={onOpen} onContextMenu={handleContextMenu}>
|
||||
<div className="project-thumb-grid">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<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>
|
||||
{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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -245,19 +245,14 @@
|
|||
height: 32px;
|
||||
flex-shrink: 0;
|
||||
object-fit: contain;
|
||||
/* The PNG has a light-gray background. Invert it so the black
|
||||
dragon becomes white-ish, then multiply against the sidebar
|
||||
so the (now-bright) gray background falls back out. Simpler
|
||||
route: a subtle drop-shadow + mix-blend. */
|
||||
mix-blend-mode: screen;
|
||||
filter: drop-shadow(0 0 6px rgba(91, 124, 250, 0.25));
|
||||
/* Convert the dark logo to white so it pops on the dark sidebar.
|
||||
brightness(0) collapses everything to black, invert(1) flips to white.
|
||||
Works on both the original dark PNG and any transparent white PNG. */
|
||||
filter: brightness(0) invert(1) drop-shadow(0 0 6px rgba(91, 124, 250, 0.25));
|
||||
}
|
||||
.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
|
||||
|
|
@ -297,10 +292,9 @@
|
|||
width: 180px;
|
||||
height: 180px;
|
||||
object-fit: contain;
|
||||
/* Drop the gray PNG background — see .brand-logo comment for the
|
||||
trick. Soft accent halo so the silhouette has presence. */
|
||||
mix-blend-mode: screen;
|
||||
/* Convert to white — same approach as .brand-logo. */
|
||||
filter:
|
||||
brightness(0) invert(1)
|
||||
drop-shadow(0 0 24px rgba(91, 124, 250, 0.28))
|
||||
drop-shadow(0 0 48px rgba(91, 124, 250, 0.18));
|
||||
animation: launcherLogoIn 600ms cubic-bezier(0.2, 0.7, 0.2, 1) both;
|
||||
|
|
|
|||
Loading…
Reference in a new issue