openvibers/openvibe.community Commits

Feed: fold a person's run within two hours, and never repeat a card (#393)

OP OpenVibers committed 1 days ago · 2ea76d3625 · 3 files, +51 −11

The run window was 15 minutes from the run's first card, so 11-11's feed showed
one streamer's evening of stream restarts as nine rows, and another's morning of
uploads twenty minutes apart as five "added 2 videos" rows. A card now joins a
run when it is within two hours of any card already in it.

Trending cards were deduplicated only against the cards placed before them, so a
trending card could repeat a Pulse card further down the page; three trending
"iZoR_One's Stream" cards also sat beside his own folded recordings. A trending
card is now skipped when the page shows its link or its title ("Recording: "
aside), a folded run's own cards included.

Ask 360 (the feed folds its duplicates).

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex bbe0c71..183191b 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -2,6 +2,12 @@
## Unreleased
++- The Feed folds a person's run of one kind on one site while each card is within two hours of the run (it was 15
++ minutes from the run's first card): 11-11's feed showed one streamer's evening of restarts as nine rows and another's
++ morning of uploads as five "added 2 videos" rows. A trending card is skipped when the page already shows its link or
++ its title ("Recording: " aside), a folded run's own cards included; before, a trending card could repeat a Pulse
++ card further down the page. Test: `test/feed-api.test.js`.
++
- Community records `orientation applied` on new Media image objects after Media normalizes their EXIF before upload.
This keeps already-corrected screenshots and attachments out of the later AI orientation queue. Test:
`test/attachments.test.js`.
ddiff --git a/server/pulse/feed.js b/server/pulse/feed.js
iindex d78d8f0..6c5fb2c 100644
---- a/server/pulse/feed.js
++++ b/server/pulse/feed.js
@@@ -78,17 +78,23 @@ function card(i) {
const svcOf = (i) => String((i.source && i.source.service) || i.source_service || '');
const typeOf = (i) => String((i.source && i.source.type) || i.source_type || '');
const idOf = (i) => String((i.source && i.source.id) || i.source_id || '');
--const BURST_MS = 15 * 60_000;
++// A person's run of one kind on one site is one card ("Maticus added 7 videos"). A card joins a run when it is within
++// two hours of any card already in it, so a morning of uploads twenty minutes apart, or an evening of stream restarts,
++// reads as one card (ask 360: the feed folds its duplicates). At 15 minutes from the run's first card, 11-11's feed
++// showed one streamer's evening as nine rows.
++const BURST_MS = 2 * 60 * 60_000;
const BURST_WORD = { pic: 'photos', clip: 'clips', video: 'videos', game: 'achievements', quest: 'quests', avatar: 'profile updates' };
++/** A card's title for "already on the page": a stream's recording and the stream are one title. */
++const titleKey = (c) => String(c.title || '').replace(/^recording:\s*/i, '').trim().toLowerCase();
function foldBursts(pairs) {
const groups = [];
for (const p of pairs) {
const actor = p.row.actor_subject || (p.row.actor && p.row.actor.subject) || (p.row.actor_name && `name:${p.row.actor_name}`);
const at = Date.parse(p.card.at || '');
const prior = actor && Number.isFinite(at) && BURST_WORD[p.card.kind] && groups.find((g) =>
-- g.actor === actor && g.card.kind === p.card.kind && g.site === p.card.site && Math.abs(g.at - at) <= BURST_MS);
-- if (prior) { prior.cards.push(p.card); continue; }
-- groups.push({ actor, at, site: p.card.site, card: p.card, cards: [p.card] });
++ g.actor === actor && g.card.kind === p.card.kind && g.site === p.card.site && g.ats.some((t) => Math.abs(t - at) <= BURST_MS));
++ if (prior) { prior.cards.push(p.card); prior.ats.push(at); continue; }
++ groups.push({ actor, ats: Number.isFinite(at) ? [at] : [], site: p.card.site, card: p.card, cards: [p.card] });
}
return groups.map((g) => {
if (g.cards.length < 2) return g.card;
@@@ -325,17 +331,25 @@ function createFeedApi({ pulse, viewers, limits, db, networkUrl = 'https://openv
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.
++ // A trending card is skipped when the page already shows its link or its title, a folded run's own cards
++ // included: three "iZoR_One's Stream" cards beside his folded recordings said the same thing four times.
++ const folded = foldBursts(picked);
++ const shown = new Set(), titles = new Set();
++ const note = (c) => {
++ for (const x of [c, ...(c.group ? c.group.items : [])]) { shown.add(x.url); titles.add(titleKey(x)); }
++ };
++ folded.forEach(note);
++ const fresh = (c) => !shown.has(c.url) && !titles.has(titleKey(c));
const items = [];
let k = 0;
-- foldBursts(picked).forEach((c, n) => {
++ folded.forEach((c, n) => {
items.push(c);
-- if ((n + 1) % 3 === 0 && k < Math.min(t.length, Math.floor(want / 4))) {
++ while ((n + 1) % 3 === 0 && k < Math.min(t.length, Math.floor(want / 4))) {
const extra = t[k++];
-- if (!items.some((x) => x.url === extra.url)) items.push(extra);
++ if (fresh(extra)) { items.push(extra); note(extra); break; }
}
});
-- 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 (fresh(c)) { items.push(c); note(c); } }
// 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 };
ddiff --git a/test/feed-api.test.js b/test/feed-api.test.js
iindex 471b858..5406382 100644
---- a/test/feed-api.test.js
++++ b/test/feed-api.test.js
@@@ -10,7 +10,11 @@ const { boot, check, done } = require('./helpers/app');
const FRAME = { cards: [
{ site: 'media-hub', title: 'Trending', items: [
-- { title: 'A trending video', url: 'https://openvibe.video/v/1', meta: '1.2k views', image: { src: 'https://openvibe.media/t/1.webp' } },
++ { title: 'A trending video', url: 'https://openvibe.video/v/7', meta: '1.2k views', image: { src: 'https://openvibe.media/t/1.webp' } },
++ // The same title as a Pulse card on the page ("Recording: " aside): skipped, never a second card for it.
++ { title: 'Recording: A video', url: 'https://openvibe.video/v/8', image: { src: 'https://openvibe.media/t/8.webp' } },
++ // The same link as a Pulse card further down the page: skipped too.
++ { title: 'Same link, other words', url: 'https://openvibe.video/v/1', image: { src: 'https://openvibe.media/t/9.webp' } },
{ title: 'No picture', url: 'https://openvibe.video/v/2' },
{ title: 'Not https', url: 'http://openvibe.video/v/3', image: { src: 'https://openvibe.media/t/3.webp' } },
] },
@@@ -140,11 +144,27 @@ const at = (minsAgo) => new Date(Date.now() - minsAgo * 60_000).toISOString().re
await check('trending pictures are woven in: https, with a picture, kinds the feed shows', async () => {
const j = (await t.get('/api/v1/feed')).json();
const tr = j.items.filter((x) => x.label === 'Trending');
-- assert.deepStrictEqual(tr.map((x) => x.title), ['A trending video'], 'only the https one with a picture');
++ assert.deepStrictEqual(tr.map((x) => x.title), ['A trending video'],
++ 'only the https one with a picture, and never a link or a title the page already shows');
assert.strictEqual(tr[0].kind, 'video');
assert.strictEqual(tr[0].media.thumb, 'https://openvibe.media/t/1.webp');
});
++ await check('a person\'s run folds while each card is within two hours of the run, and a gap ends it', async () => {
++ const { foldBursts } = require('../server/pulse/feed');
++ const base = Date.parse('2026-11-11T07:35:00Z');
++ const pair = (id, mins, kind = 'video', who = 'sub_maticus') => ({ row: { actor_subject: who },
++ card: { id, kind, site: 'media-hub', url: `https://openvibe.video/watch?v=${id}`, title: id,
++ at: new Date(base + mins * 60_000).toISOString(), actor: { name: 'Maticus' }, media: null } });
++ // 07:35, 07:55, 09:10, 09:44, 10:06 and 10:13 (11-11's morning, which showed as five rows), then 15:00.
++ const out = foldBursts([pair('a', 158), pair('b', 151), pair('c', 129), pair('d', 95), pair('e', 20), pair('f', 0),
++ pair('clip', 140, 'clip'), pair('other', 150, 'video', 'sub_ada'), pair('later', 445)]);
++ const run = out.find((c) => c.group);
++ assert.strictEqual(run.title, 'Maticus added 6 videos');
++ assert.deepStrictEqual(run.group.items.map((x) => x.id), ['a', 'b', 'c', 'd', 'e', 'f']);
++ assert.deepStrictEqual(out.map((c) => c.id), ['a', 'clip', 'other', 'later'], 'a clip, someone else and a card past the gap stay apart');
++ });
++
await check('LIVE only for a channel that is live now', async () => {
const feedTools = require('../server/pulse/feed');
const current = await feedTools.createLiveNow({ liveUrl: 'https://openvibe.live', fetchImpl: feedFetch })();