openvibers/openvibe.live Commits

Recently Online: live first-timers listed, slot-less streams shown (#248)

OP OpenVibers committed 25 days ago · 6e62c622fc · 3 files, +47 −7

* Recently Online lists someone live on their first stream (it waited for the stream to end), and a stream that never had a slot is the card's channel entry instead of 'No streams' (owner, 2026-10-18: Jax_eats_tacks live but missing; Dreezy with no streams)

* Recently Online: a slot-less stream's entry shows its public recording's picture (Media by stream id; Dreezy's VOD)

Changes

ddiff --git a/server/db/database.js b/server/db/database.js
iindex 866abf64..b5a9c354 100644
---- a/server/db/database.js
++++ b/server/db/database.js
@@@ -1286,9 +1286,11 @@ async function isManagedStreamSlugTaken(userId, slug, excludeId = null) {
async function getRecentlyOnlineStreamers(limit = 20, offset = 0) {
// Use a correlated subquery to aggregate managed streams per user — avoids session-row
// duplication that occurred when LEFT JOIN managed_streams was used in the outer query.
++ // Someone live right now is in it too (the route marks their card live), first stream or not: a first-timer
++ // used to be missing until their stream ended, while everyone else live was listed (owner, 2026-10-18).
return await all(`
SELECT u.id AS user_id, u.username, u.display_name, u.avatar_url, u.profile_color,
-- MAX(s.ended_at) AS last_online_at,
++ MAX(CASE WHEN s.is_live = 1 THEN ov_now() ELSE s.ended_at END) AS last_online_at,
o.overview AS ai_overview, o.overview_short AS ai_overview_short,
(
SELECT json_agg(json_build_object(
@@@ -1302,12 +1304,19 @@ async function getRecentlyOnlineStreamers(limit = 20, offset = 0) {
))::text
FROM managed_streams ms2
WHERE ms2.user_id = u.id
-- AND EXISTS (SELECT 1 FROM streams sx WHERE sx.managed_stream_id = ms2.id AND sx.ended_at IS NOT NULL)
-- ) AS managed_streams_json
++ AND EXISTS (SELECT 1 FROM streams sx WHERE sx.managed_stream_id = ms2.id AND (sx.ended_at IS NOT NULL OR sx.is_live = 1))
++ ) AS managed_streams_json,
++ -- The latest stream that never had a slot (the route lists it as the channel itself).
++ (
++ SELECT json_build_object('stream_id', s3.id, 'title', s3.title, 'last_live_at', s3.ended_at)::text
++ FROM streams s3
++ WHERE s3.user_id = u.id AND s3.managed_stream_id IS NULL AND (s3.ended_at IS NOT NULL OR s3.is_live = 1)
++ ORDER BY s3.id DESC LIMIT 1
++ ) AS loose_stream_json
FROM streams s
JOIN users u ON s.user_id = u.id
LEFT JOIN streamer_overviews o ON o.user_id = u.id
-- WHERE s.is_live = 0 AND s.ended_at IS NOT NULL
++ WHERE (s.is_live = 0 AND s.ended_at IS NOT NULL) OR s.is_live = 1
GROUP BY u.id, o.overview, o.overview_short
ORDER BY last_online_at DESC
LIMIT ? OFFSET ?
@@@ -1318,7 +1327,7 @@ async function countRecentlyOnlineStreamers() {
return (await get(`
SELECT COUNT(DISTINCT user_id) AS count
FROM streams
-- WHERE is_live = 0 AND ended_at IS NOT NULL
++ WHERE (is_live = 0 AND ended_at IS NOT NULL) OR is_live = 1
`))?.count || 0;
}
ddiff --git a/server/streaming/routes.js b/server/streaming/routes.js
iindex b8f33bbd..c036090a 100644
---- a/server/streaming/routes.js
++++ b/server/streaming/routes.js
@@@ -1193,6 +1193,10 @@ router.get('/recently-online', async (req, res) => {
for (const s of streamers) {
try {
const parsed = JSON.parse(s.managed_streams_json || '[]');
++ // A stream that never had a slot is listed as the channel itself, so someone who streamed is never
++ // "No streams" (Dreezy's first browser stream had no slot, 2026-10-18).
++ const loose = s.loose_stream_json ? JSON.parse(s.loose_stream_json) : null;
++ if (loose) parsed.push({ managed_stream_id: null, stream_id: loose.stream_id || null, slug: null, title: loose.title || null, protocol: null, last_live_at: loose.last_live_at || null, vod_thumbnail: null });
// Sort by most recently live first; put null last
s.managed_streams = parsed.sort((a, b) => {
if (!a.last_live_at && !b.last_live_at) return 0;
@@@ -1202,6 +1206,7 @@ router.get('/recently-online', async (req, res) => {
});
} catch { s.managed_streams = []; }
delete s.managed_streams_json;
++ delete s.loose_stream_json;
}
// VOD rows (and their thumbnails) live in OpenVibe.Media — the local SQL
// can't join them, so batch-resolve the latest thumb per managed stream.
@@@ -1221,6 +1226,17 @@ router.get('/recently-online', async (req, res) => {
} catch (err) {
console.warn('[Streaming] recently-online thumb enrichment failed:', err.message);
}
++ // A slot-less entry's picture is its own stream's public recording (Media answers by stream id).
++ try {
++ const loose = streamers.flatMap(s => (s.managed_streams || []).filter(ms => ms.managed_stream_id == null && ms.stream_id && !ms.vod_thumbnail));
++ if (loose.length) {
++ const media = require('../media-client');
++ const vods = await require('../media-proxy/lookups').publicVodsForStreams(loose.map(ms => ms.stream_id), { budgetMs: 3000 });
++ loose.forEach((ms) => { const v = vods.get(Number(ms.stream_id)); if (v && v.thumbnail_url) ms.vod_thumbnail = media.publicUrl(v.thumbnail_url); });
++ }
++ } catch (err) {
++ console.warn('[Streaming] recently-online slot-less thumbnails failed:', err.message);
++ }
// Who on this page is live right now: the card says so (and which slot), instead of "last online 22h ago".
try {
const ids = streamers.map(s => s.user_id).filter(Boolean);
@@@ -1232,7 +1248,8 @@ router.get('/recently-online', async (req, res) => {
if (!mine.length) continue;
s.live = { started_at: mine[0].started_at, viewer_count: mine.reduce((n, l) => n + (Number(l.viewer_count) || 0), 0), title: mine[0].title || null };
for (const ms of (s.managed_streams || [])) {
-- const l = mine.find(x => x.managed_stream_id != null && Number(x.managed_stream_id) === Number(ms.managed_stream_id));
++ const l = mine.find(x => (ms.managed_stream_id == null ? x.managed_stream_id == null
++ : x.managed_stream_id != null && Number(x.managed_stream_id) === Number(ms.managed_stream_id)));
if (l) { ms.is_live = true; ms.live_started_at = l.started_at; if (l.thumbnail_url) ms.live_thumbnail = l.thumbnail_url; }
}
// The live slot leads the card's list.
ddiff --git a/test/recently-online-live.test.js b/test/recently-online-live.test.js
iindex 118eed5c..e7b71e84 100644
---- a/test/recently-online-live.test.js
++++ b/test/recently-online-live.test.js
@@@ -17,7 +17,7 @@ const db = require('../server/db/database');
(async () => {
await db.initDb();
const mk = async (username) => { await db.createUser({ username, email: `${username}@x`, password_hash: 'x', display_name: username, stream_key: username.padEnd(32, '0') }); const id = (await db.get('SELECT id FROM users WHERE username = ?', [username])).id; await db.ensureChannel(id); return id; };
-- const izor = await mk('izor'), quiet1 = await mk('quietone');
++ const izor = await mk('izor'), quiet1 = await mk('quietone'), first = await mk('firsttimer'), loose = await mk('dreezy');
const slot = async (uid, slug) => Number((await db.run('INSERT INTO managed_streams (user_id, slug, title, protocol, stream_key, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ov_now(), ov_now()) RETURNING id', [uid, slug, `${slug} title`, 'webrtc', `${slug}-key-${uid}`])).lastInsertRowid);
const ranked = await slot(izor, 'ranked'), chill = await slot(izor, 'chill'), q = await slot(quiet1, 'main');
const stream = async (uid, ms, { live = false, startedAgo, endedAgo = null, viewers = 0 }) => await db.run(
@@@ -28,7 +28,12 @@ const db = require('../server/db/database');
await stream(izor, ranked, { startedAgo: 2000, endedAgo: 1900 });
await stream(izor, ranked, { live: true, startedAgo: 120, viewers: 7 });
await stream(quiet1, q, { startedAgo: 600, endedAgo: 500 });
++ // A first stream, live now and on no slot; and a streamer whose only (ended) stream never had a slot.
++ await stream(first, null, { live: true, startedAgo: 3, viewers: 1 });
++ await stream(loose, null, { startedAgo: 300, endedAgo: 298 });
++ // Media's answer for a slot-less stream's public recording (lookups asks Media by stream id).
++ require('../server/media-proxy/lookups').publicVodsForStreams = async (ids) => new Map(ids.map((id) => [Number(id), { id: 9, thumbnail_url: '/t/dreezy-part-2.jpg' }]));
const app = express();
app.use('/api/streams', require('../server/streaming/routes'));
const server = await new Promise((r) => { const s = http.createServer(app).listen(0, '127.0.0.1', () => r(s)); });
@@@ -43,6 +48,15 @@ const db = require('../server/db/database');
assert.deepStrictEqual(iz.managed_streams.map((m) => [m.slug, !!m.is_live]), [['ranked', true], ['chill', false]], 'the live slot first');
assert.strictEqual(iz.managed_streams[0].live_thumbnail, 'https://openvibe.media/live/frame.jpg');
assert.ok(!qu.live && !qu.managed_streams.some((m) => m.is_live), 'an offline streamer is not live');
++ // Live on a first stream is listed (it used to wait for the stream to end), its slot-less stream live.
++ const ft = body.streamers.find((s) => s.username === 'firsttimer');
++ assert.ok(ft && ft.live, 'a first-timer live now is listed, live');
++ assert.deepStrictEqual(ft.managed_streams.map((m) => [m.slug, !!m.is_live]), [[null, true]]);
++ // A slot-less stream is the channel's entry, never "No streams".
++ const dz = body.streamers.find((s) => s.username === 'dreezy');
++ assert.deepStrictEqual(dz.managed_streams.map((m) => [m.slug, m.title, !!m.last_live_at]), [[null, 'old', true]]);
++ assert.ok(String(dz.managed_streams[0].vod_thumbnail).endsWith('/t/dreezy-part-2.jpg'), "with its recording's picture");
++ assert.strictEqual(body.total, 4);
console.log('recently-online live: all checks passed');
} finally { server.close(); }
process.exit(0);