openvibers/openrestream Commits

OpenRestream: OpenVibe Live on by default, and the site says what it is (open restreaming) (#28)

OP OpenVibers committed 9 Oct 2026 · d8d3a4ce82 · 11 files, +341 −43

* OpenRestream: the product at openre.stream is OpenRestream (owner, 2026-10-09)

Every name people read says OpenRestream instead of OpenRe or OpenRe.Stream, and repository paths say
OpenVibers/OpenRestream (renamed on GitHub the same day). Ids are unchanged (openre, openre.*, ov_openre).

* OpenRestream: OpenVibe Live on by default, and the site says what it is (open restreaming)

- server/live-link.js: a person's new stream also shows on their OpenVibe Live channel. It asks Live for a slot bound
  to the stream (POST /internal/openre/slots, live.openre.slot.bind, contracts 0.126.0, this service's Network token
  for openvibe.live), records it as the stream's live:managed_stream ref (label: the channel URL) and turns
  mirror_to_live on; Live's mirror then makes each session live on openvibe.live/@username.
- The create form's 'Show it live on my OpenVibe Live channel too' is ticked by default; the API links a person's
  own stream unless show_on_live is false and answers live: { linked, channel_url | code, reason }. Services (Live's
  slots, Bot's robots) name their own refs and are never linked. Switching it on in Settings or with PATCH
  mirror_to_live links once; switching it off keeps the slot, so on again needs no new slot.
- A refusal (no Live account yet, slots full, someone else's slot, banned, Live unreachable) keeps the stream with
  Live off and the page says why. store.definitions.addRef adds a ref idempotently.
- Copy (owner, 2026-10-09: an open restreaming platform with OpenVibe Live on by default): the home page ('Go live
  once. Stream everywhere.'), title, meta description and llms.txt. Browser go-live is still named, on OpenVibe.Live.
- test/live-link.test.js (UI default, untick, switch on/off/on, no Live account, API person vs service);
  test/discovery.test.js follows the new copy. README and .env.example document it.

Changes

ddiff --git a/.env.example b/.env.example
iindex 51e1d49..a9f3a7a 100644
---- a/.env.example
++++ b/.env.example
@@@ -167,6 +167,10 @@ MEDIA_API_KEY=
# ── Links ─────────────────────────────────────────────────────
#OV_LIVE_URL=https://openvibe.live
++# OpenVibe Live on by default (server/live-link.js): a person's new stream also gets a slot on their Live channel,
++# through Live's loopback POST /internal/openre/slots. OPENRE_LIVE_LINK=off turns it off.
++#OV_LIVE_INTERNAL_URL=http://127.0.0.1:3000
++#OPENRE_LIVE_LINK=on
# Per-actor limits on a signed-in person's API writes (server/api/actor-limits.js, roadmap WS-R task 4), per minute
# and per hour; key rotations, destination tests and new streams have tighter numbers. Services are not counted.
ddiff --git a/README.md b/README.md
iindex f352d42..5baca83 100644
---- a/README.md
++++ b/README.md
@@@ -1,6 +1,8 @@
# OpenRestream
--> Ingest and restream: stream definitions, keys, sessions, transport workers, outputs and output health.
++> Open restreaming at openre.stream: go live once from OBS or any RTMP encoder and stream to Twitch, YouTube, Kick and
++> any RTMP, RTMPS or SRT server at the same time, with OpenVibe Live on by default. Underneath: stream definitions,
++> keys, sessions, transport workers, outputs and output health.
**Status:** alpha (roadmap Wave 7). Tested end to end with real RTMP. Deployed internally on `openvibe-ovh` since 2026-09-23 (API and coordinator on 127.0.0.1:4500; RTMP ingest public on port 1936 at `ingest.openre.stream` since the cutover runbook's phase A on 2026-09-23; Live wired with `OPENRE_URL` and an `openre.session.*` subscription; one rehearsal broadcast passed). **openre.stream serves its own site since 2026-10-09** (plan T11, cutover step B0 (a)): sign in with OpenVibe, create a stream with its RTMP key, and send it to other channels. No Live slot is ingested here yet: every slot is still ingested by Live until its per-slot cutover (`docs/cutover.md` phase B).
**Domain:** `openre.stream` (UI + API), `ingest.openre.stream` (RTMP, DNS only)
@@@ -199,6 +201,25 @@ sudo deploy/scripts/deploy.sh status # deploy-legacy.sh
coordinator drains the newer one. The schema code only adds, so an older release reads a newer database.
- First install: create the `openre` OAuth client and grants in Network, `/etc/openvibe/openre.env` (0600), the certificate for `openre.stream`, DNS for `openre.stream` (proxied) and `ingest.openre.stream` (DNS only), firewall 1936/tcp, then `release`, `api`, `workers`, and `systemctl enable --now openre-api openre-session-coordinator`.
++## OpenVibe Live on by default
++
++A stream someone creates on openre.stream also goes live on their OpenVibe Live channel unless they untick "Show it
++live on my OpenVibe Live channel too" (the create form, ticked by default; the API's `show_on_live: false` for a
++person's own stream; services such as Live and Bot name their own refs and are never linked). `server/live-link.js`
++asks Live for a slot on the owner's channel bound to the stream (`POST /internal/openre/slots` on Live, capability
++`live.openre.slot.bind`, contracts 0.126.0, with this service's Network token for audience `openvibe.live`). The slot
++becomes the stream's `live:managed_stream` ref (its label is the channel URL) and `mirror_to_live` turns on, so Live's
++mirror makes each session a live stream on `openvibe.live/@username`, with chat, clips and VODs.
++
++- **Refusals** keep the stream with Live off and say why on the page:
++ - `live.no_account`: the person signs in to openvibe.live once;
++ - `live.slot_limit`: their Live slots are full;
++ - `live.slot_taken`, `live.account_banned`, or Live unreachable.
++- **Switching it off** in Settings stops the mirror; the slot stays recorded, so switching it on again needs no new slot.
++- **Config:** `OV_LIVE_INTERNAL_URL` (default `http://127.0.0.1:3000`). `OPENRE_LIVE_LINK=off` turns linking off, and
++ it is off in a restore drill and without `OV_OAUTH_CLIENT_SECRET`.
++- **Test:** `test/live-link.test.js`.
++
## Live integration (patch)
[docs/live-patch.diff](docs/live-patch.diff) is the Live side, made against OpenVibe.Live `f11f809` and verified there (applies with `git apply`; Live's `npm test` 63/63 on Node 22.22.1). It is applied in Live (`f0ca18b`) and deployed in production, switched off: `OPENRE_URL` is not set in Live's env. **Switch off (the default) changes nothing**: with `OPENRE_URL` unset, or a slot on `'live'`, every changed code path returns what it returned before (`test/openre-switch.test.js` in the patch). The only observable difference is additive: owner responses built from `managed_streams` rows carry the two new columns (`ingest_authority: 'live'`, `openre_stream_id: null`). It adds:
ddiff --git a/server/api/v1.js b/server/api/v1.js
iindex c0a2ba1..4136c15 100644
---- a/server/api/v1.js
++++ b/server/api/v1.js
@@@ -32,7 +32,7 @@ const { testDestination } = require('../destination-test');
function iso(ms) { return ms ? new Date(ms).toISOString() : null; }
--function createV1Router({ rt, auth }) {
++function createV1Router({ rt, auth, liveLink = null }) {
const { store, config } = rt;
const router = express.Router();
const guard = auth.guard;
@@@ -161,8 +161,13 @@ function createV1Router({ rt, auth }) {
mirror_to_live: b.mirror_to_live, 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.
++ let live = null;
++ if (liveLink && req.caller.kind === 'user' && b.show_on_live !== false) live = await liveLink.link(await store.definitions.get(definition.id));
res.set('Cache-Control', 'no-store');
-- return res.status(201).json({ stream: await publicDefinition(definition), key: { id: key.id, key: key.key, hint: key.hint, shown_once: true } });
++ 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 } : {}) });
}));
router.get('/streams/:id', guard('openre.stream.read'), handle(async (req, res) => {
@@@ -174,11 +179,15 @@ function createV1Router({ rt, auth }) {
const d = await loadDefinition(req, res, req.params.id);
if (!d) return;
const b = req.body || {};
-- const updated = await store.definitions.update(d.id, {
++ // 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);
++ 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: 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,
});
-- res.json({ stream: await publicDefinition(updated) });
++ let live = null;
++ if (linkNow) { live = await liveLink.link(await store.definitions.get(d.id)); updated = await store.definitions.get(d.id); }
++ res.json({ stream: await publicDefinition(updated), ...(live ? { live } : {}) });
}));
router.delete('/streams/:id', guard('openre.stream.write'), handle(async (req, res) => {
ddiff --git a/server/app.js b/server/app.js
iindex c9c210c..9371360 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -128,7 +128,9 @@ function createApp({ rt, auth, keys, log = console, fetchImpl }) {
app.get('/api/ready', readiness.handler);
const apiJson = express.json({ limit: '256kb', type: ['application/json', 'application/*+json'] });
-- const v1 = createV1Router({ rt, auth });
++ // OpenVibe Live on by default: a person's new stream also shows on their Live channel (server/live-link.js).
++ const liveLink = require('./live-link').createLiveLink({ store, config, fetchImpl, log });
++ const v1 = createV1Router({ rt, auth, liveLink });
// Per-actor limits on a signed-in person's writes (server/api/actor-limits.js; roadmap WS-R task 4).
const actorLimits = require('./api/actor-limits').createOpenReActorLimits({ registry: metrics.registry, valkey });
app.use('/api/v1', apiJson, auth.middleware({ services: true }), actorLimits, v1.router);
@@@ -155,7 +157,7 @@ function createApp({ rt, auth, keys, log = console, fetchImpl }) {
app.use('/auth', createSsoRoutes({ config, auth, fetchImpl }));
// Crawl and machine-readability artifacts (robots.txt, sitemap.xml, llms.txt): openvibe-shared/seo.
app.use('/', createDiscoveryRoutes({ config }));
-- app.use('/', createUiRouter({ rt, auth, limits: actorLimits }));
++ app.use('/', createUiRouter({ rt, auth, limits: actorLimits, liveLink }));
app.use((req, res) => contracts.http.sendProblem(res, 404, 'openre.not_found', { detail: `no route ${req.method} ${req.path}`, ctx: req.ov }));
// eslint-disable-next-line no-unused-vars
ddiff --git a/server/discovery.js b/server/discovery.js
iindex 894d61d..a1fcab0 100644
---- a/server/discovery.js
++++ b/server/discovery.js
@@@ -32,9 +32,11 @@ function createDiscoveryRoutes({ config }) {
router.get('/llms.txt', (_req, res) => res.type('text/plain').set('Cache-Control', cache.htmlHeaders()).send(
seo.llmsTxt({
name: 'OpenRestream',
-- summary: 'Go live from your browser with no OBS and no follower minimum: on the OpenVibe network you open openvibe.live, press Go Live and allow your camera. OpenRestream is the network\'s ingest and restream service behind it: it takes an RTMP feed from an encoder such as OBS and restreams the session to RTMP and SRT destinations.',
++ summary: 'OpenRestream (openre.stream) is an open restreaming platform: send one stream from OBS or any RTMP encoder and it goes to Twitch, YouTube, Kick and any RTMP, RTMPS or SRT server at the same time, with the person\'s OpenVibe Live channel on by default. It is free and open source, with no follower minimum. With no OBS, people go live from their browser on OpenVibe.Live (openvibe.live, the Go Live button).',
details: [
-- 'OpenRestream is the ingest and restream authority behind OpenVibe.Live: channels, discovery and watch pages stay on openvibe.live. OpenRestream itself serves a JSON/REST API and a small server-rendered owner UI at openre.stream. The service is alpha.',
++ 'OpenRestream is also the ingest and restream engine of the OpenVibe network. It serves a JSON/REST API and server-rendered pages at openre.stream (sign in, streams, keys, destinations, sessions). Channels, chat, discovery and watch pages are on openvibe.live. The service is alpha.',
++ '',
++ 'OpenVibe Live on by default: a stream someone creates on openre.stream also goes live on their openvibe.live channel (its own slot there, managed on OpenRestream), with chat, clips and VODs. It can be switched off for any stream; the other destinations keep working. A person needs an OpenVibe Live account, made by signing in to openvibe.live once.',
'',
'Going live, as it runs today:',
'- From a browser, with nothing to install: on OpenVibe.Live (https://openvibe.live, the Go Live button; guide at https://openvibe.live/docs/go-live-in-your-browser). There is no follower, subscriber or eligibility threshold.',
ddiff --git a/server/live-link.js b/server/live-link.js
nnew file mode 100644
iindex 0000000..69bc793
---- /dev/null
++++ b/server/live-link.js
@@@ -0,0 +1,76 @@
++'use strict';
++/**
++ * OpenVibe Live on by default (owner, 2026-10-09; contracts 0.126.0). A stream someone makes here also shows on their
++ * OpenVibe Live channel unless they switch it off:
++ *
++ * link(definition) asks Live for a slot on the owner's channel bound to this stream (POST /internal/openre/slots,
++ * capability live.openre.slot.bind, OpenRestream's Network service token for audience openvibe.live). Live answers
++ * with the slot; the definition records it as its live:managed_stream ref (label: the channel URL) and turns
++ * mirror_to_live on. Live's mirror then makes each session a live stream on openvibe.live/@username.
++ *
++ * A refusal leaves the stream as it is with mirror_to_live off and says why (no Live account yet: the person signs in
++ * to openvibe.live once and switches it on again). Off without OV_OAUTH_CLIENT_SECRET, in a restore drill, or with
++ * OPENRE_LIVE_LINK=off.
++ */
++const { createServiceTokenClient } = require('openvibe-sdk/auth');
++
++const REASONS = {
++ 'live.no_account': 'You have no OpenVibe Live channel yet. Sign in to openvibe.live once, then switch this on again.',
++ 'live.slot_limit': 'Your OpenVibe Live channel already has all its stream slots. Remove one on openvibe.live, then switch this on again.',
++ 'live.slot_taken': 'This stream is linked to another OpenVibe Live channel.',
++ 'live.account_banned': 'Your OpenVibe Live account cannot go live.',
++};
++
++function createLiveLink({ store, config, env = process.env, fetchImpl = globalThis.fetch, log = console }) {
++ const base = String(env.OV_LIVE_INTERNAL_URL || 'http://127.0.0.1:3000').replace(/\/+$/, '');
++ const enabled = Boolean(config.oauth && config.oauth.clientSecret) && env.OPENRE_LIVE_LINK !== 'off' && !config.drill;
++ const tokens = enabled ? createServiceTokenClient({ tokenUrl: `${config.networkInternalUrl}/oauth/token`, clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret, audience: 'openvibe.live', fetch: fetchImpl }) : null;
++
++ /** The Live slot this definition already shows on, or null. */
++ function slotOf(d) {
++ return (d.external_refs || []).find(r => r.service === 'live' && r.type === 'managed_stream') || null;
++ }
++
++ async function bind(d) {
++ const res = await fetchImpl(`${base}/internal/openre/slots`, {
++ method: 'POST',
++ headers: { 'Content-Type': 'application/json', Accept: 'application/json', Authorization: `Bearer ${await tokens.getToken()}` },
++ body: JSON.stringify({ subject: d.owner_subject, openre_stream_id: d.id, title: d.title, protocol: (d.protocols || ['rtmp'])[0] }),
++ signal: AbortSignal.timeout(10000),
++ });
++ if (res.status === 401 && tokens.invalidate) tokens.invalidate();
++ const body = await res.json().catch(() => null);
++ if (res.ok && body && Number.isInteger(body.managed_stream_id)) return { ok: true, slotId: body.managed_stream_id, channelUrl: body.channel_url || null };
++ const code = (body && (body.code || body.error)) || `live.http_${res.status}`;
++ return { ok: false, code, reason: REASONS[code] || `OpenVibe Live did not link the stream (${code}).` };
++ }
++
++ /**
++ * Show definition `d` on its owner's OpenVibe Live channel: { linked: true, channel_url } or
++ * { linked: false, code, reason }. mirror_to_live follows the outcome.
++ */
++ async function link(d) {
++ if (!enabled) return { linked: false, code: 'openre.live_link_off', reason: 'Linking to OpenVibe Live is not configured here.' };
++ const have = slotOf(d);
++ if (have) {
++ if (!d.mirror_to_live) await store.definitions.update(d.id, { mirror_to_live: true });
++ return { linked: true, channel_url: have.label || null };
++ }
++ let r;
++ try { r = await bind(d); } catch (err) {
++ log.warn(`[live-link] ${d.id}: ${err.message}`);
++ r = { ok: false, code: 'live.unreachable', reason: 'OpenVibe Live did not answer. Try switching it on again in a minute.' };
++ }
++ if (!r.ok) {
++ if (d.mirror_to_live) await store.definitions.update(d.id, { mirror_to_live: false });
++ return { linked: false, code: r.code, reason: r.reason };
++ }
++ await store.definitions.addRef(d.id, { service: 'live', type: 'managed_stream', id: String(r.slotId), label: r.channelUrl ? r.channelUrl.slice(0, 120) : null });
++ await store.definitions.update(d.id, { mirror_to_live: true });
++ return { linked: true, channel_url: r.channelUrl };
++ }
++
++ return { enabled, link, slotOf };
++}
++
++module.exports = { createLiveLink, REASONS };
ddiff --git a/server/store/definitions.js b/server/store/definitions.js
iindex 0fded58..71dc067 100644
---- a/server/store/definitions.js
++++ b/server/store/definitions.js
@@@ -170,6 +170,28 @@ function createDefinitions({ db, config, events, clock }) {
return await row(id);
}
++ /**
++ * Add one external ref to a definition (the Live slot OpenVibe Live gave it, say). Idempotent: a ref the
++ * definition already has changes nothing. Refused (409 openre.ref_taken) when another live definition holds it.
++ */
++ async function addRef(id, ref) {
++ const [r] = validateRefs([ref]);
++ return await db.tx(async () => {
++ const current = await get(id);
++ if (!current || current.state === 'archived') throw new StoreError(404, 'openre.stream_not_found', 'no such stream definition');
++ if (current.external_refs.some(x => x.service === r.service && x.type === r.type && x.id === r.id)) return current;
++ if (!SHARED_REF_TYPES.has(r.type)) {
++ const taken = await q.byRef.get(r.service, r.type, r.id);
++ if (taken && taken.definition_id !== id && (await row(taken.definition_id)).state !== 'archived') {
++ throw new StoreError(409, 'openre.ref_taken', `${r.service}:${r.type}:${r.id} already belongs to another stream definition`);
++ }
++ }
++ await q.insertRef.run(id, r.service, r.type, r.id, r.label, now());
++ await db.prepare('UPDATE stream_definitions SET revision = revision + 1, updated_at = ? WHERE id = ?').run(now(), id);
++ return await get(id);
++ });
++ }
++
async function archive(id) {
const current = await get(id);
if (!current || current.state === 'archived') throw new StoreError(404, 'openre.stream_not_found', 'no such stream definition');
@@@ -288,7 +310,7 @@ function createDefinitions({ db, config, events, clock }) {
return out;
}
-- return { get, list, findByRef, create, update, archive, rotateKey, expireGraceKeys, keys, resolveIngestKey, ingestEndpoints, issueKey, row };
++ return { get, list, findByRef, create, update, addRef, archive, rotateKey, expireGraceKeys, keys, resolveIngestKey, ingestEndpoints, issueKey, row };
}
module.exports = { createDefinitions, StoreError, PROTOCOLS, RECORDING_MODES, VISIBILITIES, cleanText, parseJson, validateRefs };
ddiff --git a/server/ui/layout.js b/server/ui/layout.js
iindex 305878b..0099f9d 100644
---- a/server/ui/layout.js
++++ b/server/ui/layout.js
@@@ -34,10 +34,10 @@ main.ore{max-width:1040px;margin:0 auto;padding:24px 16px 64px}
@media (max-width:640px){.ore th:nth-child(n+4),.ore td:nth-child(n+4){display:none}}
`;
--const DESCRIPTION = 'OpenRestream: stream definitions, ingest keys, sessions and restream outputs for the OpenVibe network.';
++const DESCRIPTION = 'OpenRestream: open restreaming. Go live once from OBS and stream to Twitch, YouTube, Kick and any RTMP or SRT server at the same time, with OpenVibe Live on by default. Free and open source.';
function renderPage({ title, body, user, canonicalPath = '/', robots = 'noindex,nofollow', config, description = DESCRIPTION, styles = [] }) {
-- const pageTitle = title ? `${title} · ${SITE}` : `${SITE} — ingest and restream for OpenVibe`;
++ const pageTitle = title ? `${title} · ${SITE}` : `${SITE} — go live once, stream everywhere`;
const nav = {
service: 'openre',
apiBase: NETWORK_URL,
ddiff --git a/server/ui/routes.js b/server/ui/routes.js
iindex c981906..479c3c2 100644
---- a/server/ui/routes.js
++++ b/server/ui/routes.js
@@@ -23,11 +23,14 @@ const { StoreError } = require('../store/definitions');
const { testDestination } = require('../destination-test');
const iso = (ms) => (ms ? new Date(ms).toISOString().replace('T', ' ').slice(0, 19) + ' UTC' : '—');
++const { REASONS: LIVE_REASONS } = require('../live-link');
++// A channel URL comes from Live's answer; only an http(s) URL becomes a link.
++const httpUrl = (u) => (/^https?:\/\/[^\s"'<>]+$/.test(String(u || '')) ? String(u) : null);
const pill = (state) => `<span class="pill ${esc(state)}">${esc(state)}</span>`;
function csrfFor(token) { return crypto.createHash('sha256').update(`openre-csrf:${token}`).digest('hex').slice(0, 32); }
--function createUiRouter({ rt, auth, limits = null }) {
++function createUiRouter({ rt, auth, limits = null, liveLink = null }) {
const { store, config } = rt;
const router = express.Router();
router.use(express.urlencoded({ extended: false, limit: '64kb' }));
@@@ -91,7 +94,7 @@ function createUiRouter({ rt, auth, limits = null }) {
const mine = signedIn ? await store.definitions.list({ owner_subject: req.caller.subject }) : [];
page(req, res, {
canonicalPath: '/', robots: 'index,follow', styles: [showcase.STYLESHEET],
-- description: 'Go live from your browser with no OBS and no follower minimum on OpenVibe.Live, or restream an OBS feed to your other channels with OpenRestream.',
++ description: 'OpenRestream: open restreaming. Go live once from OBS and stream to Twitch, YouTube, Kick and any RTMP or SRT server at the same time, with OpenVibe Live on by default. Free, open source, no follower minimum.',
body: `${frontPage(config)}
${signedIn ? `<h2>Your streams</h2>${mine.length ? await streamTable(mine) : '<p class="muted">No streams yet.</p>'}<p><a href="/streams">Manage streams</a></p>` : '<p><a href="/auth/login?next=/streams">Sign in with OpenVibe</a> to manage your streams.</p>'}
${frame.shipped({ service: 'openre', title: 'Recently shipped on OpenRestream' })}`,
@@@ -99,37 +102,37 @@ ${frame.shipped({ service: 'openre', title: 'Recently shipped on OpenRestream' }
});
/**
-- * The front page (openvibe-shared/showcase). It leads with going live from a browser, which today happens on
-- * OpenVibe.Live; it states that only RTMP ingest and RTMP/SRT restreaming run here, and that WHIP and JSMPEG
-- * are still Live's (the WebRTC and JSMPEG workers are ported but not deployed).
++ * The front page (openvibe-shared/showcase). OpenRestream is an open restreaming platform with OpenVibe Live on by
++ * default (owner, 2026-10-09). It says that RTMP ingest and RTMP/RTMPS/SRT restreaming run here, and that going live
++ * from a browser happens on OpenVibe.Live for now (the WebRTC and JSMPEG workers are ported but not deployed).
*/
function frontPage(cfg) {
const live = cfg.liveUrl;
return showcase.hero({
-- eyebrow: 'OpenRestream · alpha',
-- title: 'Go live from your browser.', accent: 'No OBS, no follower minimum.',
-- lede: 'Open OpenVibe.Live, press Go Live and allow your camera: nothing to install, and no follower or subscriber threshold. OpenRestream is the ingest and restream service behind it. With an encoder such as OBS, it takes your RTMP feed and sends the session on to your other channels.',
++ eyebrow: 'OpenRestream · open restreaming',
++ title: 'Go live once.', accent: 'Stream everywhere.',
++ lede: 'Send one stream from OBS or any RTMP encoder, and OpenRestream sends it to Twitch, YouTube, Kick and any RTMP, RTMPS or SRT server at the same time. Your OpenVibe Live channel is on by default, with chat, clips and VODs. It is free and open source, with no follower minimum.',
actions: [
-- { label: 'Go live in your browser', href: live, primary: true },
-- { label: 'Restream with OBS', href: '/streams' },
-- { label: 'Read the guide', href: `${live}/docs/go-live-in-your-browser` },
++ { label: 'Start restreaming', href: '/streams', primary: true },
++ { label: 'No OBS? Go live from your browser', href: live },
++ { label: 'How browser streaming works', href: `${live}/docs/go-live-in-your-browser` },
],
-- note: 'Alpha. RTMP ingest and RTMP/SRT restreaming run here today; browser (WHIP) and JSMPEG ingest are still served by OpenVibe.Live. Channels, discovery and watch pages stay on openvibe.live.',
++ note: 'Alpha. RTMP ingest and RTMP, RTMPS and SRT restreaming run here. Going live from a browser happens on OpenVibe.Live for now.',
}) + showcase.features({
title: 'What OpenRestream does',
items: [
-- { icon: 'ov:stream', title: 'Ingest keys you control', text: 'Create a stream and get its RTMP server and key. The key is shown once, stored only as a hash, and can be rotated any time.' },
-- { icon: 'ov:live', title: 'One stream, several channels', text: 'Send a session to several RTMP or SRT destinations at once, each with its own health, logs and retry with backoff.' },
-- { icon: 'ov:video', title: 'Recording', text: 'Per stream: record a VOD, keep clips only, or record nothing. OpenVibe.Media keeps the recordings.' },
-- { icon: 'ov:check', title: 'Nothing to qualify for', text: 'Any signed-in OpenVibe account can create a stream. There is no follower, subscriber or eligibility threshold.' },
++ { 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: '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.' },
],
}) + showcase.steps({
title: 'Restream with OBS',
items: [
{ title: 'Sign in', text: 'With your OpenVibe account.', href: '/auth/login?next=/streams' },
-- { title: 'Create a stream', text: 'Copy the RTMP server and the key; the key is shown only once.' },
-- { title: 'Add destinations', text: 'The server and key of each channel you also stream to. Test each one before you go live.' },
-- { title: 'Go live from OBS', text: 'Each destination shows its health and logs while you stream.' },
++ { title: 'Create a stream', text: 'Copy the RTMP server and key; the key is shown only once. OpenVibe Live is already ticked.' },
++ { title: 'Add destinations', text: 'The server and key of each platform you also stream to. Test each one before you go live.' },
++ { title: 'Go live from OBS', text: 'Every destination, and your OpenVibe Live channel, shows its health while you stream.' },
],
});
}
@@@ -152,11 +155,19 @@ ${frame.shipped({ service: 'openre', title: 'Recently shipped on OpenRestream' }
<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>
<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>
<button type="submit">Create stream</button><p class="muted">The ingest key is shown once, on the next page.</p>`)}</div>`,
});
});
-- async function keyPage(req, res, definition, key, heading) {
++ /** Where the stream shows on OpenVibe Live, or why it does not (the outcome of liveLink.link). */
++ function liveNote(live) {
++ if (!live) return '';
++ if (live.linked) return `<div class="card"><p><strong>OpenVibe Live is on.</strong> When you go live, this stream also shows on ${httpUrl(live.channel_url) ? `<a href="${esc(live.channel_url)}">${esc(live.channel_url.replace(/^https?:\/\//, ''))}</a>` : 'your OpenVibe Live channel'}, with chat, clips and VODs. Switch it off in the stream's settings.</p></div>`;
++ return `<div class="flash bad">${esc(live.reason || 'OpenVibe Live did not link this stream.')}</div>`;
++ }
++
++ async function keyPage(req, res, definition, key, heading, live = null) {
const ep = await store.definitions.ingestEndpoints(definition);
res.set('Cache-Control', 'no-store');
page(req, res, {
@@@ -165,6 +176,7 @@ ${frame.shipped({ service: 'openre', title: 'Recently shipped on OpenRestream' }
<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)}
<p><a href="/streams/${esc(definition.id)}">Continue to the stream</a></p>`,
});
}
@@@ -175,7 +187,9 @@ ${frame.shipped({ service: 'openre', title: 'Recently shipped on OpenRestream' }
owner_subject: req.caller.subject, title: b.title, recording_mode: b.recording_mode,
recording_visibility: b.recording_visibility, created_by: req.caller.subject,
});
-- await keyPage(req, res, definition, key, 'Stream created');
++ // 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;
++ await keyPage(req, res, definition, key, 'Stream created', live);
});
router.get('/streams/:id', async (req, res) => {
@@@ -193,6 +207,7 @@ ${frame.shipped({ service: 'openre', title: 'Recently shipped on OpenRestream' }
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)}
<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>
@@@ -217,12 +232,21 @@ ${dests.length ? `<table><tr><th>Destination</th><th>Output</th><th>Health</th><
<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>
<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' : ''}> Mirror sessions into my OpenVibe.Live channel (consent; Live also has to switch this slot to OpenRestream)</label>
++<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>
<button type="submit">Save</button>`)}</div>
<h2>Sessions</h2>${sessionTable(sessions)}`,
});
});
++ /** The stream page's OpenVibe Live line: where it shows, that it is off, or why switching it on failed. */
++ function liveStatus(d, failed) {
++ const slot = liveLink ? liveLink.slotOf(d) : null;
++ const reason = failed && LIVE_REASONS[failed];
++ const flash = failed ? `<div class="flash bad">${esc(reason || 'OpenVibe Live did not link this stream.')}</div>` : '';
++ if (d.mirror_to_live && slot) return `${flash}<p><span class="pill live">on</span> OpenVibe Live: sessions show on ${httpUrl(slot.label) ? `<a href="${esc(slot.label)}">${esc(String(slot.label).replace(/^https?:\/\//, ''))}</a>` : 'your OpenVibe Live channel'}.</p>`;
++ return `${flash}<p class="muted">OpenVibe Live is off for this stream: switch it on in Settings.</p>`;
++ }
++
function destinationFields(x) {
const sel = (name, opts, cur) => `<select name="${name}">${opts.map(o => `<option${o === cur ? ' selected' : ''}>${o}</option>`).join('')}</select>`;
return `<div class="row"><div><label>Platform</label>${sel('platform', ['twitch', 'youtube', 'kick', 'custom'], x.platform)}</div>
@@@ -257,7 +281,13 @@ ${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 || {};
-- 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: b.mirror_to_live === '1' });
++ 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 });
++ 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));
++ if (!r.linked) return res.redirect(303, `/streams/${d.id}?live=${encodeURIComponent(r.code)}`);
++ }
res.redirect(303, `/streams/${d.id}`);
});
ddiff --git a/test/discovery.test.js b/test/discovery.test.js
iindex 0e5568e..8ad291b 100644
---- a/test/discovery.test.js
++++ b/test/discovery.test.js
@@@ -35,14 +35,17 @@ t('sitemap.xml is XML and lists the public pages, including /', async () => {
}
});
--t('llms.txt is plain text, leads with browser go-live and says what runs today', async () => {
++t('llms.txt is plain text, leads with open restreaming (OpenVibe Live on by default) and says what runs today', async () => {
const api = await bootApi({});
try {
const r = await request(api.base, 'GET', '/llms.txt');
assert.strictEqual(r.status, 200);
assert.match(r.headers.get('content-type'), /^text\/plain/);
assert.ok(r.text.startsWith('# OpenRestream\n'), 'starts with the site heading');
-- assert.match(r.text.split('\n').slice(0, 4).join(' '), /Go live from your browser with no OBS and no follower minimum/, 'browser go-live leads');
++ const lead = r.text.split('\n').slice(0, 4).join(' ');
++ assert.match(lead, /OpenRestream \(openre\.stream\) is an open restreaming platform/, 'open restreaming leads');
++ assert.match(lead, /OpenVibe Live channel on by default/);
++ assert.match(lead, /go live from their browser on OpenVibe\.Live/, 'browser go-live is still named, where it runs');
// Only the RTMP ingest and restream workers run in production: WHIP and JSMPEG are ported, not served yet.
assert.match(r.text, /WHIP, RFC 9725\) and JSMPEG ingest are ported to OpenRestream and tested, but their workers do not run in production yet/);
} finally {
@@@ -50,15 +53,17 @@ t('llms.txt is plain text, leads with browser go-live and says what runs today',
}
});
--t('the front page leads with browser go-live, links to Live for it, and says what runs here', async () => {
++t('the front page leads with open restreaming, links to Live for browser go-live, and says what runs here', async () => {
const api = await bootApi({});
try {
const r = await request(api.base, 'GET', '/');
assert.strictEqual(r.status, 200);
-- assert.match(r.text, /<h1>Go live from your browser\.<span class="sc-accent"> No OBS, no follower minimum\.<\/span><\/h1>/);
-- assert.match(r.text, /<meta name="description" content="Go live from your browser with no OBS and no follower minimum/);
-- assert.ok(r.text.includes(`href="${api.config.liveUrl}"`), 'the primary action opens OpenVibe.Live, where browser go-live runs today');
-- assert.match(r.text, /browser \(WHIP\) and JSMPEG ingest are still served by OpenVibe\.Live/);
++ assert.match(r.text, /<h1>Go live once\.<span class="sc-accent"> Stream everywhere\.<\/span><\/h1>/);
++ assert.match(r.text, /<meta name="description" content="OpenRestream: open restreaming\./);
++ assert.match(r.text, /<title>OpenRestream — go live once, stream everywhere<\/title>/);
++ assert.match(r.text, /OpenVibe Live on by default/);
++ assert.ok(r.text.includes(`href="${api.config.liveUrl}"`), 'browser go-live opens OpenVibe.Live, where it runs today');
++ assert.match(r.text, /Going live from a browser happens on OpenVibe\.Live for now/);
assert.match(r.text, /href="\/shared\/showcase\.css\?v=[0-9a-f]{12}"/);
assert.strictEqual((r.text.match(/<h1[ >]/g) || []).length, 1, 'one h1');
} finally {
ddiff --git a/test/live-link.test.js b/test/live-link.test.js
nnew file mode 100644
iindex 0000000..237390a
---- /dev/null
++++ b/test/live-link.test.js
@@@ -0,0 +1,127 @@
++'use strict';
++/**
++ * OpenVibe Live on by default (owner, 2026-10-09; contracts 0.126.0). A person's new stream asks Live for a slot on
++ * their channel (POST /internal/openre/slots with OpenRestream's service token) unless they untick it; the slot becomes
++ * the stream's live:managed_stream ref and mirror_to_live turns on. A refusal keeps the stream with Live off and says
++ * why. Services (Live's own slots, Bot's robots) are never linked here. Switching it on later links once.
++ */
++const assert = require('assert');
++const { bootApi, request, userToken, serviceToken, suite, OWNER, OTHER } = require('./helpers');
++const { csrfFor } = require('../server/ui/routes');
++
++const t = suite('live-link');
++const liveCalls = [];
++let slotSeq = 70;
++const realFetch = globalThis.fetch;
++async function fetchImpl(url, opts = {}) {
++ const u = String(url);
++ if (u.endsWith('/oauth/token')) {
++ return new Response(JSON.stringify({ access_token: 'svc-openre-live', token_type: 'Bearer', expires_in: 300 }), { status: 200, headers: { 'Content-Type': 'application/json' } });
++ }
++ if (u === 'http://127.0.0.1:3000/internal/openre/slots') {
++ assert.strictEqual(opts.headers.Authorization, 'Bearer svc-openre-live');
++ const b = JSON.parse(opts.body);
++ liveCalls.push(b);
++ if (b.subject === OTHER) {
++ return new Response(JSON.stringify({ type: 'https://openvibe.network/problems/live.no_account', status: 409, code: 'live.no_account', detail: 'no Live account' }), { status: 409, headers: { 'Content-Type': 'application/problem+json' } });
++ }
++ return new Response(JSON.stringify({ managed_stream_id: ++slotSeq, channel_url: 'https://openvibe.live/@owner', created: true }), { status: 201, headers: { 'Content-Type': 'application/json' } });
++ }
++ return realFetch(url, opts);
++}
++
++let api;
++const ownerToken = userToken({ subjectId: OWNER });
++const otherToken = userToken({ subjectId: OTHER });
++const form = (token, fields) => ({ cookie: `ov_token=${token}`, form: { ...fields, _csrf: csrfFor(token) } });
++const streamOf = async (id, token = ownerToken) => (await request(api.base, 'GET', `/api/v1/streams/${id}`, { token })).body.stream;
++const liveRef = (s) => (s.external_refs || []).find(r => r.service === 'live' && r.type === 'managed_stream');
++
++t('a new stream made on the page is on OpenVibe Live by default', async () => {
++ api = await bootApi({ env: { OV_OAUTH_CLIENT_ID: 'openre', OV_OAUTH_CLIENT_SECRET: 'test-secret' }, fetchImpl });
++ const page = await request(api.base, 'GET', '/streams', { cookie: `ov_token=${ownerToken}` });
++ assert.match(page.text, /name="show_on_live" value="1" checked/, 'the box is ticked on the form');
++ const created = await request(api.base, 'POST', '/streams', form(ownerToken, { title: 'Saturday', recording_mode: 'none', recording_visibility: 'public', show_on_live: '1' }));
++ assert.strictEqual(created.status, 200, created.text.slice(0, 300));
++ assert.match(created.text, /OpenVibe Live is on/);
++ assert.match(created.text, /href="https:\/\/openvibe\.live\/@owner"/);
++ assert.strictEqual(liveCalls.length, 1);
++ assert.strictEqual(liveCalls[0].subject, OWNER);
++ assert.strictEqual(liveCalls[0].title, 'Saturday');
++ const id = /\/streams\/(std_[0-9A-Z]+)/.exec(created.text)[1];
++ assert.strictEqual(liveCalls[0].openre_stream_id, id);
++ const s = await streamOf(id);
++ assert.strictEqual(s.mirror_to_live, true);
++ assert.deepStrictEqual(liveRef(s) && { id: liveRef(s).id, label: liveRef(s).label }, { id: '71', label: 'https://openvibe.live/@owner' });
++ const shown = await request(api.base, 'GET', `/streams/${id}`, { cookie: `ov_token=${ownerToken}` });
++ assert.match(shown.text, /OpenVibe Live: sessions show on <a href="https:\/\/openvibe\.live\/@owner"/);
++});
++
++t('unticked, the stream stays off Live and Live is not asked', async () => {
++ const before = liveCalls.length;
++ const created = await request(api.base, 'POST', '/streams', form(ownerToken, { title: 'Off', recording_mode: 'none', recording_visibility: 'public' }));
++ assert.strictEqual(created.status, 200);
++ assert.doesNotMatch(created.text, /OpenVibe Live is on/);
++ assert.strictEqual(liveCalls.length, before);
++ const id = /\/streams\/(std_[0-9A-Z]+)/.exec(created.text)[1];
++ const s = await streamOf(id);
++ assert.strictEqual(s.mirror_to_live, false);
++ assert.ok(!liveRef(s));
++
++ // Switching it on in Settings links once; saving again does not ask Live again.
++ const save = (v) => request(api.base, 'POST', `/streams/${id}`, form(ownerToken, { title: 'Off', recording_mode: 'none', recording_visibility: 'public', playback_visibility: 'public', state: 'active', ...(v ? { mirror_to_live: '1' } : {}) }));
++ let r = await save(true);
++ assert.strictEqual(r.status, 303);
++ assert.strictEqual(r.headers.get('location'), `/streams/${id}`);
++ assert.strictEqual(liveCalls.length, before + 1);
++ assert.strictEqual((await streamOf(id)).mirror_to_live, true);
++ r = await save(true);
++ assert.strictEqual(liveCalls.length, before + 1, 'already linked: Live is not asked again');
++ r = await save(false);
++ const off = await streamOf(id);
++ assert.strictEqual(off.mirror_to_live, false, 'switched off');
++ assert.ok(liveRef(off), 'the slot stays recorded, so switching on again needs no new slot');
++ r = await save(true);
++ assert.strictEqual(liveCalls.length, before + 1);
++ assert.strictEqual((await streamOf(id)).mirror_to_live, true);
++});
++
++t('without a Live account the stream is made, Live stays off, and the page says why', async () => {
++ const created = await request(api.base, 'POST', '/streams', form(otherToken, { title: 'Theirs', recording_mode: 'none', recording_visibility: 'public', show_on_live: '1' }));
++ assert.strictEqual(created.status, 200);
++ assert.match(created.text, /Sign in to openvibe\.live once/);
++ const id = /\/streams\/(std_[0-9A-Z]+)/.exec(created.text)[1];
++ const s = await streamOf(id, otherToken);
++ assert.strictEqual(s.mirror_to_live, false);
++ assert.ok(!liveRef(s));
++ const r = await request(api.base, 'POST', `/streams/${id}`, form(otherToken, { title: 'Theirs', recording_mode: 'none', recording_visibility: 'public', playback_visibility: 'public', state: 'active', mirror_to_live: '1' }));
++ assert.strictEqual(r.headers.get('location'), `/streams/${id}?live=live.no_account`);
++ const page = await request(api.base, 'GET', `/streams/${id}?live=live.no_account`, { cookie: `ov_token=${otherToken}` });
++ assert.match(page.text, /flash bad">You have no OpenVibe Live channel yet/);
++ assert.strictEqual((await streamOf(id, otherToken)).mirror_to_live, false);
++});
++
++t('the API links a person\'s stream by default, never a service\'s', async () => {
++ let before = liveCalls.length;
++ const mine = await request(api.base, 'POST', '/api/v1/streams', { token: ownerToken, body: { title: 'From an app' } });
++ assert.strictEqual(mine.status, 201, mine.text);
++ assert.deepStrictEqual(mine.body.live, { linked: true, channel_url: 'https://openvibe.live/@owner' });
++ assert.strictEqual(mine.body.stream.mirror_to_live, true);
++ assert.strictEqual(liveCalls.length, before + 1);
++ const optOut = await request(api.base, 'POST', '/api/v1/streams', { token: ownerToken, body: { title: 'Not on Live', show_on_live: false } });
++ assert.strictEqual(optOut.body.live, undefined);
++ assert.strictEqual(optOut.body.stream.mirror_to_live, false);
++ before = liveCalls.length;
++ const svc = await request(api.base, 'POST', '/api/v1/streams', { token: serviceToken('live', ['openre.stream.write']), headers: { 'X-OV-Subject': OWNER }, body: { title: 'A Live slot', external_refs: [{ service: 'live', type: 'managed_stream', id: '9001' }] } });
++ assert.strictEqual(svc.status, 201, svc.text);
++ assert.strictEqual(svc.body.live, undefined);
++ assert.strictEqual(liveCalls.length, before, 'a service names its own refs');
++ const theirs = await request(api.base, 'POST', '/api/v1/streams', { token: otherToken, body: { title: 'No account' } });
++ assert.strictEqual(theirs.body.live.linked, false);
++ assert.strictEqual(theirs.body.live.code, 'live.no_account');
++ assert.strictEqual(theirs.body.stream.mirror_to_live, false);
++});
++
++t('teardown', async () => { await api.close(); });
++
++t.run();