openvibers/openvibe.media Commits
A deleted recording's or clip's thumbnail goes with it: derivatives follow their object's delete (migration 0013, with the backfill), restore brings them back, and /t/ stops serving a deleted item's picture (57 were still served) (#146)
Changes
@@@ -2,6 +2,11 @@## Unreleased++- A deleted recording's or clip's thumbnail goes with it (2026-10-20). Migration 0013 adds a trigger that marks an++ object's derivatives (`media_variants`) deleted whenever it is, and backfills the ones left behind; restoring a native++ object brings back the derivatives deleted with it. `/t/vod-<id>-…` and `/t/clip-<id>-…` answer the empty pixel once++ the item's row is gone (the file stays on disk). The reconcile found 57 such thumbnails still served, and the++ thumbnail folder held files for 1,336 recordings and 73 clips that no longer exist.- Bound local CMAF and packed timeline copies with a configurable byte budget and free-space floor. The hourly sweepevicts least recently served copies only after durable size and SHA-256 verification, preserving all timeline rowsand durable playback.nnew file mode 100644@@@ -0,0 +1,23 @@++-- phase: migrate++-- A derived object (media_variants: a recording's or clip's thumbnail) is deleted with the object it was made from. It++-- was not: deleting a VOD marked its own object deleted (the row-delete triggers) and left its thumbnail objects ready,++-- so the reconcile listed 57 thumbnails of deleted recordings as still served (deleted_publicly_reachable, 2026-10-20).++-- The trigger follows every way an object becomes deleted (row deletes, soft deletes, account deletion); restoring a++-- native object brings back the derivatives deleted with it (server/objects/model.js restore). Re-running is a no-op.++CREATE OR REPLACE FUNCTION trg_media_objects_derivatives_follow() RETURNS trigger LANGUAGE plpgsql AS $$++BEGIN++ IF NEW.lifecycle_status = 'deleted' AND OLD.lifecycle_status IS DISTINCT FROM 'deleted' THEN++ UPDATE media_objects d SET lifecycle_status = 'deleted', deleted_at = COALESCE(d.deleted_at, NEW.deleted_at, ov_now()), updated_at = ov_now()++ FROM media_variants v++ WHERE v.object_id = NEW.id AND d.id = v.derived_object_id AND d.lifecycle_status != 'deleted';++ END IF;++ RETURN NULL;++END $$;++DROP TRIGGER IF EXISTS trg_media_objects_derivatives_follow ON media_objects;++CREATE TRIGGER trg_media_objects_derivatives_follow AFTER UPDATE OF lifecycle_status ON media_objects++ FOR EACH ROW EXECUTE FUNCTION trg_media_objects_derivatives_follow();++++-- The derivatives already left behind by deletes before this release.++UPDATE media_objects d SET lifecycle_status = 'deleted', deleted_at = COALESCE(d.deleted_at, p.deleted_at, ov_now()), updated_at = ov_now()++ FROM media_variants v JOIN media_objects p ON p.id = v.object_id++ WHERE d.id = v.derived_object_id AND p.lifecycle_status = 'deleted' AND d.lifecycle_status != 'deleted';@@@ -627,8 +627,17 @@ async function restore(obj) {if (md.purged_at || md.no_restore) return null; // a zone deletion's tombstones are never restoredconst back = md.pre_delete_status && md.pre_delete_status !== 'deleted' ? md.pre_delete_status : 'ready';delete md.pre_delete_status; delete md.retention_until; delete md.deleted_by;-- await db.run(`UPDATE media_objects SET lifecycle_status = ?, deleted_at = NULL, metadata = ?, updated_at = ov_now() WHERE id = ?`,-- [back, JSON.stringify(md), obj.id]);++ await db.getDb().tx(async () => {++ await db.run(`UPDATE media_objects SET lifecycle_status = ?, deleted_at = NULL, metadata = ?, updated_at = ov_now() WHERE id = ?`,++ [back, JSON.stringify(md), obj.id]);++ // Its derivatives (thumbnails) were deleted with it (migration 0013's trigger): those come back too, not ones++ // deleted on their own before it.++ if (obj.deleted_at) {++ await db.run(`UPDATE media_objects d SET lifecycle_status = 'ready', deleted_at = NULL, updated_at = ov_now()++ FROM media_variants v WHERE v.object_id = ? AND d.id = v.derived_object_id AND d.lifecycle_status = 'deleted' AND d.deleted_at >= ?`,++ [obj.id, obj.deleted_at]);++ }++ });return await getObject(obj.id);}@@@ -306,6 +306,27 @@ function sendFile(res, filePath, size, contentType, cacheControl) {stream.pipe(res);}++// A recording's or clip's thumbnail is named for it (vod-<id>-<time>.jpg, clip-<id>-<time>.jpg). Once the item is deleted++// its row is gone (every delete path removes it) and the thumbnail is no longer served: 57 thumbnails of deleted++// recordings still answered on 2026-10-20. The file stays on disk. Asked per item, remembered for a minute.++const ITEM_THUMB_RE = /^(vod|clip)-(\d{1,15})-/;++const ITEM_TTL_MS = 60 * 1000;++const _itemExists = new Map(); // 'vod:12' → { at, exists }++async function thumbnailItemGone(filename) {++ const m = ITEM_THUMB_RE.exec(filename);++ if (!m) return false;++ const key = `${m[1]}:${m[2]}`;++ const hit = _itemExists.get(key);++ if (hit && Date.now() - hit.at < ITEM_TTL_MS) return !hit.exists;++ let exists;++ try {++ exists = Boolean(await require('../db/database').get(`SELECT 1 AS x FROM ${m[1] === 'vod' ? 'vods' : 'clips'} WHERE id = ?`, [Number(m[2])]));++ } catch { return false; } // no database to ask (a test, a drill): serve as before++ if (_itemExists.size > 5000) _itemExists.clear();++ _itemExists.set(key, { at: Date.now(), exists });++ return !exists;++}++async function serveThumbnail(req, res) {if (require('../drill').refuseBytes(res)) return; // a restore drill serves no stored filetry {@@@ -316,7 +337,7 @@ async function serveThumbnail(req, res) {const spec = require('./variants').parse(req.query);if (spec && spec.error) return res.status(400).json({ error: spec.error });-- if (!filename || !fs.existsSync(filePath)) {++ if (!filename || !fs.existsSync(filePath) || await thumbnailItemGone(filename)) {// Return a 1x1 transparent pixel JPEG as fallbackconst pixel = Buffer.from('/9j/4AAQSkZJRgABAQEASABIAAD/2wBDAP//////////////////////////////////////////////////////////////////////////////////////2wBDAf//////////////////////////////////////////////////////////////////////////////////////wAARCAABAAEDASIAAhEBAxEB/8QAFAABAAAAAAAAAAAAAAAAAAAACf/EABQQAQAAAAAAAAAAAAAAAAAAAAD/xAAUAQEAAAAAAAAAAAAAAAAAAAAA/8QAFBEBAAAAAAAAAAAAAAAAAAAAAP/aAAwDAQACEQMRAD8AKwA=',nnew file mode 100644@@@ -0,0 +1,93 @@++'use strict';++// A deleted recording's or clip's thumbnail goes with it (migration 0013, server/thumbnails/thumbnail-service.js):++// the derived thumbnail object is deleted by the trigger whenever its parent is (and comes back when a native parent++// is restored), and /t/vod-<id>-… answers the empty pixel once the item's row is gone, as for a missing file. On++// 2026-10-20 the reconcile found 57 thumbnails of deleted recordings still served.++const assert = require('assert');++const fs = require('fs');++const os = require('os');++const path = require('path');++const http = require('http');++++(async () => {++ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-media-deleted-thumbs-'));++ const dir = (n) => { const d = path.join(tmp, n); fs.mkdirSync(d, { recursive: true }); return d; };++ Object.assign(process.env, {++ VOD_PATH: dir('vods'), CLIPS_PATH: dir('clips'), FILES_PATH: dir('files'), THUMBNAILS_PATH: dir('thumbnails'),++ PASTES_PATH: dir('pastes'), OBJECTS_PATH: dir('objects'), MEDIA_PUBLIC_URL: 'https://media.test',++ });++ const db = require('../server/db/database');++ const model = require('../server/objects/model');++ const thumbs = require('../server/thumbnails/thumbnail-service');++ const d = db.getDb();++ let failed = 0;++ const check = async (name, fn) => {++ try { await fn(); console.log(` ✓ ${name}`); } catch (err) { failed++; console.log(` ✗ ${name}\n ${err.stack}`); }++ };++ const write = (p, content) => { fs.writeFileSync(p, content); return p; };++ const V = d.prepare(`INSERT INTO vods (id, app_id, file_path, file_size, duration_seconds, visibility, is_public, storage_provider, thumbnail_url) OVERRIDING SYSTEM VALUE++ VALUES (?, 'live', ?, 100, 60, 'public', 1, 'local', ?) RETURNING id`);++ const vod = async (id) => {++ write(path.join(process.env.VOD_PATH, `${id}.webm`), Buffer.alloc(100));++ write(path.join(process.env.THUMBNAILS_PATH, `vod-${id}-1.jpg`), `thumb of ${id}`);++ await V.run(id, path.join(process.env.VOD_PATH, `${id}.webm`), `/t/vod-${id}-1.jpg`);++ await model.sync('vod', id);++ return (await d.prepare('SELECT object_id FROM vods WHERE id = ?').get(id)).object_id;++ };++ const thumbOf = async (objectId) => await db.get(`SELECT d.id, d.lifecycle_status FROM media_variants v JOIN media_objects d ON d.id = v.derived_object_id++ WHERE v.object_id = ? AND v.variant_name = 'thumbnail'`, [objectId]);++++ const server = http.createServer((req, res) => {++ req.params = { id: decodeURIComponent(new URL(req.url, 'http://x').pathname.replace(/^\/t\//, '')) };++ req.query = {};++ res.status = (code) => { res.statusCode = code; return res; };++ res.json = (b) => res.end(JSON.stringify(b));++ thumbs.serveThumbnail(req, res);++ });++ await new Promise((r) => server.listen(0, '127.0.0.1', r));++ const get = async (p) => {++ const r = await fetch(`http://127.0.0.1:${server.address().port}${p}`);++ return { status: r.status, cache: r.headers.get('cache-control'), body: Buffer.from(await r.arrayBuffer()).toString() };++ };++++ await check('a recording\'s thumbnail object is deleted with it, by every path that deletes the row', async () => {++ const obj = await vod(7);++ assert.strictEqual((await thumbOf(obj)).lifecycle_status, 'ready');++ await db.run('DELETE FROM vods WHERE id = 7');++ assert.strictEqual((await db.get('SELECT lifecycle_status FROM media_objects WHERE id = ?', [obj])).lifecycle_status, 'deleted');++ assert.strictEqual((await thumbOf(obj)).lifecycle_status, 'deleted', 'its thumbnail followed');++ });++++ await check('/t/ serves a live item\'s thumbnail, and only the empty pixel once the item is deleted', async () => {++ await vod(8);++ const live = await get('/t/vod-8-1.jpg');++ assert.deepStrictEqual([live.status, live.body, live.cache], [200, 'thumb of 8', 'public, max-age=604800, immutable']);++ const gone = await get('/t/vod-7-1.jpg');++ assert.strictEqual(gone.status, 200);++ assert.notStrictEqual(gone.body, 'thumb of 7', 'not the deleted recording\'s picture');++ assert.strictEqual(gone.cache, 'no-cache', 'the pixel, as for a missing file');++ assert.ok(fs.existsSync(path.join(process.env.THUMBNAILS_PATH, 'vod-7-1.jpg')), 'the file stays on disk');++ write(path.join(process.env.THUMBNAILS_PATH, 'stream-live-3.jpg'), 'live frame');++ assert.strictEqual((await get('/t/stream-live-3.jpg')).body, 'live frame', 'a live stream\'s picture names no item');++ });++++ await check('restoring a native object brings back the derivatives deleted with it, not one deleted before', async () => {++ const parent = await model.createObject({ app_id: 'live', kind: 'file', lifecycle_status: 'ready' });++ const withIt = await model.createObject({ app_id: 'live', kind: 'thumbnail', lifecycle_status: 'ready' });++ const earlier = await model.createObject({ app_id: 'live', kind: 'thumbnail', lifecycle_status: 'ready' });++ await model.setVariant(parent, 'thumbnail', withIt);++ await model.setVariant(parent, 'poster', earlier);++ await db.run("UPDATE media_objects SET lifecycle_status = 'deleted', deleted_at = '2020-01-01 00:00:00' WHERE id = ?", [earlier]);++ await model.softDelete(await model.getObject(parent));++ assert.strictEqual((await model.getObject(withIt)).lifecycle_status, 'deleted', 'the trigger follows a soft delete too');++ await model.restore(await model.getObject(parent));++ assert.strictEqual((await model.getObject(parent)).lifecycle_status, 'ready');++ assert.strictEqual((await model.getObject(withIt)).lifecycle_status, 'ready', 'deleted with it: back with it');++ assert.strictEqual((await model.getObject(earlier)).lifecycle_status, 'deleted', 'deleted on its own before: stays deleted');++ });++++ server.close();++ fs.rmSync(tmp, { recursive: true, force: true });++ if (failed) { console.log(`\n${failed} check(s) failed`); process.exit(1); }++ console.log('\ndeleted item thumbnails: all checks passed');++})().catch((e) => { console.error(e); process.exit(1); });@@@ -118,13 +118,20 @@ const crypto = require('crypto');const rc = await run('reconcile-objects.js', ['--json']);assert.strictEqual(rc.code, 1, 'issues → exit 1');assert.ok(JSON.parse(rc.out).issues.orphan_location.count === 1);-- // A deleted object's thumbnail that is still served is reachable too.++ // A deleted object's thumbnail is deleted with it (migration 0013), so it is not flagged; one left ready (as++ // before that migration) is.write(path.join(env.THUMBNAILS_PATH, 'vod-1-5.jpg'), 'jpg');await db.run("UPDATE vods SET thumbnail_url = '/t/vod-1-5.jpg' WHERE id = 1");await model.sync('vod', 1);++ const vod1 = await objId(1);await db.run('DELETE FROM vods WHERE id = 1');++ const derived = (await db.get("SELECT derived_object_id AS id FROM media_variants WHERE object_id = ? AND variant_name = 'thumbnail'", [vod1])).id;++ assert.strictEqual((await model.getObject(derived)).lifecycle_status, 'deleted', 'the thumbnail went with its recording');++ const isDerivative = (i) => i.reason.startsWith('derivative');++ assert.ok(!(await reconcile({})).issues.deleted_publicly_reachable.items.some(isDerivative), 'so it is not flagged');++ await db.run("UPDATE media_objects SET lifecycle_status = 'ready', deleted_at = NULL WHERE id = ?", [derived]);const t = await reconcile({});-- assert.ok(t.issues.deleted_publicly_reachable.items.some(i => i.reason.startsWith('derivative')), 'thumbnail of a deleted vod is flagged');++ assert.ok(t.issues.deleted_publicly_reachable.items.some(isDerivative), 'a thumbnail left behind is flagged');await db.run('UPDATE vods SET file_size = ? WHERE id = 3', [5 * MB]);await model.sync('vod', 3);@@@ -26,6 +26,12 @@ const http = require('http');.composite([{ input: Buffer.from('<svg width="640" height="360"><circle cx="320" cy="180" r="120" fill="#fff"/></svg>') }]).jpeg({ quality: 85 }).toFile(file);await jpeg(path.join(process.env.THUMBNAILS_PATH, 'vod-1-1.jpg'), '#3366cc');++ // A recording's thumbnail is served while its row exists (a deleted item's is not: deleted-item-thumbnails.test.js).++ // Under the test runner there is a database; run alone there is none, and the thumbnail is served as before.++ try {++ await require('../server/db/database').getDb().prepare(`INSERT INTO vods (id, app_id, file_path, file_size, duration_seconds, visibility, is_public, storage_provider)++ OVERRIDING SYSTEM VALUE VALUES (1, 'live', '/v/1.webm', 100, 60, 'public', 1, 'local') RETURNING id`).run();++ } catch { /* no database */ }await jpeg(path.join(process.env.THUMBNAILS_PATH, 'stream-live-1.jpg'), '#cc3333');const server = http.createServer((req, res) => {
@@ -2,6 +2,11 @@
@@ -2,6 +2,11 @@
## Unreleased
## Unreleased
+- A deleted recording's or clip's thumbnail goes with it (2026-10-20). Migration 0013 adds a trigger that marks an
+ object's derivatives (`media_variants`) deleted whenever it is, and backfills the ones left behind; restoring a native
+ object brings back the derivatives deleted with it. `/t/vod-<id>-…` and `/t/clip-<id>-…` answer the empty pixel once
+ the item's row is gone (the file stays on disk). The reconcile found 57 such thumbnails still served, and the
+ thumbnail folder held files for 1,336 recordings and 73 clips that no longer exist.
- Bound local CMAF and packed timeline copies with a configurable byte budget and free-space floor. The hourly sweep
- Bound local CMAF and packed timeline copies with a configurable byte budget and free-space floor. The hourly sweep
evicts least recently served copies only after durable size and SHA-256 verification, preserving all timeline rows
evicts least recently served copies only after durable size and SHA-256 verification, preserving all timeline rows
and durable playback.
and durable playback.
new file mode 100644
new file mode 100644
@@ -0,0 +1,23 @@
@@ -0,0 +1,23 @@
+-- phase: migrate
+-- A derived object (media_variants: a recording's or clip's thumbnail) is deleted with the object it was made from. It
+-- was not: deleting a VOD marked its own object deleted (the row-delete triggers) and left its thumbnail objects ready,
+-- so the reconcile listed 57 thumbnails of deleted recordings as still served (deleted_publicly_reachable, 2026-10-20).
+-- The trigger follows every way an object becomes deleted (row deletes, soft deletes, account deletion); restoring a
+-- native object brings back the derivatives deleted with it (server/objects/model.js restore). Re-running is a no-op.
+CREATE OR REPLACE FUNCTION trg_media_objects_derivatives_follow() RETURNS trigger LANGUAGE plpgsql AS $$
+BEGIN
+ IF NEW.lifecycle_status = 'deleted' AND OLD.lifecycle_status IS DISTINCT FROM 'deleted' THEN
+ UPDATE media_objects d SET lifecycle_status = 'deleted', deleted_at = COALESCE(d.deleted_at, NEW.deleted_at, ov_now()), updated_at = ov_now()
+ FROM media_variants v
+ WHERE v.object_id = NEW.id AND d.id = v.derived_object_id AND d.lifecycle_status != 'deleted';
+ END IF;
+ RETURN NULL;
+END $$;
+DROP TRIGGER IF EXISTS trg_media_objects_derivatives_follow ON media_objects;
+CREATE TRIGGER trg_media_objects_derivatives_follow AFTER UPDATE OF lifecycle_status ON media_objects
+ FOR EACH ROW EXECUTE FUNCTION trg_media_objects_derivatives_follow();
+
+-- The derivatives already left behind by deletes before this release.
+UPDATE media_objects d SET lifecycle_status = 'deleted', deleted_at = COALESCE(d.deleted_at, p.deleted_at, ov_now()), updated_at = ov_now()
+ FROM media_variants v JOIN media_objects p ON p.id = v.object_id
+ WHERE d.id = v.derived_object_id AND p.lifecycle_status = 'deleted' AND d.lifecycle_status != 'deleted';
@@ -627,8 +627,17 @@ async function restore(obj) {
@@ -627,8 +627,17 @@ async function restore(obj) {
if (md.purged_at || md.no_restore) return null; // a zone deletion's tombstones are never restored
if (md.purged_at || md.no_restore) return null; // a zone deletion's tombstones are never restored
const back = md.pre_delete_status && md.pre_delete_status !== 'deleted' ? md.pre_delete_status : 'ready';
const back = md.pre_delete_status && md.pre_delete_status !== 'deleted' ? md.pre_delete_status : 'ready';
delete md.pre_delete_status; delete md.retention_until; delete md.deleted_by;
delete md.pre_delete_status; delete md.retention_until; delete md.deleted_by;
- await db.run(`UPDATE media_objects SET lifecycle_status = ?, deleted_at = NULL, metadata = ?, updated_at = ov_now() WHERE id = ?`,
- [back, JSON.stringify(md), obj.id]);
+ await db.getDb().tx(async () => {
+ await db.run(`UPDATE media_objects SET lifecycle_status = ?, deleted_at = NULL, metadata = ?, updated_at = ov_now() WHERE id = ?`,
+ [back, JSON.stringify(md), obj.id]);
+ // Its derivatives (thumbnails) were deleted with it (migration 0013's trigger): those come back too, not ones
+ // deleted on their own before it.
+ if (obj.deleted_at) {
+ await db.run(`UPDATE media_objects d SET lifecycle_status = 'ready', deleted_at = NULL, updated_at = ov_now()
+ FROM media_variants v WHERE v.object_id = ? AND d.id = v.derived_object_id AND d.lifecycle_status = 'deleted' AND d.deleted_at >= ?`,
+ [obj.id, obj.deleted_at]);
+ }
+ });
return await getObject(obj.id);
return await getObject(obj.id);
}
}
@@ -306,6 +306,27 @@ function sendFile(res, filePath, size, contentType, cacheControl) {
@@ -306,6 +306,27 @@ function sendFile(res, filePath, size, contentType, cacheControl) {
stream.pipe(res);
stream.pipe(res);
}
}
+// A recording's or clip's thumbnail is named for it (vod-<id>-<time>.jpg, clip-<id>-<time>.jpg). Once the item is deleted
+// its row is gone (every delete path removes it) and the thumbnail is no longer served: 57 thumbnails of deleted
+// recordings still answered on 2026-10-20. The file stays on disk. Asked per item, remembered for a minute.
+const ITEM_THUMB_RE = /^(vod|clip)-(\d{1,15})-/;
+const ITEM_TTL_MS = 60 * 1000;
+const _itemExists = new Map(); // 'vod:12' → { at, exists }
+async function thumbnailItemGone(filename) {
+ const m = ITEM_THUMB_RE.exec(filename);
+ if (!m) return false;
+ const key = `${m[1]}:${m[2]}`;
+ const hit = _itemExists.get(key);
+ if (hit && Date.now() - hit.at < ITEM_TTL_MS) return !hit.exists;
+ let exists;
+ try {
+ exists = Boolean(await require('../db/database').get(`SELECT 1 AS x FROM ${m[1] === 'vod' ? 'vods' : 'clips'} WHERE id = ?`, [Number(m[2])]));
+ } catch { return false; } // no database to ask (a test, a drill): serve as before
+ if (_itemExists.size > 5000) _itemExists.clear();
+ _itemExists.set(key, { at: Date.now(), exists });
+ return !exists;
+}
+
async function serveThumbnail(req, res) {
async function serveThumbnail(req, res) {
if (require('../drill').refuseBytes(res)) return; // a restore drill serves no stored file
if (require('../drill').refuseBytes(res)) return; // a restore drill serves no stored file
try {
try {
@@ -316,7 +337,7 @@ async function serveThumbnail(req, res) {
@@ -316,7 +337,7 @@ async function serveThumbnail(req, res) {
const spec = require('./variants').parse(req.query);
const spec = require('./variants').parse(req.query);
if (spec && spec.error) return res.status(400).json({ error: spec.error });
if (spec && spec.error) return res.status(400).json({ error: spec.error });
- if (!filename || !fs.existsSync(filePath)) {
+ if (!filename || !fs.existsSync(filePath) || await thumbnailItemGone(filename)) {
// Return a 1x1 transparent pixel JPEG as fallback
// Return a 1x1 transparent pixel JPEG as fallback
const pixel = Buffer.from(
const pixel = Buffer.from(
'/9j/4AAQSkZJRgABAQEASABIAAD/2wBDAP//////////////////////////////////////////////////////////////////////////////////////2wBDAf//////////////////////////////////////////////////////////////////////////////////////wAARCAABAAEDASIAAhEBAxEB/8QAFAABAAAAAAAAAAAAAAAAAAAACf/EABQQAQAAAAAAAAAAAAAAAAAAAAD/xAAUAQEAAAAAAAAAAAAAAAAAAAAA/8QAFBEBAAAAAAAAAAAAAAAAAAAAAP/aAAwDAQACEQMRAD8AKwA=',
'/9j/4AAQSkZJRgABAQEASABIAAD/2wBDAP//////////////////////////////////////////////////////////////////////////////////////2wBDAf//////////////////////////////////////////////////////////////////////////////////////wAARCAABAAEDASIAAhEBAxEB/8QAFAABAAAAAAAAAAAAAAAAAAAACf/EABQQAQAAAAAAAAAAAAAAAAAAAAD/xAAUAQEAAAAAAAAAAAAAAAAAAAAA/8QAFBEBAAAAAAAAAAAAAAAAAAAAAP/aAAwDAQACEQMRAD8AKwA=',
new file mode 100644
new file mode 100644
@@ -0,0 +1,93 @@
@@ -0,0 +1,93 @@
+'use strict';
+// A deleted recording's or clip's thumbnail goes with it (migration 0013, server/thumbnails/thumbnail-service.js):
+// the derived thumbnail object is deleted by the trigger whenever its parent is (and comes back when a native parent
+// is restored), and /t/vod-<id>-… answers the empty pixel once the item's row is gone, as for a missing file. On
+// 2026-10-20 the reconcile found 57 thumbnails of deleted recordings still served.
+const assert = require('assert');
+const fs = require('fs');
+const os = require('os');
+const path = require('path');
+const http = require('http');
+
+(async () => {
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-media-deleted-thumbs-'));
+ const dir = (n) => { const d = path.join(tmp, n); fs.mkdirSync(d, { recursive: true }); return d; };
+ Object.assign(process.env, {
+ VOD_PATH: dir('vods'), CLIPS_PATH: dir('clips'), FILES_PATH: dir('files'), THUMBNAILS_PATH: dir('thumbnails'),
+ PASTES_PATH: dir('pastes'), OBJECTS_PATH: dir('objects'), MEDIA_PUBLIC_URL: 'https://media.test',
+ });
+ const db = require('../server/db/database');
+ const model = require('../server/objects/model');
+ const thumbs = require('../server/thumbnails/thumbnail-service');
+ const d = db.getDb();
+ let failed = 0;
+ const check = async (name, fn) => {
+ try { await fn(); console.log(` ✓ ${name}`); } catch (err) { failed++; console.log(` ✗ ${name}\n ${err.stack}`); }
+ };
+ const write = (p, content) => { fs.writeFileSync(p, content); return p; };
+ const V = d.prepare(`INSERT INTO vods (id, app_id, file_path, file_size, duration_seconds, visibility, is_public, storage_provider, thumbnail_url) OVERRIDING SYSTEM VALUE
+ VALUES (?, 'live', ?, 100, 60, 'public', 1, 'local', ?) RETURNING id`);
+ const vod = async (id) => {
+ write(path.join(process.env.VOD_PATH, `${id}.webm`), Buffer.alloc(100));
+ write(path.join(process.env.THUMBNAILS_PATH, `vod-${id}-1.jpg`), `thumb of ${id}`);
+ await V.run(id, path.join(process.env.VOD_PATH, `${id}.webm`), `/t/vod-${id}-1.jpg`);
+ await model.sync('vod', id);
+ return (await d.prepare('SELECT object_id FROM vods WHERE id = ?').get(id)).object_id;
+ };
+ const thumbOf = async (objectId) => await db.get(`SELECT d.id, d.lifecycle_status FROM media_variants v JOIN media_objects d ON d.id = v.derived_object_id
+ WHERE v.object_id = ? AND v.variant_name = 'thumbnail'`, [objectId]);
+
+ const server = http.createServer((req, res) => {
+ req.params = { id: decodeURIComponent(new URL(req.url, 'http://x').pathname.replace(/^\/t\//, '')) };
+ req.query = {};
+ res.status = (code) => { res.statusCode = code; return res; };
+ res.json = (b) => res.end(JSON.stringify(b));
+ thumbs.serveThumbnail(req, res);
+ });
+ await new Promise((r) => server.listen(0, '127.0.0.1', r));
+ const get = async (p) => {
+ const r = await fetch(`http://127.0.0.1:${server.address().port}${p}`);
+ return { status: r.status, cache: r.headers.get('cache-control'), body: Buffer.from(await r.arrayBuffer()).toString() };
+ };
+
+ await check('a recording\'s thumbnail object is deleted with it, by every path that deletes the row', async () => {
+ const obj = await vod(7);
+ assert.strictEqual((await thumbOf(obj)).lifecycle_status, 'ready');
+ await db.run('DELETE FROM vods WHERE id = 7');
+ assert.strictEqual((await db.get('SELECT lifecycle_status FROM media_objects WHERE id = ?', [obj])).lifecycle_status, 'deleted');
+ assert.strictEqual((await thumbOf(obj)).lifecycle_status, 'deleted', 'its thumbnail followed');
+ });
+
+ await check('/t/ serves a live item\'s thumbnail, and only the empty pixel once the item is deleted', async () => {
+ await vod(8);
+ const live = await get('/t/vod-8-1.jpg');
+ assert.deepStrictEqual([live.status, live.body, live.cache], [200, 'thumb of 8', 'public, max-age=604800, immutable']);
+ const gone = await get('/t/vod-7-1.jpg');
+ assert.strictEqual(gone.status, 200);
+ assert.notStrictEqual(gone.body, 'thumb of 7', 'not the deleted recording\'s picture');
+ assert.strictEqual(gone.cache, 'no-cache', 'the pixel, as for a missing file');
+ assert.ok(fs.existsSync(path.join(process.env.THUMBNAILS_PATH, 'vod-7-1.jpg')), 'the file stays on disk');
+ write(path.join(process.env.THUMBNAILS_PATH, 'stream-live-3.jpg'), 'live frame');
+ assert.strictEqual((await get('/t/stream-live-3.jpg')).body, 'live frame', 'a live stream\'s picture names no item');
+ });
+
+ await check('restoring a native object brings back the derivatives deleted with it, not one deleted before', async () => {
+ const parent = await model.createObject({ app_id: 'live', kind: 'file', lifecycle_status: 'ready' });
+ const withIt = await model.createObject({ app_id: 'live', kind: 'thumbnail', lifecycle_status: 'ready' });
+ const earlier = await model.createObject({ app_id: 'live', kind: 'thumbnail', lifecycle_status: 'ready' });
+ await model.setVariant(parent, 'thumbnail', withIt);
+ await model.setVariant(parent, 'poster', earlier);
+ await db.run("UPDATE media_objects SET lifecycle_status = 'deleted', deleted_at = '2020-01-01 00:00:00' WHERE id = ?", [earlier]);
+ await model.softDelete(await model.getObject(parent));
+ assert.strictEqual((await model.getObject(withIt)).lifecycle_status, 'deleted', 'the trigger follows a soft delete too');
+ await model.restore(await model.getObject(parent));
+ assert.strictEqual((await model.getObject(parent)).lifecycle_status, 'ready');
+ assert.strictEqual((await model.getObject(withIt)).lifecycle_status, 'ready', 'deleted with it: back with it');
+ assert.strictEqual((await model.getObject(earlier)).lifecycle_status, 'deleted', 'deleted on its own before: stays deleted');
+ });
+
+ server.close();
+ fs.rmSync(tmp, { recursive: true, force: true });
+ if (failed) { console.log(`\n${failed} check(s) failed`); process.exit(1); }
+ console.log('\ndeleted item thumbnails: all checks passed');
+})().catch((e) => { console.error(e); process.exit(1); });
@@ -118,13 +118,20 @@ const crypto = require('crypto');
@@ -118,13 +118,20 @@ const crypto = require('crypto');
const rc = await run('reconcile-objects.js', ['--json']);
const rc = await run('reconcile-objects.js', ['--json']);
assert.strictEqual(rc.code, 1, 'issues → exit 1');
assert.strictEqual(rc.code, 1, 'issues → exit 1');
assert.ok(JSON.parse(rc.out).issues.orphan_location.count === 1);
assert.ok(JSON.parse(rc.out).issues.orphan_location.count === 1);
- // A deleted object's thumbnail that is still served is reachable too.
+ // A deleted object's thumbnail is deleted with it (migration 0013), so it is not flagged; one left ready (as
+ // before that migration) is.
write(path.join(env.THUMBNAILS_PATH, 'vod-1-5.jpg'), 'jpg');
write(path.join(env.THUMBNAILS_PATH, 'vod-1-5.jpg'), 'jpg');
await db.run("UPDATE vods SET thumbnail_url = '/t/vod-1-5.jpg' WHERE id = 1");
await db.run("UPDATE vods SET thumbnail_url = '/t/vod-1-5.jpg' WHERE id = 1");
await model.sync('vod', 1);
await model.sync('vod', 1);
+ const vod1 = await objId(1);
await db.run('DELETE FROM vods WHERE id = 1');
await db.run('DELETE FROM vods WHERE id = 1');
+ const derived = (await db.get("SELECT derived_object_id AS id FROM media_variants WHERE object_id = ? AND variant_name = 'thumbnail'", [vod1])).id;
+ assert.strictEqual((await model.getObject(derived)).lifecycle_status, 'deleted', 'the thumbnail went with its recording');
+ const isDerivative = (i) => i.reason.startsWith('derivative');
+ assert.ok(!(await reconcile({})).issues.deleted_publicly_reachable.items.some(isDerivative), 'so it is not flagged');
+ await db.run("UPDATE media_objects SET lifecycle_status = 'ready', deleted_at = NULL WHERE id = ?", [derived]);
const t = await reconcile({});
const t = await reconcile({});
- assert.ok(t.issues.deleted_publicly_reachable.items.some(i => i.reason.startsWith('derivative')), 'thumbnail of a deleted vod is flagged');
+ assert.ok(t.issues.deleted_publicly_reachable.items.some(isDerivative), 'a thumbnail left behind is flagged');
await db.run('UPDATE vods SET file_size = ? WHERE id = 3', [5 * MB]);
await db.run('UPDATE vods SET file_size = ? WHERE id = 3', [5 * MB]);
await model.sync('vod', 3);
await model.sync('vod', 3);
@@ -26,6 +26,12 @@ const http = require('http');
@@ -26,6 +26,12 @@ const http = require('http');
.composite([{ input: Buffer.from('<svg width="640" height="360"><circle cx="320" cy="180" r="120" fill="#fff"/></svg>') }])
.composite([{ input: Buffer.from('<svg width="640" height="360"><circle cx="320" cy="180" r="120" fill="#fff"/></svg>') }])
.jpeg({ quality: 85 }).toFile(file);
.jpeg({ quality: 85 }).toFile(file);
await jpeg(path.join(process.env.THUMBNAILS_PATH, 'vod-1-1.jpg'), '#3366cc');
await jpeg(path.join(process.env.THUMBNAILS_PATH, 'vod-1-1.jpg'), '#3366cc');
+ // A recording's thumbnail is served while its row exists (a deleted item's is not: deleted-item-thumbnails.test.js).
+ // Under the test runner there is a database; run alone there is none, and the thumbnail is served as before.
+ try {
+ await require('../server/db/database').getDb().prepare(`INSERT INTO vods (id, app_id, file_path, file_size, duration_seconds, visibility, is_public, storage_provider)
+ OVERRIDING SYSTEM VALUE VALUES (1, 'live', '/v/1.webm', 100, 60, 'public', 1, 'local') RETURNING id`).run();
+ } catch { /* no database */ }
await jpeg(path.join(process.env.THUMBNAILS_PATH, 'stream-live-1.jpg'), '#cc3333');
await jpeg(path.join(process.env.THUMBNAILS_PATH, 'stream-live-1.jpg'), '#cc3333');
const server = http.createServer((req, res) => {
const server = http.createServer((req, res) => {