openvibers/openvibe.live Commits

First-stream follow-up names who chatted, links the best clip and carries the recording's picture (#321)

OP OpenVibers committed 21 days ago · 5054541849 · 2 files, +83 −20

Plan C3: after someone's first stream, the notification now names up to three people who chatted (chat is public;
someone who only watched is never named), links the stream's most-viewed public clip when there is one, and sends the
recording's thumbnail as rich_content.thumbnail, the picture Network's notification list shows. A lookup that fails
names nothing and the notification still goes.

Changes

ddiff --git a/server/streaming/first-stream-followup.js b/server/streaming/first-stream-followup.js
iindex 7f2b374c..6cbac91f 100644
---- a/server/streaming/first-stream-followup.js
++++ b/server/streaming/first-stream-followup.js
@@@ -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.username
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 <= ?
@@@ -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 };
ddiff --git a/test/first-stream-followup.test.js b/test/first-stream-followup.test.js
iindex c64dbbec..cbcc1f46 100644
---- a/test/first-stream-followup.test.js
++++ b/test/first-stream-followup.test.js
@@@ -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, 19
await 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, 19
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(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); });