openvibers/openrestream Commits
Record only, to my videos: a stream that never goes live and lands privately in OpenVibe.Video (plan D1) (#96)
Changes
nnew file mode 100644@@@ -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;@@@ -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); }@@@ -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'),};}@@@ -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). */@@@ -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 () => {@@@ -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 sJOIN destinations d ON d.definition_id = s.definition_id++ JOIN stream_definitions sd ON sd.id = s.definition_idLEFT 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 NULLAND 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 () => {@@@ -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, woconst 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 sJOIN stream_definitions d ON d.id = s.definition_id LEFT JOIN recordings r ON r.session_id = s.idWHERE 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, woasync 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, woreturn 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, wolet 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, wostream_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, wolet 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, wostream_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,},});@@@ -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}}`;@@@ -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)}</pbody: `<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)}</preturn `<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>'}nnew file mode 100644@@@ -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();
new file mode 100644
new file mode 100644
@@ -0,0 +1,5 @@
@@ -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;
@@ -59,6 +59,7 @@ function createV1Router({ rt, auth, liveLink = null }) {
@@ -59,6 +59,7 @@ function createV1Router({ rt, auth, liveLink = null }) {
recording_visibility: d.recording_visibility,
recording_visibility: d.recording_visibility,
playback_visibility: d.playback_visibility,
playback_visibility: d.playback_visibility,
mirror_to_live: d.mirror_to_live,
mirror_to_live: d.mirror_to_live,
+ record_to: d.record_to || null,
state: d.state,
state: d.state,
revision: d.revision,
revision: d.revision,
external_refs: d.external_refs,
external_refs: d.external_refs,
@@ -158,13 +159,13 @@ function createV1Router({ rt, auth, liveLink = null }) {
@@ -158,13 +159,13 @@ function createV1Router({ rt, auth, liveLink = null }) {
owner_subject: owner,
owner_subject: owner,
title: b.title, description: b.description, protocols: b.protocols,
title: b.title, description: b.description, protocols: b.protocols,
recording_mode: b.recording_mode, recording_visibility: b.recording_visibility, playback_visibility: b.playback_visibility,
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),
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
// 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;
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');
res.set('Cache-Control', 'no-store');
const current = live ? await store.definitions.get(definition.id) : definition;
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 } : {}) });
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 }) {
@@ -180,10 +181,13 @@ function createV1Router({ rt, auth, liveLink = null }) {
if (!d) return;
if (!d) return;
const b = req.body || {};
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).
// 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, {
let updated = await store.definitions.update(d.id, {
title: b.title, description: b.description, protocols: b.protocols, recording_mode: b.recording_mode,
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,
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;
let live = null;
if (linkNow) { live = await liveLink.link(await store.definitions.get(d.id)); updated = await store.definitions.get(d.id); }
if (linkNow) { live = await liveLink.link(await store.definitions.get(d.id)); updated = await store.definitions.get(d.id); }
@@ -213,10 +213,16 @@ function load(env = process.env) {
@@ -213,10 +213,16 @@ function load(env = process.env) {
// media.object.upload, namespace live). The shared tenant key is gone (C-42).
// media.object.upload, namespace live). The shared tenant key is gone (C-42).
enabled: env.OPENRE_RECORDING !== 'off',
enabled: env.OPENRE_RECORDING !== 'off',
startDelayMs: int(env.OPENRE_RECORDING_START_DELAY_MS, 2000),
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) ──
// ── Live compatibility (links on the UI only; never a data dependency) ──
liveUrl: trim(env.OV_LIVE_URL || 'https://openvibe.live'),
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'),
};
};
}
}
@@ -22,11 +22,12 @@ class MediaError extends Error {
@@ -22,11 +22,12 @@ class MediaError extends Error {
constructor(message, status, body) { super(message); this.status = status || 0; this.body = body || null; }
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 })
? 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.
// A restore drill (OPENRE_DRILL) never asks Media for anything.
const configured = config.media.enabled && !config.drill && Boolean(tokens);
const configured = config.media.enabled && !config.drill && Boolean(tokens);
@@ -54,7 +55,13 @@ function createMediaClient({ config, fetchImpl = globalThis.fetch }) {
@@ -54,7 +55,13 @@ function createMediaClient({ config, fetchImpl = globalThis.fetch }) {
return {
return {
configured,
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 }),
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 } }),
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). */
/** Media RTP ingest: allocate a local RTP/RTCP port pair Media listens on (Live server/streaming/recorder.js). */
@@ -17,6 +17,9 @@ const PROTOCOLS = Object.freeze(['rtmp', 'webrtc', 'jsmpeg']);
@@ -17,6 +17,9 @@ const PROTOCOLS = Object.freeze(['rtmp', 'webrtc', 'jsmpeg']);
const PROTOCOL_ALIASES = Object.freeze({ whip: 'webrtc' });
const PROTOCOL_ALIASES = Object.freeze({ whip: 'webrtc' });
const RECORDING_MODES = Object.freeze(['vod', 'clips', 'none']);
const RECORDING_MODES = Object.freeze(['vod', 'clips', 'none']);
const VISIBILITIES = Object.freeze(['public', 'unlisted', 'private']);
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_SERVICE_RE = /^[a-z][a-z0-9-]{1,39}$/;
const REF_TYPE_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.
// Owner-level reference types may be shared by several definitions of the same person.
@@ -55,9 +58,9 @@ function createDefinitions({ db, config, events, clock }) {
@@ -55,9 +58,9 @@ function createDefinitions({ db, config, events, clock }) {
const q = {
const q = {
insert: db.prepare(`INSERT INTO stream_definitions (id, owner_subject, title, description, protocols, recording_mode,
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,
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 = ?'),
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'),
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 (?, ?, ?, ?, ?, ?)'),
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 }) {
@@ -81,6 +84,7 @@ function createDefinitions({ db, config, events, clock }) {
...d,
...d,
protocols: parseJson(d.protocols, ['rtmp']),
protocols: parseJson(d.protocols, ['rtmp']),
mirror_to_live: Boolean(d.mirror_to_live),
mirror_to_live: Boolean(d.mirror_to_live),
+ record_to: d.record_to || null,
external_refs: await q.refs.all(id),
external_refs: await q.refs.all(id),
};
};
}
}
@@ -121,6 +125,23 @@ function createDefinitions({ db, config, events, clock }) {
@@ -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.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 (input.state !== undefined) {
if (!['active', 'disabled'].includes(input.state)) throw new StoreError(400, 'openre.invalid_field', 'state must be active or disabled');
if (!['active', 'disabled'].includes(input.state)) throw new StoreError(400, 'openre.invalid_field', 'state must be active or disabled');
out.state = input.state;
out.state = input.state;
@@ -143,7 +164,7 @@ function createDefinitions({ db, config, events, clock }) {
@@ -143,7 +164,7 @@ function createDefinitions({ db, config, events, clock }) {
async function create(input) {
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');
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 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 refs = validateRefs(input.external_refs);
const id = newId('stream', now());
const id = newId('stream', now());
return await db.tx(async () => {
return await db.tx(async () => {
@@ -218,10 +218,12 @@ function createOutputs({ db, config, events, clock, box, definitions, sessions }
@@ -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. */
/** Coordinator: every live session gets an output per enabled, auto-start, usable destination. */
async function ensureAutoOutputs() {
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
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 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
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());
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());
for (const r of rows) await q.insertOut.run(newId('output', now()), r.session_id, r.destination_id, now(), now());
return rows.length;
return rows.length;
@@ -237,6 +239,8 @@ function createOutputs({ db, config, events, clock, box, definitions, sessions }
@@ -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.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.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');
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];
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');
if (!session) throw new StoreError(409, 'openre.not_live', 'the stream is not live');
return await db.tx(async () => {
return await db.tx(async () => {
@@ -9,6 +9,11 @@
@@ -9,6 +9,11 @@
*
*
* Driven by the session coordinator, never by a transport worker or the API: a worker only has to
* 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.
* 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 { newId } = require('../ids');
const { TYPES } = require('../events');
const { TYPES } = require('../events');
@@ -29,7 +34,7 @@ function createRecordings({ db, config, events, clock, definitions, sessions, wo
@@ -29,7 +34,7 @@ function createRecordings({ db, config, events, clock, definitions, sessions, wo
const q = {
const q = {
get: db.prepare('SELECT * FROM recordings WHERE id = ?'),
get: db.prepare('SELECT * FROM recordings WHERE id = ?'),
bySession: db.prepare('SELECT * FROM recordings WHERE session_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
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`),
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)
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
@@ -48,7 +53,10 @@ function createRecordings({ db, config, events, clock, definitions, sessions, wo
async function ensureRequests() {
async function ensureRequests() {
const rows = await q.missing.all(now() - config.media.startDelayMs);
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;
return rows.length;
}
}
@@ -65,6 +73,28 @@ function createRecordings({ db, config, events, clock, definitions, sessions, wo
@@ -65,6 +73,28 @@ function createRecordings({ db, config, events, clock, definitions, sessions, wo
return r ? r.id : undefined;
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. */
/** The egress base URL of the worker that owns this session (its RTP descriptor API), or null. */
async function egressBase(session) {
async function egressBase(session) {
const owner = session.worker_id ? await workers.get(session.worker_id) : null;
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
@@ -108,16 +138,7 @@ function createRecordings({ db, config, events, clock, definitions, sessions, wo
let vodId = rec.media_vod_id;
let vodId = rec.media_vod_id;
try {
try {
if (!vodId) {
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);
vodId = String(out && out.id);
rec = await update(rec.id, { media_vod_id: vodId, state: 'requested' });
rec = await update(rec.id, { media_vod_id: vodId, state: 'requested' });
}
}
@@ -147,7 +168,7 @@ function createRecordings({ db, config, events, clock, definitions, sessions, wo
@@ -147,7 +168,7 @@ function createRecordings({ db, config, events, clock, definitions, sessions, wo
stream_id: definition.id,
stream_id: definition.id,
owner: { type: 'user', id: definition.owner_subject },
owner: { type: 'user', id: definition.owner_subject },
mode: rec.mode,
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,
external_refs: definition.external_refs,
},
},
});
});
@@ -173,6 +194,8 @@ function createRecordings({ db, config, events, clock, definitions, sessions, wo
@@ -173,6 +194,8 @@ function createRecordings({ db, config, events, clock, definitions, sessions, wo
}
}
async function step(rec, media) {
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);
const session = await sessions.get(rec.session_id);
if (!session) return await update(rec.id, { state: 'failed', last_error: 'session missing' });
if (!session) return await update(rec.id, { state: 'failed', last_error: 'session missing' });
const definition = await definitions.row(session.definition_id);
const definition = await definitions.row(session.definition_id);
@@ -210,18 +233,7 @@ function createRecordings({ db, config, events, clock, definitions, sessions, wo
@@ -210,18 +233,7 @@ function createRecordings({ db, config, events, clock, definitions, sessions, wo
let vodId = rec.media_vod_id;
let vodId = rec.media_vod_id;
try {
try {
if (!vodId) {
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);
vodId = String(out && out.id);
rec = await update(rec.id, { media_vod_id: vodId, state: 'requested' });
rec = await update(rec.id, { media_vod_id: vodId, state: 'requested' });
}
}
@@ -248,7 +260,7 @@ function createRecordings({ db, config, events, clock, definitions, sessions, wo
@@ -248,7 +260,7 @@ function createRecordings({ db, config, events, clock, definitions, sessions, wo
stream_id: definition.id,
stream_id: definition.id,
owner: { type: 'user', id: definition.owner_subject },
owner: { type: 'user', id: definition.owner_subject },
mode: rec.mode,
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,
external_refs: definition.external_refs,
},
},
});
});
@@ -43,6 +43,12 @@ main.ore .sc-hero,main.ore .sc-sec,main.ore .sc-cta{padding-left:0;padding-right
@@ -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.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 .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 .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}}
@media (max-width:640px){.ore th:nth-child(n+4),.ore td:nth-child(n+4){display:none}}
`;
`;
@@ -185,7 +185,7 @@ ${frame.shipped({ service: 'openre', title: 'Recently shipped on OpenRestream' }
@@ -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: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: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: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: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.' },
{ 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
@@ -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>'}
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', `
<div class="card"><h2>New stream</h2>${form(req, '/streams', `
<label>Title</label><input type="text" name="title" maxlength="140" required>
<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>
<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>`,
<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
@@ -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>`;
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) {
async function keyPage(req, res, definition, key, heading, live = null) {
const ep = await store.definitions.ingestEndpoints(definition);
const ep = await store.definitions.ingestEndpoints(definition);
res.set('Cache-Control', 'no-store');
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
@@ -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>
<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>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>
<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>`,
<p><a href="/streams/${esc(definition.id)}">Continue to the stream</a></p>`,
});
});
}
}
post('/streams', async (req, res) => {
post('/streams', async (req, res) => {
const b = req.body || {};
const b = req.body || {};
+ const recordOnly = b.record_to === 'videos';
const { definition, key } = await store.definitions.create({
const { definition, key } = await store.definitions.create({
owner_subject: req.caller.subject, title: b.title, recording_mode: b.recording_mode,
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);
await keyPage(req, res, definition, key, 'Stream created', live);
});
});
@@ -281,7 +302,7 @@ ${liveNote(live)}
@@ -281,7 +302,7 @@ ${liveNote(live)}
title: d.title, canonicalPath: `/streams/${d.id}`,
title: d.title, canonicalPath: `/streams/${d.id}`,
body: `<h1>${esc(d.title)} ${pill(d.state)}</h1>
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>
<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>
<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>'}
${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>
<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
@@ -290,23 +311,24 @@ ${form(req, `/streams/${d.id}/rotate`, `<div class="row"><div><label>Old key sta
</div>
</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>
<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>
${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) => {
${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);
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>
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 ? 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>${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>`;
<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>'}
}))).join('')}</table>` : '<p class="muted">No destinations.</p>'}
<h3>Add a destination</h3>${form(req, `/streams/${d.id}/destinations`, destinationFields({}))}</div>
<h3>Add a destination</h3>${form(req, `/streams/${d.id}/destinations`, destinationFields({}))}</div>
<div class="card"><h2>Settings</h2>${form(req, `/streams/${d.id}`, `
<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)}">
<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>
<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>
<button type="submit">Save</button>`)}</div>
<h2>Sessions</h2>${sessionTable(sessions)}`,
<h2>Sessions</h2>${sessionTable(sessions)}`,
});
});
@@ -355,8 +377,10 @@ ${dests.length ? `<table><tr><th>Destination</th><th>Output</th><th>Health</th><
@@ -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);
const d = await ownDefinition(req, res, req.params.id);
if (!d) return;
if (!d) return;
const b = req.body || {};
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) {
if (want && liveLink) {
// Switching it on gets the Live slot first; refused, it stays off and the page says why.
// 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));
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><
@@ -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>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>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>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>
<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>
</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>'}
<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>'}
new file mode 100644
new file mode 100644
@@ -0,0 +1,115 @@
@@ -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();