openvibers/openvibe.live Commits

A live picture within 15 s and every 30 s while watched, and a recording's last live frame (355) (#800)

OP OpenVibers committed 2 days ago · d034e2e23c · 10 files, +295 −76

* A live picture within 15 s and every 30 s while watched, and a recording's last live frame (355)

* Merge remote-tracking branch 'origin/main' into cx-thumbs-previews-Live

Changes

ddiff --git a/server/home/routes.js b/server/home/routes.js
iindex aa429262..31225e7c 100644
---- a/server/home/routes.js
++++ b/server/home/routes.js
@@@ -275,6 +275,8 @@ router.get('/featured', async (req, res) => {
res.set('Cache-Control', 'public, max-age=10');
try {
const live = (await db.getLiveStreams() || []).filter((s) => !s.is_nsfw);
++ require('../media-proxy/live-preview').attach(live);
++ require('../streaming/live-thumb-cadence').markListed(live.map((x) => x.id));
if (!live.length) return res.json({ stream: null, count: 0 });
let idx = Math.floor(Date.now() / FEATURED_ROTATE_MS) % live.length;
const not = parseInt(req.query.not, 10);
ddiff --git a/server/index.js b/server/index.js
iindex 9ce45884..be8d8f12 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -1670,7 +1670,6 @@ async function start() {
webrtcSFU.closeRoom(`stream-${stream.id}`);
}
}
-- const liveVodThumbGeneratedAt = new Map(); // streamId → last Media frame-grab time
const maintenanceInterval = setInterval(async () => {
if (heartbeatCleanupRunning) return;
heartbeatCleanupRunning = true;
@@@ -1723,66 +1722,9 @@ async function start() {
// Also clean up old live-stream thumbnails (>1 hour)
liveThumbs.cleanupOldThumbnails();
-- // Generate server-side thumbnails for RTMP streams (no client capture available).
-- // A managed-slot "Go Live" publishes under the SLOT's stream key, so prefer
-- // managed_streams.stream_key over the personal users.stream_key — the RTMP
-- // server's activeStreams map (and the HTTP-FLV URL) are keyed by the publish key.
-- const rtmpStreams = await db.all(
-- `SELECT s.id, COALESCE(ms.stream_key, u.stream_key) AS stream_key FROM streams s
-- JOIN users u ON s.user_id = u.id
-- LEFT JOIN managed_streams ms ON s.managed_stream_id = ms.id
-- WHERE s.is_live = 1 AND s.protocol = 'rtmp'`
-- );
-- for (const rs of rtmpStreams) {
-- if (!rs.stream_key || !rtmpServer.isReceiving(rs.stream_key)) continue;
-- if (!await liveThumbs.shouldRefreshLiveThumbnail(rs.id, 120000)) continue;
-- liveThumbs.generateLiveStreamThumbnail(rs.id, rs.stream_key, { minAgeMs: 120000 }).catch(() => {});
-- }
--
-- // Generate server-side thumbnails for JSMPEG streams (broadcaster uses FFmpeg, no browser preview)
-- const jsmpegStreams = await db.all(
-- `SELECT s.id, u.stream_key FROM streams s
-- JOIN users u ON s.user_id = u.id
-- WHERE s.is_live = 1 AND s.protocol = 'jsmpeg'`
-- );
-- for (const js of jsmpegStreams) {
-- if (!await liveThumbs.shouldRefreshLiveThumbnail(js.id, 120000)) continue;
-- const channelInfo = jsmpegRelay.getChannelInfo(js.stream_key);
-- if (channelInfo && channelInfo.videoPort) {
-- liveThumbs.generateJSMPEGThumbnail(js.id, channelInfo.videoPort).catch(() => {});
-- }
-- }
--
-- // WebRTC/WHIP/browser live thumbnails: the broadcaster client's periodic
-- // canvas-capture POST covers browser publishers with a visible tab, but WHIP
-- // publishers (OBS) and hidden tabs send nothing. Fallback: have Media extract
-- // a frame from the in-progress recording (fragmented mp4 — readable while
-- // growing) and use that as both the live card and RECORDING-card thumbnail.
-- const webrtcStreams = await db.all(
-- `SELECT id FROM streams WHERE is_live = 1 AND protocol NOT IN ('rtmp', 'jsmpeg')`
-- );
-- for (const wsStream of webrtcStreams) {
-- if (await liveThumbs.shouldRefreshLiveThumbnail(wsStream.id, 120000)) {
-- const lastGen = liveVodThumbGeneratedAt.get(wsStream.id) || 0;
-- if (Date.now() - lastGen < 120000) continue;
-- liveVodThumbGeneratedAt.set(wsStream.id, Date.now());
-- // First choice: a frame straight from the SFU (works for OBS/WHIP and hidden
-- // tabs, and needs no recording). The Media in-progress-VOD frame is the backup.
-- const grabbed = await liveThumbs.generateWebrtcThumbnail(wsStream.id, { minAgeMs: 120000 }).catch(() => null);
-- if (grabbed) continue;
-- const rec = recorder.activeRecordings.get(wsStream.id);
-- if (!rec || !rec.vodId) continue;
-- mediaClient.generateThumbnail('vod', rec.vodId)
-- .then(async (out) => {
-- const url = mediaClient.publicUrl(out?.url);
-- if (url) await db.run('UPDATE streams SET thumbnail_url = ? WHERE id = ?', [url, wsStream.id]);
-- })
-- .catch(() => {});
-- }
-- }
-- for (const [sid] of liveVodThumbGeneratedAt) {
-- if (!webrtcStreams.some(s => s.id === sid)) liveVodThumbGeneratedAt.delete(sid);
-- }
++ // Live pictures are their own 5 s timer below (server/streaming/live-thumb-cadence.js): a first
++ // picture within 15 s of go-live and a fresh one about every 30 s while someone is watching. This
++ // one-minute maintenance pass used to grab every 2 minutes for every live stream, watched or not.
} catch (err) {
console.error('[Heartbeat] Cleanup error:', err.message);
} finally {
@@@ -1791,6 +1733,14 @@ async function start() {
}, 60000);
if (typeof maintenanceInterval.unref === 'function') maintenanceInterval.unref();
++ // 8a2. Live pictures (plan 355): a first frame within 15 s of going live and a fresh one about every 30 s while
++ // someone watches the stream's page or home. One ffmpeg grab per stream per interval; skipped when unwatched.
++ const livePictureInterval = setInterval(() => {
++ require('./streaming/live-thumb-cadence').tick()
++ .catch((err) => console.warn('[Thumbnails] live picture tick:', err.message));
++ }, require('./streaming/live-thumb-cadence').TICK_MS);
++ if (typeof livePictureInterval.unref === 'function') livePictureInterval.unref();
++
// 8b. Canonical identity: report every Live<->Network link to Network's identity_legacy_map
// (roadmap Wave 1). Idempotent on Network's side; daily, first run a few minutes after boot.
// Then fill in the subject of every linked account no token has told Live about yet.
ddiff --git a/server/media-client.js b/server/media-client.js
iindex f691f30b..806741b9 100644
---- a/server/media-client.js
++++ b/server/media-client.js
@@@ -342,6 +342,17 @@ async function generateThumbnail(kind, id, opts = {}) {
return await request('POST', `/thumbnails/${kind}/${id}`, opts);
}
++/**
++ * Upload an image as a VOD/clip's thumbnail (Media's POST /thumbnails/:kind/:id with a multipart `thumbnail`).
++ * Live uses it for the one picture Media cannot take: the last frame of the live stream a recording came from
++ * (server/media-proxy/live-picture-fallback.js), still on disk when the recording finalizes.
++ */
++async function uploadThumbnail(kind, id, buffer, { contentType = 'image/jpeg' } = {}) {
++ const form = new FormData();
++ form.append('thumbnail', new Blob([buffer], { type: contentType }), 'thumbnail.jpg');
++ return await request('POST', `/thumbnails/${kind}/${id}`, { body: form, timeoutMs: 30000 });
++}
++
// ── Public URL builders (MEDIA_PUBLIC_URL) ───────────────────────────────────
function vodPlaybackUrl(id) { return `${MEDIA_PUBLIC_URL}/v/${id}`; }
@@@ -371,6 +382,9 @@ function setPlaybackUrls(row, kind) {
const file = kind === 'clip' ? clipUrl(row.id) : vodPlaybackUrl(row.id);
row.file_url = row.file_url || publicUrl(row.playback_url) || file;
if (row.hls_url) row.hls_url = publicUrl(row.hls_url);
++ // The hover preview (preview.make): Media-relative /p/<name> on the same public origin as the thumbnails.
++ if (row.preview_url) row.preview_url = publicUrl(row.preview_url);
++ if (row.preview_webm_url) row.preview_webm_url = publicUrl(row.preview_webm_url);
row.playback_url = row.hls_url || row.file_url;
return row;
}
ddiff --git a/server/media-proxy/live-picture-fallback.js b/server/media-proxy/live-picture-fallback.js
nnew file mode 100644
iindex 00000000..76f6899f
---- /dev/null
++++ b/server/media-proxy/live-picture-fallback.js
@@@ -0,0 +1,55 @@
++/**
++ * OpenVibe.Live — the live stream's last picture as a recording's thumbnail
++ *
++ * A recording can finish without a picture: the frame grab failed, the file is too short or damaged at the seek point,
++ * or the recording started before the source had video. When the recording came from a live stream, Live already has a
++ * picture of it — the last frame its live grab captured, still on disk for an hour after the stream ends. Media's own
++ * thumbnail wins whenever it has one; this only fills a hole, so a recording made from a live stream never shows the
++ * grey placeholder when a real frame of that stream exists.
++ *
++ * Called from the vod.ready outcome (server/media-proxy/outcomes.js), after the recording's row is committed.
++ */
++'use strict';
++const fs = require('fs');
++const path = require('path');
++const db = require('../db/database');
++const media = require('../media-client');
++const liveThumbs = require('./live-thumbs');
++
++/** A local live-picture file from a streams.thumbnail_url value, or null. */
++function livePictureFile(url) {
++ const m = /^\/api\/thumbnails\/(stream-[A-Za-z0-9_.-]+\.(?:jpg|png))$/.exec(String(url || ''));
++ if (!m) return null;
++ const file = path.join(liveThumbs.THUMB_DIR, m[1]);
++ try { return fs.statSync(file).isFile() ? file : null; } catch { return null; }
++}
++
++/**
++ * Give a finished recording the live stream's last picture when it has no thumbnail of its own.
++ * → { adopted: boolean, reason? }
++ */
++async function adoptForVod(vodId, data = {}) {
++ const id = Number(vodId);
++ if (!Number.isSafeInteger(id) || id <= 0) return { adopted: false, reason: 'no_id' };
++ let vod;
++ try { vod = await media.getVod(id); } catch { return { adopted: false, reason: 'media_unreachable' }; }
++ if (!vod) return { adopted: false, reason: 'no_row' };
++ if (vod.thumbnail_url) return { adopted: false, reason: 'has_picture' };
++ if (Number(vod.audio_only)) return { adopted: false, reason: 'audio_only' };
++ const streamId = Number(vod.stream_id || data.stream_id || data.streamId);
++ if (!Number.isSafeInteger(streamId) || streamId <= 0) return { adopted: false, reason: 'no_stream' };
++ let stream = null;
++ try { stream = await db.getStreamById(streamId); } catch { /* no row */ }
++ const file = livePictureFile(stream && stream.thumbnail_url);
++ if (!file) return { adopted: false, reason: 'no_live_picture' };
++ try {
++ const out = await media.uploadThumbnail('vod', id, fs.readFileSync(file));
++ if (!out || !out.url) return { adopted: false, reason: 'media_refused' };
++ console.log(`[VOD] Recording ${id} took the live picture of stream ${streamId} as its thumbnail`);
++ return { adopted: true };
++ } catch (err) {
++ return { adopted: false, reason: err.message };
++ }
++}
++
++module.exports = { adoptForVod, livePictureFile };
ddiff --git a/server/media-proxy/live-preview.js b/server/media-proxy/live-preview.js
nnew file mode 100644
iindex 00000000..be022417
---- /dev/null
++++ b/server/media-proxy/live-preview.js
@@@ -0,0 +1,34 @@
++/**
++ * OpenVibe.Live — a live stream's cheap preview_url
++ *
++ * A live card's hover preview does not need a second encode: while a stream is being recorded, Media already serves the
++ * recording's DVR as an EVENT HLS playlist at the live edge (GET /v/<vod>/dvr.m3u8, server/public/routes.js in
++ * OpenVibe.Media). It is full-resolution, not a low-bitrate ladder, but it costs one request and no extra copy — the
++ * closest thing Live already has to a live preview, so that is what a live stream's preview_url names.
++ *
++ * The playlist exists only for an MP4 recording in progress (a browser MediaRecorder stream may be WebM), and never
++ * for a private one: Media answers 404 then and the player keeps the thumbnail/recording URL. Null when there is no
++ * active recording to preview.
++ */
++'use strict';
++const media = require('../media-client');
++
++/** The preview URL for one live stream right now, or null. */
++function previewUrlFor(streamId, { recorder = null } = {}) {
++ try {
++ const rec = (recorder || require('../streaming/recorder')).activeRecordings.get(Number(streamId));
++ if (!rec || !rec.vodId) return null;
++ if (String(rec.protocol || '').toLowerCase() === 'jsmpeg') return null;
++ return media.vodDvrPlaylistUrl(rec.vodId);
++ } catch { return null; }
++}
++
++/** Set preview_url on each stream row (null when there is nothing to preview). */
++function attach(streams, opts = {}) {
++ for (const s of streams || []) {
++ if (s && s.id != null) s.preview_url = previewUrlFor(s.id, opts);
++ }
++ return streams;
++}
++
++module.exports = { previewUrlFor, attach };
ddiff --git a/server/media-proxy/live-thumbs.js b/server/media-proxy/live-thumbs.js
iindex 8595cdd1..9bd0ded3 100644
---- a/server/media-proxy/live-thumbs.js
++++ b/server/media-proxy/live-thumbs.js
@@@ -2,8 +2,8 @@
* OpenVibe.Live — live-stream thumbnails (Live-local, ephemeral)
*
* VOD/clip/paste thumbnails moved to OpenVibe.Media, but LIVE stream thumbnails
-- * are transient live-state (refreshed every ~2 min, deleted an hour after the
-- * stream ends), so they stay local: captured here (client canvas POST, RTMP FLV
++ * are transient live-state (refreshed every ~30 s while watched, deleted an hour
++ * after the stream ends), so they stay local: captured here (client canvas POST, RTMP FLV
* grab, or JSMPEG relay tap), written to data/live-thumbs/, and served from
* /api/thumbnails/stream-<id>-<ts>.jpg by the thumbnails proxy router.
*
@@@ -21,7 +21,7 @@ const db = require('../db/database');
const THUMB_DIR = require('../paths').dir('LIVE_THUMBS_PATH', 'live-thumbs');
const THUMB_WIDTH = 640;
const THUMB_QUALITY = 6;
--const LIVE_THUMB_MIN_INTERVAL_MS = 120000;
++const LIVE_THUMB_MIN_INTERVAL_MS = 30000;
const CLIENT_THUMB_WRITE_MIN_INTERVAL_MS = 15000;
const activeLiveThumbnailJobs = new Set();
ddiff --git a/server/media-proxy/outcomes.js b/server/media-proxy/outcomes.js
iindex c9253711..98a9ab6d 100644
---- a/server/media-proxy/outcomes.js
++++ b/server/media-proxy/outcomes.js
@@@ -127,6 +127,9 @@ async function apply(event, data) {
outcome: 'vod_ready',
after: async () => {
try { require('../streaming/recorder').onVodSettled(vodId); } catch { /* */ }
++ // No picture of its own? A recording made from a live stream takes that stream's last frame
++ // (media-proxy/live-picture-fallback.js), still on disk for an hour after the stream ended.
++ try { await require('./live-picture-fallback').adoptForVod(vodId, data); } catch { /* best effort */ }
// Kick the on-finalize AI pass right away (both budget-gated).
try {
const ai = require('../ai/ai-analysis');
ddiff --git a/server/streaming/live-thumb-cadence.js b/server/streaming/live-thumb-cadence.js
nnew file mode 100644
iindex 00000000..be9ff66e
---- /dev/null
++++ b/server/streaming/live-thumb-cadence.js
@@@ -0,0 +1,120 @@
++/**
++ * OpenVibe.Live — live pictures (plan 355)
++ *
++ * Every live stream has a picture within 15 s of going live, and a fresh one about every 30 s while someone is
++ * watching the channel page or the home/list pages. The browser/WHIP publisher posts its own frame every 15 s; this
++ * server-side tick is the fallback for RTMP/JSMPEG and for a browser whose tab is hidden or whose publisher is OBS.
++ *
++ * Cost: one ffmpeg grab per stream per interval, never one per page. The grabbers already dedupe concurrent work
++ * (live-thumbs.js activeLiveThumbnailJobs) and only a frame taken from a keyframe becomes the picture.
++ *
++ * - no picture yet → grab on the first tick after go-live (watched or not: a card must never be blank)
++ * - picture, watched → grab when it is 30 s old
++ * - picture, nobody watching → leave it alone (the live thumbnail of an unwatched stream is not being looked at)
++ * - a grab in the last 15 s → wait (a failing source is not hammered every 5 s)
++ *
++ * "Watching" is the player sockets in the stream's room (broadcast-server), the viewer count Chat reports, or the
++ * stream appearing in a public live list in the last 90 s (the home page and the channel lists call those).
++ */
++'use strict';
++const db = require('../db/database');
++
++const TICK_MS = 5000;
++const PICTURE_INTERVAL_MS = 30000;
++const ATTEMPT_GAP_MS = 15000;
++const LISTED_FRESH_MS = 90000;
++
++const lastAttempt = new Map(); // streamId → when the last grab started
++const listed = new Map(); // streamId → when a public live list last named it
++
++/** A public live list just named these streams: someone may be looking at their cards. */
++function markListed(ids, now = Date.now()) {
++ for (const id of ids || []) {
++ const n = Number(id);
++ if (Number.isSafeInteger(n) && n > 0) listed.set(n, now);
++ }
++ if (listed.size > 5000) {
++ for (const [id, at] of listed) if (now - at > 10 * 60 * 1000) listed.delete(id);
++ }
++}
++
++/** Pure: is anyone watching this stream, by any of the cheap signals? */
++function isWatched({ viewerCount = 0, socketCount = 0, listedAt = 0, now = Date.now() } = {}) {
++ return Number(viewerCount) > 0 || Number(socketCount) > 0 || (Number(listedAt) > 0 && now - Number(listedAt) <= LISTED_FRESH_MS);
++}
++
++/** Pure: should this stream be grabbed now? */
++function shouldGrab({ hasPicture, ageMs = Infinity, watched = false, sinceAttemptMs = Infinity } = {}) {
++ if (sinceAttemptMs < ATTEMPT_GAP_MS) return false;
++ if (!hasPicture) return true; // first picture: always
++ if (!watched) return false; // fresh enough and nobody is looking
++ return !(Number(ageMs) >= 0 && Number(ageMs) < PICTURE_INTERVAL_MS);
++}
++
++/** The video grab for one stream, by protocol; the Media in-progress recording is the WebRTC/WHIP backup. */
++async function grab(stream) {
++ const liveThumbs = require('../media-proxy/live-thumbs');
++ const protocol = String(stream.protocol || '').toLowerCase();
++ if (protocol === 'rtmp') {
++ const rtmpServer = require('./rtmp-server');
++ if (!stream.stream_key || !rtmpServer.isReceiving(stream.stream_key)) return null;
++ return await liveThumbs.generateLiveStreamThumbnail(stream.id, stream.stream_key, { minAgeMs: PICTURE_INTERVAL_MS });
++ }
++ if (protocol === 'jsmpeg') {
++ const relay = require('./jsmpeg-relay');
++ const info = stream.stream_key ? relay.getChannelInfo(stream.stream_key) : null;
++ if (!info || !info.videoPort) return null;
++ return await liveThumbs.generateJSMPEGThumbnail(stream.id, info.videoPort);
++ }
++ const grabbed = await liveThumbs.generateWebrtcThumbnail(stream.id, { minAgeMs: PICTURE_INTERVAL_MS }).catch(() => null);
++ if (grabbed) return grabbed;
++ // No SFU producer (OBS/WHIP in a shape the grabber cannot tap): a frame from Media's in-progress recording.
++ try {
++ const rec = require('./recorder').activeRecordings.get(Number(stream.id));
++ if (!rec || !rec.vodId) return null;
++ const mediaClient = require('../media-client');
++ const out = await mediaClient.generateThumbnail('vod', rec.vodId);
++ const url = mediaClient.publicUrl(out && out.url);
++ if (url) await db.run('UPDATE streams SET thumbnail_url = ? WHERE id = ?', [url, stream.id]);
++ return url;
++ } catch { return null; }
++}
++
++/** One pass over the live streams. Never throws (the timer logs). */
++async function tick({ now = Date.now(), deps = {} } = {}) {
++ if (require('../drill').enabled) return { checked: 0, grabbed: 0 };
++ const liveThumbs = deps.liveThumbs || require('../media-proxy/live-thumbs');
++ const broadcast = deps.broadcast || require('./broadcast-server');
++ const rows = await (deps.liveStreams ? deps.liveStreams()
++ : db.all(`SELECT s.id, s.protocol, s.thumbnail_url, s.viewer_count, COALESCE(ms.stream_key, u.stream_key) AS stream_key
++ FROM streams s JOIN users u ON s.user_id = u.id
++ LEFT JOIN managed_streams ms ON s.managed_stream_id = ms.id
++ WHERE s.is_live = 1`));
++ let grabbed = 0;
++ for (const stream of rows || []) {
++ let socketCount = 0;
++ try { socketCount = broadcast.getViewerCount(stream.id) || 0; } catch { /* no signaling */ }
++ const watched = isWatched({ viewerCount: stream.viewer_count, socketCount, listedAt: listed.get(Number(stream.id)) || 0, now });
++ let ageMs = Infinity;
++ try { const state = await liveThumbs.getStreamThumbnailState(stream.id); if (state.exists) ageMs = state.ageMs; } catch { /* no file */ }
++ const hasPicture = !!stream.thumbnail_url;
++ const sinceAttemptMs = now - (lastAttempt.get(Number(stream.id)) || 0);
++ if (!shouldGrab({ hasPicture, ageMs, watched, sinceAttemptMs })) continue;
++ lastAttempt.set(Number(stream.id), now);
++ grabbed++;
++ grab(stream).catch((err) => console.warn(`[Thumbnails] live picture for stream ${stream.id}:`, err.message));
++ }
++ // Forget ended streams: their timers must not keep a decision for a later session.
++ const liveIds = new Set((rows || []).map((r) => Number(r.id)));
++ for (const id of lastAttempt.keys()) if (!liveIds.has(id)) lastAttempt.delete(id);
++ for (const id of listed.keys()) if (!liveIds.has(id)) listed.delete(id);
++ return { checked: (rows || []).length, grabbed };
++}
++
++/** Test seam: forget the in-memory decisions. */
++function reset() { lastAttempt.clear(); listed.clear(); }
++
++module.exports = {
++ TICK_MS, PICTURE_INTERVAL_MS, ATTEMPT_GAP_MS, LISTED_FRESH_MS,
++ markListed, isWatched, shouldGrab, tick, reset,
++};
ddiff --git a/server/streaming/routes.js b/server/streaming/routes.js
iindex ed083deb..8c530be2 100644
---- a/server/streaming/routes.js
++++ b/server/streaming/routes.js
@@@ -273,6 +273,9 @@ router.get('/channel/:username', optionalAuth, async (req, res) => {
// Get live streams (may be multiple with different protocols)
const liveStreams = await db.getLiveStreamsByUserId(channel.user_id) || [];
++ // The live card's hover preview: Media's DVR playlist of the recording in progress (media-proxy/live-preview.js).
++ require('../media-proxy/live-preview').attach(liveStreams);
++ require('./live-thumb-cadence').markListed(liveStreams.map((x) => x.id));
for (const liveStream of liveStreams) {
// Use managed stream key from the JOIN, else fall back to user key
const lsKey = liveStream.managed_stream_key
@@@ -735,6 +738,9 @@ router.get('/channel/:username/live', async (req, res) => {
if (!channel) return res.status(404).json({ error: 'Channel not found' });
const liveStreams = await db.getLiveStreamsByUserId(channel.user_id) || [];
++ // The live card's hover preview: Media's DVR playlist of the recording in progress (media-proxy/live-preview.js).
++ require('../media-proxy/live-preview').attach(liveStreams);
++ require('./live-thumb-cadence').markListed(liveStreams.map((x) => x.id));
for (const liveStream of liveStreams) {
// Use managed stream key from the JOIN, else fall back to user key
const lsKey = liveStream.managed_stream_key
@@@ -1198,6 +1204,8 @@ router.get('/', optionalAuth, async (req, res) => {
// viewer's prefs say. Every other surface shows it with its chips; this is the only place a label hides.
let streams = (await db.getLiveStreams()).filter((s) => !(s.managed_stream_id && insidePip.has(Number(s.managed_stream_id)))
&& !contentLabels.labelsOf(s).labels.includes('18+'));
++ require('../media-proxy/live-preview').attach(streams);
++ require('./live-thumb-cadence').markListed(streams.map((x) => x.id));
// Filters narrow the list in place, so its order (viewer_count DESC, started_at DESC) is untouched.
// A stream matches on its own tags or the AI's, without carrying either into the response.
if (wantedTags.length) {
ddiff --git a/test/live-thumb-cadence.test.js b/test/live-thumb-cadence.test.js
iindex 7dbce3a7..a30ee960 100644
---- a/test/live-thumb-cadence.test.js
++++ b/test/live-thumb-cadence.test.js
@@@ -1,10 +1,12 @@
/**
* Legacy parity (roadmap D20): live thumbnails refresh on a steady cadence.
*
-- * capture a browser broadcaster posts a frame about every 2 minutes (115 s, checked on its 30 s
-- * heartbeat, never from a hidden tab); the server's 60 s maintenance loop grabs one for
-- * RTMP, JSMPEG and WebRTC/WHIP streams whose thumbnail is 2 minutes old; writes closer
-- * than 15 s apart are dropped
++ * capture a browser broadcaster posts a frame on its 30 s heartbeat (never from a hidden tab);
++ * the server's 5 s live-picture tick grabs one for RTMP, JSMPEG and WebRTC/WHIP streams
++ * whose thumbnail is 30 s old, always for a stream with no picture yet (a first picture
++ * within 15 s of going live) and only while somebody watches (a viewer socket, Chat's
++ * viewer count, or the stream named in a public live list in the last 90 s); writes
++ * closer than 15 s apart are dropped
* show the home live grid re-reads the list at least once a minute and crossfades to the new
* file (each capture gets a new name); the broadcaster's RTMP/JSMPEG preview polls every
* 10 s
@@@ -69,10 +71,10 @@ const get = (p) => new Promise((resolve, reject) => {
const first = await liveThumbs.saveLiveThumbnail(sid, jpeg('one'));
assert.match(first, /^\/api\/thumbnails\/stream-\d+-\d+\.jpg$/);
assert.ok(!await liveThumbs.shouldRefreshLiveThumbnail(sid), 'a fresh thumbnail is not replaced');
-- age((await current()).filePath, 119_000);
-- assert.ok(!await liveThumbs.shouldRefreshLiveThumbnail(sid), 'still kept just under 2 minutes');
-- age((await current()).filePath, 121_000);
-- assert.ok(await liveThumbs.shouldRefreshLiveThumbnail(sid), 'replaced once 2 minutes old');
++ age((await current()).filePath, 25_000);
++ assert.ok(!await liveThumbs.shouldRefreshLiveThumbnail(sid), 'still kept just under 30 s');
++ age((await current()).filePath, 31_000);
++ assert.ok(await liveThumbs.shouldRefreshLiveThumbnail(sid), 'replaced once 30 s old');
age((await current()).filePath, 5_000);
assert.strictEqual(await liveThumbs.saveLiveThumbnail(sid, jpeg('two')), first, 'writes closer than 15 s apart are dropped');
age((await current()).filePath, 16_000);
@@@ -81,7 +83,7 @@ const get = (p) => new Promise((resolve, reject) => {
const second = await liveThumbs.saveLiveThumbnail(sid, jpeg('two'));
assert.notStrictEqual(second, first, 'a new capture gets a new file name (cards and caches see a new URL)');
assert.ok(!fs.existsSync(oldFile), 'and the previous file is removed');
-- console.log('OK capture: 2-minute refresh, 15 s write floor, new name per capture');
++ console.log('OK capture: 30 s refresh, 15 s write floor, new name per capture');
// ── the stable name the broadcaster preview polls ──
let r = await get(`/api/thumbnails/stream-${sid}-live.jpg?t=1`);
@@@ -119,9 +121,23 @@ const get = (p) => new Promise((resolve, reject) => {
const loop = index.slice(index.indexOf('const maintenanceInterval = setInterval('), index.indexOf("if (typeof maintenanceInterval.unref === 'function')"));
assert.match(loop, /\}, 60000\);\s*$/, 'the maintenance loop runs every minute');
assert.match(loop, /liveThumbs\.cleanupOldThumbnails\(\);/);
-- assert.match(loop, /shouldRefreshLiveThumbnail\(rs\.id, 120000\)[\s\S]*generateLiveStreamThumbnail\(rs\.id, rs\.stream_key, \{ minAgeMs: 120000 \}\)/, 'RTMP: every 2 minutes');
-- assert.match(loop, /shouldRefreshLiveThumbnail\(js\.id, 120000\)[\s\S]*generateJSMPEGThumbnail/, 'JSMPEG: every 2 minutes');
-- assert.match(loop, /shouldRefreshLiveThumbnail\(wsStream\.id, 120000\)[\s\S]*generateWebrtcThumbnail\(wsStream\.id, \{ minAgeMs: 120000 \}\)/, 'WebRTC/WHIP: every 2 minutes, from the SFU');
++ assert.ok(!/generateWebrtcThumbnail|generateLiveStreamThumbnail|generateJSMPEGThumbnail/.test(loop), 'the 2-minute grabs are gone from the maintenance loop');
++ assert.match(index, /require\('\.\/streaming\/live-thumb-cadence'\)\.tick\(\)/, 'the live-picture timer calls the cadence tick');
++ assert.match(index, /\}, require\('\.\/streaming\/live-thumb-cadence'\)\.TICK_MS\)/, 'on its own 5 s interval');
++
++ // The cadence itself: first picture at once, 30 s while watched, nothing while nobody looks, 15 s between attempts.
++ const cadence = require('../server/streaming/live-thumb-cadence');
++ assert.strictEqual(cadence.TICK_MS, 5000);
++ assert.strictEqual(cadence.PICTURE_INTERVAL_MS, 30000);
++ assert.strictEqual(cadence.shouldGrab({ hasPicture: false, ageMs: Infinity, watched: false, sinceAttemptMs: Infinity }), true, 'a first picture does not wait for a viewer');
++ assert.strictEqual(cadence.shouldGrab({ hasPicture: true, ageMs: 10_000, watched: true, sinceAttemptMs: Infinity }), false, 'a fresh picture is kept');
++ assert.strictEqual(cadence.shouldGrab({ hasPicture: true, ageMs: 31_000, watched: true, sinceAttemptMs: Infinity }), true, 'a 30 s old picture is refreshed for a viewer');
++ assert.strictEqual(cadence.shouldGrab({ hasPicture: true, ageMs: 31_000, watched: false, sinceAttemptMs: Infinity }), false, 'and left alone for nobody');
++ assert.strictEqual(cadence.shouldGrab({ hasPicture: true, ageMs: 31_000, watched: true, sinceAttemptMs: 5_000 }), false, 'a grab that just started is not doubled');
++ assert.strictEqual(cadence.isWatched({ viewerCount: 2 }), true, 'Chat’s viewer count is a watcher');
++ assert.strictEqual(cadence.isWatched({ socketCount: 1 }), true, 'so is a player socket');
++ assert.strictEqual(cadence.isWatched({ listedAt: Date.now() - 30_000 }), true, 'and a stream on the home/list pages a moment ago');
++ assert.strictEqual(cadence.isWatched({ listedAt: Date.now() - 120_000 }), false, 'but not an old list');
const state = read('public/js/broadcast-state.js');
const bc = read('public/js/broadcast.js');
@@@ -146,6 +162,23 @@ const get = (p) => new Promise((resolve, reject) => {
assert.match(home, /_crossfadeThumb\(img, s\.thumbnail_url\);/, 'and crossfade to the new thumbnail');
console.log('OK the capture and display loops keep that cadence');
++ // ── the live preview and the live-picture fallback (plan 355) ──
++ const livePreview = require('../server/media-proxy/live-preview');
++ const empty = { activeRecordings: new Map() };
++ assert.strictEqual(livePreview.previewUrlFor(sid, { recorder: empty }), null, 'no recording: no live preview');
++ const recording = { activeRecordings: new Map([[sid, { vodId: 42, protocol: 'webrtc' }]]) };
++ assert.match(livePreview.previewUrlFor(sid, { recorder: recording }), /\/v\/42\/dvr\.m3u8$/, 'a recording in progress: the DVR playlist');
++ assert.strictEqual(livePreview.previewUrlFor(sid, { recorder: { activeRecordings: new Map([[sid, { vodId: 42, protocol: 'jsmpeg' }]]) } }), null, 'JSMPEG has no DVR');
++ const attached = livePreview.attach([{ id: sid }], { recorder: recording });
++ assert.match(attached[0].preview_url, /\/v\/42\/dvr\.m3u8$/, 'the stream row carries it');
++
++ const fallback = require('../server/media-proxy/live-picture-fallback');
++ await liveThumbs.saveLiveThumbnail(sid, jpeg('four')); // the clean-up above removed the last one
++ const currentUrl = (await db.get('SELECT thumbnail_url FROM streams WHERE id = ?', [sid])).thumbnail_url;
++ assert.strictEqual(fallback.livePictureFile(currentUrl), path.join(liveThumbs.THUMB_DIR, path.basename(currentUrl)),
++ 'the live stream’s current picture is a local file');
++ assert.strictEqual(fallback.livePictureFile('https://openvibe.media/t/vod-9-1.jpg'), null, 'a Media URL is not a live picture');
++
server.close();
console.log('\n✅ live thumbnail cadence checks passed');
process.exit(0);