openvibers/openvibe.community Commits
Feed: fold a person's run within two hours, and never repeat a card (#393)
Changes
@@@ -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`.@@@ -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://openvpicked.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 };@@@ -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().reawait 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 })();
@@ -2,6 +2,12 @@
@@ -2,6 +2,12 @@
## Unreleased
## 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.
- 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:
This keeps already-corrected screenshots and attachments out of the later AI orientation queue. Test:
`test/attachments.test.js`.
`test/attachments.test.js`.
@@ -78,17 +78,23 @@ function card(i) {
@@ -78,17 +78,23 @@ function card(i) {
const svcOf = (i) => String((i.source && i.source.service) || i.source_service || '');
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 typeOf = (i) => String((i.source && i.source.type) || i.source_type || '');
const idOf = (i) => String((i.source && i.source.id) || i.source_id || '');
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' };
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) {
function foldBursts(pairs) {
const groups = [];
const groups = [];
for (const p of pairs) {
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 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 at = Date.parse(p.card.at || '');
const prior = actor && Number.isFinite(at) && BURST_WORD[p.card.kind] && groups.find((g) =>
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) => {
return groups.map((g) => {
if (g.cards.length < 2) return g.card;
if (g.cards.length < 2) return g.card;
@@ -325,17 +331,25 @@ function createFeedApi({ pulse, viewers, limits, db, networkUrl = 'https://openv
@@ -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)));
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.
+ // 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 = [];
const items = [];
let k = 0;
let k = 0;
- foldBursts(picked).forEach((c, n) => {
+ folded.forEach((c, n) => {
items.push(c);
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++];
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.
// 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));
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 };
@@ -10,7 +10,11 @@ const { boot, check, done } = require('./helpers/app');
@@ -10,7 +10,11 @@ const { boot, check, done } = require('./helpers/app');
const FRAME = { cards: [
const FRAME = { cards: [
{ site: 'media-hub', title: 'Trending', items: [
{ 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: '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' } },
{ 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
@@ -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 () => {
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 j = (await t.get('/api/v1/feed')).json();
const tr = j.items.filter((x) => x.label === 'Trending');
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].kind, 'video');
assert.strictEqual(tr[0].media.thumb, 'https://openvibe.media/t/1.webp');
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 () => {
await check('LIVE only for a channel that is live now', async () => {
const feedTools = require('../server/pulse/feed');
const feedTools = require('../server/pulse/feed');
const current = await feedTools.createLiveNow({ liveUrl: 'https://openvibe.live', fetchImpl: feedFetch })();
const current = await feedTools.createLiveNow({ liveUrl: 'https://openvibe.live', fetchImpl: feedFetch })();