From 8e0e94de3d44eab2f5f4ebbcff75ab023b6d8cc4 Mon Sep 17 00:00:00 2001 From: ZGaetano Date: Tue, 26 May 2026 14:10:44 +0000 Subject: [PATCH] =?UTF-8?q?fix:=20close=20all=2024=20open=20issues=20(#40?= =?UTF-8?q?=E2=80=93#94)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../db/migrations/016-fix-job-type-enum.sql | 37 ++++ .../017-live-asset-unique-index.sql | 13 ++ services/mam-api/src/routes/assets.js | 174 ++++++++++++++---- services/mam-api/src/routes/jobs.js | 43 ++++- services/mam-api/src/routes/recorders.js | 7 +- services/mam-api/src/routes/upload.js | 27 ++- services/mam-api/src/s3/client.js | 13 +- services/mam-api/src/scheduler.js | 20 ++ services/web-ui/nginx.conf | 2 + services/web-ui/public/login.html | 5 +- services/web-ui/public/screens-asset.jsx | 18 ++ services/web-ui/public/screens-ingest.jsx | 7 +- services/web-ui/public/screens-library.jsx | 24 ++- services/web-ui/public/screens-projects.jsx | 43 ++++- services/web-ui/public/styles-fixes.css | 20 +- 15 files changed, 383 insertions(+), 70 deletions(-) create mode 100644 services/mam-api/src/db/migrations/016-fix-job-type-enum.sql create mode 100644 services/mam-api/src/db/migrations/017-live-asset-unique-index.sql diff --git a/services/mam-api/src/db/migrations/016-fix-job-type-enum.sql b/services/mam-api/src/db/migrations/016-fix-job-type-enum.sql new file mode 100644 index 0000000..4453915 --- /dev/null +++ b/services/mam-api/src/db/migrations/016-fix-job-type-enum.sql @@ -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; diff --git a/services/mam-api/src/db/migrations/017-live-asset-unique-index.sql b/services/mam-api/src/db/migrations/017-live-asset-unique-index.sql new file mode 100644 index 0000000..6c69e2c --- /dev/null +++ b/services/mam-api/src/db/migrations/017-live-asset-unique-index.sql @@ -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'; diff --git a/services/mam-api/src/routes/assets.js b/services/mam-api/src/routes/assets.js index 841d00d..f22fbf9 100644 --- a/services/mam-api/src/routes/assets.js +++ b/services/mam-api/src/routes/assets.js @@ -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( - `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`, - [id] - ); - if (result.rows.length === 0) return res.status(404).json({ error: 'No matching live asset' }); - res.json({ id }); + // 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`, + [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 }); diff --git a/services/mam-api/src/routes/jobs.js b/services/mam-api/src/routes/jobs.js index 0de5201..59402cc 100644 --- a/services/mam-api/src/routes/jobs.js +++ b/services/mam-api/src/routes/jobs.js @@ -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); diff --git a/services/mam-api/src/routes/recorders.js b/services/mam-api/src/routes/recorders.js index 074f8c1..1a575fc 100644 --- a/services/mam-api/src/routes/recorders.js +++ b/services/mam-api/src/routes/recorders.js @@ -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'; diff --git a/services/mam-api/src/routes/upload.js b/services/mam-api/src/routes/upload.js index acc7fe1..3b92c84 100644 --- a/services/mam-api/src/routes/upload.js +++ b/services/mam-api/src/routes/upload.js @@ -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) { diff --git a/services/mam-api/src/s3/client.js b/services/mam-api/src/s3/client.js index b7f8829..5c30a7f 100644 --- a/services/mam-api/src/s3/client.js +++ b/services/mam-api/src/s3/client.js @@ -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) ───────── diff --git a/services/mam-api/src/scheduler.js b/services/mam-api/src/scheduler.js index 5caf3e2..399d2fe 100644 --- a/services/mam-api/src/scheduler.js +++ b/services/mam-api/src/scheduler.js @@ -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 { diff --git a/services/web-ui/nginx.conf b/services/web-ui/nginx.conf index 6a52389..e912487 100644 --- a/services/web-ui/nginx.conf +++ b/services/web-ui/nginx.conf @@ -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; diff --git a/services/web-ui/public/login.html b/services/web-ui/public/login.html index 4436aa3..ea93071 100644 --- a/services/web-ui/public/login.html +++ b/services/web-ui/public/login.html @@ -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 @@
- Dragonflight + Dragonflight
Dragonflight
diff --git a/services/web-ui/public/screens-asset.jsx b/services/web-ui/public/screens-asset.jsx index 9167b86..61b2f8d 100644 --- a/services/web-ui/public/screens-asset.jsx +++ b/services/web-ui/public/screens-asset.jsx @@ -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 && (
+ +
)} diff --git a/services/web-ui/public/screens-ingest.jsx b/services/web-ui/public/screens-ingest.jsx index 6f2c87b..a0e8af9 100644 --- a/services/web-ui/public/screens-ingest.jsx +++ b/services/web-ui/public/screens-ingest.jsx @@ -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(() => { diff --git a/services/web-ui/public/screens-library.jsx b/services/web-ui/public/screens-library.jsx index 9c38a6d..e1b13d4 100644 --- a/services/web-ui/public/screens-library.jsx +++ b/services/web-ui/public/screens-library.jsx @@ -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 }) { }} /> )} + {/* Status badges and duration — inside the relative wrapper so + position:absolute is anchored to the thumbnail, not the card (#52) */} +
+ {asset.status === 'live' && LIVE} + {asset.status === 'processing' && Processing} + {asset.status === 'error' && Error} +
+ {(asset.type === 'video' || !asset.type) && asset.duration !== '—' &&
{asset.duration}
}
-
- {asset.status === 'live' && LIVE} - {asset.status === 'processing' && Processing} - {asset.status === 'error' && Error} -
- {(asset.type === 'video' || !asset.type) && asset.duration !== '—' &&
{asset.duration}
}
{asset.name}
diff --git a/services/web-ui/public/screens-projects.jsx b/services/web-ui/public/screens-projects.jsx index 99d6e30..8447af8 100644 --- a/services/web-ui/public/screens-projects.jsx +++ b/services/web-ui/public/screens-projects.jsx @@ -117,7 +117,16 @@ function Projects({ onOpenProject, navigate }) {
) : view === 'grid' ? (
- {filtered.map(p => onOpenProject(p)} />)} + {filtered.map(p => ( + onOpenProject(p)} + onRename={() => renameProject(p)} + onDelete={() => deleteProject(p)} + /> + ))}
) : (
@@ -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 ( -
+
{Array.from({ length: 4 }).map((_, i) => (
@@ -241,6 +271,13 @@ function ProjectCard({ project, assets, onOpen }) {
)}
+ {ctx && ( +
e.stopPropagation()}> + + + +
+ )}
); } diff --git a/services/web-ui/public/styles-fixes.css b/services/web-ui/public/styles-fixes.css index 9de51b1..615901d 100644 --- a/services/web-ui/public/styles-fixes.css +++ b/services/web-ui/public/styles-fixes.css @@ -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;