openvibers/openvibe.mediahub Commits
A record-only recording is filed though Media's event carries no meta: read it back from Media (#116)
Changes
@@@ -192,12 +192,26 @@ function createUploads({ s, media, config, log = console, events = null }) {* nothing (one upload per VOD, migration 0018). It counts toward the person's storage like any video, but is never* refused for it: the session was already recorded. → the row, or null when the VOD is not such a recording.*/-- async function importRecording(vod) {-- const meta = vod && vod.meta && typeof vod.meta === 'object' ? vod.meta : {};-- if (!vod || String(vod.app_id) !== String(config.media.app) || meta.source !== 'openre' || meta.record_only !== true) return null;++ async function importRecording(event) {++ if (!event || String(event.app_id) !== String(config.media.app)) return null;++ const vodId = Number(event.id);++ if (!Number.isSafeInteger(vodId) || vodId < 1) return null;++ // Media leaves a VOD's meta out of its events (they reach every subscriber, server/events.js slim): it is read++ // back from Media here, with this tenant's own access, for a VOD of this tenant only. Media unreachable throws,++ // so Events delivers the event again; a VOD Media no longer has is nothing to file.++ let vod = event;++ if (!event.meta) {++ const out = await media.vodGet(vodId);++ if (!out.ok) {++ if (out.status === 404 || out.status === 410) return null;++ throw new Error(`OpenVibe.Media could not be read for VOD ${vodId} (${out.code || out.status})`);++ }++ vod = { ...event, ...(out.data && (out.data.vod || out.data)) };++ }++ const meta = vod.meta && typeof vod.meta === 'object' ? vod.meta : {};++ if (meta.source !== 'openre' || meta.record_only !== true) return null;const subject = String(meta.owner_subject || '');-- const vodId = Number(vod.id);-- if (!/^usr_[0-9A-HJKMNP-TV-Z]{26}$/.test(subject) || !Number.isSafeInteger(vodId) || vodId < 1) return null;++ if (!/^usr_[0-9A-HJKMNP-TV-Z]{26}$/.test(subject)) return null;const known = await s.db.maybe('SELECT * FROM video_uploads WHERE media_vod_id = $1', [vodId]);if (known) return known;const now = s.iso();@@@ -123,6 +123,7 @@ async function startMedia({ maxSingle = 256 * 1024 * 1024 } = {}) {file_url: v.status === 'ready' ? `https://openvibe.media/v/vod-${v.id}.mp4` : null,hls_url: v.status === 'ready' ? `https://openvibe.media/o/med_vod${v.id}/master.m3u8` : null,thumbnail_url: v.status === 'ready' ? `https://openvibe.media/t/vod-${v.id}.jpg` : null,++ meta: v.meta || {},});if (parts.length === 4 && req.method === 'POST') {const body = JSON.parse(raw.toString('utf8') || '{}');@@@ -3,6 +3,8 @@* Record only, to my videos (plan 2026-10-22 §3 D1): OpenRestream records a record-only stream into this tenant as a* private Media VOD whose meta names the owner; when Media says it is ready (media.vod.ready, signed on* POST /internal/events) it becomes that person's private video here (server/video/uploads.js importRecording).++ * Media's events carry no meta (server/events.js slim, as in production on 2026-10-23), so MediaHub reads the VOD back++ * from Media; Media unreachable answers 503 and Events delivers the event again.* - only a VOD of this tenant marked record-only by OpenRestream, with an owner subject, is filed; anything else,* and anything not from Media, is acknowledged and left alone;* - it is private and ready, plays for its owner only, and a redelivery files nothing twice;@@@ -37,10 +39,16 @@ function vget(base, path, cookie) {// The VOD OpenRestream asked Media for (server/store/recordings.js vodFields), finished.const meta = { source: 'openre', openre_session_id: 'ses_01JABCDEFGHJKMNPQRSTVWXYZ0', openre_stream_id: 'str_01JABCDEFGHJKMNPQRSTVWXYZ0', protocol: 'rtmp', mode: 'vod', record_only: true, owner_subject: kim.subject };-- t.media.vods.set(900, { id: 900, title: 'Morning take', visibility: 'private', user_id: null, meta, bytes: Buffer.alloc(4096, 1), types: [], status: 'ready' });++ const vod = (id, m) => t.media.vods.set(id, { id, title: 'Morning take', visibility: 'private', user_id: null, meta: m, bytes: Buffer.alloc(4096, 1), types: [], status: 'ready' });++ vod(900, meta);++ vod(902, { ...meta, record_only: false });++ vod(903, { source: 'media-hub', record_only: true, owner_subject: kim.subject });++ vod(904, { ...meta, owner_subject: 'kim' });++ vod(907, meta);++ // The event as Media sends it: the VOD without its meta.const ready = (over = {}) => ({id: 900, app_id: 'media-hub', title: 'Morning take', user_id: null, visibility: 'private', is_recording: false,-- duration_seconds: 42.5, file_size: 4096, file_url: 'https://openvibe.media/v/vod-900.mp4', thumbnail_url: 'https://openvibe.media/t/vod-900.jpg', meta, ...over,++ duration_seconds: 42.5, file_size: 4096, file_url: 'https://openvibe.media/v/vod-900.mp4', thumbnail_url: 'https://openvibe.media/t/vod-900.jpg', ...over,});const deliver = (payload, { source = 'media', type = 'media.vod.ready' } = {}) => {const event = { event_id: `evt_${Math.random().toString(16).slice(2)}`, event_type: type, source, at: new Date().toISOString(), payload };@@@ -65,15 +73,24 @@ function vget(base, path, cookie) {});await check('any other VOD, or one not from Media, is acknowledged and left alone', async () => {-- const live = await deliver(ready({ id: 901, app_id: 'live', meta: { source: 'openre', mode: 'vod' } }));++ const live = await deliver(ready({ id: 901, app_id: 'live' }));assert.deepStrictEqual([live.status, live.json().outcome], [200, 'ignored:not_record_only']);-- assert.strictEqual((await deliver(ready({ id: 902, meta: { ...meta, record_only: false } }))).json().outcome, 'ignored:not_record_only');-- assert.strictEqual((await deliver(ready({ id: 903, meta: { source: 'media-hub', record_only: true, owner_subject: kim.subject } }))).json().outcome, 'ignored:not_record_only');-- assert.strictEqual((await deliver(ready({ id: 904, meta: { ...meta, owner_subject: 'kim' } }))).json().outcome, 'ignored:not_record_only');++ for (const id of [902, 903, 904]) assert.strictEqual((await deliver(ready({ id }))).json().outcome, 'ignored:not_record_only', `VOD ${id}`);++ assert.strictEqual((await deliver(ready({ id: 906 }))).json().outcome, 'ignored:not_record_only', 'a VOD Media no longer has');assert.strictEqual((await deliver(ready({ id: 905 }), { source: 'network' })).json().outcome, 'ignored:source');assert.strictEqual((await rows()).length, 1);});++ await check('Media unreachable: 503, nothing filed, and the redelivery files it', async () => {++ t.media.state.tokenRejects = true;++ const down = await deliver(ready({ id: 907 }));++ assert.strictEqual(down.status, 503);++ assert.strictEqual((await rows()).length, 1);++ t.media.state.tokenRejects = false;++ assert.strictEqual((await deliver(ready({ id: 907 }))).json().outcome, 'filed');++ assert.strictEqual((await rows()).length, 2);++ });++await check('it plays for its owner only, and their channel name is filled in when they open their videos', async () => {const [row] = await rows();assert.strictEqual((await vget(t.base, `/u/${row.id}`)).status, 404, 'private: not for the signed-out');
@@ -192,12 +192,26 @@ function createUploads({ s, media, config, log = console, events = null }) {
@@ -192,12 +192,26 @@ function createUploads({ s, media, config, log = console, events = null }) {
* nothing (one upload per VOD, migration 0018). It counts toward the person's storage like any video, but is never
* nothing (one upload per VOD, migration 0018). It counts toward the person's storage like any video, but is never
* refused for it: the session was already recorded. → the row, or null when the VOD is not such a recording.
* refused for it: the session was already recorded. → the row, or null when the VOD is not such a recording.
*/
*/
- async function importRecording(vod) {
- const meta = vod && vod.meta && typeof vod.meta === 'object' ? vod.meta : {};
- if (!vod || String(vod.app_id) !== String(config.media.app) || meta.source !== 'openre' || meta.record_only !== true) return null;
+ async function importRecording(event) {
+ if (!event || String(event.app_id) !== String(config.media.app)) return null;
+ const vodId = Number(event.id);
+ if (!Number.isSafeInteger(vodId) || vodId < 1) return null;
+ // Media leaves a VOD's meta out of its events (they reach every subscriber, server/events.js slim): it is read
+ // back from Media here, with this tenant's own access, for a VOD of this tenant only. Media unreachable throws,
+ // so Events delivers the event again; a VOD Media no longer has is nothing to file.
+ let vod = event;
+ if (!event.meta) {
+ const out = await media.vodGet(vodId);
+ if (!out.ok) {
+ if (out.status === 404 || out.status === 410) return null;
+ throw new Error(`OpenVibe.Media could not be read for VOD ${vodId} (${out.code || out.status})`);
+ }
+ vod = { ...event, ...(out.data && (out.data.vod || out.data)) };
+ }
+ const meta = vod.meta && typeof vod.meta === 'object' ? vod.meta : {};
+ if (meta.source !== 'openre' || meta.record_only !== true) return null;
const subject = String(meta.owner_subject || '');
const subject = String(meta.owner_subject || '');
- const vodId = Number(vod.id);
- if (!/^usr_[0-9A-HJKMNP-TV-Z]{26}$/.test(subject) || !Number.isSafeInteger(vodId) || vodId < 1) return null;
+ if (!/^usr_[0-9A-HJKMNP-TV-Z]{26}$/.test(subject)) return null;
const known = await s.db.maybe('SELECT * FROM video_uploads WHERE media_vod_id = $1', [vodId]);
const known = await s.db.maybe('SELECT * FROM video_uploads WHERE media_vod_id = $1', [vodId]);
if (known) return known;
if (known) return known;
const now = s.iso();
const now = s.iso();
@@ -123,6 +123,7 @@ async function startMedia({ maxSingle = 256 * 1024 * 1024 } = {}) {
@@ -123,6 +123,7 @@ async function startMedia({ maxSingle = 256 * 1024 * 1024 } = {}) {
file_url: v.status === 'ready' ? `https://openvibe.media/v/vod-${v.id}.mp4` : null,
file_url: v.status === 'ready' ? `https://openvibe.media/v/vod-${v.id}.mp4` : null,
hls_url: v.status === 'ready' ? `https://openvibe.media/o/med_vod${v.id}/master.m3u8` : null,
hls_url: v.status === 'ready' ? `https://openvibe.media/o/med_vod${v.id}/master.m3u8` : null,
thumbnail_url: v.status === 'ready' ? `https://openvibe.media/t/vod-${v.id}.jpg` : null,
thumbnail_url: v.status === 'ready' ? `https://openvibe.media/t/vod-${v.id}.jpg` : null,
+ meta: v.meta || {},
});
});
if (parts.length === 4 && req.method === 'POST') {
if (parts.length === 4 && req.method === 'POST') {
const body = JSON.parse(raw.toString('utf8') || '{}');
const body = JSON.parse(raw.toString('utf8') || '{}');
@@ -3,6 +3,8 @@
@@ -3,6 +3,8 @@
* Record only, to my videos (plan 2026-10-22 §3 D1): OpenRestream records a record-only stream into this tenant as a
* Record only, to my videos (plan 2026-10-22 §3 D1): OpenRestream records a record-only stream into this tenant as a
* private Media VOD whose meta names the owner; when Media says it is ready (media.vod.ready, signed on
* private Media VOD whose meta names the owner; when Media says it is ready (media.vod.ready, signed on
* POST /internal/events) it becomes that person's private video here (server/video/uploads.js importRecording).
* POST /internal/events) it becomes that person's private video here (server/video/uploads.js importRecording).
+ * Media's events carry no meta (server/events.js slim, as in production on 2026-10-23), so MediaHub reads the VOD back
+ * from Media; Media unreachable answers 503 and Events delivers the event again.
* - only a VOD of this tenant marked record-only by OpenRestream, with an owner subject, is filed; anything else,
* - only a VOD of this tenant marked record-only by OpenRestream, with an owner subject, is filed; anything else,
* and anything not from Media, is acknowledged and left alone;
* and anything not from Media, is acknowledged and left alone;
* - it is private and ready, plays for its owner only, and a redelivery files nothing twice;
* - it is private and ready, plays for its owner only, and a redelivery files nothing twice;
@@ -37,10 +39,16 @@ function vget(base, path, cookie) {
@@ -37,10 +39,16 @@ function vget(base, path, cookie) {
// The VOD OpenRestream asked Media for (server/store/recordings.js vodFields), finished.
// The VOD OpenRestream asked Media for (server/store/recordings.js vodFields), finished.
const meta = { source: 'openre', openre_session_id: 'ses_01JABCDEFGHJKMNPQRSTVWXYZ0', openre_stream_id: 'str_01JABCDEFGHJKMNPQRSTVWXYZ0', protocol: 'rtmp', mode: 'vod', record_only: true, owner_subject: kim.subject };
const meta = { source: 'openre', openre_session_id: 'ses_01JABCDEFGHJKMNPQRSTVWXYZ0', openre_stream_id: 'str_01JABCDEFGHJKMNPQRSTVWXYZ0', protocol: 'rtmp', mode: 'vod', record_only: true, owner_subject: kim.subject };
- t.media.vods.set(900, { id: 900, title: 'Morning take', visibility: 'private', user_id: null, meta, bytes: Buffer.alloc(4096, 1), types: [], status: 'ready' });
+ const vod = (id, m) => t.media.vods.set(id, { id, title: 'Morning take', visibility: 'private', user_id: null, meta: m, bytes: Buffer.alloc(4096, 1), types: [], status: 'ready' });
+ vod(900, meta);
+ vod(902, { ...meta, record_only: false });
+ vod(903, { source: 'media-hub', record_only: true, owner_subject: kim.subject });
+ vod(904, { ...meta, owner_subject: 'kim' });
+ vod(907, meta);
+ // The event as Media sends it: the VOD without its meta.
const ready = (over = {}) => ({
const ready = (over = {}) => ({
id: 900, app_id: 'media-hub', title: 'Morning take', user_id: null, visibility: 'private', is_recording: false,
id: 900, app_id: 'media-hub', title: 'Morning take', user_id: null, visibility: 'private', is_recording: false,
- duration_seconds: 42.5, file_size: 4096, file_url: 'https://openvibe.media/v/vod-900.mp4', thumbnail_url: 'https://openvibe.media/t/vod-900.jpg', meta, ...over,
+ duration_seconds: 42.5, file_size: 4096, file_url: 'https://openvibe.media/v/vod-900.mp4', thumbnail_url: 'https://openvibe.media/t/vod-900.jpg', ...over,
});
});
const deliver = (payload, { source = 'media', type = 'media.vod.ready' } = {}) => {
const deliver = (payload, { source = 'media', type = 'media.vod.ready' } = {}) => {
const event = { event_id: `evt_${Math.random().toString(16).slice(2)}`, event_type: type, source, at: new Date().toISOString(), payload };
const event = { event_id: `evt_${Math.random().toString(16).slice(2)}`, event_type: type, source, at: new Date().toISOString(), payload };
@@ -65,15 +73,24 @@ function vget(base, path, cookie) {
@@ -65,15 +73,24 @@ function vget(base, path, cookie) {
});
});
await check('any other VOD, or one not from Media, is acknowledged and left alone', async () => {
await check('any other VOD, or one not from Media, is acknowledged and left alone', async () => {
- const live = await deliver(ready({ id: 901, app_id: 'live', meta: { source: 'openre', mode: 'vod' } }));
+ const live = await deliver(ready({ id: 901, app_id: 'live' }));
assert.deepStrictEqual([live.status, live.json().outcome], [200, 'ignored:not_record_only']);
assert.deepStrictEqual([live.status, live.json().outcome], [200, 'ignored:not_record_only']);
- assert.strictEqual((await deliver(ready({ id: 902, meta: { ...meta, record_only: false } }))).json().outcome, 'ignored:not_record_only');
- assert.strictEqual((await deliver(ready({ id: 903, meta: { source: 'media-hub', record_only: true, owner_subject: kim.subject } }))).json().outcome, 'ignored:not_record_only');
- assert.strictEqual((await deliver(ready({ id: 904, meta: { ...meta, owner_subject: 'kim' } }))).json().outcome, 'ignored:not_record_only');
+ for (const id of [902, 903, 904]) assert.strictEqual((await deliver(ready({ id }))).json().outcome, 'ignored:not_record_only', `VOD ${id}`);
+ assert.strictEqual((await deliver(ready({ id: 906 }))).json().outcome, 'ignored:not_record_only', 'a VOD Media no longer has');
assert.strictEqual((await deliver(ready({ id: 905 }), { source: 'network' })).json().outcome, 'ignored:source');
assert.strictEqual((await deliver(ready({ id: 905 }), { source: 'network' })).json().outcome, 'ignored:source');
assert.strictEqual((await rows()).length, 1);
assert.strictEqual((await rows()).length, 1);
});
});
+ await check('Media unreachable: 503, nothing filed, and the redelivery files it', async () => {
+ t.media.state.tokenRejects = true;
+ const down = await deliver(ready({ id: 907 }));
+ assert.strictEqual(down.status, 503);
+ assert.strictEqual((await rows()).length, 1);
+ t.media.state.tokenRejects = false;
+ assert.strictEqual((await deliver(ready({ id: 907 }))).json().outcome, 'filed');
+ assert.strictEqual((await rows()).length, 2);
+ });
+
await check('it plays for its owner only, and their channel name is filled in when they open their videos', async () => {
await check('it plays for its owner only, and their channel name is filled in when they open their videos', async () => {
const [row] = await rows();
const [row] = await rows();
assert.strictEqual((await vget(t.base, `/u/${row.id}`)).status, 404, 'private: not for the signed-out');
assert.strictEqual((await vget(t.base, `/u/${row.id}`)).status, 404, 'private: not for the signed-out');