From 1afb150237eb2a9e92f062a072cae6a908916950 Mon Sep 17 00:00:00 2001 From: ZGaetano Date: Sat, 23 May 2026 10:28:42 -0400 Subject: [PATCH] feat(assets): cleanup-live-orphans + retry handles non-error states MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two changes for issue #7 (HLS cleanup + orphan reaper) and the user's "SRT clips ingest but won't play" complaint: 1) New POST /assets/cleanup-live-orphans — lists every directory under /live// and deletes the ones whose UUIDs don't match an asset row. These accumulate when a recorder crashes mid-capture: the live HLS dir is created but no asset is ever finalized in the DB, so the files just sit on disk forever. 2) POST /assets/:id/retry now also works for assets that are 'ready' or 'archived' but have no proxy_s3_key. The original behavior (only re-queue when status='error') made it impossible to re-generate a proxy for older recorder captures that landed without one — the user could see a thumbnail in the library but the player would just show "Preview not yet available" with no retry path. --- services/mam-api/src/routes/assets.js | 83 +++++++++++++++++++++++++-- 1 file changed, 79 insertions(+), 4 deletions(-) diff --git a/services/mam-api/src/routes/assets.js b/services/mam-api/src/routes/assets.js index f1de1aa..f5ed248 100644 --- a/services/mam-api/src/routes/assets.js +++ b/services/mam-api/src/routes/assets.js @@ -1,6 +1,8 @@ import express from 'express'; import { Queue } from 'bullmq'; import { v4 as uuidv4 } from 'uuid'; +import { promises as fs } from 'node:fs'; +import path from 'node:path'; import pool from '../db/pool.js'; import { getSignedUrlForObject, deleteObject, s3Client, getS3Bucket } from '../s3/client.js'; import { GetObjectCommand } from '@aws-sdk/client-s3'; @@ -221,6 +223,57 @@ router.post('/cleanup-live', async (req, res, next) => { } }); +// POST /cleanup-live-orphans +// Reaps /live// directories that have no matching asset row in the DB. +// These accumulate when a recorder crashes mid-capture or when an asset row +// is deleted after the HLS dir was created. Closes part of #7. +router.post('/cleanup-live-orphans', async (_req, res, next) => { + try { + const liveRoot = process.env.LIVE_DIR || '/live'; + let entries; + try { + entries = await fs.readdir(liveRoot, { withFileTypes: true }); + } catch (err) { + if (err.code === 'ENOENT') return res.json({ reaped: 0, kept: 0, dirs: [] }); + throw err; + } + const uuidRe = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + + const dirIds = entries + .filter(e => e.isDirectory() && uuidRe.test(e.name)) + .map(e => e.name); + + if (dirIds.length === 0) return res.json({ reaped: 0, kept: 0, dirs: [] }); + + // Find which of those UUIDs correspond to live or in-flight assets. + // We keep dirs that match ANY asset row (regardless of status) so that + // an archived asset's HLS scrubber isn't yanked out from under it. + const known = await pool.query( + 'SELECT id FROM assets WHERE id = ANY($1::uuid[])', + [dirIds] + ); + const keep = new Set(known.rows.map(r => r.id)); + + const reaped = []; + const kept = []; + for (const id of dirIds) { + if (keep.has(id)) { kept.push(id); continue; } + const fullPath = path.join(liveRoot, id); + try { + await fs.rm(fullPath, { recursive: true, force: true }); + reaped.push(id); + console.log(`[assets] reaped orphan live dir ${fullPath}`); + } catch (err) { + console.warn(`[assets] failed to reap ${fullPath}: ${err.message}`); + } + } + + res.json({ reaped: reaped.length, kept: kept.length, dirs: reaped }); + } catch (err) { + next(err); + } +}); + // GET /:id router.get('/:id', async (req, res, next) => { try { @@ -371,15 +424,30 @@ router.post('/backfill-proxies', async (_req, res, next) => { } catch (err) { next(err); } }); -// POST /:id/retry +// POST /:id/retry — re-queue the proxy job. +// +// Originally this only fired for status='error', which meant an archived or +// 'ready'-but-proxy-less asset (e.g. an old recorder capture that never got +// a browser-playable proxy) had no way back. Now we also accept assets that +// have a hi-res source but no proxy_s3_key, regardless of status — the UI +// uses this as the user-facing "Generate proxy" action. router.post('/:id/retry', async (req, res, next) => { try { const { id } = req.params; 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.status !== 'error') return res.status(400).json({ error: `Asset is not in error state (current: ${asset.status})` }); - if (!asset.original_s3_key) return res.status(400).json({ error: 'Asset has no source file to reprocess' }); + if (!asset.original_s3_key) { + return res.status(400).json({ error: 'Asset has no source file to reprocess' }); + } + const canRetry = + asset.status === 'error' || + !asset.proxy_s3_key; // works for 'ready' or 'archived' that lost / never had a proxy + if (!canRetry) { + return res.status(400).json({ + error: `Nothing to retry — asset is ${asset.status} and already has a proxy.`, + }); + } const proxyKey = asset.proxy_s3_key || `proxies/${id}.mp4`; await proxyQueue.add('generate', { assetId: id, inputKey: asset.original_s3_key, outputKey: proxyKey }); const updated = await pool.query( @@ -430,7 +498,14 @@ router.get('/:id/stream', async (req, res, next) => { if (a.proxy_s3_key) return res.json({ url: `/api/v1/assets/${id}/video`, type: 'mp4' }); const orig = a.original_s3_key; if (orig && orig.toLowerCase().endsWith('.mp4')) return res.json({ url: `/api/v1/assets/${id}/video`, type: 'mp4' }); - return res.json({ url: null, type: null, reason: 'no_proxy' }); + // No browser-playable source — let the UI surface a "Generate proxy" + // CTA. has_source tells the UI whether retry is even possible. + return res.json({ + url: null, + type: null, + reason: 'no_proxy', + has_source: !!a.original_s3_key, + }); } catch (err) { next(err); }