openvibers/openvibe.media Commits
No thumbnail retries for what has no picture: a clip whose cut failed answers 404 with no job, and a recording under review that gives no frame fails as media_unavailable (404) instead of a retryable 500 (#190)
Changes
@@@ -53,6 +53,12 @@ async function run(job) {// generate_failed on 2026-09-23: ffmpeg was run on 0-byte files and Live got a 500).// A virtual clip has no file: its frame comes from its VOD (five such clips failed every page view as// generate_failed on 2026-10-17: ffmpeg was pointed at the clip row's own path, which never existed).++ // A clip whose cut failed has no picture to take: its window could not be read (clips 495-498 of VOD 6010,++ // 2026-10-27: their window lies in the recording's damaged end, and every page view after a Live restart ran this++ // job again and got a 500).++ if (kind === 'clip' && row.status === 'failed' && !row.file_path) {++ throw new JobError('media_unavailable', 'The clip was never cut, so it has no picture', { permanent: true, status: 404 });++ }const virtual = kind === 'clip' && !row.file_path && row.vod_id != null;const recording = virtual ? await db.getVodById(row.vod_id, job.app_id) : (kind === 'vod' ? row : null);if (virtual && !recording) throw new JobError('media_unavailable', `The clip's recording (vod ${row.vod_id}) is gone`, { permanent: true, status: 404 });@@@ -74,6 +80,11 @@ async function run(job) {const url = kind === 'vod'? await thumbService.generateVodThumbnail(id, source.value): await thumbService.generateClipThumbnail(id, source.value, virtual ? { seekSeconds: (Number(row.start_time) || 0) + 0.5 } : {});++ // A recording the health scan flagged for review that gives no frame is damaged where the frame was taken (VOD 6013,++ // 2026-10-27: no duration, VP9 data ffmpeg cannot decode): that is not going to change by asking again.++ if (!url && recording && recording.health_status === 'needs_review') {++ throw new JobError('media_unavailable', 'The recording is damaged where a frame would be taken (its health scan flagged it for review)', { permanent: true, status: 404 });++ }if (!url) throw new JobError('generate_failed', 'Failed to generate thumbnail');return { url, kind, id };}@@@ -90,6 +90,12 @@ router.post('/:kind/:id', tenantAuth({ verb: 'write', allowUser: true }), uploadreturn res.status(404).json({ error: 'The recording has no video', code: 'media.no_video' });}++ // A clip whose cut failed has no picture to take, and that does not change by asking: no job (thumbnail.js says++ // the same if one runs anyway).++ if (kind === 'clip' && row.status === 'failed' && !row.file_path) {++ return res.status(404).json({ error: 'Media file unavailable', code: 'media.clip_not_cut' });++ }++// Generate from the media file (local or presigned remote) as a thumbnail.regenerate job.const queue = require('../jobs/queue');const worker = require('../jobs/worker');@@@ -67,12 +67,41 @@ const express = require('express');const job = await queue.get(r.body.job_id);assert.deepStrictEqual([job.status, job.error_code, job.attempts], ['failed', 'no_video', 1]);} else console.log('audio-only case skipped (ffmpeg not found)');++ // A clip whose cut failed (clips 495-498 of VOD 6010, 2026-10-27): 404 at once, and no job is made for it.++ const jobsBefore = (await db.all('SELECT COUNT(*) AS n FROM media_jobs'))[0].n;++ await db.run("INSERT INTO clips (id, app_id, vod_id, title, status) OVERRIDING SYSTEM VALUE VALUES (495, 'live', ?, 'never cut', 'failed')", [missing]);++ const notCut = await post('/api/v1/live/thumbnails/clip/495');++ assert.deepStrictEqual([notCut.status, notCut.body.code], [404, 'media.clip_not_cut'], JSON.stringify(notCut.body));++ assert.strictEqual((await db.all('SELECT COUNT(*) AS n FROM media_jobs'))[0].n, jobsBefore, 'no job for a clip that was never cut');++ const notCutJob = await queue.enqueue({ appId: 'live', type: 'thumbnail.regenerate', params: { kind: 'clip', id: 495 }, maxAttempts: 1, createdBy: 'test' });++ await require('../server/jobs/worker').runNow(notCutJob.job.id);++ const ran = await queue.get(notCutJob.job.id);++ assert.deepStrictEqual([ran.status, ran.error_code], ['failed', 'media_unavailable'], 'the job says the same when it runs anyway');++ // A recording whose data cannot be decoded where the frame is taken (VOD 6013, 2026-10-27): media_unavailable++ // (404) once its health scan flagged it for review; a recording not flagged still fails as retryable.++ if (require('child_process').spawnSync('ffmpeg', ['-version']).status === 0) {++ const full = path.join(V, 'full.webm');++ const mk = require('child_process').spawnSync('ffmpeg', ['-v', 'error', '-y', '-f', 'lavfi', '-i', 'testsrc=size=320x240:rate=10:duration=4',++ '-c:v', 'libvpx-vp9', '-b:v', '400k', full]);++ assert.strictEqual(mk.status, 0, String(mk.stderr));++ const damaged = path.join(V, 'damaged.webm');++ fs.writeFileSync(damaged, fs.readFileSync(full).subarray(0, 1500)); // the header and a broken first frame++ const insDamaged = async (health) => Number((await db.run(`INSERT INTO vods (app_id, title, file_path, file_size, is_public, visibility, health_status)++ VALUES ('live', 't', ?, 1500, 1, 'public', ?) RETURNING id`, [damaged, health])).lastInsertRowid);++ const flagged = await post(`/api/v1/live/thumbnails/vod/${await insDamaged('needs_review')}`);++ assert.strictEqual(flagged.status, 404, JSON.stringify(flagged.body));++ assert.strictEqual((await queue.get(flagged.body.job_id)).error_code, 'media_unavailable');++ const unflagged = await post(`/api/v1/live/thumbnails/vod/${await insDamaged('ok')}`);++ assert.strictEqual(unflagged.status, 500, JSON.stringify(unflagged.body));++ assert.strictEqual((await queue.get(unflagged.body.job_id)).error_code, 'generate_failed');++ await db.run("DELETE FROM media_jobs WHERE error_code = 'generate_failed'"); // the check below is about the others++ } else console.log('damaged-recording case skipped (ffmpeg not found)');assert.ok(/empty \(0 bytes\)/.test((await queue.list('live', { type: 'thumbnail.regenerate' })).jobs.find(j => JSON.parse(j.params).id === marked).error));assert.strictEqual((await db.all("SELECT COUNT(*) AS n FROM media_jobs WHERE error_code = 'generate_failed'"))[0].n, 0, 'none of them is a generate failure any more');server.close();await db.close();fs.rmSync(tmp, { recursive: true, force: true });-- console.log('✅ thumbnail.regenerate on an empty or missing recording: media_unavailable at once; on an audio-only one: no_video; 404 from the v1 route');++ console.log('✅ thumbnail.regenerate on an empty or missing recording, a clip never cut or a damaged recording under review: media_unavailable; on an audio-only one: no_video; 404 from the v1 route');process.exit(0);})().catch((err) => { console.error(err); server.close(); process.exit(1); });})().catch((err) => { console.error(err); process.exit(1); });
@@ -53,6 +53,12 @@ async function run(job) {
@@ -53,6 +53,12 @@ async function run(job) {
// generate_failed on 2026-09-23: ffmpeg was run on 0-byte files and Live got a 500).
// generate_failed on 2026-09-23: ffmpeg was run on 0-byte files and Live got a 500).
// A virtual clip has no file: its frame comes from its VOD (five such clips failed every page view as
// A virtual clip has no file: its frame comes from its VOD (five such clips failed every page view as
// generate_failed on 2026-10-17: ffmpeg was pointed at the clip row's own path, which never existed).
// generate_failed on 2026-10-17: ffmpeg was pointed at the clip row's own path, which never existed).
+ // A clip whose cut failed has no picture to take: its window could not be read (clips 495-498 of VOD 6010,
+ // 2026-10-27: their window lies in the recording's damaged end, and every page view after a Live restart ran this
+ // job again and got a 500).
+ if (kind === 'clip' && row.status === 'failed' && !row.file_path) {
+ throw new JobError('media_unavailable', 'The clip was never cut, so it has no picture', { permanent: true, status: 404 });
+ }
const virtual = kind === 'clip' && !row.file_path && row.vod_id != null;
const virtual = kind === 'clip' && !row.file_path && row.vod_id != null;
const recording = virtual ? await db.getVodById(row.vod_id, job.app_id) : (kind === 'vod' ? row : null);
const recording = virtual ? await db.getVodById(row.vod_id, job.app_id) : (kind === 'vod' ? row : null);
if (virtual && !recording) throw new JobError('media_unavailable', `The clip's recording (vod ${row.vod_id}) is gone`, { permanent: true, status: 404 });
if (virtual && !recording) throw new JobError('media_unavailable', `The clip's recording (vod ${row.vod_id}) is gone`, { permanent: true, status: 404 });
@@ -74,6 +80,11 @@ async function run(job) {
@@ -74,6 +80,11 @@ async function run(job) {
const url = kind === 'vod'
const url = kind === 'vod'
? await thumbService.generateVodThumbnail(id, source.value)
? await thumbService.generateVodThumbnail(id, source.value)
: await thumbService.generateClipThumbnail(id, source.value, virtual ? { seekSeconds: (Number(row.start_time) || 0) + 0.5 } : {});
: await thumbService.generateClipThumbnail(id, source.value, virtual ? { seekSeconds: (Number(row.start_time) || 0) + 0.5 } : {});
+ // A recording the health scan flagged for review that gives no frame is damaged where the frame was taken (VOD 6013,
+ // 2026-10-27: no duration, VP9 data ffmpeg cannot decode): that is not going to change by asking again.
+ if (!url && recording && recording.health_status === 'needs_review') {
+ throw new JobError('media_unavailable', 'The recording is damaged where a frame would be taken (its health scan flagged it for review)', { permanent: true, status: 404 });
+ }
if (!url) throw new JobError('generate_failed', 'Failed to generate thumbnail');
if (!url) throw new JobError('generate_failed', 'Failed to generate thumbnail');
return { url, kind, id };
return { url, kind, id };
}
}
@@ -90,6 +90,12 @@ router.post('/:kind/:id', tenantAuth({ verb: 'write', allowUser: true }), upload
@@ -90,6 +90,12 @@ router.post('/:kind/:id', tenantAuth({ verb: 'write', allowUser: true }), upload
return res.status(404).json({ error: 'The recording has no video', code: 'media.no_video' });
return res.status(404).json({ error: 'The recording has no video', code: 'media.no_video' });
}
}
+ // A clip whose cut failed has no picture to take, and that does not change by asking: no job (thumbnail.js says
+ // the same if one runs anyway).
+ if (kind === 'clip' && row.status === 'failed' && !row.file_path) {
+ return res.status(404).json({ error: 'Media file unavailable', code: 'media.clip_not_cut' });
+ }
+
// Generate from the media file (local or presigned remote) as a thumbnail.regenerate job.
// Generate from the media file (local or presigned remote) as a thumbnail.regenerate job.
const queue = require('../jobs/queue');
const queue = require('../jobs/queue');
const worker = require('../jobs/worker');
const worker = require('../jobs/worker');
@@ -67,12 +67,41 @@ const express = require('express');
@@ -67,12 +67,41 @@ const express = require('express');
const job = await queue.get(r.body.job_id);
const job = await queue.get(r.body.job_id);
assert.deepStrictEqual([job.status, job.error_code, job.attempts], ['failed', 'no_video', 1]);
assert.deepStrictEqual([job.status, job.error_code, job.attempts], ['failed', 'no_video', 1]);
} else console.log('audio-only case skipped (ffmpeg not found)');
} else console.log('audio-only case skipped (ffmpeg not found)');
+ // A clip whose cut failed (clips 495-498 of VOD 6010, 2026-10-27): 404 at once, and no job is made for it.
+ const jobsBefore = (await db.all('SELECT COUNT(*) AS n FROM media_jobs'))[0].n;
+ await db.run("INSERT INTO clips (id, app_id, vod_id, title, status) OVERRIDING SYSTEM VALUE VALUES (495, 'live', ?, 'never cut', 'failed')", [missing]);
+ const notCut = await post('/api/v1/live/thumbnails/clip/495');
+ assert.deepStrictEqual([notCut.status, notCut.body.code], [404, 'media.clip_not_cut'], JSON.stringify(notCut.body));
+ assert.strictEqual((await db.all('SELECT COUNT(*) AS n FROM media_jobs'))[0].n, jobsBefore, 'no job for a clip that was never cut');
+ const notCutJob = await queue.enqueue({ appId: 'live', type: 'thumbnail.regenerate', params: { kind: 'clip', id: 495 }, maxAttempts: 1, createdBy: 'test' });
+ await require('../server/jobs/worker').runNow(notCutJob.job.id);
+ const ran = await queue.get(notCutJob.job.id);
+ assert.deepStrictEqual([ran.status, ran.error_code], ['failed', 'media_unavailable'], 'the job says the same when it runs anyway');
+ // A recording whose data cannot be decoded where the frame is taken (VOD 6013, 2026-10-27): media_unavailable
+ // (404) once its health scan flagged it for review; a recording not flagged still fails as retryable.
+ if (require('child_process').spawnSync('ffmpeg', ['-version']).status === 0) {
+ const full = path.join(V, 'full.webm');
+ const mk = require('child_process').spawnSync('ffmpeg', ['-v', 'error', '-y', '-f', 'lavfi', '-i', 'testsrc=size=320x240:rate=10:duration=4',
+ '-c:v', 'libvpx-vp9', '-b:v', '400k', full]);
+ assert.strictEqual(mk.status, 0, String(mk.stderr));
+ const damaged = path.join(V, 'damaged.webm');
+ fs.writeFileSync(damaged, fs.readFileSync(full).subarray(0, 1500)); // the header and a broken first frame
+ const insDamaged = async (health) => Number((await db.run(`INSERT INTO vods (app_id, title, file_path, file_size, is_public, visibility, health_status)
+ VALUES ('live', 't', ?, 1500, 1, 'public', ?) RETURNING id`, [damaged, health])).lastInsertRowid);
+ const flagged = await post(`/api/v1/live/thumbnails/vod/${await insDamaged('needs_review')}`);
+ assert.strictEqual(flagged.status, 404, JSON.stringify(flagged.body));
+ assert.strictEqual((await queue.get(flagged.body.job_id)).error_code, 'media_unavailable');
+ const unflagged = await post(`/api/v1/live/thumbnails/vod/${await insDamaged('ok')}`);
+ assert.strictEqual(unflagged.status, 500, JSON.stringify(unflagged.body));
+ assert.strictEqual((await queue.get(unflagged.body.job_id)).error_code, 'generate_failed');
+ await db.run("DELETE FROM media_jobs WHERE error_code = 'generate_failed'"); // the check below is about the others
+ } else console.log('damaged-recording case skipped (ffmpeg not found)');
assert.ok(/empty \(0 bytes\)/.test((await queue.list('live', { type: 'thumbnail.regenerate' })).jobs.find(j => JSON.parse(j.params).id === marked).error));
assert.ok(/empty \(0 bytes\)/.test((await queue.list('live', { type: 'thumbnail.regenerate' })).jobs.find(j => JSON.parse(j.params).id === marked).error));
assert.strictEqual((await db.all("SELECT COUNT(*) AS n FROM media_jobs WHERE error_code = 'generate_failed'"))[0].n, 0, 'none of them is a generate failure any more');
assert.strictEqual((await db.all("SELECT COUNT(*) AS n FROM media_jobs WHERE error_code = 'generate_failed'"))[0].n, 0, 'none of them is a generate failure any more');
server.close();
server.close();
await db.close();
await db.close();
fs.rmSync(tmp, { recursive: true, force: true });
fs.rmSync(tmp, { recursive: true, force: true });
- console.log('✅ thumbnail.regenerate on an empty or missing recording: media_unavailable at once; on an audio-only one: no_video; 404 from the v1 route');
+ console.log('✅ thumbnail.regenerate on an empty or missing recording, a clip never cut or a damaged recording under review: media_unavailable; on an audio-only one: no_video; 404 from the v1 route');
process.exit(0);
process.exit(0);
})().catch((err) => { console.error(err); server.close(); process.exit(1); });
})().catch((err) => { console.error(err); server.close(); process.exit(1); });
})().catch((err) => { console.error(err); process.exit(1); });
})().catch((err) => { console.error(err); process.exit(1); });