openvibers/openrestream Commits

OpenRestream's front page shows its real activity (live now, streams, destinations, sessions), where a stream can go, a WebSite block, and a description that fits a search result (it was 205 characters) (#91)

OP OpenVibers committed 22 days ago · c4b97eb364 · 3 files, +56 −9

Changes

ddiff --git a/server/ui/layout.js b/server/ui/layout.js
iindex b43d65d..6fe4ed5 100644
---- a/server/ui/layout.js
++++ b/server/ui/layout.js
@@@ -27,6 +27,8 @@ main.ore .sc-hero,main.ore .sc-sec,main.ore .sc-cta{padding-left:0;padding-right
.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-stats{margin:0;display:grid;gap:10px}.ore-stats div{border:1px solid var(--ore-line);border-radius:12px;padding:12px 16px;background:var(--bg-card,#131c2e)}
++.ore-stats dt{font-size:1.25rem;font-weight:700}.ore-stats dd{margin:2px 0 0;color:var(--ore-muted);font-size:.92rem}
.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}
@@@ -44,7 +46,7 @@ main.ore .sc-hero,main.ore .sc-sec,main.ore .sc-cta{padding-left:0;padding-right
@media (max-width:640px){.ore th:nth-child(n+4),.ore td:nth-child(n+4){display:none}}
`;
--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.';
++const DESCRIPTION = 'OpenRestream: go live once from OBS and restream to Twitch, YouTube, Kick and any RTMP or SRT server at once, with OpenVibe Live on by default.';
function renderPage({ title, body, user, canonicalPath = '/', robots = 'noindex,nofollow', config, description = DESCRIPTION, styles = [], jsonLd = null }) {
const pageTitle = title ? `${title} · ${SITE}` : `${SITE} — go live once, stream everywhere`;
ddiff --git a/server/ui/routes.js b/server/ui/routes.js
iindex fa41d6f..60b7313 100644
---- a/server/ui/routes.js
++++ b/server/ui/routes.js
@@@ -31,7 +31,7 @@ const pill = (state) => `<span class="pill ${esc(state)}">${esc(state)}</span>`;
// The front page's description reaches the <meta>, og:*/twitter:* and the softwareApp JSON-LD from
// 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 = 'Go live once from OBS and restream to Twitch, YouTube, Kick and any RTMP or SRT server at once, with OpenVibe Live on by default. Free and open source.';
// The front page's questions, answered from what OpenRestream does today (README): shown as a list and as FAQPage JSON-LD.
const FRONT_FAQ = [
@@@ -43,6 +43,14 @@ const FRONT_FAQ = [
{ 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.' },
];
++// The platforms a destination can be (store/outputs.js PLATFORMS), as the front page lists them: what each one needs.
++const DESTINATIONS = [
++ { id: 'twitch', name: 'Twitch', text: 'Your Twitch ingest server and stream key from the Twitch dashboard.' },
++ { id: 'youtube', name: 'YouTube', text: 'The RTMP or RTMPS URL and stream key from YouTube Studio\'s Go live page.' },
++ { id: 'kick', name: 'Kick', text: 'Kick\'s RTMPS server and stream key from your creator dashboard.' },
++ { id: 'custom', name: 'Any RTMP, RTMPS or SRT server', text: 'Another platform, a friend\'s server or your own: a URL and a key or passphrase.' },
++];
++
function csrfFor(token) { return crypto.createHash('sha256').update(`openre-csrf:${token}`).digest('hex').slice(0, 32); }
function createUiRouter({ rt, auth, limits = null, liveLink = null }) {
@@@ -104,17 +112,36 @@ function createUiRouter({ rt, auth, limits = null, liveLink = null }) {
// What shipped on OpenRestream: the shared update log every OpenVibe site has.
router.get('/updates', (req, res) => page(req, res, { canonicalPath: '/updates', robots: 'index,follow', title: 'What shipped on OpenRestream', body: frame.updatesBody({ service: 'openre', siteName: 'OpenRestream' }) + `<script src="${ovServe.url('shipped.js')}" defer></script>` }));
++ // The front page's real figures, read at most once a minute: streams set up, destinations by platform, sessions run
++ // and the ones live now. A figure that is 0 is not shown, and a failed read shows none.
++ let figuresCache = { at: 0, value: null };
++ async function figures() {
++ if (figuresCache.value && Date.now() - figuresCache.at < 60_000) return figuresCache.value;
++ try {
++ const one = async (sql) => Number(((await rt.db.prepare(sql).get()) || {}).n) || 0;
++ const value = {
++ streams: await one("SELECT count(*) AS n FROM stream_definitions WHERE state <> 'archived'"),
++ destinations: await one('SELECT count(*) AS n FROM destinations'),
++ sessions: await one('SELECT count(*) AS n FROM ingest_sessions'),
++ live: await one("SELECT count(*) AS n FROM ingest_sessions WHERE state IN ('starting', 'live')"),
++ };
++ figuresCache = { at: Date.now(), value };
++ return value;
++ } catch { return null; }
++ }
++
router.get('/', async (req, res) => {
const signedIn = req.caller.kind === 'user' && req.caller.subject;
++ const f = await figures();
const mine = signedIn ? await store.definitions.list({ owner_subject: req.caller.subject }) : [];
page(req, res, {
canonicalPath: '/', robots: 'index,follow', styles: [showcase.STYLESHEET],
description: FRONT_PAGE_DESCRIPTION,
// Structured data for the front page only: the site is a web application (openre.stream).
-- jsonLd: [seo.jsonLd.softwareApp({
++ jsonLd: [seo.jsonLd.website({ name: 'OpenRestream', url: `${config.baseUrl}/`, description: FRONT_PAGE_DESCRIPTION }), seo.jsonLd.softwareApp({
name: 'OpenRestream', url: `${config.baseUrl}/`, description: FRONT_PAGE_DESCRIPTION, category: 'MultimediaApplication',
}), seo.jsonLd.faq(FRONT_FAQ)],
-- body: `${frontPage(config)}
++ body: `${frontPage(config, f)}
${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' })}`,
});
@@@ -132,8 +159,15 @@ ${frame.shipped({ service: 'openre', title: 'Recently shipped on OpenRestream' }
return `rtmp://${host}${r.publicPort && Number(r.publicPort) !== 1935 ? `:${r.publicPort}` : ''}/live`;
};
-- function frontPage(cfg) {
++ function frontPage(cfg, f) {
const live = cfg.liveUrl;
++ const n = (v) => Number(v).toLocaleString('en-US');
++ const stats = f ? [
++ [f.live, f.live === 1 ? 'stream live right now' : 'streams live right now'],
++ [f.streams, f.streams === 1 ? 'stream set up' : 'streams set up'],
++ [f.destinations, f.destinations === 1 ? 'destination added' : 'destinations added'],
++ [f.sessions, f.sessions === 1 ? 'time gone live' : 'times gone live'],
++ ].filter(([v]) => v > 0) : [];
return showcase.hero({
eyebrow: 'OpenRestream · open restreaming',
title: 'Go live once.', accent: 'Stream everywhere.',
@@@ -144,6 +178,7 @@ ${frame.shipped({ service: 'openre', title: 'Recently shipped on OpenRestream' }
{ label: 'How browser streaming works', href: `${live}/docs/go-live-in-your-browser` },
],
note: 'Alpha. RTMP ingest and RTMP, RTMPS and SRT restreaming run here. Going live from a browser happens on OpenVibe.Live for now.',
++ html: stats.length ? `<dl class="ore-stats">${stats.map(([v, d]) => `<div><dt>${n(v)}</dt><dd>${esc(d)}</dd></div>`).join('')}</dl>` : undefined,
}) + showcase.features({
title: 'What OpenRestream does',
items: [
@@@ -154,6 +189,11 @@ ${frame.shipped({ service: 'openre', title: 'Recently shipped on OpenRestream' }
{ 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.' },
],
++ }) + showcase.features({
++ id: 'destinations',
++ title: 'Where it can stream',
++ lede: 'Add each destination once, test it, and it goes live with you every time.',
++ items: DESTINATIONS.map((d) => ({ title: d.name, text: d.text })),
}) + showcase.steps({
title: 'Restream with OBS',
items: [
ddiff --git a/test/discovery.test.js b/test/discovery.test.js
iindex 539c841..7035f03 100644
---- a/test/discovery.test.js
++++ b/test/discovery.test.js
@@@ -73,7 +73,12 @@ t('the front page leads with open restreaming, links to Live for browser go-live
const r = await request(api.base, 'GET', '/');
assert.strictEqual(r.status, 200);
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, /<meta name="description" content="Go live once from OBS and restream to Twitch, YouTube, Kick and any RTMP or SRT server at once/);
++ const desc = /<meta name="description" content="([^"]*)"/.exec(r.text)[1];
++ assert.ok(desc.length <= 160, `the description fits a search result (${desc.length})`);
++ const types = [...r.text.matchAll(/<script type="application\/ld\+json">([\s\S]*?)<\/script>/g)].map((m) => JSON.parse(m[1])['@type']);
++ assert.ok(types.includes('WebSite') && types.includes('FAQPage'), `the site and the questions as structured data: ${types}`);
++ assert.ok(r.text.includes('id="destinations"') && r.text.includes('Any RTMP, RTMPS or SRT server'), 'where it can stream');
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');
@@@ -90,9 +95,9 @@ t('the front page leads with open restreaming, links to Live for browser go-live
assert.match(r.text, /<meta property="og:title" content="OpenRestream — go live once, stream everywhere">/);
assert.match(r.text, /<meta name="twitter:card" content="summary_large_image">/);
assert.match(r.text, /<meta property="og:image" content="https:\/\/openre\.stream\/shared\/social\/openre\.jpg\?v=[0-9a-f]{12}">/);
-- const ld = /<script type="application\/ld\+json">(.*?)<\/script>/s.exec(r.text);
-- assert.ok(ld, 'a JSON-LD script is present');
-- const app = JSON.parse(ld[1]);
++ const blocks = [...r.text.matchAll(/<script type="application\/ld\+json">(.*?)<\/script>/gs)].map((m) => JSON.parse(m[1]));
++ assert.ok(blocks.length, 'JSON-LD is present');
++ const app = blocks.find((x) => x['@type'] === 'WebApplication') || {};
assert.strictEqual(app['@type'], 'WebApplication');
assert.strictEqual(app.name, 'OpenRestream');
assert.strictEqual(app.applicationCategory, 'MultimediaApplication');