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:
parent
508e978fe5
commit
1afb150237
1 changed files with 79 additions and 4 deletions
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue