diff --git a/services/capture/src/capture-manager.js b/services/capture/src/capture-manager.js index e221b36..703355a 100644 --- a/services/capture/src/capture-manager.js +++ b/services/capture/src/capture-manager.js @@ -392,6 +392,11 @@ class CaptureManager { this.state.sessionId = null; this.state.processes = {}; + // No frames received → the upload (if any) produced a 0-byte object. + // Surface that so the shutdown handler can mark the asset as 'error' + // instead of posting a broken hi-res key downstream. + const framesReceived = this.state.framesReceived; + return { sessionId, projectId: currentSession.projectId, @@ -404,6 +409,8 @@ class CaptureManager { startedAt: currentSession.startedAt, stoppedAt, duration, + framesReceived, + empty: framesReceived === 0, }; } diff --git a/services/capture/src/index.js b/services/capture/src/index.js index c626f18..a56f115 100644 --- a/services/capture/src/index.js +++ b/services/capture/src/index.js @@ -113,31 +113,52 @@ async function gracefulShutdown(signal) { console.log(`[shutdown] stopping active session ${status.sessionId}...`); try { const completed = await captureManager.stop(status.sessionId); - console.log(`[shutdown] session ${completed.sessionId} finalised; duration=${completed.duration}s`); + console.log(`[shutdown] session ${completed.sessionId} finalised; duration=${completed.duration}s frames=${completed.framesReceived}`); - try { - const res = await fetch(`${MAM_API_URL}/api/v1/assets`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - projectId: completed.projectId, - binId: completed.binId, - clipName: completed.clipName, - sourceType: completed.sourceType, - hiresKey: completed.hiresKey, - proxyKey: completed.proxyKey, - needsProxy: completed.proxyKey === null, - duration: completed.duration, - capturedAt: completed.startedAt, - }), - }); - if (!res.ok) { - console.warn(`[shutdown] mam-api /assets returned ${res.status}: ${await res.text()}`); - } else { - console.log('[shutdown] asset registered with mam-api'); + const liveAssetId = process.env.ASSET_ID || null; + + // No frames received → the source never connected (bad SRT URL, dead + // SDI signal, RTMP stream key mismatch, etc.). The S3 upload at this + // point is 0 bytes and would just clog the proxy queue with "moov + // atom not found" failures. Mark the pre-created live asset as + // 'error' and skip the POST /assets registration entirely. + if (completed.empty) { + console.warn('[shutdown] no frames received — marking asset as error and skipping registration'); + if (liveAssetId) { + try { + await fetch(`${MAM_API_URL}/api/v1/assets/${liveAssetId}/mark-empty`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + }); + } catch (e) { + console.error('[shutdown] failed to flag empty asset:', e.message); + } + } + } else { + try { + const res = await fetch(`${MAM_API_URL}/api/v1/assets`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + projectId: completed.projectId, + binId: completed.binId, + clipName: completed.clipName, + sourceType: completed.sourceType, + hiresKey: completed.hiresKey, + proxyKey: completed.proxyKey, + needsProxy: completed.proxyKey === null, + duration: completed.duration, + capturedAt: completed.startedAt, + }), + }); + if (!res.ok) { + console.warn(`[shutdown] mam-api /assets returned ${res.status}: ${await res.text()}`); + } else { + console.log('[shutdown] asset registered with mam-api'); + } + } catch (mamErr) { + console.error('[shutdown] failed to register asset:', mamErr.message); } - } catch (mamErr) { - console.error('[shutdown] failed to register asset:', mamErr.message); } } catch (err) { console.error('[shutdown] error during stop:', err); diff --git a/services/mam-api/src/routes/assets.js b/services/mam-api/src/routes/assets.js index 6078080..f1de1aa 100644 --- a/services/mam-api/src/routes/assets.js +++ b/services/mam-api/src/routes/assets.js @@ -302,6 +302,26 @@ router.post('/:id/copy', async (req, res, next) => { } }); +// POST /:id/mark-empty — flag a pre-created live asset as 'error' because +// the recorder finished a session without any frames (bad source URL, dead +// SDI signal, etc.). Called by the capture sidecar's shutdown handler. +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 }); + } catch (err) { next(err); } +}); + // POST /:id/generate-proxy — re-queue proxy for an asset that has a hi-res // master but no proxy_s3_key. Used to backfill recorder-captured clips that // pre-date the auto-proxy-on-finalize fix. diff --git a/services/web-ui/public/data.jsx b/services/web-ui/public/data.jsx index 3481344..a083d5d 100644 --- a/services/web-ui/public/data.jsx +++ b/services/web-ui/public/data.jsx @@ -173,3 +173,6 @@ async function loadData() { } window.ZAMPP_API = { fetch: apiFetch, loadData, fmtDuration, fmtSize, fmtRelative }; +// Library re-renders after mutations: expose normalizeAsset so the screen +// can re-fetch /assets and produce rows with the same shape as the boot load. +window.normalizeAsset = normalizeAsset; diff --git a/services/web-ui/public/screens-library.jsx b/services/web-ui/public/screens-library.jsx index 5e3655b..5997870 100644 --- a/services/web-ui/public/screens-library.jsx +++ b/services/web-ui/public/screens-library.jsx @@ -1,14 +1,53 @@ // screens-library.jsx function Library({ navigate, onOpenAsset, openProject }) { - const { ASSETS: ALL_ASSETS, BINS, PROJECTS } = window.ZAMPP_DATA; + const { BINS, PROJECTS } = window.ZAMPP_DATA; const [view, setView] = React.useState('grid'); const [filter, setFilter] = React.useState('all'); const [search, setSearch] = React.useState(''); + // Local state lets us re-render after delete / move-to-bin without forcing + // a full app reload — keeps ZAMPP_DATA in sync as the cache of record. + const [allAssets, setAllAssets] = React.useState(window.ZAMPP_DATA.ASSETS || []); + const [ctxMenu, setCtxMenu] = React.useState(null); // { asset, x, y } + + const refreshAssets = React.useCallback(() => { + window.ZAMPP_API.fetch('/assets?limit=500') + .then(r => { + const list = Array.isArray(r) ? r : (r.assets || []); + const proj = {}; + (PROJECTS || []).forEach(p => { proj[p.id] = p.name; }); + const normalized = list.map(a => window.normalizeAsset ? window.normalizeAsset(a, proj) : a); + window.ZAMPP_DATA.ASSETS = normalized; + setAllAssets(normalized); + }) + .catch(() => {}); + }, [PROJECTS]); + + // Dismiss the context menu on any outside click (capture phase so clicking + // a menu item still fires before the menu unmounts). + React.useEffect(() => { + if (!ctxMenu) return; + const close = () => setCtxMenu(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); + }; + }, [ctxMenu]); + + const openCtx = (asset, e) => { + e.preventDefault(); + e.stopPropagation(); + setCtxMenu({ asset, x: e.clientX, y: e.clientY }); + }; let assets = openProject - ? ALL_ASSETS.filter(function(a) { return a.project_id === openProject.id; }) - : ALL_ASSETS; + ? allAssets.filter(function(a) { return a.project_id === openProject.id; }) + : allAssets; + const ALL_ASSETS = allAssets; if (filter !== 'all') assets = assets.filter(function(a) { return a.status === filter; }); if (search) assets = assets.filter(function(a) { return a.name.toLowerCase().includes(search.toLowerCase()); }); @@ -94,7 +133,11 @@ function Library({ navigate, onOpenAsset, openProject }) {