openvibers/openrestream Commits

Record only, to my videos: a stream that never goes live and lands privately in OpenVibe.Video (plan D1) (#96)

OP OpenVibers committed 21 days ago · aec0df9305 · 10 files, +260 −56

Changes

ddiff --git a/migrations/0006_record_to.sql b/migrations/0006_record_to.sql
nnew file mode 100644
iindex 0000000..4e04da6
---- /dev/null
++++ b/migrations/0006_record_to.sql
@@@ -0,0 +1,5 @@
++-- phase: expand
++-- A stream can record privately to its owner's videos instead of going out (the owner's ask, plan 2026-10-22 §3 D1):
++-- record_to 'videos' files each session's recording in OpenVibe.Video (Media app media-hub) as the owner's private
++-- video, starts no restream output and never mirrors to OpenVibe Live. NULL, every existing stream, is unchanged.
++ALTER TABLE stream_definitions ADD COLUMN IF NOT EXISTS record_to text;
ddiff --git a/server/api/v1.js b/server/api/v1.js
iindex 4136c15..dca6eed 100644
---- a/server/api/v1.js
++++ b/server/api/v1.js
@@@ -59,6 +59,7 @@ function createV1Router({ rt, auth, liveLink = null }) {
recording_visibility: d.recording_visibility,
playback_visibility: d.playback_visibility,
mirror_to_live: d.mirror_to_live,
++ record_to: d.record_to || null,
state: d.state,
revision: d.revision,
external_refs: d.external_refs,
@@@ -158,13 +159,13 @@ function createV1Router({ rt, auth, liveLink = null }) {
owner_subject: owner,
title: b.title, description: b.description, protocols: b.protocols,
recording_mode: b.recording_mode, recording_visibility: b.recording_visibility, playback_visibility: b.playback_visibility,
-- mirror_to_live: b.mirror_to_live, external_refs: b.external_refs,
++ mirror_to_live: b.mirror_to_live, record_to: b.record_to, external_refs: b.external_refs,
created_by: auth.actorOf(req.caller),
});
// OpenVibe Live on by default for a person's own stream (show_on_live: false opts out). A service names its
-- // own refs (Live's slots, Bot's robots), so it is never linked here.
++ // own refs (Live's slots, Bot's robots), so it is never linked here, and a record-only stream never goes live.
let live = null;
-- if (liveLink && req.caller.kind === 'user' && b.show_on_live !== false) live = await liveLink.link(await store.definitions.get(definition.id));
++ if (liveLink && req.caller.kind === 'user' && b.show_on_live !== false && !definition.record_to) live = await liveLink.link(await store.definitions.get(definition.id));
res.set('Cache-Control', 'no-store');
const current = live ? await store.definitions.get(definition.id) : definition;
return res.status(201).json({ stream: await publicDefinition(current), key: { id: key.id, key: key.key, hint: key.hint, shown_once: true }, ...(live ? { live } : {}) });
@@@ -180,10 +181,13 @@ function createV1Router({ rt, auth, liveLink = null }) {
if (!d) return;
const b = req.body || {};
// A person switching mirror_to_live on gets their Live slot first (it stays off, with the reason, when Live refuses).
-- const linkNow = liveLink && req.caller.kind === 'user' && b.mirror_to_live === true && !liveLink.slotOf(d);
++ // A record-only stream is refused instead (openre.record_only): it never goes live.
++ const recordOnly = b.record_to !== undefined ? b.record_to === 'videos' : d.record_to === 'videos';
++ const linkNow = liveLink && req.caller.kind === 'user' && b.mirror_to_live === true && !liveLink.slotOf(d) && !recordOnly;
let updated = await store.definitions.update(d.id, {
title: b.title, description: b.description, protocols: b.protocols, recording_mode: b.recording_mode,
recording_visibility: b.recording_visibility, playback_visibility: b.playback_visibility, mirror_to_live: linkNow ? undefined : b.mirror_to_live, state: b.state,
++ record_to: b.record_to,
});
let live = null;
if (linkNow) { live = await liveLink.link(await store.definitions.get(d.id)); updated = await store.definitions.get(d.id); }
ddiff --git a/server/config.js b/server/config.js
iindex 43e76ff..832c4a5 100644
---- a/server/config.js
++++ b/server/config.js
@@@ -213,10 +213,16 @@ function load(env = process.env) {
// media.object.upload, namespace live). The shared tenant key is gone (C-42).
enabled: env.OPENRE_RECORDING !== 'off',
startDelayMs: int(env.OPENRE_RECORDING_START_DELAY_MS, 2000),
++ // A record-only stream (record_to 'videos') files its recordings in OpenVibe.Video's tenant instead, as the
++ // owner's private video (grant media.object.upload, namespace media-hub); MediaHub picks each one up when
++ // Media says it is ready.
++ videosAppId: env.OPENRE_VIDEOS_MEDIA_APP || 'media-hub',
},
// ── Live compatibility (links on the UI only; never a data dependency) ──
liveUrl: trim(env.OV_LIVE_URL || 'https://openvibe.live'),
++ // Where a record-only stream's recordings are watched and edited (links on the UI only).
++ videoUrl: trim(env.OV_VIDEO_URL || 'https://openvibe.video'),
};
}
ddiff --git a/server/media-client.js b/server/media-client.js
iindex bbb65be..e86415c 100644
---- a/server/media-client.js
++++ b/server/media-client.js
@@@ -22,11 +22,12 @@ class MediaError extends Error {
constructor(message, status, body) { super(message); this.status = status || 0; this.body = body || null; }
}
--function createMediaClient({ config, fetchImpl = globalThis.fetch }) {
-- const base = `${config.media.url}/api/v1/${encodeURIComponent(config.media.appId)}`;
-- const tokens = config.oauth.clientSecret
++function createMediaClient({ config, fetchImpl = globalThis.fetch, appId = config.media.appId, tokens: shared = null }) {
++ const base = `${config.media.url}/api/v1/${encodeURIComponent(appId)}`;
++ const tokens = shared || (config.oauth.clientSecret
? createServiceTokenClient({ tokenUrl: `${config.networkInternalUrl}/oauth/token`, clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret, audience: 'openvibe.media', fetch: fetchImpl })
-- : null;
++ : null);
++ const others = new Map();
// A restore drill (OPENRE_DRILL) never asks Media for anything.
const configured = config.media.enabled && !config.drill && Boolean(tokens);
@@@ -54,7 +55,13 @@ function createMediaClient({ config, fetchImpl = globalThis.fetch }) {
return {
configured,
-- appId: config.media.appId,
++ appId,
++ /** The same calls against another Media app (a record-only stream's recordings go to OpenVibe.Video's). */
++ forApp(other) {
++ if (!other || other === appId) return this;
++ if (!others.has(other)) others.set(other, createMediaClient({ config, fetchImpl, appId: other, tokens }));
++ return others.get(other);
++ },
createVod: async (fields) => await request('POST', '/vods', { body: fields }),
ingestRtmp: async (vodId, rtmpUrl) => await request('POST', `/vods/${encodeURIComponent(vodId)}/ingest/rtmp`, { body: { rtmp_url: rtmpUrl } }),
/** Media RTP ingest: allocate a local RTP/RTCP port pair Media listens on (Live server/streaming/recorder.js). */
ddiff --git a/server/store/definitions.js b/server/store/definitions.js
iindex 71dc067..4b0d560 100644
---- a/server/store/definitions.js
++++ b/server/store/definitions.js
@@@ -17,6 +17,9 @@ const PROTOCOLS = Object.freeze(['rtmp', 'webrtc', 'jsmpeg']);
const PROTOCOL_ALIASES = Object.freeze({ whip: 'webrtc' });
const RECORDING_MODES = Object.freeze(['vod', 'clips', 'none']);
const VISIBILITIES = Object.freeze(['public', 'unlisted', 'private']);
++// record_to: where a stream goes instead of out. 'videos' records each session privately to the owner's OpenVibe.Video
++// (plan 2026-10-22 §3 D1): a VOD, private, no restream output, never on OpenVibe Live. null: a normal stream.
++const RECORD_TO = Object.freeze(['videos']);
const REF_SERVICE_RE = /^[a-z][a-z0-9-]{1,39}$/;
const REF_TYPE_RE = /^[a-z][a-z0-9_]{1,39}$/;
// Owner-level reference types may be shared by several definitions of the same person.
@@@ -55,9 +58,9 @@ function createDefinitions({ db, config, events, clock }) {
const q = {
insert: db.prepare(`INSERT INTO stream_definitions (id, owner_subject, title, description, protocols, recording_mode,
-- recording_visibility, playback_visibility, mirror_to_live, state, revision, created_at, updated_at)
++ recording_visibility, playback_visibility, mirror_to_live, record_to, state, revision, created_at, updated_at)
VALUES (@id, @owner_subject, @title, @description, @protocols, @recording_mode, @recording_visibility,
-- @playback_visibility, @mirror_to_live, 'active', 1, @now, @now)`),
++ @playback_visibility, @mirror_to_live, @record_to, 'active', 1, @now, @now)`),
get: db.prepare('SELECT * FROM stream_definitions WHERE id = ?'),
refs: db.prepare('SELECT service, type, ref_id AS id, label FROM external_refs WHERE definition_id = ? ORDER BY created_at, service, type'),
insertRef: db.prepare('INSERT INTO external_refs (definition_id, service, type, ref_id, label, created_at) VALUES (?, ?, ?, ?, ?, ?)'),
@@@ -81,6 +84,7 @@ function createDefinitions({ db, config, events, clock }) {
...d,
protocols: parseJson(d.protocols, ['rtmp']),
mirror_to_live: Boolean(d.mirror_to_live),
++ record_to: d.record_to || null,
external_refs: await q.refs.all(id),
};
}
@@@ -121,6 +125,23 @@ function createDefinitions({ db, config, events, clock }) {
}
}
if (input.mirror_to_live !== undefined) out.mirror_to_live = input.mirror_to_live ? 1 : 0;
++ if (input.record_to !== undefined) {
++ if (input.record_to !== null && input.record_to !== '' && !RECORD_TO.includes(input.record_to)) {
++ throw new StoreError(400, 'openre.invalid_field', `record_to must be ${RECORD_TO.join(' or ')}, or null`);
++ }
++ out.record_to = input.record_to || null;
++ }
++ // Recording only, to the owner's videos: always a private VOD, never on OpenVibe Live. What the request did not
++ // name follows; what it named against it is refused, so nobody believes a record-only stream went out.
++ const recordTo = out.record_to !== undefined ? out.record_to : (base.record_to || null);
++ if (recordTo === 'videos') {
++ if (out.mirror_to_live) throw new StoreError(400, 'openre.record_only', 'a stream that records only to your videos never goes to OpenVibe Live');
++ for (const [k, want] of [['recording_mode', 'vod'], ['recording_visibility', 'private'], ['playback_visibility', 'private']]) {
++ if (out[k] !== undefined && out[k] !== want && input.record_to === undefined) throw new StoreError(400, 'openre.record_only', `a stream that records only to your videos has ${k} ${want}`);
++ out[k] = want;
++ }
++ out.mirror_to_live = 0;
++ }
if (input.state !== undefined) {
if (!['active', 'disabled'].includes(input.state)) throw new StoreError(400, 'openre.invalid_field', 'state must be active or disabled');
out.state = input.state;
@@@ -143,7 +164,7 @@ function createDefinitions({ db, config, events, clock }) {
async function create(input) {
if (!ids.isSubjectId('user', input.owner_subject)) throw new StoreError(400, 'openre.invalid_owner', 'owner_subject must be a usr_… subject id');
const given = Object.fromEntries(Object.entries(input).filter(([, v]) => v !== undefined && v !== null && v !== ''));
-- const fields = normalize({ protocols: ['rtmp'], recording_mode: 'vod', recording_visibility: 'public', playback_visibility: 'public', mirror_to_live: false, ...given });
++ const fields = normalize({ protocols: ['rtmp'], recording_mode: 'vod', recording_visibility: 'public', playback_visibility: 'public', mirror_to_live: false, record_to: null, ...given });
const refs = validateRefs(input.external_refs);
const id = newId('stream', now());
return await db.tx(async () => {
ddiff --git a/server/store/outputs.js b/server/store/outputs.js
iindex e8975a5..bf0a8ff 100644
---- a/server/store/outputs.js
++++ b/server/store/outputs.js
@@@ -218,10 +218,12 @@ function createOutputs({ db, config, events, clock, box, definitions, sessions }
/** Coordinator: every live session gets an output per enabled, auto-start, usable destination. */
async function ensureAutoOutputs() {
++ // A record-only stream (record_to set) never goes out: its destinations wait until it is a normal stream again.
const rows = await db.prepare(`SELECT s.id AS session_id, d.id AS destination_id FROM ingest_sessions s
JOIN destinations d ON d.definition_id = s.definition_id
++ JOIN stream_definitions sd ON sd.id = s.definition_id
LEFT JOIN outputs o ON o.session_id = s.id AND o.destination_id = d.id
-- WHERE s.state = 'live' AND d.enabled = 1 AND d.auto_start = 1 AND d.hold_reason IS NULL
++ WHERE s.state = 'live' AND sd.record_to IS NULL AND d.enabled = 1 AND d.auto_start = 1 AND d.hold_reason IS NULL
AND d.stream_key_enc IS NOT NULL AND (d.cooldown_until IS NULL OR d.cooldown_until <= ?) AND o.id IS NULL`).all(now());
for (const r of rows) await q.insertOut.run(newId('output', now()), r.session_id, r.destination_id, now(), now());
return rows.length;
@@@ -237,6 +239,8 @@ function createOutputs({ db, config, events, clock, box, definitions, sessions }
if (d.hold_reason) throw new StoreError(409, 'openre.destination_held', `held: ${d.hold_reason}`);
if (!d.enabled) throw new StoreError(409, 'openre.destination_disabled', 'enable the destination first');
if (!d.stream_key_enc) throw new StoreError(409, 'openre.destination_no_key', 'the destination has no stream key');
++ const owner = await db.prepare('SELECT record_to FROM stream_definitions WHERE id = ?').get(d.definition_id);
++ if (owner && owner.record_to) throw new StoreError(409, 'openre.record_only', 'this stream records only to your videos: nothing goes out');
const session = (await sessions.list({ definition_id: d.definition_id, state: 'live', limit: 1 }))[0];
if (!session) throw new StoreError(409, 'openre.not_live', 'the stream is not live');
return await db.tx(async () => {
ddiff --git a/server/store/recordings.js b/server/store/recordings.js
iindex e117365..a470dcd 100644
---- a/server/store/recordings.js
++++ b/server/store/recordings.js
@@@ -9,6 +9,11 @@
*
* Driven by the session coordinator, never by a transport worker or the API: a worker only has to
* keep the loopback play URL up, and Media's ffmpeg pulls from it. Media finalises.
++ *
++ * A record-only stream (record_to 'videos', plan 2026-10-22 §3 D1) records into OpenVibe.Video's Media app
++ * (config.media.videosAppId) as a private VOD whose meta names the owner subject; when Media says it is ready,
++ * MediaHub files it as that person's private video, where the AI edit is offered. The recording row keeps its app, so
++ * every later call goes to the same tenant.
*/
const { newId } = require('../ids');
const { TYPES } = require('../events');
@@@ -29,7 +34,7 @@ function createRecordings({ db, config, events, clock, definitions, sessions, wo
const q = {
get: db.prepare('SELECT * FROM recordings WHERE id = ?'),
bySession: db.prepare('SELECT * FROM recordings WHERE session_id = ?'),
-- missing: db.prepare(`SELECT s.id AS session_id, d.recording_mode AS mode FROM ingest_sessions s
++ missing: db.prepare(`SELECT s.id AS session_id, d.recording_mode AS mode, d.record_to FROM ingest_sessions s
JOIN stream_definitions d ON d.id = s.definition_id LEFT JOIN recordings r ON r.session_id = s.id
WHERE s.state = 'live' AND d.recording_mode IN ('vod', 'clips') AND s.live_at <= ? AND r.id IS NULL`),
insert: db.prepare(`INSERT INTO recordings (id, session_id, mode, state, media_app, created_at, updated_at)
@@@ -48,7 +53,10 @@ function createRecordings({ db, config, events, clock, definitions, sessions, wo
async function ensureRequests() {
const rows = await q.missing.all(now() - config.media.startDelayMs);
-- for (const r of rows) await q.insert.run(newId('recording', now()), r.session_id, r.mode, config.media.appId, now(), now());
++ for (const r of rows) {
++ const app = r.record_to === 'videos' ? config.media.videosAppId : config.media.appId;
++ await q.insert.run(newId('recording', now()), r.session_id, r.mode, app, now(), now());
++ }
return rows.length;
}
@@@ -65,6 +73,28 @@ function createRecordings({ db, config, events, clock, definitions, sessions, wo
return r ? r.id : undefined;
}
++ /**
++ * What Media is asked to make for a session. Media's 'live' tenant files VODs under Live-local ids; they travel as
++ * typed references on the definition (compatibility only, never identity). A record-only stream's VOD is private
++ * and names its owner instead, for MediaHub.
++ */
++ function vodFields(definition, session, rec) {
++ const meta = { source: 'openre', openre_session_id: session.id, openre_stream_id: definition.id, protocol: session.protocol, mode: rec.mode };
++ if (definition.record_to === 'videos') {
++ return { title: definition.title || 'Recording', visibility: 'private', meta: { ...meta, record_only: true, owner_subject: definition.owner_subject } };
++ }
++ const liveUser = refOf(definition, 'live', 'user');
++ const liveSlot = refOf(definition, 'live', 'managed_stream');
++ return {
++ title: definition.title || 'Stream Recording',
++ user_id: liveUser != null ? Number(liveUser) : undefined,
++ managed_stream_id: liveSlot != null ? Number(liveSlot) : undefined,
++ clips_only: rec.mode === 'clips' || undefined,
++ visibility: definition.recording_visibility,
++ meta,
++ };
++ }
++
/** The egress base URL of the worker that owns this session (its RTP descriptor API), or null. */
async function egressBase(session) {
const owner = session.worker_id ? await workers.get(session.worker_id) : null;
@@@ -108,16 +138,7 @@ function createRecordings({ db, config, events, clock, definitions, sessions, wo
let vodId = rec.media_vod_id;
try {
if (!vodId) {
-- const liveUser = refOf(definition, 'live', 'user');
-- const liveSlot = refOf(definition, 'live', 'managed_stream');
-- const out = await media.createVod({
-- title: definition.title || 'Stream Recording',
-- user_id: liveUser != null ? Number(liveUser) : undefined,
-- managed_stream_id: liveSlot != null ? Number(liveSlot) : undefined,
-- clips_only: rec.mode === 'clips' || undefined,
-- visibility: definition.recording_visibility,
-- meta: { source: 'openre', openre_session_id: session.id, openre_stream_id: definition.id, protocol: 'webrtc', mode: rec.mode },
-- });
++ const out = await media.createVod(vodFields(definition, session, rec));
vodId = String(out && out.id);
rec = await update(rec.id, { media_vod_id: vodId, state: 'requested' });
}
@@@ -147,7 +168,7 @@ function createRecordings({ db, config, events, clock, definitions, sessions, wo
stream_id: definition.id,
owner: { type: 'user', id: definition.owner_subject },
mode: rec.mode,
-- media: { app: config.media.appId, vod_id: r.media_vod_id },
++ media: { app: rec.media_app || config.media.appId, vod_id: r.media_vod_id },
external_refs: definition.external_refs,
},
});
@@@ -173,6 +194,8 @@ function createRecordings({ db, config, events, clock, definitions, sessions, wo
}
async function step(rec, media) {
++ // Every call for a recording goes to the tenant it was filed under (a record-only stream's: OpenVibe.Video's).
++ if (rec.media_app && media.forApp) media = media.forApp(rec.media_app);
const session = await sessions.get(rec.session_id);
if (!session) return await update(rec.id, { state: 'failed', last_error: 'session missing' });
const definition = await definitions.row(session.definition_id);
@@@ -210,18 +233,7 @@ function createRecordings({ db, config, events, clock, definitions, sessions, wo
let vodId = rec.media_vod_id;
try {
if (!vodId) {
-- // Media's 'live' tenant files VODs under Live-local ids; they travel as typed
-- // references on the definition (compatibility only, never identity).
-- const liveUser = refOf(definition, 'live', 'user');
-- const liveSlot = refOf(definition, 'live', 'managed_stream');
-- const out = await media.createVod({
-- title: definition.title || 'Stream Recording',
-- user_id: liveUser != null ? Number(liveUser) : undefined,
-- managed_stream_id: liveSlot != null ? Number(liveSlot) : undefined,
-- clips_only: rec.mode === 'clips' || undefined,
-- visibility: definition.recording_visibility,
-- meta: { source: 'openre', openre_session_id: session.id, openre_stream_id: definition.id, protocol: session.protocol, mode: rec.mode },
-- });
++ const out = await media.createVod(vodFields(definition, session, rec));
vodId = String(out && out.id);
rec = await update(rec.id, { media_vod_id: vodId, state: 'requested' });
}
@@@ -248,7 +260,7 @@ function createRecordings({ db, config, events, clock, definitions, sessions, wo
stream_id: definition.id,
owner: { type: 'user', id: definition.owner_subject },
mode: rec.mode,
-- media: { app: config.media.appId, vod_id: r.media_vod_id },
++ media: { app: rec.media_app || config.media.appId, vod_id: r.media_vod_id },
external_refs: definition.external_refs,
},
});
ddiff --git a/server/ui/layout.js b/server/ui/layout.js
iindex 6fe4ed5..5354f3f 100644
---- a/server/ui/layout.js
++++ b/server/ui/layout.js
@@@ -43,6 +43,12 @@ main.ore .sc-hero,main.ore .sc-sec,main.ore .sc-cta{padding-left:0;padding-right
.ore .pill.live,.ore .pill.ok{background:var(--ore-ok);color:#04110a}.ore .pill.starting,.ore .pill.ending,.ore .pill.pending,.ore .pill.error,.ore .pill.draining{background:var(--ore-warn);color:#1a1000}
.ore .pill.failed,.ore .pill.lost{background:#b91c1c;color:#fff}
.ore .flash{border-left:4px solid var(--ore-accent);padding:8px 12px;background:var(--ore-panel);margin:12px 0}.ore .flash.bad{border-color:var(--ore-bad)}
++.ore .ore-choice{border:0;padding:0;margin:.4em 0 .7em}.ore .ore-choice legend{color:var(--ore-muted);font-size:.88rem;padding:0;margin-bottom:.35em}
++.ore .ore-options{display:grid;gap:8px;grid-template-columns:repeat(auto-fit,minmax(min(100%,240px),1fr))}
++.ore .ore-option{display:flex;gap:10px;align-items:flex-start;margin:0;padding:10px 12px;border:1px solid var(--ore-line);border-radius:8px;background:#0a0f18;color:var(--ore-text);font-size:.95rem;cursor:pointer}
++.ore .ore-option:has(input:checked){border-color:var(--ore-accent);box-shadow:inset 0 0 0 1px var(--ore-accent)}.ore .ore-option:has(input:focus-visible){outline:2px solid var(--ore-accent);outline-offset:2px}
++.ore .ore-option input{margin:.3em 0 0;accent-color:var(--ore-accent);flex:none}.ore .ore-option strong{display:block}.ore .ore-option .muted{display:block;font-size:.86rem;margin-top:2px}
++.ore form:has(input[name=record_to][value=videos]:checked) .ore-live-only{display:none}
@media (max-width:640px){.ore th:nth-child(n+4),.ore td:nth-child(n+4){display:none}}
`;
ddiff --git a/server/ui/routes.js b/server/ui/routes.js
iindex 60b7313..977f485 100644
---- a/server/ui/routes.js
++++ b/server/ui/routes.js
@@@ -185,7 +185,7 @@ ${frame.shipped({ service: 'openre', title: 'Recently shipped on OpenRestream' }
{ icon: 'ov:live', title: 'One stream, every platform', text: 'Twitch, YouTube, Kick and any RTMP, RTMPS or SRT server at once. Each destination has its own health, logs and retries.' },
{ icon: 'ov:stream', title: 'OpenVibe Live on by default', text: 'Every stream also goes live on your openvibe.live channel, with chat, clips and VODs. Switch it off for any stream.' },
{ icon: 'ov:check', title: 'Keys you control', text: 'Each stream has its own RTMP key, shown once and stored only as a hash. Rotate it any time.' },
-- { icon: 'ov:video', title: 'Recording', text: 'Per stream: record a VOD, keep clips only, or record nothing. OpenVibe.Media keeps the recordings.' },
++ { icon: 'ov:video', title: 'Recording', text: 'Per stream: record a VOD, keep clips only, or record nothing. Or record only: nothing goes live, and each session lands privately in your OpenVibe.Video, ready for the AI edit.' },
{ icon: 'ov:code', title: 'Open API and SDK', text: 'Streams, keys, sessions and destinations through the OpenVibe API and SDK, for your own tools and bots.' },
{ icon: 'ov:community', title: 'Free, open, no minimum', text: 'Any OpenVibe account can restream from the first minute: no follower, subscriber or eligibility threshold, and the code is on GitHub.' },
],
@@@ -227,9 +227,10 @@ ${FRONT_FAQ.map((f) => `<details><summary>${esc(f.q)}</summary><p>${esc(f.a)}</p
body: `<h1>Streams</h1>${list.length ? await streamTable(list) : '<p class="muted">No streams yet.</p>'}
<div class="card"><h2>New stream</h2>${form(req, '/streams', `
<label>Title</label><input type="text" name="title" maxlength="140" required>
--<div class="row"><div><label>Recording</label><select name="recording_mode"><option value="vod">Record a VOD</option><option value="clips">Clips only</option><option value="none">Do not record</option></select></div>
++${whereItGoes(null)}
++<div class="ore-live-only"><div class="row"><div><label>Recording</label><select name="recording_mode"><option value="vod">Record a VOD</option><option value="clips">Clips only</option><option value="none">Do not record</option></select></div>
<div><label>Recording visibility</label><select name="recording_visibility"><option>public</option><option>unlisted</option><option>private</option></select></div></div>
--<label><input type="checkbox" name="show_on_live" value="1" checked> Show it live on my OpenVibe Live channel too</label>
++<label><input type="checkbox" name="show_on_live" value="1" checked> Show it live on my OpenVibe Live channel too</label></div>
<button type="submit">Create stream</button><p class="muted">The ingest key is shown once, on the next page.</p>`)}</div>`,
});
});
@@@ -241,6 +242,25 @@ ${FRONT_FAQ.map((f) => `<details><summary>${esc(f.q)}</summary><p>${esc(f.a)}</p
return `<div class="flash bad">${esc(live.reason || 'OpenVibe Live did not link this stream.')}</div>`;
}
++ /**
++ * The choice between a normal stream and one that records only to the owner's videos (record_to 'videos', plan
++ * 2026-10-22 §3 D1). Choosing "record only" hides the fields that only a stream going out uses (CSS :has, no
++ * script); the server forces them anyway.
++ */
++ function whereItGoes(recordTo) {
++ const option = (value, title, text) => `<label class="ore-option"><input type="radio" name="record_to" value="${value}"${(recordTo || '') === value ? ' checked' : ''}><span><strong>${title}</strong><span class="muted">${text}</span></span></label>`;
++ return `<fieldset class="ore-choice"><legend>Where it goes</legend><div class="ore-options">
++${option('', 'Go live', 'Restream to your destinations, and to your OpenVibe Live channel if you like. The recording follows the settings below.')}
++${option('videos', 'Record only, to my videos', `Nothing goes live. Each session is recorded privately to your <a href="${esc(config.videoUrl)}/me/videos">OpenVibe.Video</a>, where the AI edit adds captions, chapters and a summary and can cut an edited copy.`)}
++</div></fieldset>`;
++ }
++
++ /** What a record-only stream does, on its pages. */
++ function recordNote(d) {
++ if (d.record_to !== 'videos') return '';
++ return `<div class="card"><p><span class="pill ok">record only</span> Each session is recorded privately to <a href="${esc(config.videoUrl)}/me/videos">your videos on OpenVibe.Video</a>, where the AI edit is one click away. Nothing goes live: OpenVibe Live and your restream destinations stay off until you switch this back in Settings.</p></div>`;
++ }
++
async function keyPage(req, res, definition, key, heading, live = null) {
const ep = await store.definitions.ingestEndpoints(definition);
res.set('Cache-Control', 'no-store');
@@@ -250,19 +270,20 @@ ${FRONT_FAQ.map((f) => `<details><summary>${esc(f.q)}</summary><p>${esc(f.a)}</p
<div class="card"><p><strong>Copy the key now.</strong> OpenRestream stores only a hash of it; this page is the only time it is shown.</p>
<label>Server (OBS: Settings → Stream → Custom)</label><code class="secret">${esc(ep.rtmp ? ep.rtmp.url : '')}</code>
<label>Stream key</label><code class="secret">${esc(key.key)}</code></div>
--${liveNote(live)}
++${liveNote(live)}${recordNote(definition)}
<p><a href="/streams/${esc(definition.id)}">Continue to the stream</a></p>`,
});
}
post('/streams', async (req, res) => {
const b = req.body || {};
++ const recordOnly = b.record_to === 'videos';
const { definition, key } = await store.definitions.create({
owner_subject: req.caller.subject, title: b.title, recording_mode: b.recording_mode,
-- recording_visibility: b.recording_visibility, created_by: req.caller.subject,
++ recording_visibility: b.recording_visibility, record_to: recordOnly ? 'videos' : null, created_by: req.caller.subject,
});
-- // OpenVibe Live on by default: the box is ticked on the form, unticking it opts out.
-- const live = liveLink && b.show_on_live === '1' ? await liveLink.link(await store.definitions.get(definition.id)) : null;
++ // OpenVibe Live on by default: the box is ticked on the form, unticking it opts out. A record-only stream never goes live.
++ const live = liveLink && b.show_on_live === '1' && !recordOnly ? await liveLink.link(await store.definitions.get(definition.id)) : null;
await keyPage(req, res, definition, key, 'Stream created', live);
});
@@@ -281,7 +302,7 @@ ${liveNote(live)}
title: d.title, canonicalPath: `/streams/${d.id}`,
body: `<h1>${esc(d.title)} ${pill(d.state)}</h1>
<p class="muted mono">${esc(d.id)}${d.external_refs.length ? ` · linked to ${esc(d.external_refs.map(r => `${r.service}:${r.type}:${r.id}`).join(', '))}` : ''}</p>
--${liveStatus(d, req.query.live)}
++${d.record_to ? recordNote(d) : liveStatus(d, req.query.live)}
<div class="card"><h2>Ingest</h2>
${ep.rtmp ? `<label>RTMP server</label><code class="secret">${esc(ep.rtmp.url)}</code>` : '<p class="muted">RTMP is not enabled for this stream.</p>'}
<table><tr><th>Key</th><th>Status</th><th>Created</th><th>Last used</th></tr>${keys.map(k => `<tr><td class="mono">ork_…${esc(k.hint)}</td><td>${pill(k.status)}${k.grace_until ? ` until ${esc(iso(k.grace_until))}` : ''}</td><td>${esc(iso(k.created_at))}</td><td>${esc(iso(k.last_used_at))}</td></tr>`).join('') || '<tr><td colspan="4">No usable key: rotate to get one.</td></tr>'}</table>
@@@ -290,23 +311,24 @@ ${form(req, `/streams/${d.id}/rotate`, `<div class="row"><div><label>Old key sta
</div>
<div class="card"><h2>Now</h2>${open ? `<p>${pill(open.state)} since ${esc(iso(open.live_at || open.created_at))} on ${esc(open.worker_kind)} generation ${esc(open.worker_generation)} · <a href="/sessions/${esc(open.id)}">session</a></p>
${form(req, `/sessions/${open.id}/end`, '<button type="submit" class="danger">End session</button>', { cls: 'inline', confirm: 'Disconnect the encoder?' })}` : '<p class="muted">Offline.</p>'}</div>
--<div class="card"><h2>Restream destinations</h2>
++<div class="card"><h2>Restream destinations</h2>${d.record_to ? '<p class="muted">Off while this stream records only to your videos.</p>' : ''}
${dests.length ? `<table><tr><th>Destination</th><th>Output</th><th>Health</th><th>Actions</th></tr>${(await Promise.all(dests.map(async (x) => {
const o = await outFor(x.id);
return `<tr><td><strong>${esc(x.name || x.platform)}</strong> <span class="muted">${esc(x.platform)}</span><br><span class="mono">${esc(x.server_url)}</span><br><span class="muted">key ${esc(x.stream_key_hint || 'not set')}${x.enabled ? '' : ' · disabled'}${x.auto_start ? ' · auto-start' : ''}${x.hold_reason ? ` · held: ${esc(x.hold_reason)}` : ''}${x.cooldown_ms ? ` · cooling down until ${esc(x.cooldown_until)}` : ''}</span></td>
<td>${o ? pill(o.state) : '<span class="muted">—</span>'}</td>
<td>${o && o.progress ? `${esc(o.progress.fps ?? '?')} fps · ${esc(o.progress.bitrate_kbps ?? '?')} kbit/s · speed ${esc(o.progress.speed ?? '?')}` : ''}${o && o.last_error ? `<br><span class="muted">${esc(o.last_error)}</span>` : ''}${!o && x.last_error ? `<span class="muted">${esc(x.last_error)}</span>` : ''}</td>
--<td>${form(req, `/destinations/${x.id}/test`, '<button type="submit" class="secondary">Test</button>', { cls: 'inline' })}${open ? (o && ['pending', 'starting', 'live', 'error'].includes(o.state) ? form(req, `/destinations/${x.id}/stop`, '<button type="submit" class="secondary">Stop</button>', { cls: 'inline' }) : form(req, `/destinations/${x.id}/start`, '<button type="submit">Start</button>', { cls: 'inline' })) : ''}
++<td>${form(req, `/destinations/${x.id}/test`, '<button type="submit" class="secondary">Test</button>', { cls: 'inline' })}${open && !d.record_to ? (o && ['pending', 'starting', 'live', 'error'].includes(o.state) ? form(req, `/destinations/${x.id}/stop`, '<button type="submit" class="secondary">Stop</button>', { cls: 'inline' }) : form(req, `/destinations/${x.id}/start`, '<button type="submit">Start</button>', { cls: 'inline' })) : ''}
<a href="/destinations/${esc(x.id)}">Edit</a>${form(req, `/destinations/${x.id}/delete`, '<button type="submit" class="danger">Delete</button>', { cls: 'inline', confirm: 'Delete this destination?' })}</td></tr>`;
}))).join('')}</table>` : '<p class="muted">No destinations.</p>'}
<h3>Add a destination</h3>${form(req, `/streams/${d.id}/destinations`, destinationFields({}))}</div>
<div class="card"><h2>Settings</h2>${form(req, `/streams/${d.id}`, `
<label>Title</label><input type="text" name="title" maxlength="140" value="${esc(d.title)}">
--<div class="row"><div><label>Recording</label><select name="recording_mode">${['vod', 'clips', 'none'].map(m => `<option value="${m}"${d.recording_mode === m ? ' selected' : ''}>${m}</option>`).join('')}</select></div>
--<div><label>Recording visibility</label><select name="recording_visibility">${['public', 'unlisted', 'private'].map(m => `<option${d.recording_visibility === m ? ' selected' : ''}>${m}</option>`).join('')}</select></div>
--<div><label>Playback</label><select name="playback_visibility">${['public', 'unlisted', 'private'].map(m => `<option${d.playback_visibility === m ? ' selected' : ''}>${m}</option>`).join('')}</select></div>
++${whereItGoes(d.record_to)}
++<div class="row"><div class="ore-live-only"><label>Recording</label><select name="recording_mode">${['vod', 'clips', 'none'].map(m => `<option value="${m}"${d.recording_mode === m ? ' selected' : ''}>${m}</option>`).join('')}</select></div>
++<div class="ore-live-only"><label>Recording visibility</label><select name="recording_visibility">${['public', 'unlisted', 'private'].map(m => `<option${d.recording_visibility === m ? ' selected' : ''}>${m}</option>`).join('')}</select></div>
++<div class="ore-live-only"><label>Playback</label><select name="playback_visibility">${['public', 'unlisted', 'private'].map(m => `<option${d.playback_visibility === m ? ' selected' : ''}>${m}</option>`).join('')}</select></div>
<div><label>State</label><select name="state">${['active', 'disabled'].map(m => `<option${d.state === m ? ' selected' : ''}>${m}</option>`).join('')}</select></div></div>
--<label><input type="checkbox" name="mirror_to_live" value="1"${d.mirror_to_live ? ' checked' : ''}> Show it live on my OpenVibe Live channel too</label>
++<label class="ore-live-only"><input type="checkbox" name="mirror_to_live" value="1"${d.mirror_to_live ? ' checked' : ''}> Show it live on my OpenVibe Live channel too</label>
<button type="submit">Save</button>`)}</div>
<h2>Sessions</h2>${sessionTable(sessions)}`,
});
@@@ -355,8 +377,10 @@ ${dests.length ? `<table><tr><th>Destination</th><th>Output</th><th>Health</th><
const d = await ownDefinition(req, res, req.params.id);
if (!d) return;
const b = req.body || {};
-- const want = b.mirror_to_live === '1';
-- await store.definitions.update(d.id, { title: b.title, recording_mode: b.recording_mode, recording_visibility: b.recording_visibility, playback_visibility: b.playback_visibility, state: b.state, mirror_to_live: want && liveLink ? undefined : want });
++ // Record only wins: the fields it hides are still posted, and the store sets them for a record-only stream.
++ const recordTo = b.record_to === 'videos' ? 'videos' : null;
++ const want = b.mirror_to_live === '1' && !recordTo;
++ await store.definitions.update(d.id, { title: b.title, recording_mode: b.recording_mode, recording_visibility: b.recording_visibility, playback_visibility: b.playback_visibility, state: b.state, record_to: recordTo, mirror_to_live: want && liveLink ? undefined : want });
if (want && liveLink) {
// Switching it on gets the Live slot first; refused, it stays off and the page says why.
const r = await liveLink.link(await store.definitions.get(d.id));
@@@ -457,7 +481,7 @@ ${dests.length ? `<table><tr><th>Destination</th><th>Output</th><th>Health</th><
<tr><th>Worker</th><td>${esc(s.worker_kind)} generation ${esc(s.worker_generation)} <span class="mono muted">${esc(s.worker_id)}</span></td></tr>
<tr><th>Live since</th><td>${esc(iso(s.live_at))}</td></tr><tr><th>Ended</th><td>${esc(iso(s.ended_at))} ${esc(s.end_reason || s.failure_reason || '')}</td></tr>
<tr><th>Media</th><td>${s.media_info ? esc(`${s.media_info.video_codec || '?'} ${s.media_info.width || '?'}×${s.media_info.height || '?'} @${s.media_info.fps || '?'} · ${s.media_info.audio_codec || 'no audio'} · ${s.media_info.bitrate_kbps || '?'} kbit/s`) : '—'}</td></tr>
--<tr><th>Recording</th><td>${rec ? `${pill(rec.state)} ${rec.media_vod_id ? `Media ${esc(rec.media_app)} VOD ${esc(rec.media_vod_id)}` : ''} ${esc(rec.last_error || '')}` : '<span class="muted">none</span>'}</td></tr>
++<tr><th>Recording</th><td>${rec ? `${pill(rec.state)} ${rec.media_vod_id ? `Media ${esc(rec.media_app)} VOD ${esc(rec.media_vod_id)}` : ''} ${esc(rec.last_error || '')}${rec.media_app === config.media.videosAppId ? `<br>Private, in <a href="${esc(config.videoUrl)}/me/videos">your videos on OpenVibe.Video</a> once it is ready.` : ''}` : '<span class="muted">none</span>'}</td></tr>
<tr><th>Playback</th><td>${pb && pb.flv && s.state === 'live' ? `<a class="mono" href="${esc(pb.flv.public_url)}">${esc(pb.flv.public_url)}</a> (HTTP-FLV)` : '—'}</td></tr>
</table>${open ? form(req, `/sessions/${s.id}/end`, '<button type="submit" class="danger">End session</button>', { confirm: 'Disconnect the encoder?' }) : ''}</div>
<h2>Outputs</h2>${outputs.length ? `<table><tr><th>Output</th><th>State</th><th>Health</th><th>Restarts</th></tr>${(await outputs.map(o => `<tr><td class="mono">${esc(o.id)}<br><span class="muted">${esc(o.destination_id)}</span></td><td>${pill(o.state)}</td><td>${o.progress ? `${esc(o.progress.fps ?? '?')} fps · ${esc(o.progress.bitrate_kbps ?? '?')} kbit/s` : ''} ${esc(o.last_error || '')}</td><td>${esc(o.restart_attempts)}/${esc(o.max_restart_attempts)}</td></tr>`)).join('')}</table>` : '<p class="muted">No outputs.</p>'}
ddiff --git a/test/record-only.test.js b/test/record-only.test.js
nnew file mode 100644
iindex 0000000..7ea27f1
---- /dev/null
++++ b/test/record-only.test.js
@@@ -0,0 +1,115 @@
++'use strict';
++// Record only, to my videos (record_to 'videos'; plan 2026-10-22 §3 D1, the owner's "record privately, then an AI edit"):
++// the stream is always a private VOD and never on OpenVibe Live; each session records into OpenVibe.Video's Media app
++// (media-hub) with the owner in the VOD's meta, for MediaHub; restream destinations never start; the form offers it.
++const assert = require('assert');
++const http = require('http');
++const { runtime, manualClock, suite, OWNER, silent, bootApi, request, userToken, outboxEnvelopes } = require('./helpers');
++const { createCoordinator } = require('../server/coordinator');
++const { createMediaClient } = require('../server/media-client');
++const { csrfFor } = require('../server/ui/routes');
++
++const t = suite('record-only');
++
++function stub(handler) {
++ return new Promise((resolve) => {
++ const s = http.createServer((req, res) => {
++ let body = '';
++ req.on('data', (c) => { body += c; });
++ req.on('end', () => { let parsed = null; try { parsed = body ? JSON.parse(body) : null; } catch { parsed = null; } handler(req, res, parsed, body); });
++ });
++ s.listen(0, '127.0.0.1', () => resolve({ url: `http://127.0.0.1:${s.address().port}`, close: () => new Promise(r => { s.closeAllConnections(); s.close(r); }) }));
++ });
++}
++const reply = (res, status, obj) => { res.statusCode = status; res.setHeader('Content-Type', 'application/json'); res.end(JSON.stringify(obj || {})); };
++const refused = async (p, code) => { await assert.rejects(p, (err) => err.code === code && err.status === 400); };
++
++t('a record-only stream is a private VOD that never goes to OpenVibe Live', async () => {
++ const rt = await runtime({ clock: manualClock() });
++ const { definition: d } = await rt.store.definitions.create({ owner_subject: OWNER, title: 'Diary', record_to: 'videos', recording_visibility: 'public', mirror_to_live: false });
++ assert.deepStrictEqual([d.record_to, d.recording_mode, d.recording_visibility, d.playback_visibility, d.mirror_to_live], ['videos', 'vod', 'private', 'private', false]);
++ await refused(rt.store.definitions.create({ owner_subject: OWNER, title: 'x', record_to: 'videos', mirror_to_live: true }), 'openre.record_only');
++ await refused(rt.store.definitions.create({ owner_subject: OWNER, title: 'x', record_to: 'tape' }), 'openre.invalid_field');
++ // Once record-only: switching OpenVibe Live on, or a public recording, is refused; the stream is left as it was.
++ await refused(rt.store.definitions.update(d.id, { mirror_to_live: true }), 'openre.record_only');
++ await refused(rt.store.definitions.update(d.id, { recording_visibility: 'public' }), 'openre.record_only');
++ assert.strictEqual((await rt.store.definitions.get(d.id)).recording_visibility, 'private');
++ // Back to a normal stream: it can go out again, and keeps what it had until changed.
++ const normal = await rt.store.definitions.update(d.id, { record_to: null, mirror_to_live: true });
++ assert.deepStrictEqual([normal.record_to, normal.mirror_to_live, normal.recording_visibility], [null, true, 'private']);
++ // And a normal stream switched to record-only loses OpenVibe Live in the same change.
++ const again = await rt.store.definitions.update(d.id, { record_to: 'videos', recording_visibility: 'public' });
++ assert.deepStrictEqual([again.record_to, again.mirror_to_live, again.recording_visibility, again.playback_visibility], ['videos', false, 'private', 'private']);
++});
++
++t('each session records privately into OpenVibe.Video\'s Media app, naming the owner; destinations never start', async () => {
++ const calls = [];
++ const media = await stub((req, res, body) => {
++ calls.push({ method: req.method, url: req.url, body });
++ if (req.url === '/api/v1/media-hub/vods') return reply(res, 201, { id: 777 });
++ if (req.url === '/api/v1/media-hub/vods/777/ingest/rtmp') return reply(res, 202, { status: 'recording' });
++ if (req.url === '/api/v1/media-hub/vods/777/finalize') return reply(res, 200, {});
++ return reply(res, 404);
++ });
++ const network = await stub((req, res) => reply(res, 200, { access_token: 'svc-media', token_type: 'Bearer', expires_in: 300 }));
++ const clock = manualClock();
++ const rt = await runtime({ clock, env: { MEDIA_URL: media.url, OV_NETWORK_INTERNAL_URL: network.url, OV_OAUTH_CLIENT_SECRET: 's3cret', OPENRE_RECORDING_START_DELAY_MS: '0' } });
++ const coordinator = createCoordinator({ rt, media: createMediaClient({ config: rt.config }), log: silent });
++ const { definition, key } = await rt.store.definitions.create({ owner_subject: OWNER, title: 'Morning take', record_to: 'videos', external_refs: [{ service: 'live', type: 'user', id: '8' }] });
++ const dest = await rt.store.outputs.createDestination(definition.id, { platform: 'twitch', name: 'T', server_url: 'rtmps://live.twitch.tv/app', stream_key: 'example-not-a-secret', enabled: true, auto_start: true });
++ const w = await rt.store.workers.register({ kind: 'rtmp-ingest', endpoints: { rtmpPlayPort: 19390, flvPort: 19391 } });
++ await rt.store.workers.ready(w.id);
++ const a = await rt.store.sessions.admit({ definition, key, protocol: 'rtmp', worker: await rt.store.workers.get(w.id) });
++ await rt.store.sessions.transition(a.session.id, 'live');
++
++ await coordinator.tick();
++ await coordinator.tick();
++ assert.deepStrictEqual(calls.map(c => `${c.method} ${c.url}`), ['POST /api/v1/media-hub/vods', 'POST /api/v1/media-hub/vods/777/ingest/rtmp']);
++ const made = calls[0].body;
++ assert.deepStrictEqual([made.visibility, made.title, made.user_id, made.meta.record_only, made.meta.owner_subject, made.meta.openre_session_id],
++ ['private', 'Morning take', undefined, true, OWNER, a.session.id], 'private, the owner named, no Live ids');
++ const rec = await rt.store.recordings.bySession(a.session.id);
++ assert.deepStrictEqual([rec.media_app, rec.state], ['media-hub', 'recording']);
++ const ev = (await outboxEnvelopes(rt.db)).find(e => e.event_type === 'openre.recording.requested');
++ assert.deepStrictEqual(ev.payload.media, { app: 'media-hub', vod_id: '777' });
++
++ // No output was made for the destination, and starting it by hand is refused.
++ assert.deepStrictEqual(await rt.store.outputs.outputsOfSession(a.session.id), []);
++ await assert.rejects(rt.store.outputs.startDestination(dest.id), (err) => err.code === 'openre.record_only' && err.status === 409);
++
++ await rt.store.sessions.finish(a.session.id, { reason: 'publisher_disconnected' });
++ await coordinator.tick();
++ assert.strictEqual(calls.pop().url, '/api/v1/media-hub/vods/777/finalize', 'finalized in the same tenant');
++ assert.strictEqual((await rt.store.recordings.bySession(a.session.id)).state, 'finalized');
++ await media.close();
++ await network.close();
++});
++
++t('the form offers "Record only, to my videos" and the stream page says where the recordings go', async () => {
++ const api = await bootApi();
++ const cookie = `ov_token=${userToken({ subjectId: OWNER })}`;
++ const token = cookie.slice('ov_token='.length);
++ try {
++ const form = await request(api.base, 'GET', '/streams', { cookie });
++ assert.match(form.text, /name="record_to" value="videos"/);
++ assert.match(form.text, /Record only, to my videos/);
++ const created = await request(api.base, 'POST', '/streams', { cookie, form: { title: 'Private take', record_to: 'videos', recording_mode: 'clips', recording_visibility: 'public', show_on_live: '1', _csrf: csrfFor(token) } });
++ assert.strictEqual(created.status, 200);
++ assert.match(created.text, /record only/);
++ const d = (await api.rt.store.definitions.list({ owner_subject: OWNER })).find(x => x.title === 'Private take');
++ assert.deepStrictEqual([d.record_to, d.recording_mode, d.recording_visibility, d.mirror_to_live], ['videos', 'vod', 'private', false]);
++ const page = await request(api.base, 'GET', `/streams/${d.id}`, { cookie });
++ assert.match(page.text, /your videos on OpenVibe\.Video/);
++ assert.match(page.text, /Off while this stream records only to your videos/);
++ assert.match(page.text, /name="record_to" value="videos" checked/);
++ // Switched back in Settings: a normal stream again.
++ const saved = await request(api.base, 'POST', `/streams/${d.id}`, { cookie, form: { title: 'Private take', record_to: '', recording_mode: 'vod', recording_visibility: 'unlisted', playback_visibility: 'public', state: 'active', _csrf: csrfFor(token) } });
++ assert.strictEqual(saved.status, 303);
++ const back = await api.rt.store.definitions.get(d.id);
++ assert.deepStrictEqual([back.record_to, back.recording_visibility], [null, 'unlisted']);
++ } finally {
++ await api.close();
++ }
++});
++
++t.run();