feat(assets): cleanup-live-orphans + retry handles non-error states

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/<uuid>/ 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.
This commit is contained in:
Zac Gaetano 2026-05-23 10:28:42 -04:00
parent 508e978fe5
commit 1afb150237

View file

@ -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/<uuid>/ 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);
}