openvibers/openrestream Commits
OpenRestream: the front page gains OBS settings and questions (FAQPage JSON-LD) (#59)
Changes
@@@ -26,6 +26,8 @@ main.ore{max-width:1040px;margin:0 auto;padding:24px 16px 64px}.ore h1{font-size:1.6rem;margin:.2em 0 .4em}.ore h2{font-size:1.15rem;margin:1.6em 0 .6em}.ore h3{font-size:1rem;margin:1em 0 .4em}.ore a{color:#93c5fd}.ore p.muted,.ore .muted{color:var(--ore-muted)}.ore .card{background:var(--ore-panel);border:1px solid var(--ore-line);border-radius:10px;padding:14px 16px;margin:12px 0}++.ore-faq details{border-bottom:1px solid var(--ore-line);padding:.75rem 0}.ore-faq details:first-of-type{border-top:1px solid var(--ore-line)}++.ore-faq summary{cursor:pointer;font-weight:600;list-style-position:inside}.ore-faq summary:hover{color:#93c5fd}.ore-faq details p{margin:.5rem 0 0;color:var(--ore-muted);max-width:46rem}.ore table{width:100%;border-collapse:collapse;font-size:.92rem}.ore th,.ore td{text-align:left;padding:6px 8px;border-bottom:1px solid var(--ore-line);vertical-align:top}.ore code,.ore .mono{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:.88rem;word-break:break-all}.ore .secret{display:block;background:#0a0f18;border:1px dashed var(--ore-warn);padding:10px;border-radius:8px;user-select:all}@@@ -33,6 +33,16 @@ const pill = (state) => `<span class="pill ${esc(state)}">${esc(state)}</span>`;// one place (server/ui/layout.js clips it to 160 for the meta tags).const FRONT_PAGE_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.';++// The front page's questions, answered from what OpenRestream does today (README): shown as a list and as FAQPage JSON-LD.++const FRONT_FAQ = [++ { q: 'Is OpenRestream free?', a: 'Yes. Any OpenVibe account can restream from the first minute, with no follower, subscriber or eligibility minimum. The code is open source (AGPL-3.0).' },++ { q: 'Which platforms can I stream to?', a: 'Any platform that takes RTMP, RTMPS or SRT: Twitch, YouTube, Kick and others. Add each destination\'s server and key once; every destination has its own health, logs and retries.' },++ { q: 'Does OpenRestream re-encode my stream?', a: 'Not for a stream sent from OBS or another RTMP encoder: it is copied to every destination as it arrives, so each platform gets the quality you sent.' },++ { q: 'Can I go live without OBS?', a: 'Yes. On OpenVibe.Live you can go live from your browser, with nothing to install and no follower minimum.' },++ { q: 'Are my keys safe?', a: 'Your OpenRestream stream key is shown once and stored only as a hash; rotate it any time. Destination keys and SRT passphrases are encrypted (AES-256-GCM), the API only ever shows their last four characters, and logged command lines are redacted.' },++ { q: 'Does my stream also appear on OpenVibe Live?', a: 'By default, yes: every stream also goes live on your openvibe.live channel with chat, clips and VODs. Switch it off for any stream.' },++];++function csrfFor(token) { return crypto.createHash('sha256').update(`openre-csrf:${token}`).digest('hex').slice(0, 32); }function createUiRouter({ rt, auth, limits = null, liveLink = null }) {@@@ -103,7 +113,7 @@ function createUiRouter({ rt, auth, limits = null, liveLink = null }) {// Structured data for the front page only: the site is a web application (openre.stream).jsonLd: [seo.jsonLd.softwareApp({name: 'OpenRestream', url: `${config.baseUrl}/`, description: FRONT_PAGE_DESCRIPTION, category: 'MultimediaApplication',-- })],++ }), seo.jsonLd.faq(FRONT_FAQ)],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' })}`,@@@ -115,6 +125,13 @@ ${frame.shipped({ service: 'openre', title: 'Recently shipped on OpenRestream' }* 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).*/++ // What streamers paste into OBS (config.rtmp.publicHost/publicPort): rtmp://<host>[:port]/live.++ const ingestServer = (cfg) => {++ const r = cfg.rtmp || {};++ const host = r.publicHost || 'ingest.openre.stream';++ return `rtmp://${host}${r.publicPort && Number(r.publicPort) !== 1935 ? `:${r.publicPort}` : ''}/live`;++ };++function frontPage(cfg) {const live = cfg.liveUrl;return showcase.hero({@@@ -145,7 +162,13 @@ ${frame.shipped({ service: 'openre', title: 'Recently shipped on OpenRestream' }{ 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.' },],-- });++ }) + showcase.code({++ title: 'OBS settings',++ lede: 'In OBS, open Settings → Stream, choose Custom, and paste the server and the key from your stream\'s page.',++ samples: [{ label: 'Settings → Stream', code: `Service: Custom...\nServer: ${ingestServer(cfg)}\nStream Key: (from your stream's page, shown once)` }],++ }) + `<section class="sc-sec ore-faq" aria-labelledby="ore-faq-h"><h2 id="ore-faq-h">Questions</h2>++${FRONT_FAQ.map((f) => `<details><summary>${esc(f.q)}</summary><p>${esc(f.a)}</p></details>`).join('\n')}++</section>`;}async function streamTable(list) {@@@ -77,6 +77,12 @@ t('the front page leads with open restreaming, links to Live for browser go-liveassert.strictEqual(app['@type'], 'WebApplication');assert.strictEqual(app.name, 'OpenRestream');assert.strictEqual(app.applicationCategory, 'MultimediaApplication');++ // The questions, as a visible list and as FAQPage JSON-LD; the OBS settings name the ingest server.++ const all = [...r.text.matchAll(/<script type="application\/ld\+json">(.*?)<\/script>/gs)].map((m) => JSON.parse(m[1]));++ const faq = all.find((x) => x['@type'] === 'FAQPage');++ assert.ok(faq && faq.mainEntity.length >= 5, 'FAQPage JSON-LD');++ assert.ok(faq.mainEntity.every((q) => r.text.includes(`<summary>${q.name.replace(/'/g, ''')}</summary>`) || r.text.includes(`<summary>${q.name}</summary>`)), 'every JSON-LD question is on the page');++ assert.match(r.text, /Server: +rtmp:\/\/[^\s<]+\/live/);} finally {await api.close();}
@@ -26,6 +26,8 @@ main.ore{max-width:1040px;margin:0 auto;padding:24px 16px 64px}
@@ -26,6 +26,8 @@ main.ore{max-width:1040px;margin:0 auto;padding:24px 16px 64px}
.ore h1{font-size:1.6rem;margin:.2em 0 .4em}.ore h2{font-size:1.15rem;margin:1.6em 0 .6em}.ore h3{font-size:1rem;margin:1em 0 .4em}
.ore h1{font-size:1.6rem;margin:.2em 0 .4em}.ore h2{font-size:1.15rem;margin:1.6em 0 .6em}.ore h3{font-size:1rem;margin:1em 0 .4em}
.ore a{color:#93c5fd}.ore p.muted,.ore .muted{color:var(--ore-muted)}
.ore a{color:#93c5fd}.ore p.muted,.ore .muted{color:var(--ore-muted)}
.ore .card{background:var(--ore-panel);border:1px solid var(--ore-line);border-radius:10px;padding:14px 16px;margin:12px 0}
.ore .card{background:var(--ore-panel);border:1px solid var(--ore-line);border-radius:10px;padding:14px 16px;margin:12px 0}
+.ore-faq details{border-bottom:1px solid var(--ore-line);padding:.75rem 0}.ore-faq details:first-of-type{border-top:1px solid var(--ore-line)}
+.ore-faq summary{cursor:pointer;font-weight:600;list-style-position:inside}.ore-faq summary:hover{color:#93c5fd}.ore-faq details p{margin:.5rem 0 0;color:var(--ore-muted);max-width:46rem}
.ore table{width:100%;border-collapse:collapse;font-size:.92rem}.ore th,.ore td{text-align:left;padding:6px 8px;border-bottom:1px solid var(--ore-line);vertical-align:top}
.ore table{width:100%;border-collapse:collapse;font-size:.92rem}.ore th,.ore td{text-align:left;padding:6px 8px;border-bottom:1px solid var(--ore-line);vertical-align:top}
.ore code,.ore .mono{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:.88rem;word-break:break-all}
.ore code,.ore .mono{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:.88rem;word-break:break-all}
.ore .secret{display:block;background:#0a0f18;border:1px dashed var(--ore-warn);padding:10px;border-radius:8px;user-select:all}
.ore .secret{display:block;background:#0a0f18;border:1px dashed var(--ore-warn);padding:10px;border-radius:8px;user-select:all}
@@ -33,6 +33,16 @@ const pill = (state) => `<span class="pill ${esc(state)}">${esc(state)}</span>`;
@@ -33,6 +33,16 @@ const pill = (state) => `<span class="pill ${esc(state)}">${esc(state)}</span>`;
// one place (server/ui/layout.js clips it to 160 for the meta tags).
// one place (server/ui/layout.js clips it to 160 for the meta tags).
const FRONT_PAGE_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.';
const FRONT_PAGE_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.';
+// The front page's questions, answered from what OpenRestream does today (README): shown as a list and as FAQPage JSON-LD.
+const FRONT_FAQ = [
+ { q: 'Is OpenRestream free?', a: 'Yes. Any OpenVibe account can restream from the first minute, with no follower, subscriber or eligibility minimum. The code is open source (AGPL-3.0).' },
+ { q: 'Which platforms can I stream to?', a: 'Any platform that takes RTMP, RTMPS or SRT: Twitch, YouTube, Kick and others. Add each destination\'s server and key once; every destination has its own health, logs and retries.' },
+ { q: 'Does OpenRestream re-encode my stream?', a: 'Not for a stream sent from OBS or another RTMP encoder: it is copied to every destination as it arrives, so each platform gets the quality you sent.' },
+ { q: 'Can I go live without OBS?', a: 'Yes. On OpenVibe.Live you can go live from your browser, with nothing to install and no follower minimum.' },
+ { q: 'Are my keys safe?', a: 'Your OpenRestream stream key is shown once and stored only as a hash; rotate it any time. Destination keys and SRT passphrases are encrypted (AES-256-GCM), the API only ever shows their last four characters, and logged command lines are redacted.' },
+ { q: 'Does my stream also appear on OpenVibe Live?', a: 'By default, yes: every stream also goes live on your openvibe.live channel with chat, clips and VODs. Switch it off for any stream.' },
+];
+
function csrfFor(token) { return crypto.createHash('sha256').update(`openre-csrf:${token}`).digest('hex').slice(0, 32); }
function csrfFor(token) { return crypto.createHash('sha256').update(`openre-csrf:${token}`).digest('hex').slice(0, 32); }
function createUiRouter({ rt, auth, limits = null, liveLink = null }) {
function createUiRouter({ rt, auth, limits = null, liveLink = null }) {
@@ -103,7 +113,7 @@ function createUiRouter({ rt, auth, limits = null, liveLink = null }) {
@@ -103,7 +113,7 @@ function createUiRouter({ rt, auth, limits = null, liveLink = null }) {
// Structured data for the front page only: the site is a web application (openre.stream).
// Structured data for the front page only: the site is a web application (openre.stream).
jsonLd: [seo.jsonLd.softwareApp({
jsonLd: [seo.jsonLd.softwareApp({
name: 'OpenRestream', url: `${config.baseUrl}/`, description: FRONT_PAGE_DESCRIPTION, category: 'MultimediaApplication',
name: 'OpenRestream', url: `${config.baseUrl}/`, description: FRONT_PAGE_DESCRIPTION, category: 'MultimediaApplication',
- })],
+ }), seo.jsonLd.faq(FRONT_FAQ)],
body: `${frontPage(config)}
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>'}
${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' })}`,
${frame.shipped({ service: 'openre', title: 'Recently shipped on OpenRestream' })}`,
@@ -115,6 +125,13 @@ ${frame.shipped({ service: 'openre', title: 'Recently shipped on OpenRestream' }
@@ -115,6 +125,13 @@ ${frame.shipped({ service: 'openre', title: 'Recently shipped on OpenRestream' }
* default (owner, 2026-10-09). It says that RTMP ingest and RTMP/RTMPS/SRT restreaming run here, and that going live
* 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).
* from a browser happens on OpenVibe.Live for now (the WebRTC and JSMPEG workers are ported but not deployed).
*/
*/
+ // What streamers paste into OBS (config.rtmp.publicHost/publicPort): rtmp://<host>[:port]/live.
+ const ingestServer = (cfg) => {
+ const r = cfg.rtmp || {};
+ const host = r.publicHost || 'ingest.openre.stream';
+ return `rtmp://${host}${r.publicPort && Number(r.publicPort) !== 1935 ? `:${r.publicPort}` : ''}/live`;
+ };
+
function frontPage(cfg) {
function frontPage(cfg) {
const live = cfg.liveUrl;
const live = cfg.liveUrl;
return showcase.hero({
return showcase.hero({
@@ -145,7 +162,13 @@ ${frame.shipped({ service: 'openre', title: 'Recently shipped on OpenRestream' }
@@ -145,7 +162,13 @@ ${frame.shipped({ service: 'openre', title: 'Recently shipped on OpenRestream' }
{ title: 'Add destinations', text: 'The server and key of each platform you also stream to. Test each one before you go live.' },
{ 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.' },
{ title: 'Go live from OBS', text: 'Every destination, and your OpenVibe Live channel, shows its health while you stream.' },
],
],
- });
+ }) + showcase.code({
+ title: 'OBS settings',
+ lede: 'In OBS, open Settings → Stream, choose Custom, and paste the server and the key from your stream\'s page.',
+ samples: [{ label: 'Settings → Stream', code: `Service: Custom...\nServer: ${ingestServer(cfg)}\nStream Key: (from your stream's page, shown once)` }],
+ }) + `<section class="sc-sec ore-faq" aria-labelledby="ore-faq-h"><h2 id="ore-faq-h">Questions</h2>
+${FRONT_FAQ.map((f) => `<details><summary>${esc(f.q)}</summary><p>${esc(f.a)}</p></details>`).join('\n')}
+</section>`;
}
}
async function streamTable(list) {
async function streamTable(list) {
@@ -77,6 +77,12 @@ t('the front page leads with open restreaming, links to Live for browser go-live
@@ -77,6 +77,12 @@ t('the front page leads with open restreaming, links to Live for browser go-live
assert.strictEqual(app['@type'], 'WebApplication');
assert.strictEqual(app['@type'], 'WebApplication');
assert.strictEqual(app.name, 'OpenRestream');
assert.strictEqual(app.name, 'OpenRestream');
assert.strictEqual(app.applicationCategory, 'MultimediaApplication');
assert.strictEqual(app.applicationCategory, 'MultimediaApplication');
+ // The questions, as a visible list and as FAQPage JSON-LD; the OBS settings name the ingest server.
+ const all = [...r.text.matchAll(/<script type="application\/ld\+json">(.*?)<\/script>/gs)].map((m) => JSON.parse(m[1]));
+ const faq = all.find((x) => x['@type'] === 'FAQPage');
+ assert.ok(faq && faq.mainEntity.length >= 5, 'FAQPage JSON-LD');
+ assert.ok(faq.mainEntity.every((q) => r.text.includes(`<summary>${q.name.replace(/'/g, ''')}</summary>`) || r.text.includes(`<summary>${q.name}</summary>`)), 'every JSON-LD question is on the page');
+ assert.match(r.text, /Server: +rtmp:\/\/[^\s<]+\/live/);
} finally {
} finally {
await api.close();
await api.close();
}
}