openvibers/openvibe.media Commits

Proxy R2 opening reads through Media so HLS keeps its CORS (#407)

OP OpenVibers committed 2 days ago · 19fc5eefa2 · 3 files, +41 −2

Changes

ddiff --git a/docs/startup-tier.md b/docs/startup-tier.md
iindex 6ff0b9f..dd45411 100644
---- a/docs/startup-tier.md
++++ b/docs/startup-tier.md
@@@ -1,6 +1,6 @@
# Recording opening tier (ask 288)
--With HLS enabled, `MEDIA_HLS_STARTUP_SECONDS` chooses how many opening seconds use R2 (180 by default, 0 disables, maximum 300). Ask 288 explicitly requests this placement enabled. Later segments use B2. Small initialization segments remain on R2; a packed chunk crossing the boundary stays on R2 as a whole. The original complete file is untouched.
++With HLS enabled, `MEDIA_HLS_STARTUP_SECONDS` chooses how many opening seconds use R2 (180 by default, 0 disables, maximum 300). Ask 288 explicitly requests this placement enabled. Later segments use B2. Small initialization segments remain on R2; a packed chunk crossing the boundary stays on R2 as a whole. R2 answers without an `Access-Control-Allow-Origin` header, so a browser in CORS mode (native HLS and hls.js both) cannot read a cross-origin redirect to it; an unpacked R2 row is therefore streamed through Media like a packed slice, which carries Media's CORS headers. Without that, the init segment fails and the page falls back to the complete file (or stops playing). The original complete file is untouched.
The existing heavy worker runs `object.startup-tier` after cuts and lazily when a master playlist is read. A public read does not wait for a copy. Lazy requests are capped by `MEDIA_HLS_BACKFILL_PER_HOUR` (20 by default), the queue's tenant limit, a ten-minute in-process throttle and a source/policy idempotency key. First-time access to an old recording can therefore still use B2 until the job completes. Each job copies at most 512 MiB and takes at most 30 minutes; unavailable providers, holds and verification failures retain the current routing.
@@@ -8,6 +8,6 @@ A joined recording uses its complete timeline: a later part does not get another
A durable, internal-only `storage.startup.cleanup` job keeps old copies for 360 seconds: the presigned URL lifetime is 300 seconds and the joined timeline cache is 20 seconds. It refuses held objects and removes a copy only if no timeline row still names it. Failed deletes retry in the light worker. Packing keeps provider boundaries and preserves the current provider for each chunk.
--Validation: `ov test test/startup-tier.test.js` and `ov test test/media-timeline.test.js`, then the full suite. Production baseline and after-deploy measurements are in the PR and `~/openvibe/research-2026-11-10/player-starts.md`. The baseline recording (`/o/med_01M729VRTW6E3NPTTM008H0GAA`) used B2 at both `source/p1-000001.m4s` (0 s) and `source/p2-000019.m4s` (217.297 s). Once tiered, the first should report `X-OV-Media-Provider: r2` and the latter `b2`.
++Validation: `ov test test/startup-tier.test.js`, `ov test test/timeline-cache.test.js` and `ov test test/media-timeline.test.js`, then the full suite. Production baseline and after-deploy measurements are in the PR and `~/openvibe/research-2026-11-10/player-starts.md`. The baseline recording (`/o/med_01M729VRTW6E3NPTTM008H0GAA`) used B2 at both `source/p1-000001.m4s` (0 s) and `source/p2-000019.m4s` (217.297 s). Once tiered, the first should report `X-OV-Media-Provider: r2` and the latter `b2`.
Next for the design: none; this is storage, jobs and diagnostic headers only.
ddiff --git a/server/objects/routes.js b/server/objects/routes.js
iindex 2c70efa..4e289dd 100644
---- a/server/objects/routes.js
++++ b/server/objects/routes.js
@@@ -1105,6 +1105,16 @@ async function sendSegmentRow(req, res, { obj, closed }, row, rendition) {
if (await sendSlice(req, res, { url, ...slice }) !== false) return;
continue;
}
++ // R2 answers without Access-Control-Allow-Origin, so a browser in CORS mode (native HLS and hls.js both)
++ // cannot read a cross-origin redirect to it; an init segment would fail and the page falls back to the file.
++ // Stream an unpacked opening row through Media like a packed slice, which carries Media's CORS headers.
++ if (loc.provider === 'r2') {
++ const size = Number(row.byte_length)
++ || Number((await require('../vod/vod-storage').headObject('r2', loc.key).catch(() => null) || {}).size);
++ if (!(size > 0)) continue;
++ if (await sendSlice(req, res, { url, offset: 0, length: size, headers }) !== false) return;
++ continue;
++ }
res.set('Cache-Control', 'private, max-age=0');
res.set('X-OV-Media-Provider', loc.provider);
return res.redirect(302, url);
ddiff --git a/test/timeline-cache.test.js b/test/timeline-cache.test.js
iindex e2c5775..62274e8 100644
---- a/test/timeline-cache.test.js
++++ b/test/timeline-cache.test.js
@@@ -124,6 +124,35 @@ const { EventEmitter } = require('events');
} finally {
router.route = realRoute; router.sessionFor = realSession; storage.presignGet = realPresign; global.fetch = realFetch;
}
++ // An unpacked R2 row is proxied through Media: R2 sends no CORS header, so a browser following a redirect
++ // to it cannot read the init segment and the page falls back to the file (or stops).
++ const initBytes = Buffer.from('init-data');
++ await timeline.replace('cache-r2init', 'source', [{ seq: 0, name: 'init.mp4', start_ms: 0, end_ms: 0,
++ key: 'cache/r2init', local_path: null, durable_provider: 'r2', packed_object_id: null,
++ byte_offset: null, byte_length: initBytes.length, sha256: crypto.createHash('sha256').update(initBytes).digest('hex'), durability: 'durable' }]);
++ const initRow = await timeline.byName('cache-r2init', 'source', 'init.mp4');
++ const realHead = storage.headObject;
++ router.route = async () => ({ provider: 'r2', candidates: [{ provider: 'r2', key: initRow.key }] });
++ storage.headObject = async (provider, key) => provider === 'r2' && key === initRow.key ? { size: initBytes.length } : null;
++ storage.presignGet = async () => 'https://r2.invalid/init';
++ global.fetch = async (_url, opts) => {
++ const m = /^bytes=(\d+)-(\d+)$/.exec(opts.headers.range);
++ return new Response(initBytes.subarray(Number(m[1]), Number(m[2]) + 1), { status: 206 });
++ };
++ try {
++ const req = new EventEmitter();
++ req.headers = {};
++ const res = { destroyed: false, writeHead(code, headers) { this.code = code; this.headers = headers; }, end(body) { this.body = body; },
++ set() { this.reduced = true; }, redirect() { this.redirected = true; } };
++ await require('../server/objects/routes').sendSegmentRow(req, res, { obj: { id: 'cache-r2init' }, closed: false }, initRow, 'source');
++ assert.strictEqual(res.code, 200);
++ assert.strictEqual(res.headers['X-OV-Media-Provider'], 'r2');
++ assert.deepStrictEqual(res.body, initBytes, 'the R2 opening is streamed by Media');
++ assert.ok(!res.redirected, 'no cross-origin redirect to a store without CORS');
++ } finally {
++ storage.headObject = realHead;
++ router.route = realRoute;
++ }
assert.strictEqual(fs.existsSync(only), true);
console.log('timeline cache: oldest verified copies and packed range evicted; sole copy retained');