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 express from 'express';
|
||||||
import { Queue } from 'bullmq';
|
import { Queue } from 'bullmq';
|
||||||
import { v4 as uuidv4 } from 'uuid';
|
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 pool from '../db/pool.js';
|
||||||
import { getSignedUrlForObject, deleteObject, s3Client, getS3Bucket } from '../s3/client.js';
|
import { getSignedUrlForObject, deleteObject, s3Client, getS3Bucket } from '../s3/client.js';
|
||||||
import { GetObjectCommand } from '@aws-sdk/client-s3';
|
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
|
// GET /:id
|
||||||
router.get('/:id', async (req, res, next) => {
|
router.get('/:id', async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
|
|
@ -371,15 +424,30 @@ router.post('/backfill-proxies', async (_req, res, next) => {
|
||||||
} catch (err) { next(err); }
|
} 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) => {
|
router.post('/:id/retry', async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
const r = await pool.query('SELECT * FROM assets WHERE id = $1', [id]);
|
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' });
|
if (r.rows.length === 0) return res.status(404).json({ error: 'Asset not found' });
|
||||||
const asset = r.rows[0];
|
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) {
|
||||||
if (!asset.original_s3_key) return res.status(400).json({ error: 'Asset has no source file to reprocess' });
|
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`;
|
const proxyKey = asset.proxy_s3_key || `proxies/${id}.mp4`;
|
||||||
await proxyQueue.add('generate', { assetId: id, inputKey: asset.original_s3_key, outputKey: proxyKey });
|
await proxyQueue.add('generate', { assetId: id, inputKey: asset.original_s3_key, outputKey: proxyKey });
|
||||||
const updated = await pool.query(
|
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' });
|
if (a.proxy_s3_key) return res.json({ url: `/api/v1/assets/${id}/video`, type: 'mp4' });
|
||||||
const orig = a.original_s3_key;
|
const orig = a.original_s3_key;
|
||||||
if (orig && orig.toLowerCase().endsWith('.mp4')) return res.json({ url: `/api/v1/assets/${id}/video`, type: 'mp4' });
|
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) {
|
} catch (err) {
|
||||||
next(err);
|
next(err);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue