openvibers/openvibe.live Commits
First-stream follow-up names who chatted, links the best clip and carries the recording's picture (#321)
Changes
@@@ -5,7 +5,10 @@* first stream ends, they get one notification on the network: how long it ran, how many signed-in viewers watched* (watch_time: one row per person per stream, the streamer left out; peak_viewers counts connections, not people),* where the recording is (a public VOD on OpenVibe.Video once Media has it, else the after-show report, else their-- * channel), and the channel address to share before going live again.++ * channel), and the channel address to share before going live again. 10-23: it also names up to three people who++ * chatted (chat is public; a viewer who only watched is never named), links the stream's most-viewed public clip when++ * one exists (an auto-clip of a chat spike, or a viewer's), and carries the recording's thumbnail as its picture, the++ * share card the notification list shows.** run() every 5 minutes (server/index.js): first streams that ended 5 minutes to 2 hours ago, each notified once* (app_state `first_stream_followup:<user id>`). A stream under 30 seconds is a test and gets nothing.@@@ -25,25 +28,56 @@ function minutes(seconds) {return m < 1 ? 'Under a minute' : m === 1 ? '1 minute' : `${m} minutes`;}++/** '@a', '@a and @b', '@a, @b and @c', '@a, @b, @c and others' (at most three names). */++function names(list) {++ const at = list.slice(0, 3).map((n) => `@${n}`);++ if (list.length > 3) return `${at.join(', ')} and others`;++ return at.length > 1 ? `${at.slice(0, -1).join(', ')} and ${at[at.length - 1]}` : at[0];++}++/** The notification for a first stream: { title, message, url }. */--function messageFor({ username, seconds, vodUrl, watchers = 0 }) {++function messageFor({ username, seconds, vodUrl, watchers = 0, chatters = [], clipUrl = null }) {const channel = `${LIVE_URL}/@${encodeURIComponent(username)}`;const where = vodUrl ? 'The recording is on OpenVibe.Video, ready to watch, clip and share.': seconds >= RECAP_MIN_SECONDS ? 'Your after-show report is ready, and the recording follows on your channel.': 'The recording appears on your channel once it is ready.';++ const said = chatters.length ? ` ${names(chatters)} chatted with you.` : '';++ const clip = clipUrl ? ` A clip of it is up too: ${clipUrl.replace('https://', '')}.` : '';return {title: 'Your first stream is done 🎉',-- message: `${minutes(seconds)} live${watchers > 0 ? `, watched by ${watchers} signed-in ${watchers === 1 ? 'viewer' : 'viewers'}` : ''}. ${where} Next time, share ${channel.replace('https://', '')} before you go live so people can find you.`,++ message: `${minutes(seconds)} live${watchers > 0 ? `, watched by ${watchers} signed-in ${watchers === 1 ? 'viewer' : 'viewers'}` : ''}.${said} ${where}${clip} Next time, share ${channel.replace('https://', '')} before you go live so people can find you.`,url: vodUrl || null,};}--async function run({ now = Date.now, push = null, vodFor = null } = {}) {++/**++ * The stream's public recording: { id, url, thumbnail } or null. A test's stand-in may answer the URL alone.++ */++async function publicVod(stream) {++ const [s] = await require('../media-proxy/lookups').attachPublicVods([{ id: stream.id }]);++ return s && s.vod_id ? { id: s.vod_id, url: require('../web/video-url').videoUrl('vod', s.vod_id), thumbnail: s.vod_thumbnail_url || null } : null;++}++++/** The stream's most-viewed public, ready clip (by the stream or its recording), as its OpenVibe.Video URL. */++async function bestClip(stream, vodId) {++ const lookups = require('../media-proxy/lookups');++ const best = (await lookups.streamClips(stream.id, vodId))++ .filter((c) => lookups.isPublic(c) && (!c.status || c.status === 'ready'))++ .sort((a, b) => (Number(b.view_count) || 0) - (Number(a.view_count) || 0) || Number(b.id) - Number(a.id))[0];++ return best ? require('../web/video-url').videoUrl('clip', best.id) : null;++}++++/** Up to four people who chatted on the stream, busiest first, the streamer left out. */++async function chattersOf(stream) {++ const top = (await require('../chat/chat-reads').topChatters({ streamId: stream.id, limit: 5 })) || [];++ return top.filter((r) => r.username && (r.user_id == null || Number(r.user_id) !== Number(stream.user_id))).map((r) => r.username).slice(0, 4);++}++++async function run({ now = Date.now, push = null, vodFor = null, clipFor = null, chattersFor = null } = {}) {const send = push || require('../utils/notify').pushNotification;-- const findVod = vodFor || (async (stream) => {-- const [s] = await require('../media-proxy/lookups').attachPublicVods([{ id: stream.id }]);-- return s && s.vod_id ? require('../web/video-url').videoUrl('vod', s.vod_id) : null;-- });++ const findVod = vodFor || publicVod;++ const findClip = clipFor || bestClip;++ const findChatters = chattersFor || chattersOf;const rows = await db.all(`SELECT s.id, s.user_id, s.duration_seconds, s.started_at, s.ended_at, u.usernameFROM streams s JOIN users u ON u.id = s.user_idWHERE COALESCE(s.is_live, 0) = 0 AND s.ended_at IS NOT NULL AND s.ended_at >= ? AND s.ended_at <= ?@@@ -57,15 +91,25 @@ async function run({ now = Date.now, push = null, vodFor = null } = {}) {// Recorded first, so a crash after this never sends twice (a missed one is better than a repeated one).await db.setState(stateKey(s.user_id), String(s.id));if (seconds < MIN_SECONDS) continue;-- let vodUrl = null;-- try { vodUrl = await findVod(s); } catch { /* Media unreachable: the channel link instead */ }++ let vod = null;++ try { vod = await findVod(s); } catch { /* Media unreachable: the channel link instead */ }++ if (typeof vod === 'string') vod = { url: vod };++ let clipUrl = null;++ try { clipUrl = await findClip(s, vod && vod.id); } catch { /* no clip named */ }++ let chatters = [];++ try { chatters = await findChatters(s); } catch { /* Chat unreachable: nobody named */ }const seen = await db.get('SELECT COUNT(*) AS n FROM watch_time WHERE stream_id = ? AND user_id <> ?', [s.id, s.user_id]);-- const m = messageFor({ username: s.username, seconds, vodUrl, watchers: Number(seen && seen.n) || 0 });++ const m = messageFor({ username: s.username, seconds, vodUrl: vod && vod.url, watchers: Number(seen && seen.n) || 0, chatters, clipUrl });const url = m.url || (seconds >= RECAP_MIN_SECONDS ? `${LIVE_URL}/recap/${s.id}` : `${LIVE_URL}/@${encodeURIComponent(s.username)}`);-- send({ user_id: s.user_id, type: 'STREAM_SUMMARY', category: 'stream', title: m.title, message: m.message, url });++ // The recording's thumbnail is the notification's picture (Network's list shows rich_content.thumbnail).++ const thumbnail = vod && /^https:\/\//.test(String(vod.thumbnail || '')) ? vod.thumbnail : null;++ send({++ user_id: s.user_id, type: 'STREAM_SUMMARY', category: 'stream', title: m.title, message: m.message, url,++ rich_content: { thumbnail, context: { stream_id: s.id, vod_url: (vod && vod.url) || null, clip_url: clipUrl } },++ });sent++;}return { checked: rows.length, sent };}--module.exports = { run, messageFor, stateKey, MIN_SECONDS };++module.exports = { run, messageFor, names, stateKey, MIN_SECONDS };@@@ -2,7 +2,8 @@// The first stream's follow-up (server/streaming/first-stream-followup.js): one notification after someone's first// stream ends (settled for 5 minutes), never twice, never for a second stream or a test under 30 seconds; it links the// public recording when Media has it, else the after-show report (8 minutes and up), else the channel; it counts the--// signed-in viewers who watched (watch_time, the streamer left out), never peak_viewers.++// signed-in viewers who watched (watch_time, the streamer left out), never peak_viewers. It names up to three people++// who chatted (never one who only watched), links the most-viewed public clip, and carries the recording's thumbnail.const assert = require('assert');const db = require('../server/db/database');const followup = require('../server/streaming/first-stream-followup');@@@ -40,24 +41,34 @@ const sqlTime = (ms) => new Date(ms).toISOString().replace('T', ' ').slice(0, 19await db.run('UPDATE streams SET peak_viewers = 40 WHERE id = ?', [boStream]);const sent = [];-- const vodFor = async (s) => (s.id === anaStream ? 'https://openvibe.video/v/77' : null);-- const out = await followup.run({ now: () => NOW, push: (n) => sent.push(n), vodFor });++ const vodFor = async (s) => (s.id === anaStream ? { id: 77, url: 'https://openvibe.video/v/77', thumbnail: 'https://openvibe.media/t/77.jpg' } : null);++ const clipsAsked = [];++ const clipFor = async (s, vodId) => { clipsAsked.push([s.id, vodId]); return s.id === anaStream ? 'https://openvibe.video/c/9' : null; };++ const chattersFor = async (s) => (s.id === anaStream ? ['fs_v1', 'fs_v2'] : []);++ const out = await followup.run({ now: () => NOW, push: (n) => sent.push(n), vodFor, clipFor, chattersFor });assert.strictEqual(out.sent, 2, JSON.stringify(sent));const byUser = Object.fromEntries(sent.map((n) => [n.user_id, n]));assert.deepStrictEqual(Object.keys(byUser).map(Number).sort(), [ana, bo].map(Number).sort());assert.strictEqual(byUser[ana].url, 'https://openvibe.video/v/77');-- assert.match(byUser[ana].message, /^6 minutes live, watched by 2 signed-in viewers\. The recording is on OpenVibe\.Video/);++ assert.match(byUser[ana].message, /^6 minutes live, watched by 2 signed-in viewers\. @fs_v1 and @fs_v2 chatted with you\. The recording is on OpenVibe\.Video/);++ assert.match(byUser[ana].message, /A clip of it is up too: openvibe\.video\/c\/9\./);++ assert.deepStrictEqual(byUser[ana].rich_content, { thumbnail: 'https://openvibe.media/t/77.jpg', context: { stream_id: anaStream, vod_url: 'https://openvibe.video/v/77', clip_url: 'https://openvibe.video/c/9' } });++ assert.deepStrictEqual(clipsAsked.find(([id]) => id === anaStream), [anaStream, 77], 'clips are asked by the stream and its recording');++ assert.deepStrictEqual(byUser[bo].rich_content, { thumbnail: null, context: { stream_id: boStream, vod_url: null, clip_url: null } });++ assert.ok(!/chatted|clip of it/.test(byUser[bo].message), 'nobody chatted and no clip: neither is mentioned');assert.match(byUser[ana].message, /share openvibe\.live\/@fs_ana before you go live/);assert.strictEqual(byUser[bo].url, `https://openvibe.live/recap/${boStream}`, 'no public recording yet: the after-show report');assert.strictEqual(byUser[ana].type, 'STREAM_SUMMARY');assert.ok(!/viewer/i.test(byUser[bo].message), 'no signed-in viewer watched Bo: nothing said, and peak_viewers (not a person count) is never used');// Never twice; the test stream was marked too, so it never comes back either.-- assert.strictEqual((await followup.run({ now: () => NOW, push: (n) => sent.push(n), vodFor })).sent, 0);++ assert.strictEqual((await followup.run({ now: () => NOW, push: (n) => sent.push(n), vodFor, clipFor, chattersFor })).sent, 0);assert.ok(await db.getState(followup.stateKey(cy)), 'a test stream is marked, so a later run does not reconsider it');// Once settled, the stream that had just ended is notified too.-- const later = await followup.run({ now: () => NOW + 4 * 60000, push: (n) => sent.push(n), vodFor });++ // A recording, clip or Chat that fails names nothing and still sends.++ const fail = async () => { throw new Error('down'); };++ const later = await followup.run({ now: () => NOW + 4 * 60000, push: (n) => sent.push(n), vodFor: fail, clipFor: fail, chattersFor: fail });assert.strictEqual(later.sent, 1);assert.strictEqual(sent[sent.length - 1].user_id, di);@@@ -65,6 +76,14 @@ const sqlTime = (ms) => new Date(ms).toISOString().replace('T', ' ').slice(0, 19const m = followup.messageFor({ username: 'zed', seconds: 90, vodUrl: null });assert.match(m.message, /^2 minutes live\. The recording appears on your channel/);assert.match(followup.messageFor({ username: 'zed', seconds: 90, vodUrl: null, watchers: 1 }).message, /^2 minutes live, watched by 1 signed-in viewer\. /);-- console.log('first-stream follow-up: once, settled, recording/report/channel links, no test streams');++ assert.deepStrictEqual([['a'], ['a', 'b'], ['a', 'b', 'c'], ['a', 'b', 'c', 'd']].map(followup.names), ['@a', '@a and @b', '@a, @b and @c', '@a, @b, @c and others']);++ // An old stand-in answering the URL alone still links it (no picture).++ const legacy = [];++ const u = await mkUser('fs_fy');++ await mkStream(u, { endedAgoMin: 10, seconds: 120 });++ await followup.run({ now: () => NOW, push: (n) => legacy.push(n), vodFor: async () => 'https://openvibe.video/v/5', clipFor: async () => null, chattersFor: async () => [] });++ assert.strictEqual(legacy[0].url, 'https://openvibe.video/v/5');++ assert.strictEqual(legacy[0].rich_content.thumbnail, null);++ console.log('first-stream follow-up: once, settled, recording/report/channel links, chatters, clip, picture, no test streams');process.exit(0);})().catch((e) => { console.error(e); process.exit(1); });
@@ -5,7 +5,10 @@
@@ -5,7 +5,10 @@
* first stream ends, they get one notification on the network: how long it ran, how many signed-in viewers watched
* first stream ends, they get one notification on the network: how long it ran, how many signed-in viewers watched
* (watch_time: one row per person per stream, the streamer left out; peak_viewers counts connections, not people),
* (watch_time: one row per person per stream, the streamer left out; peak_viewers counts connections, not people),
* where the recording is (a public VOD on OpenVibe.Video once Media has it, else the after-show report, else their
* where the recording is (a public VOD on OpenVibe.Video once Media has it, else the after-show report, else their
- * channel), and the channel address to share before going live again.
+ * channel), and the channel address to share before going live again. 10-23: it also names up to three people who
+ * chatted (chat is public; a viewer who only watched is never named), links the stream's most-viewed public clip when
+ * one exists (an auto-clip of a chat spike, or a viewer's), and carries the recording's thumbnail as its picture, the
+ * share card the notification list shows.
*
*
* run() every 5 minutes (server/index.js): first streams that ended 5 minutes to 2 hours ago, each notified once
* run() every 5 minutes (server/index.js): first streams that ended 5 minutes to 2 hours ago, each notified once
* (app_state `first_stream_followup:<user id>`). A stream under 30 seconds is a test and gets nothing.
* (app_state `first_stream_followup:<user id>`). A stream under 30 seconds is a test and gets nothing.
@@ -25,25 +28,56 @@ function minutes(seconds) {
@@ -25,25 +28,56 @@ function minutes(seconds) {
return m < 1 ? 'Under a minute' : m === 1 ? '1 minute' : `${m} minutes`;
return m < 1 ? 'Under a minute' : m === 1 ? '1 minute' : `${m} minutes`;
}
}
+/** '@a', '@a and @b', '@a, @b and @c', '@a, @b, @c and others' (at most three names). */
+function names(list) {
+ const at = list.slice(0, 3).map((n) => `@${n}`);
+ if (list.length > 3) return `${at.join(', ')} and others`;
+ return at.length > 1 ? `${at.slice(0, -1).join(', ')} and ${at[at.length - 1]}` : at[0];
+}
+
/** The notification for a first stream: { title, message, url }. */
/** The notification for a first stream: { title, message, url }. */
-function messageFor({ username, seconds, vodUrl, watchers = 0 }) {
+function messageFor({ username, seconds, vodUrl, watchers = 0, chatters = [], clipUrl = null }) {
const channel = `${LIVE_URL}/@${encodeURIComponent(username)}`;
const channel = `${LIVE_URL}/@${encodeURIComponent(username)}`;
const where = vodUrl ? 'The recording is on OpenVibe.Video, ready to watch, clip and share.'
const where = vodUrl ? 'The recording is on OpenVibe.Video, ready to watch, clip and share.'
: seconds >= RECAP_MIN_SECONDS ? 'Your after-show report is ready, and the recording follows on your channel.'
: seconds >= RECAP_MIN_SECONDS ? 'Your after-show report is ready, and the recording follows on your channel.'
: 'The recording appears on your channel once it is ready.';
: 'The recording appears on your channel once it is ready.';
+ const said = chatters.length ? ` ${names(chatters)} chatted with you.` : '';
+ const clip = clipUrl ? ` A clip of it is up too: ${clipUrl.replace('https://', '')}.` : '';
return {
return {
title: 'Your first stream is done 🎉',
title: 'Your first stream is done 🎉',
- message: `${minutes(seconds)} live${watchers > 0 ? `, watched by ${watchers} signed-in ${watchers === 1 ? 'viewer' : 'viewers'}` : ''}. ${where} Next time, share ${channel.replace('https://', '')} before you go live so people can find you.`,
+ message: `${minutes(seconds)} live${watchers > 0 ? `, watched by ${watchers} signed-in ${watchers === 1 ? 'viewer' : 'viewers'}` : ''}.${said} ${where}${clip} Next time, share ${channel.replace('https://', '')} before you go live so people can find you.`,
url: vodUrl || null,
url: vodUrl || null,
};
};
}
}
-async function run({ now = Date.now, push = null, vodFor = null } = {}) {
+/**
+ * The stream's public recording: { id, url, thumbnail } or null. A test's stand-in may answer the URL alone.
+ */
+async function publicVod(stream) {
+ const [s] = await require('../media-proxy/lookups').attachPublicVods([{ id: stream.id }]);
+ return s && s.vod_id ? { id: s.vod_id, url: require('../web/video-url').videoUrl('vod', s.vod_id), thumbnail: s.vod_thumbnail_url || null } : null;
+}
+
+/** The stream's most-viewed public, ready clip (by the stream or its recording), as its OpenVibe.Video URL. */
+async function bestClip(stream, vodId) {
+ const lookups = require('../media-proxy/lookups');
+ const best = (await lookups.streamClips(stream.id, vodId))
+ .filter((c) => lookups.isPublic(c) && (!c.status || c.status === 'ready'))
+ .sort((a, b) => (Number(b.view_count) || 0) - (Number(a.view_count) || 0) || Number(b.id) - Number(a.id))[0];
+ return best ? require('../web/video-url').videoUrl('clip', best.id) : null;
+}
+
+/** Up to four people who chatted on the stream, busiest first, the streamer left out. */
+async function chattersOf(stream) {
+ const top = (await require('../chat/chat-reads').topChatters({ streamId: stream.id, limit: 5 })) || [];
+ return top.filter((r) => r.username && (r.user_id == null || Number(r.user_id) !== Number(stream.user_id))).map((r) => r.username).slice(0, 4);
+}
+
+async function run({ now = Date.now, push = null, vodFor = null, clipFor = null, chattersFor = null } = {}) {
const send = push || require('../utils/notify').pushNotification;
const send = push || require('../utils/notify').pushNotification;
- const findVod = vodFor || (async (stream) => {
- const [s] = await require('../media-proxy/lookups').attachPublicVods([{ id: stream.id }]);
- return s && s.vod_id ? require('../web/video-url').videoUrl('vod', s.vod_id) : null;
- });
+ const findVod = vodFor || publicVod;
+ const findClip = clipFor || bestClip;
+ const findChatters = chattersFor || chattersOf;
const rows = await db.all(`SELECT s.id, s.user_id, s.duration_seconds, s.started_at, s.ended_at, u.username
const rows = await db.all(`SELECT s.id, s.user_id, s.duration_seconds, s.started_at, s.ended_at, u.username
FROM streams s JOIN users u ON u.id = s.user_id
FROM streams s JOIN users u ON u.id = s.user_id
WHERE COALESCE(s.is_live, 0) = 0 AND s.ended_at IS NOT NULL AND s.ended_at >= ? AND s.ended_at <= ?
WHERE COALESCE(s.is_live, 0) = 0 AND s.ended_at IS NOT NULL AND s.ended_at >= ? AND s.ended_at <= ?
@@ -57,15 +91,25 @@ async function run({ now = Date.now, push = null, vodFor = null } = {}) {
@@ -57,15 +91,25 @@ async function run({ now = Date.now, push = null, vodFor = null } = {}) {
// Recorded first, so a crash after this never sends twice (a missed one is better than a repeated one).
// Recorded first, so a crash after this never sends twice (a missed one is better than a repeated one).
await db.setState(stateKey(s.user_id), String(s.id));
await db.setState(stateKey(s.user_id), String(s.id));
if (seconds < MIN_SECONDS) continue;
if (seconds < MIN_SECONDS) continue;
- let vodUrl = null;
- try { vodUrl = await findVod(s); } catch { /* Media unreachable: the channel link instead */ }
+ let vod = null;
+ try { vod = await findVod(s); } catch { /* Media unreachable: the channel link instead */ }
+ if (typeof vod === 'string') vod = { url: vod };
+ let clipUrl = null;
+ try { clipUrl = await findClip(s, vod && vod.id); } catch { /* no clip named */ }
+ let chatters = [];
+ try { chatters = await findChatters(s); } catch { /* Chat unreachable: nobody named */ }
const seen = await db.get('SELECT COUNT(*) AS n FROM watch_time WHERE stream_id = ? AND user_id <> ?', [s.id, s.user_id]);
const seen = await db.get('SELECT COUNT(*) AS n FROM watch_time WHERE stream_id = ? AND user_id <> ?', [s.id, s.user_id]);
- const m = messageFor({ username: s.username, seconds, vodUrl, watchers: Number(seen && seen.n) || 0 });
+ const m = messageFor({ username: s.username, seconds, vodUrl: vod && vod.url, watchers: Number(seen && seen.n) || 0, chatters, clipUrl });
const url = m.url || (seconds >= RECAP_MIN_SECONDS ? `${LIVE_URL}/recap/${s.id}` : `${LIVE_URL}/@${encodeURIComponent(s.username)}`);
const url = m.url || (seconds >= RECAP_MIN_SECONDS ? `${LIVE_URL}/recap/${s.id}` : `${LIVE_URL}/@${encodeURIComponent(s.username)}`);
- send({ user_id: s.user_id, type: 'STREAM_SUMMARY', category: 'stream', title: m.title, message: m.message, url });
+ // The recording's thumbnail is the notification's picture (Network's list shows rich_content.thumbnail).
+ const thumbnail = vod && /^https:\/\//.test(String(vod.thumbnail || '')) ? vod.thumbnail : null;
+ send({
+ user_id: s.user_id, type: 'STREAM_SUMMARY', category: 'stream', title: m.title, message: m.message, url,
+ rich_content: { thumbnail, context: { stream_id: s.id, vod_url: (vod && vod.url) || null, clip_url: clipUrl } },
+ });
sent++;
sent++;
}
}
return { checked: rows.length, sent };
return { checked: rows.length, sent };
}
}
-module.exports = { run, messageFor, stateKey, MIN_SECONDS };
+module.exports = { run, messageFor, names, stateKey, MIN_SECONDS };
@@ -2,7 +2,8 @@
@@ -2,7 +2,8 @@
// The first stream's follow-up (server/streaming/first-stream-followup.js): one notification after someone's first
// The first stream's follow-up (server/streaming/first-stream-followup.js): one notification after someone's first
// stream ends (settled for 5 minutes), never twice, never for a second stream or a test under 30 seconds; it links the
// stream ends (settled for 5 minutes), never twice, never for a second stream or a test under 30 seconds; it links the
// public recording when Media has it, else the after-show report (8 minutes and up), else the channel; it counts the
// public recording when Media has it, else the after-show report (8 minutes and up), else the channel; it counts the
-// signed-in viewers who watched (watch_time, the streamer left out), never peak_viewers.
+// signed-in viewers who watched (watch_time, the streamer left out), never peak_viewers. It names up to three people
+// who chatted (never one who only watched), links the most-viewed public clip, and carries the recording's thumbnail.
const assert = require('assert');
const assert = require('assert');
const db = require('../server/db/database');
const db = require('../server/db/database');
const followup = require('../server/streaming/first-stream-followup');
const followup = require('../server/streaming/first-stream-followup');
@@ -40,24 +41,34 @@ const sqlTime = (ms) => new Date(ms).toISOString().replace('T', ' ').slice(0, 19
@@ -40,24 +41,34 @@ const sqlTime = (ms) => new Date(ms).toISOString().replace('T', ' ').slice(0, 19
await db.run('UPDATE streams SET peak_viewers = 40 WHERE id = ?', [boStream]);
await db.run('UPDATE streams SET peak_viewers = 40 WHERE id = ?', [boStream]);
const sent = [];
const sent = [];
- const vodFor = async (s) => (s.id === anaStream ? 'https://openvibe.video/v/77' : null);
- const out = await followup.run({ now: () => NOW, push: (n) => sent.push(n), vodFor });
+ const vodFor = async (s) => (s.id === anaStream ? { id: 77, url: 'https://openvibe.video/v/77', thumbnail: 'https://openvibe.media/t/77.jpg' } : null);
+ const clipsAsked = [];
+ const clipFor = async (s, vodId) => { clipsAsked.push([s.id, vodId]); return s.id === anaStream ? 'https://openvibe.video/c/9' : null; };
+ const chattersFor = async (s) => (s.id === anaStream ? ['fs_v1', 'fs_v2'] : []);
+ const out = await followup.run({ now: () => NOW, push: (n) => sent.push(n), vodFor, clipFor, chattersFor });
assert.strictEqual(out.sent, 2, JSON.stringify(sent));
assert.strictEqual(out.sent, 2, JSON.stringify(sent));
const byUser = Object.fromEntries(sent.map((n) => [n.user_id, n]));
const byUser = Object.fromEntries(sent.map((n) => [n.user_id, n]));
assert.deepStrictEqual(Object.keys(byUser).map(Number).sort(), [ana, bo].map(Number).sort());
assert.deepStrictEqual(Object.keys(byUser).map(Number).sort(), [ana, bo].map(Number).sort());
assert.strictEqual(byUser[ana].url, 'https://openvibe.video/v/77');
assert.strictEqual(byUser[ana].url, 'https://openvibe.video/v/77');
- assert.match(byUser[ana].message, /^6 minutes live, watched by 2 signed-in viewers\. The recording is on OpenVibe\.Video/);
+ assert.match(byUser[ana].message, /^6 minutes live, watched by 2 signed-in viewers\. @fs_v1 and @fs_v2 chatted with you\. The recording is on OpenVibe\.Video/);
+ assert.match(byUser[ana].message, /A clip of it is up too: openvibe\.video\/c\/9\./);
+ assert.deepStrictEqual(byUser[ana].rich_content, { thumbnail: 'https://openvibe.media/t/77.jpg', context: { stream_id: anaStream, vod_url: 'https://openvibe.video/v/77', clip_url: 'https://openvibe.video/c/9' } });
+ assert.deepStrictEqual(clipsAsked.find(([id]) => id === anaStream), [anaStream, 77], 'clips are asked by the stream and its recording');
+ assert.deepStrictEqual(byUser[bo].rich_content, { thumbnail: null, context: { stream_id: boStream, vod_url: null, clip_url: null } });
+ assert.ok(!/chatted|clip of it/.test(byUser[bo].message), 'nobody chatted and no clip: neither is mentioned');
assert.match(byUser[ana].message, /share openvibe\.live\/@fs_ana before you go live/);
assert.match(byUser[ana].message, /share openvibe\.live\/@fs_ana before you go live/);
assert.strictEqual(byUser[bo].url, `https://openvibe.live/recap/${boStream}`, 'no public recording yet: the after-show report');
assert.strictEqual(byUser[bo].url, `https://openvibe.live/recap/${boStream}`, 'no public recording yet: the after-show report');
assert.strictEqual(byUser[ana].type, 'STREAM_SUMMARY');
assert.strictEqual(byUser[ana].type, 'STREAM_SUMMARY');
assert.ok(!/viewer/i.test(byUser[bo].message), 'no signed-in viewer watched Bo: nothing said, and peak_viewers (not a person count) is never used');
assert.ok(!/viewer/i.test(byUser[bo].message), 'no signed-in viewer watched Bo: nothing said, and peak_viewers (not a person count) is never used');
// Never twice; the test stream was marked too, so it never comes back either.
// Never twice; the test stream was marked too, so it never comes back either.
- assert.strictEqual((await followup.run({ now: () => NOW, push: (n) => sent.push(n), vodFor })).sent, 0);
+ assert.strictEqual((await followup.run({ now: () => NOW, push: (n) => sent.push(n), vodFor, clipFor, chattersFor })).sent, 0);
assert.ok(await db.getState(followup.stateKey(cy)), 'a test stream is marked, so a later run does not reconsider it');
assert.ok(await db.getState(followup.stateKey(cy)), 'a test stream is marked, so a later run does not reconsider it');
// Once settled, the stream that had just ended is notified too.
// Once settled, the stream that had just ended is notified too.
- const later = await followup.run({ now: () => NOW + 4 * 60000, push: (n) => sent.push(n), vodFor });
+ // A recording, clip or Chat that fails names nothing and still sends.
+ const fail = async () => { throw new Error('down'); };
+ const later = await followup.run({ now: () => NOW + 4 * 60000, push: (n) => sent.push(n), vodFor: fail, clipFor: fail, chattersFor: fail });
assert.strictEqual(later.sent, 1);
assert.strictEqual(later.sent, 1);
assert.strictEqual(sent[sent.length - 1].user_id, di);
assert.strictEqual(sent[sent.length - 1].user_id, di);
@@ -65,6 +76,14 @@ const sqlTime = (ms) => new Date(ms).toISOString().replace('T', ' ').slice(0, 19
@@ -65,6 +76,14 @@ const sqlTime = (ms) => new Date(ms).toISOString().replace('T', ' ').slice(0, 19
const m = followup.messageFor({ username: 'zed', seconds: 90, vodUrl: null });
const m = followup.messageFor({ username: 'zed', seconds: 90, vodUrl: null });
assert.match(m.message, /^2 minutes live\. The recording appears on your channel/);
assert.match(m.message, /^2 minutes live\. The recording appears on your channel/);
assert.match(followup.messageFor({ username: 'zed', seconds: 90, vodUrl: null, watchers: 1 }).message, /^2 minutes live, watched by 1 signed-in viewer\. /);
assert.match(followup.messageFor({ username: 'zed', seconds: 90, vodUrl: null, watchers: 1 }).message, /^2 minutes live, watched by 1 signed-in viewer\. /);
- console.log('first-stream follow-up: once, settled, recording/report/channel links, no test streams');
+ assert.deepStrictEqual([['a'], ['a', 'b'], ['a', 'b', 'c'], ['a', 'b', 'c', 'd']].map(followup.names), ['@a', '@a and @b', '@a, @b and @c', '@a, @b, @c and others']);
+ // An old stand-in answering the URL alone still links it (no picture).
+ const legacy = [];
+ const u = await mkUser('fs_fy');
+ await mkStream(u, { endedAgoMin: 10, seconds: 120 });
+ await followup.run({ now: () => NOW, push: (n) => legacy.push(n), vodFor: async () => 'https://openvibe.video/v/5', clipFor: async () => null, chattersFor: async () => [] });
+ assert.strictEqual(legacy[0].url, 'https://openvibe.video/v/5');
+ assert.strictEqual(legacy[0].rich_content.thumbnail, null);
+ console.log('first-stream follow-up: once, settled, recording/report/channel links, chatters, clip, picture, no test streams');
process.exit(0);
process.exit(0);
})().catch((e) => { console.error(e); process.exit(1); });
})().catch((e) => { console.error(e); process.exit(1); });