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)

OP OpenVibers committed 24 days ago · 14d1f1dd81 · 7 files, +169 −5

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex 331f092..beb7657 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -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 sweep
evicts least recently served copies only after durable size and SHA-256 verification, preserving all timeline rows
and durable playback.
ddiff --git a/migrations/0013_derivatives_follow_parent.sql b/migrations/0013_derivatives_follow_parent.sql
nnew file mode 100644
iindex 0000000..20da2f2
---- /dev/null
++++ b/migrations/0013_derivatives_follow_parent.sql
@@@ -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';
ddiff --git a/server/objects/model.js b/server/objects/model.js
iindex d601e18..ad0eaa5 100644
---- a/server/objects/model.js
++++ b/server/objects/model.js
@@@ -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
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;
-- 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);
}
ddiff --git a/server/thumbnails/thumbnail-service.js b/server/thumbnails/thumbnail-service.js
iindex 63dd928..5b16803 100644
---- a/server/thumbnails/thumbnail-service.js
++++ b/server/thumbnails/thumbnail-service.js
@@@ -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 file
try {
@@@ -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 fallback
const pixel = Buffer.from(
'/9j/4AAQSkZJRgABAQEASABIAAD/2wBDAP//////////////////////////////////////////////////////////////////////////////////////2wBDAf//////////////////////////////////////////////////////////////////////////////////////wAARCAABAAEDASIAAhEBAxEB/8QAFAABAAAAAAAAAAAAAAAAAAAACf/EABQQAQAAAAAAAAAAAAAAAAAAAAD/xAAUAQEAAAAAAAAAAAAAAAAAAAAA/8QAFBEBAAAAAAAAAAAAAAAAAAAAAP/aAAwDAQACEQMRAD8AKwA=',
ddiff --git a/test/deleted-item-thumbnails.test.js b/test/deleted-item-thumbnails.test.js
nnew file mode 100644
iindex 0000000..a38cc01
---- /dev/null
++++ b/test/deleted-item-thumbnails.test.js
@@@ -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); });
ddiff --git a/test/objects-reconcile.test.js b/test/objects-reconcile.test.js
iindex b1bb6d4..67fdb1f 100644
---- a/test/objects-reconcile.test.js
++++ b/test/objects-reconcile.test.js
@@@ -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);
ddiff --git a/test/thumb-variants.test.js b/test/thumb-variants.test.js
iindex d89a294..9617d02 100644
---- a/test/thumb-variants.test.js
++++ b/test/thumb-variants.test.js
@@@ -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) => {