openvibers/openvibe.community Commits
Rank the Community feed by live activity and relevance (#380)
Changes
@@@ -203,6 +203,38 @@ const isCurrentLive = (card, live) => {|| Math.abs(started - recorded) <= 10 * 60_000);};++const DAY_MS = 24 * 60 * 60_000;++const engagement = (card) => {++ const counts = card.counts || {};++ return Math.log1p(Math.max(0, Number(counts.likes) || 0)++ + 2 * Math.max(0, Number(counts.comments) || 0)++ + 0.2 * Math.max(0, Number(counts.views) || 0)) * 2;++};++++/** Rank the current page's Pulse candidates: on-air first, then recent activity, engagement and followed creators. */++function compareRankedCards(a, b, followedIds = new Set(), now = Date.now()) {++ const liveOrder = Number(b.kind === 'live' && b.live_now === true) - Number(a.kind === 'live' && a.live_now === true);++ if (liveOrder) return liveOrder;++ const ageDays = (card) => {++ const at = Date.parse(card.at || '');++ return Number.isFinite(at) ? Math.max(0, (now - at) / DAY_MS) : Infinity;++ };++ const aAge = ageDays(a), bAge = ageDays(b);++ // Old items remain available, but cannot lead ahead of activity from the last seven days.++ const staleOrder = Number(aAge > 7) - Number(bAge > 7);++ if (staleOrder) return staleOrder;++ const score = (card, age) => (Number.isFinite(age) ? Math.max(0, 7 - age) : 0)++ + engagement(card) + (followedIds.has(card.id) ? 4 : 0);++ const scoreOrder = score(b, bAge) - score(a, aAge);++ if (scoreOrder) return scoreOrder;++ const timeOrder = (Date.parse(b.at || '') || 0) - (Date.parse(a.at || '') || 0);++ return timeOrder || String(a.id).localeCompare(String(b.id));++}++++function rankCards(cards, followedIds = new Set(), now = Date.now()) {++ return cards.slice().sort((a, b) => compareRankedCards(a, b, followedIds, now));++}++function searchMatches(item, query) {const q = clean(query, 80).toLowerCase();if (!q) return true;@@@ -246,7 +278,8 @@ function createFeedApi({ pulse, viewers, limits, db, networkUrl = 'https://openvconst following = /^(1|true|yes|on)$/i.test(String(req.query.following || ''));const want = Math.min(Math.max(parseInt(req.query.limit, 10) || 20, 1), 40);if (following && (!req.viewer || req.viewer.kind !== 'user' || !req.viewer.token)) fail(401, 'feed.sign_in_required', 'Sign in to see the people you follow');-- const followed = following ? new Set(await pulse.followedFor(req.viewer)) : null;++ const followedSubjects = req.viewer && req.viewer.kind === 'user' && req.viewer.token++ ? new Set(await pulse.followedFor(req.viewer)) : new Set();const live = await liveNow();// Copy cached trending cards before marking them: a channel can go live again during the cache TTL.@@@ -267,7 +300,7 @@ function createFeedApi({ pulse, viewers, limits, db, networkUrl = 'https://openvfor (let index = 0; index < rows.length; index++) {const i = rows[index];const actorSubject = i.actor_subject || (i.actor && i.actor.subject) || null;-- if (followed && !(actorSubject && followed.has(actorSubject))) continue;++ if (following && !(actorSubject && followedSubjects.has(actorSubject))) continue;const c = card(i);mark([c], live);if (kind && c.kind !== kind) continue;@@@ -287,6 +320,9 @@ function createFeedApi({ pulse, viewers, limits, db, networkUrl = 'https://openv}const counts = await countsFor(db, picked.map((p) => p.row));for (const p of picked) p.card.counts = counts.get(`${typeOf(p.row)}:${idOf(p.row)}`) || {};++ const followedIds = new Set(picked.filter((p) => followedSubjects.has(p.row.actor_subject || (p.row.actor && p.row.actor.subject)))++ .map((p) => p.card.id));++ picked.splice(0, picked.length, ...rankCards(picked.map((p) => p.card), followedIds).map((c) => picked.find((p) => p.card.id === c.id)));// The first page carries trending pictures and videos, one after every three Pulse items.const items = [];@@@ -300,6 +336,8 @@ function createFeedApi({ pulse, viewers, limits, db, networkUrl = 'https://openv});const seen = new Set(items.map((c) => c.url));while (items.length < want && k < t.length) { const c = t[k++]; if (!seen.has(c.url)) { items.push(c); seen.add(c.url); } }++ // Frame cards keep their existing placement, while an actual on-air channel always leads the response.++ items.sort((a, b) => Number(b.kind === 'live' && b.live_now === true) - Number(a.kind === 'live' && a.live_now === true));return { items, next_cursor: next };}));router.get('/search', run(async (req) => {@@@ -337,4 +375,4 @@ function createFeedApi({ pulse, viewers, limits, db, networkUrl = 'https://openvreturn router;}--module.exports = { createFeedApi, kindOf, card, countsFor, channelOf, createLiveNow, isCurrentLive, searchMatches, searchExamples, foldBursts };++module.exports = { createFeedApi, kindOf, card, countsFor, channelOf, createLiveNow, isCurrentLive, searchMatches, searchExamples, foldBursts, engagement, compareRankedCards, rankCards };@@@ -193,6 +193,39 @@ const at = (minsAgo) => new Date(Date.now() - minsAgo * 60_000).toISOString().reassert.strictEqual(r.json().code, 'feed.sign_in_required');});++ await check('feed ranking puts live and fresh activity first, then uses engagement and follows', async () => {++ net.addFollow(sam.subject_id, 'channel', alex.subject_id);++ const rankPaste = async (actor, title, minutesAgo, stats = {}) => {++ const { paste } = await t.app.locals.pastes.createText(actor, { title, content: title });++ await db.prepare("UPDATE pulse_items SET occurred_at = ? WHERE source_service = 'community' AND source_type = 'paste' AND source_id = ?")++ .run(at(minutesAgo), paste.slug);++ await db.prepare('UPDATE pastes SET views = ?, likes = ? WHERE slug = ?')++ .run(stats.views || 0, stats.likes || 0, paste.slug);++ };++ await rankPaste(asAlex, 'FreshRank recent', 10);++ await rankPaste(asAlex, 'FreshRank older', 2 * 24 * 60);++ await rankPaste(asAlex, 'EngagementRank low', 30);++ await rankPaste(asAlex, 'EngagementRank high', 30, { views: 10000, likes: 100 });++ await rankPaste(asAlex, 'FollowRank followed', 25);++ await rankPaste({ ...asAlex, subject: sam.subject_id }, 'FollowRank plain', 25);++ await rankPaste(asAlex, 'StaleRank recent', 4 * 60);++ await rankPaste(asAlex, 'StaleRank old', 10 * 24 * 60, { views: 100000, likes: 1000 });++ await db.prepare(`INSERT INTO pulse_items (source_service, source_type, source_id, title, url, actor_subject, origin, visibility, occurred_at)++ VALUES ('live', 'stream', 'rank-live', 'LiveRank current', 'https://openvibe.live/@alex', ?, 'user', 'public', ?),++ ('live', 'stream', 'rank-offline', 'LiveRank offline', 'https://openvibe.live/@offline', ?, 'user', 'public', ?)`)++ .run(alex.subject_id, at(1), alex.subject_id, at(1));++++ const titles = async (q, options = {}) => (await t.get(`/api/v1/feed?q=${q}&limit=40`, options)).json().items.map((x) => x.title);++ assert.deepStrictEqual(await titles('FreshRank'), ['FreshRank recent', 'FreshRank older']);++ assert.deepStrictEqual(await titles('EngagementRank'), ['EngagementRank high', 'EngagementRank low']);++ assert.deepStrictEqual(await titles('FollowRank', { cookies: [`ov_token=${samJwt}`] }), ['FollowRank followed', 'FollowRank plain']);++ assert.deepStrictEqual(await titles('StaleRank'), ['StaleRank recent', 'StaleRank old']);++ const live = await t.get('/api/v1/feed?q=LiveRank&limit=40');++ assert.deepStrictEqual(live.json().items.map((x) => [x.title, x.kind, x.live_now]), [++ ['LiveRank current', 'live', true], ['LiveRank offline', 'stream', false],++ ]);++ });++await check('cross-site: any OpenVibe site reads it from the browser (CORS, no credentials)', async () => {const r = await t.get('/api/v1/feed', { headers: { origin: 'https://openvibe.video' } });assert.strictEqual(r.status, 200);
@@ -203,6 +203,38 @@ const isCurrentLive = (card, live) => {
@@ -203,6 +203,38 @@ const isCurrentLive = (card, live) => {
|| Math.abs(started - recorded) <= 10 * 60_000);
|| Math.abs(started - recorded) <= 10 * 60_000);
};
};
+const DAY_MS = 24 * 60 * 60_000;
+const engagement = (card) => {
+ const counts = card.counts || {};
+ return Math.log1p(Math.max(0, Number(counts.likes) || 0)
+ + 2 * Math.max(0, Number(counts.comments) || 0)
+ + 0.2 * Math.max(0, Number(counts.views) || 0)) * 2;
+};
+
+/** Rank the current page's Pulse candidates: on-air first, then recent activity, engagement and followed creators. */
+function compareRankedCards(a, b, followedIds = new Set(), now = Date.now()) {
+ const liveOrder = Number(b.kind === 'live' && b.live_now === true) - Number(a.kind === 'live' && a.live_now === true);
+ if (liveOrder) return liveOrder;
+ const ageDays = (card) => {
+ const at = Date.parse(card.at || '');
+ return Number.isFinite(at) ? Math.max(0, (now - at) / DAY_MS) : Infinity;
+ };
+ const aAge = ageDays(a), bAge = ageDays(b);
+ // Old items remain available, but cannot lead ahead of activity from the last seven days.
+ const staleOrder = Number(aAge > 7) - Number(bAge > 7);
+ if (staleOrder) return staleOrder;
+ const score = (card, age) => (Number.isFinite(age) ? Math.max(0, 7 - age) : 0)
+ + engagement(card) + (followedIds.has(card.id) ? 4 : 0);
+ const scoreOrder = score(b, bAge) - score(a, aAge);
+ if (scoreOrder) return scoreOrder;
+ const timeOrder = (Date.parse(b.at || '') || 0) - (Date.parse(a.at || '') || 0);
+ return timeOrder || String(a.id).localeCompare(String(b.id));
+}
+
+function rankCards(cards, followedIds = new Set(), now = Date.now()) {
+ return cards.slice().sort((a, b) => compareRankedCards(a, b, followedIds, now));
+}
+
function searchMatches(item, query) {
function searchMatches(item, query) {
const q = clean(query, 80).toLowerCase();
const q = clean(query, 80).toLowerCase();
if (!q) return true;
if (!q) return true;
@@ -246,7 +278,8 @@ function createFeedApi({ pulse, viewers, limits, db, networkUrl = 'https://openv
@@ -246,7 +278,8 @@ function createFeedApi({ pulse, viewers, limits, db, networkUrl = 'https://openv
const following = /^(1|true|yes|on)$/i.test(String(req.query.following || ''));
const following = /^(1|true|yes|on)$/i.test(String(req.query.following || ''));
const want = Math.min(Math.max(parseInt(req.query.limit, 10) || 20, 1), 40);
const want = Math.min(Math.max(parseInt(req.query.limit, 10) || 20, 1), 40);
if (following && (!req.viewer || req.viewer.kind !== 'user' || !req.viewer.token)) fail(401, 'feed.sign_in_required', 'Sign in to see the people you follow');
if (following && (!req.viewer || req.viewer.kind !== 'user' || !req.viewer.token)) fail(401, 'feed.sign_in_required', 'Sign in to see the people you follow');
- const followed = following ? new Set(await pulse.followedFor(req.viewer)) : null;
+ const followedSubjects = req.viewer && req.viewer.kind === 'user' && req.viewer.token
+ ? new Set(await pulse.followedFor(req.viewer)) : new Set();
const live = await liveNow();
const live = await liveNow();
// Copy cached trending cards before marking them: a channel can go live again during the cache TTL.
// Copy cached trending cards before marking them: a channel can go live again during the cache TTL.
@@ -267,7 +300,7 @@ function createFeedApi({ pulse, viewers, limits, db, networkUrl = 'https://openv
@@ -267,7 +300,7 @@ function createFeedApi({ pulse, viewers, limits, db, networkUrl = 'https://openv
for (let index = 0; index < rows.length; index++) {
for (let index = 0; index < rows.length; index++) {
const i = rows[index];
const i = rows[index];
const actorSubject = i.actor_subject || (i.actor && i.actor.subject) || null;
const actorSubject = i.actor_subject || (i.actor && i.actor.subject) || null;
- if (followed && !(actorSubject && followed.has(actorSubject))) continue;
+ if (following && !(actorSubject && followedSubjects.has(actorSubject))) continue;
const c = card(i);
const c = card(i);
mark([c], live);
mark([c], live);
if (kind && c.kind !== kind) continue;
if (kind && c.kind !== kind) continue;
@@ -287,6 +320,9 @@ function createFeedApi({ pulse, viewers, limits, db, networkUrl = 'https://openv
@@ -287,6 +320,9 @@ function createFeedApi({ pulse, viewers, limits, db, networkUrl = 'https://openv
}
}
const counts = await countsFor(db, picked.map((p) => p.row));
const counts = await countsFor(db, picked.map((p) => p.row));
for (const p of picked) p.card.counts = counts.get(`${typeOf(p.row)}:${idOf(p.row)}`) || {};
for (const p of picked) p.card.counts = counts.get(`${typeOf(p.row)}:${idOf(p.row)}`) || {};
+ const followedIds = new Set(picked.filter((p) => followedSubjects.has(p.row.actor_subject || (p.row.actor && p.row.actor.subject)))
+ .map((p) => p.card.id));
+ picked.splice(0, picked.length, ...rankCards(picked.map((p) => p.card), followedIds).map((c) => picked.find((p) => p.card.id === c.id)));
// The first page carries trending pictures and videos, one after every three Pulse items.
// The first page carries trending pictures and videos, one after every three Pulse items.
const items = [];
const items = [];
@@ -300,6 +336,8 @@ function createFeedApi({ pulse, viewers, limits, db, networkUrl = 'https://openv
@@ -300,6 +336,8 @@ function createFeedApi({ pulse, viewers, limits, db, networkUrl = 'https://openv
});
});
const seen = new Set(items.map((c) => c.url));
const seen = new Set(items.map((c) => c.url));
while (items.length < want && k < t.length) { const c = t[k++]; if (!seen.has(c.url)) { items.push(c); seen.add(c.url); } }
while (items.length < want && k < t.length) { const c = t[k++]; if (!seen.has(c.url)) { items.push(c); seen.add(c.url); } }
+ // Frame cards keep their existing placement, while an actual on-air channel always leads the response.
+ items.sort((a, b) => Number(b.kind === 'live' && b.live_now === true) - Number(a.kind === 'live' && a.live_now === true));
return { items, next_cursor: next };
return { items, next_cursor: next };
}));
}));
router.get('/search', run(async (req) => {
router.get('/search', run(async (req) => {
@@ -337,4 +375,4 @@ function createFeedApi({ pulse, viewers, limits, db, networkUrl = 'https://openv
@@ -337,4 +375,4 @@ function createFeedApi({ pulse, viewers, limits, db, networkUrl = 'https://openv
return router;
return router;
}
}
-module.exports = { createFeedApi, kindOf, card, countsFor, channelOf, createLiveNow, isCurrentLive, searchMatches, searchExamples, foldBursts };
+module.exports = { createFeedApi, kindOf, card, countsFor, channelOf, createLiveNow, isCurrentLive, searchMatches, searchExamples, foldBursts, engagement, compareRankedCards, rankCards };
@@ -193,6 +193,39 @@ const at = (minsAgo) => new Date(Date.now() - minsAgo * 60_000).toISOString().re
@@ -193,6 +193,39 @@ const at = (minsAgo) => new Date(Date.now() - minsAgo * 60_000).toISOString().re
assert.strictEqual(r.json().code, 'feed.sign_in_required');
assert.strictEqual(r.json().code, 'feed.sign_in_required');
});
});
+ await check('feed ranking puts live and fresh activity first, then uses engagement and follows', async () => {
+ net.addFollow(sam.subject_id, 'channel', alex.subject_id);
+ const rankPaste = async (actor, title, minutesAgo, stats = {}) => {
+ const { paste } = await t.app.locals.pastes.createText(actor, { title, content: title });
+ await db.prepare("UPDATE pulse_items SET occurred_at = ? WHERE source_service = 'community' AND source_type = 'paste' AND source_id = ?")
+ .run(at(minutesAgo), paste.slug);
+ await db.prepare('UPDATE pastes SET views = ?, likes = ? WHERE slug = ?')
+ .run(stats.views || 0, stats.likes || 0, paste.slug);
+ };
+ await rankPaste(asAlex, 'FreshRank recent', 10);
+ await rankPaste(asAlex, 'FreshRank older', 2 * 24 * 60);
+ await rankPaste(asAlex, 'EngagementRank low', 30);
+ await rankPaste(asAlex, 'EngagementRank high', 30, { views: 10000, likes: 100 });
+ await rankPaste(asAlex, 'FollowRank followed', 25);
+ await rankPaste({ ...asAlex, subject: sam.subject_id }, 'FollowRank plain', 25);
+ await rankPaste(asAlex, 'StaleRank recent', 4 * 60);
+ await rankPaste(asAlex, 'StaleRank old', 10 * 24 * 60, { views: 100000, likes: 1000 });
+ await db.prepare(`INSERT INTO pulse_items (source_service, source_type, source_id, title, url, actor_subject, origin, visibility, occurred_at)
+ VALUES ('live', 'stream', 'rank-live', 'LiveRank current', 'https://openvibe.live/@alex', ?, 'user', 'public', ?),
+ ('live', 'stream', 'rank-offline', 'LiveRank offline', 'https://openvibe.live/@offline', ?, 'user', 'public', ?)`)
+ .run(alex.subject_id, at(1), alex.subject_id, at(1));
+
+ const titles = async (q, options = {}) => (await t.get(`/api/v1/feed?q=${q}&limit=40`, options)).json().items.map((x) => x.title);
+ assert.deepStrictEqual(await titles('FreshRank'), ['FreshRank recent', 'FreshRank older']);
+ assert.deepStrictEqual(await titles('EngagementRank'), ['EngagementRank high', 'EngagementRank low']);
+ assert.deepStrictEqual(await titles('FollowRank', { cookies: [`ov_token=${samJwt}`] }), ['FollowRank followed', 'FollowRank plain']);
+ assert.deepStrictEqual(await titles('StaleRank'), ['StaleRank recent', 'StaleRank old']);
+ const live = await t.get('/api/v1/feed?q=LiveRank&limit=40');
+ assert.deepStrictEqual(live.json().items.map((x) => [x.title, x.kind, x.live_now]), [
+ ['LiveRank current', 'live', true], ['LiveRank offline', 'stream', false],
+ ]);
+ });
+
await check('cross-site: any OpenVibe site reads it from the browser (CORS, no credentials)', async () => {
await check('cross-site: any OpenVibe site reads it from the browser (CORS, no credentials)', async () => {
const r = await t.get('/api/v1/feed', { headers: { origin: 'https://openvibe.video' } });
const r = await t.get('/api/v1/feed', { headers: { origin: 'https://openvibe.video' } });
assert.strictEqual(r.status, 200);
assert.strictEqual(r.status, 200);