openvibers/openvibe.media Commits

H15 repair: resize, and the legacy row follows (roadmap WS-G task 7, found by the no-fake-green readiness sweep). Media's readiness is degraded again by 3 objects with no good copy: VODs 939, 1277 and 1309, the same three the 2026-09-25 repair rebaselined from their B2 copies (size and sha256 re-recorded). On 2026-09-26/27 a projection from the legacy vods row (model.js vodProjection, size_bytes = file_size) put the mid-upload sizes back (63708377, 13631488, 786432) and kept the new hashes, so scheduled verification found each B2 copy's size wrong and marked it corrupt, and the three are not playable. New plan action resize: a corrupt B2/R2 copy whose downloaded bytes hash to the object's own content_hash is the object, and only its recorded size is re-recorded (apply refuses bytes that do not match the hash). rebaseline and resize now also write the size to the legacy vods row behind the object (file_size), so its projection agrees and cannot undo the repair; rollback restores that row too. scripts/h15-repair.js passes the downloaded facts to plan. test/h15-repair.test.js covers resize, a mismatched hash (lost), no download (lost), the legacy row on resize and rebaseline, and rollback. npm test passes.

OP OpenVibers committed 28 Sept 2026 · 8b2e5429a4 · 3 files, +74 −6


Changes

ddiff --git a/scripts/h15-repair.js b/scripts/h15-repair.js
iindex 5baf407..ac098d4 100644
---- a/scripts/h15-repair.js
++++ b/scripts/h15-repair.js
@@@ -10,9 +10,12 @@
* probes and decodes it with ffprobe/ffmpeg, extracts each missing AI-moment frame again from
* its VOD's copy, and writes the plan: rebaseline, failed_recording, regenerate or lost, with the
* reason. Changes nothing. The report is for the owner (default <data>/reports/h15-repair-….json).
++ * A copy whose bytes hash to the object's own content_hash plans resize: only the recorded size was
++ * wrong (a projection from the legacy vods row put a stale size back after a rebaseline).
* node scripts/h15-repair.js --apply --backup <file.json>
* First an online backup of the database to <file>.media.db (checked with integrity_check), then
-- * the plan: re-records size and sha256 from a good remote copy, marks a failed recording
++ * the plan: re-records size and sha256 from a good remote copy (and the size on the legacy vods
++ * row, so its projection agrees), marks a failed recording
* lifecycle_status 'failed' (not playable, hidden from public players), writes a regenerated
* frame back to its location. <file.json> lists every change with its old values (the rollback
* file). Nothing is deleted, locally or remotely. Refuses to overwrite either file.
@@@ -121,8 +124,8 @@ async function main() {
probes[l.provider] = await probe(file);
} catch (err) { probes[l.provider] = { error: err.message }; }
}
-- const step = h15.plan(item, probes);
-- if (step.action === 'rebaseline') Object.assign(step, { facts: facts[step.provider], probe: { duration: probes[step.provider].duration, streams: probes[step.provider].streams } });
++ const step = h15.plan(item, probes, facts);
++ if (step.action === 'rebaseline' || step.action === 'resize') Object.assign(step, { facts: facts[step.provider], probe: { duration: probes[step.provider].duration, streams: probes[step.provider].streams } });
if (step.action === 'failed_recording') step.probe = probes[step.provider];
if (step.action === 'regenerate') {
const loc = item.locations.find((l) => l.provider === 'local');
ddiff --git a/server/objects/h15-repair.js b/server/objects/h15-repair.js
iindex 22076af..af90298 100644
---- a/server/objects/h15-repair.js
++++ b/server/objects/h15-repair.js
@@@ -10,6 +10,14 @@
* when the object was made (a recording's size captured mid-upload, no content
* hash), and that copy decodes to the end with a real duration. The copy is the
* object: size and sha256 are re-recorded from it and the location is present.
++ * resize a B2/R2 copy is "corrupt" but its bytes hash to the object's own content_hash: the
++ * bytes are exactly the object and only its recorded size is wrong. That happened
++ * after the 2026-09-25 rebaseline, when a projection from the legacy vods row
++ * (server/objects/model.js vodProjection, file_size) put the mid-upload size back
++ * and kept the hash. The size is re-recorded from the copy.
++ *
++ * rebaseline and resize also write the size to the legacy vods row behind the object (legacy_ref
++ * legacy:<app>:vod:<id>, file_size), so the projection agrees with the object and cannot undo it.
* failed_recording the only bytes are a fragment (under MIN_SECONDS, or nothing decodes): the
* recording failed when it was made, nothing was lost since. lifecycle_status
* becomes 'failed', so readiness says not playable and public players hide it.
@@@ -39,7 +47,9 @@ function judgeProbe(p) {
* The plan for one report item. probes: provider → probe of that copy (only for corrupt remote copies).
* → { object_id, action, provider?, reason, source_vod?, offset? }
*/
--function plan(item, probes = {}) {
++const VOD_REF = /^legacy:[\w-]+:vod:(\d+)$/;
++
++function plan(item, probes = {}, facts = {}) {
// What the plan was based on: apply() writes only while the row still holds it.
const base = { object_id: item.object_id, kind: item.kind, legacy_ref: item.legacy_ref || null, read: { size_bytes: item.size_bytes, content_hash: item.content_hash || null, lifecycle_status: 'ready' } };
const shot = item.kind === 'screenshot' && (item.locations || []).map((l) => SHOT_RE.exec(String(l.key || ''))).find(Boolean);
@@@ -47,6 +57,13 @@ function plan(item, probes = {}) {
return { ...base, action: 'regenerate', source_vod: Number(shot[1]), offset: Number(shot[2]), reason: `the frame at ${shot[2]} s of VOD ${shot[1]} can be extracted again` };
}
const remote = (item.locations || []).filter((l) => (l.provider === 'b2' || l.provider === 'r2') && l.state === 'corrupt');
++ // facts: provider → { size, sha256 } of the downloaded copy. Bytes that hash to the recorded hash are the object.
++ for (const l of item.content_hash ? remote : []) {
++ const f = facts[l.provider];
++ if (f && f.sha256 === item.content_hash && f.size !== item.size_bytes) {
++ return { ...base, action: 'resize', provider: l.provider, key: l.key, reason: `${l.provider} copy hashes to the object's content_hash; only the recorded size (${item.size_bytes} bytes) was wrong, the bytes are ${f.size}` };
++ }
++ }
if (remote.length && !item.content_hash) {
let fragment = null;
for (const l of remote) {
@@@ -68,15 +85,21 @@ function apply(h, step, facts = {}) {
if (!cur) return { ...step, skipped: 'object gone' };
const obj = { ...cur, ...(step.read || {}) };
const tx = h.transaction(() => {
-- if (step.action === 'rebaseline' || step.action === 'regenerate') {
++ if (step.action === 'rebaseline' || step.action === 'regenerate' || step.action === 'resize') {
const provider = step.action === 'regenerate' ? 'local' : step.provider;
const loc = h.prepare('SELECT id, state, size_bytes FROM media_locations WHERE object_id = ? AND provider = ?').get(obj.id, provider);
if (!loc) return { ...step, skipped: `no ${provider} location` };
++ if (step.action === 'resize' && facts.sha256 !== obj.content_hash) return { ...step, skipped: 'the copy does not hash to the object\'s content_hash' };
const r = h.prepare("UPDATE media_objects SET size_bytes = ?, content_hash = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ? AND size_bytes IS ? AND content_hash IS ?")
.run(facts.size, facts.sha256, obj.id, obj.size_bytes, obj.content_hash);
if (!r.changes) return { ...step, skipped: 'the object changed since it was read' };
h.prepare("UPDATE media_locations SET state = 'present', size_bytes = ?, verified_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP WHERE id = ?").run(facts.size, loc.id);
-- return { ...step, old: { size_bytes: obj.size_bytes, content_hash: obj.content_hash, location_state: loc.state, location_size: loc.size_bytes }, new: { size_bytes: facts.size, content_hash: facts.sha256, location_state: 'present' } };
++ const legacy = step.action === 'regenerate' ? null : legacySize(h, step.legacy_ref, facts.size);
++ return {
++ ...step,
++ old: { size_bytes: obj.size_bytes, content_hash: obj.content_hash, location_state: loc.state, location_size: loc.size_bytes, ...(legacy ? { legacy_file_size: legacy.old } : {}) },
++ new: { size_bytes: facts.size, content_hash: facts.sha256, location_state: 'present', ...(legacy ? { legacy_file_size: facts.size } : {}) },
++ };
}
if (step.action === 'failed_recording') {
const r = h.prepare("UPDATE media_objects SET lifecycle_status = 'failed', updated_at = CURRENT_TIMESTAMP WHERE id = ? AND lifecycle_status = ?").run(obj.id, obj.lifecycle_status);
@@@ -88,6 +111,20 @@ function apply(h, step, facts = {}) {
return tx();
}
++/**
++ * The legacy vods row behind a VOD object gets the object's size (file_size), so the projection from that
++ * row (vodProjection) agrees with the object. Inside apply()'s transaction. → { old } or null (no row).
++ */
++function legacySize(h, legacyRef, size) {
++ const m = VOD_REF.exec(String(legacyRef || ''));
++ if (!m) return null;
++ const hasTable = h.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'vods'").get();
++ const row = hasTable && h.prepare('SELECT file_size FROM vods WHERE id = ?').get(Number(m[1]));
++ if (!row) return null;
++ h.prepare('UPDATE vods SET file_size = ? WHERE id = ?').run(size, Number(m[1]));
++ return { old: row.file_size };
++}
++
/** Undo one applied change where the row still holds what the repair wrote. → { restored } | { skipped } */
function rollback(h, change) {
if (!change || !change.old || !change.new) return { ...change, skipped: 'not an applied change' };
@@@ -101,6 +138,8 @@ function rollback(h, change) {
if (!r.changes) return { ...change, skipped: 'the row changed since' };
const provider = change.action === 'regenerate' ? 'local' : change.provider;
h.prepare('UPDATE media_locations SET state = ?, size_bytes = ? WHERE object_id = ? AND provider = ?').run(change.old.location_state, change.old.location_size, change.object_id, provider);
++ const m = VOD_REF.exec(String(change.legacy_ref || ''));
++ if (m && 'legacy_file_size' in change.old) h.prepare('UPDATE vods SET file_size = ? WHERE id = ? AND file_size IS ?').run(change.old.legacy_file_size, Number(m[1]), change.new.legacy_file_size);
return { ...change, restored: true };
});
return tx();
ddiff --git a/test/h15-repair.test.js b/test/h15-repair.test.js
iindex 3315c43..c2103b9 100644
---- a/test/h15-repair.test.js
++++ b/test/h15-repair.test.js
@@@ -50,4 +50,30 @@ assert.strictEqual(h.prepare("SELECT state FROM media_locations WHERE object_id
assert.strictEqual(h.prepare("SELECT lifecycle_status FROM media_objects WHERE id = 'med_3'").get().lifecycle_status, 'ready');
assert.strictEqual(rollback(h, c1).skipped, 'the row changed since', 'rolling back twice changes nothing');
++// ── resize: the copy is the object (same hash), only the recorded size went stale again ──
++// (2026-09-26: a projection from the legacy vods row put the mid-upload size back after the rebaseline.)
++const HASH = 'b'.repeat(64);
++const reverted = vod({ object_id: 'med_4', content_hash: HASH, legacy_ref: 'legacy:live:vod:939', size_bytes: 63708377, locations: [{ provider: 'b2', key: 'vods/c.webm', state: 'corrupt', size_bytes: 63803675 }] });
++const r4 = plan(reverted, { b2: good }, { b2: { size: 63803675, sha256: HASH } });
++assert.strictEqual(r4.action, 'resize');
++assert.match(r4.reason, /hashes to the object's content_hash/);
++assert.strictEqual(plan(reverted, { b2: good }, { b2: { size: 63803675, sha256: 'c'.repeat(64) } }).action, 'lost', 'other bytes than the hash: real corruption, never resized');
++assert.strictEqual(plan(reverted, { b2: good }).action, 'lost', 'no download, no resize');
++h.exec('CREATE TABLE vods (id INTEGER PRIMARY KEY, file_size INTEGER)');
++h.prepare("INSERT INTO vods VALUES (939, 63708377), (1277, 13631488)").run();
++h.prepare("INSERT INTO media_objects VALUES ('med_4', 63708377, ?, 'ready', NULL)").run(HASH);
++h.prepare("INSERT INTO media_locations (object_id, provider, key, state, size_bytes) VALUES ('med_4', 'b2', 'vods/c.webm', 'corrupt', 63803675)").run();
++assert.match(apply(h, r4, { size: 63803675, sha256: 'c'.repeat(64) }).skipped, /does not hash/, 'resize writes only bytes that match the hash');
++const c4 = apply(h, r4, { size: 63803675, sha256: HASH });
++assert.deepStrictEqual(h.prepare("SELECT size_bytes, content_hash FROM media_objects WHERE id = 'med_4'").get(), { size_bytes: 63803675, content_hash: HASH });
++assert.strictEqual(h.prepare("SELECT state FROM media_locations WHERE object_id = 'med_4'").get().state, 'present');
++assert.strictEqual(h.prepare('SELECT file_size FROM vods WHERE id = 939').get().file_size, 63803675, 'the legacy row agrees, so its projection cannot undo the repair');
++assert.deepStrictEqual([c4.old.legacy_file_size, c4.new.legacy_file_size], [63708377, 63803675]);
++// A rebaseline writes the legacy row too.
++const c5 = apply(h, plan(vod(), { b2: good }), { size: 14609471, sha256: 'f'.repeat(64) });
++assert.strictEqual(h.prepare('SELECT file_size FROM vods WHERE id = 1277').get().file_size, 14609471);
++for (const c of [c4, c5]) assert.strictEqual(rollback(h, c).restored, true);
++assert.deepStrictEqual(h.prepare('SELECT id, file_size FROM vods ORDER BY id').all(), [{ id: 939, file_size: 63708377 }, { id: 1277, file_size: 13631488 }], 'rollback restores the legacy rows');
++assert.deepStrictEqual(h.prepare("SELECT size_bytes, content_hash FROM media_objects WHERE id = 'med_4'").get(), { size_bytes: 63708377, content_hash: HASH });
++
console.log('h15 repair: all checks passed');