openvibers/openvibe.live Commits
A stream recorded in parts is one video: Live names the parts to Media, the first part's VOD answers the whole (length, joined playlist, every part's transcript and chapters in its time), lists show the whole length, clips in the video's time (#385)
Changes
@@@ -219,7 +219,7 @@ async function recapItem(r) {const SOURCES = {vods: {async load(offset, n, { sort, since }) {-- const r = await media.listVods({ limit: n, offset, order: sort === 'top' ? 'views' : 'newest', since: since || undefined }, { timeoutMs: SOURCE_TIMEOUT_MS });++ const r = await media.listVods({ limit: n, offset, order: sort === 'top' ? 'views' : 'newest', since: since || undefined }, { whole: true, timeoutMs: SOURCE_TIMEOUT_MS });return { rows: mediaRows(r, 'vods'), total: mediaTotal(r) };},item: vodItem,@@@ -58,7 +58,7 @@ const MEDIA_POOL_TTL_MS = 30_000;async function _mediaContent() {if (Date.now() - _mediaPool.at < MEDIA_POOL_TTL_MS) return _mediaPool;const [vods, clips, pastes] = await Promise.all([-- media.listVods({ limit: 12 }).then(r => r?.vods || (Array.isArray(r) ? r : [])).catch(() => _mediaPool.vods),++ media.listVods({ limit: 12 }, { whole: true }).then(r => r?.vods || (Array.isArray(r) ? r : [])).catch(() => _mediaPool.vods),media.listClips({ limit: 12 }).then(r => r?.clips || (Array.isArray(r) ? r : [])).catch(() => _mediaPool.clips),require('../pastes-client').listPastes({ limit: 12, visibility: 'public' }).then(r => r?.pastes || (Array.isArray(r) ? r : [])).catch(() => _mediaPool.pastes),]);@@@ -1458,6 +1458,8 @@ async function start() {try { require('./ai/ai-moments-job').start(); } catch (e) { console.warn('[AI] moments job not started:', e.message); }try { require('./ai/auto-clip-job').start(); } catch (e) { console.warn('[AI] auto-clip job not started:', e.message); }try { require('./ai/clip-title').start(); } catch (e) { console.warn('[AI] clip-title job not started:', e.message); }++ // A stream recorded in parts is one video on Media (streaming/recordings.js): synced, closed, backfilled.++ try { require('./streaming/recordings').start(); } catch (e) { console.warn('[Recordings] sweep not started:', e.message); }try { require('./ai/easter-egg-job').start(); } catch (e) { console.warn('[AI] easter-egg job not started:', e.message); }// The Rundown's desk replaced the Arena's jobs (the drama desk, the beef listener and judges, 2026-10-19).try { require('./rundown/desk').start(); } catch (e) { console.warn('[Rundown] desk not started:', e.message); }@@@ -215,9 +215,27 @@ async function signedMediaUrl(kind, id, ttlS = 3600, opts = {}) {return await request('GET', `/${kind === 'clip' ? 'clips' : 'vods'}/${id}/signed-url`, { query: { ttl: ttlS }, ...opts });}--/** GET /vods?limit&offset (+ pass-through filters like username/user_id/stream_id) */++/**++ * GET /vods?limit&offset (+ pass-through filters like username/user_id/stream_id). Media lists a stream recorded in parts++ * once, by its first part, which names the recording; `whole: true` (lists people see) gives that card the whole++ * video's length (`part_duration_seconds` keeps the part's). Jobs that work inside a part's file leave it off.++ */async function listVods(query = {}, opts = {}) {-- return await request('GET', '/vods', { query, ...opts });++ const { whole = false, ...rest } = opts;++ const out = await request('GET', '/vods', { query, ...rest });++ if (whole && out && Array.isArray(out.vods)) out.vods.forEach(wholeVideo);++ return out;++}++++/** A listed first part of a recording, made to read as the whole video (its length). */++function wholeVideo(v) {++ const r = v && v.recording;++ if (r && Number(r.parts) > 1 && Number(r.duration_ms) > 0 && Number(r.first_vod_id) === Number(v.id)) {++ v.part_duration_seconds = v.duration_seconds;++ v.duration_seconds = Math.round(Number(r.duration_ms) / 1000);++ v.duration = v.duration_seconds;++ }++ return v;}/** PUT /vods/:id { title?, is_public?, visibility?, ... } — metadata update (inherited shape). */@@@ -232,6 +250,25 @@ async function deleteVod(vodId, opts = {}) {return await request('DELETE', `/vods/${vodId}`, opts);}++// ── Recordings (OpenVibe.Media docs/recordings.md) ──────────────────────────++// A stream recorded in parts is one video: Media joins the parts' timelines, lists the first part for the whole and++// lets a clip cross a join. Live names the parts (server/streaming/recordings.js).++++/** PUT /recordings/:key { parts: [{ vod_id, gap_before_ms, stream_offset_ms }], title?, state? } → the recording, or { recording: null } */++async function putRecording(key, body, opts = {}) {++ return await request('PUT', `/recordings/${encodeURIComponent(key)}`, { body, ...opts });++}++++/** GET /recordings/:key → { id, key, state, duration_ms, vod_id, parts[], hls_url? } (404 when none) */++async function getRecording(key, opts = {}) {++ return await request('GET', `/recordings/${encodeURIComponent(key)}`, opts);++}++++/** POST /recordings/:key/close: the stream ended, no more parts. */++async function closeRecording(key, opts = {}) {++ return await request('POST', `/recordings/${encodeURIComponent(key)}/close`, opts);++}++// ── Clips ────────────────────────────────────────────────────────────────────/** POST /clips { vod_id, start_s, end_s, title?, user_id? } → { id, status } */@@@ -426,7 +463,9 @@ module.exports = {// vodscreateVod, ingestRtmp, ingestRtpStart, ingestRtpStop,uploadVodChunk, finalizeVod,-- getVod, listVods, updateVod, deleteVod,++ getVod, listVods, wholeVideo, updateVod, deleteVod,++ // recordings++ putRecording, getRecording, closeRecording,// clipscreateClip, getClip, listClips, updateClip, deleteClip, recutClip, trimClip, getJob,// files + thumbnails@@@ -171,7 +171,7 @@ async function vodsForManagedStream(managedStreamId) {*/async function userVods(userId, { includePrivate = false, limit = 20 } = {}) {let rows;-- try { rows = rowsOf(await media.listVods({ user_id: userId, include_private: includePrivate ? 1 : undefined, limit }, { timeoutMs: TIMEOUT_MS }), 'vods'); }++ try { rows = rowsOf(await media.listVods({ user_id: userId, include_private: includePrivate ? 1 : undefined, limit }, { timeoutMs: TIMEOUT_MS, whole: true }), 'vods'); }catch { return []; }const keep = rows.filter((v) => finished(v) && (includePrivate || isPublic(v)));return Promise.all(keep.map(async (v) => withAiState(v, await aiState(db.getVodAiState, v.id))));@@@ -348,7 +348,7 @@ router.get('/:id/live-info', optionalAuth, async (req, res) => {router.get('/', optionalAuth, async (req, res) => {try {-- const out = await media.listVods(await usernameToUserId(req.query));++ const out = await media.listVods(await usernameToUserId(req.query), { whole: true });const vods = (await Promise.all((out?.vods || (Array.isArray(out) ? out : [])).filter(v => !isGhostVod(v)).map(withUserFields)));res.json({vods,@@@ -431,6 +431,15 @@ router.get('/:id/memories', optionalAuth, async (req, res) => {description: db.publicMemoryText(m.description),tags: m.tags ? (() => { try { return JSON.parse(m.tags); } catch { return []; } })() : [],}));++ // The first part of a joined recording answers for the whole video: every moment that was recorded, in the++ // video's time (streaming/recordings.js; the gaps between parts were not).++ if (vod.recording && Number(vod.recording.vod_id) === Number(vod.id) && vod.recording.hls_url && (vod.recording.parts || []).length > 1) {++ const recordings = require('../streaming/recordings');++ const parts = await recordings.presentParts(vod.recording);++ memories = memories.map((m) => ({ ...m, offset_seconds: recordings.toRecordingTime(parts, m.offset_seconds) }))++ .filter((m) => m.offset_seconds != null).map((m) => ({ ...m, offset_seconds: Math.round(m.offset_seconds) }));++ return res.json({ memories });++ }// A VOD that is one part of its stream (the broadcaster reconnected): the moments inside it, in its own time.// Its chapters (OpenVibe.Video) pointed eleven minutes late on part 2 of stream 2795.const part = await require('../streaming/stream-history').partForVod(vod.id);@@@ -577,6 +586,9 @@ router.get('/:id', optionalAuth, async (req, res) => {}} catch { /* */ }++ // One video over a stream recorded in parts (streaming/recordings.js; OpenVibe.Media docs/recordings.md).++ try { await presentRecording(vod); } catch (err) { console.warn('[VOD] recording:', err.message); }++const clips = co?.clips || (Array.isArray(co) ? co : []);try {if (inserted && inserted.changes > 0) {@@@ -872,6 +884,9 @@ router.post('/clips', requireAuth, memUpload.single('video'), async (req, res) =user_id: req.user.id,stream_id: effStreamId || undefined,visibility: effStream ? await db.resolveStreamClipVisibility(effStream) : 'public',++ // Times in the whole video of a stream recorded in parts (its watch page plays them as one): Media places++ // the clip on the recording, across a join too (docs/recordings.md).++ ...(['1', 'true', true, 1].includes(req.body && req.body.recording_time) ? { recording_time: 1 } : {}),});noteClip(req.user.id);await _notifyStreamerOfClip(effStreamId, req.user, sanitizedTitle, clip?.id);@@@ -908,6 +923,52 @@ async function clipPlace(stream, startTime, endTime) {* The speech heard while a VOD's stream was live (the timeline rows stamped with this VOD), as transcript segments in* the VOD's own time ([{ start, end, text }]); [] when there are none.*/++/**++ * A VOD that is part of a recording (Media's answer names it): its first part answers for the whole video once Media has++ * joined the parts (`recording.joined`, an `hls_url` over all of them):++ * - the whole length (`duration_seconds`; the part's own stays in `part_duration_seconds`) and the joined playlist;++ * - `recording.parts` [{ vod_id, start_sec, duration_sec, gap_before_sec, stream_offset_sec }] for chat replay and links;++ * - the transcript of every part in the video's time (each part's full pass, else its live captions).++ * Until then, and for every later part, it answers as a part with `recording` naming the whole (`vod_id` of its first++ * part and `start_sec`, where this part starts in it), so a page can send people to the whole video at that second.++ */++async function presentRecording(vod) {++ const rec = vod.recording;++ if (!rec || !Array.isArray(rec.parts) || rec.parts.length < 2) { delete vod.recording; return; }++ const recordings = require('../streaming/recordings');++ const parts = await recordings.presentParts(rec);++ const mine = parts.find((p) => p.vod_id === Number(vod.id)) || null;++ const joined = !!rec.hls_url;++ const first = Number(rec.vod_id) === Number(vod.id);++ vod.recording = { id: rec.id, vod_id: Number(rec.vod_id), joined, duration_seconds: Math.round(Number(rec.duration_ms) / 1000),++ start_sec: mine ? mine.start_sec : 0, parts };++ if (!first || !joined) return;++ vod.part_duration_seconds = vod.duration_seconds;++ vod.duration_seconds = vod.recording.duration_seconds;++ vod.duration = vod.recording.duration_seconds;++ vod.hls_url = rec.hls_url;++ if (rec.hls_expires_at) vod.hls_expires_at = rec.hls_expires_at;++ delete vod.part;++ const segs = [];++ let live = false;++ for (const p of parts) {++ let rows = [];++ const st = Number(p.vod_id) === Number(vod.id) ? null : await db.getVodAiState(p.vod_id).catch(() => null);++ const own = Number(p.vod_id) === Number(vod.id) && vod.transcript_source !== 'live' ? vod.ai_transcript_json : (st && st.ai_transcript_json);++ try { rows = own ? JSON.parse(own) : []; } catch { rows = []; }++ if (!rows.length) { rows = await liveTranscript(p.vod_id); if (rows.length) live = true; }++ for (const r of rows) {++ if (!r || !String(r.text || '').trim()) continue;++ segs.push({ start: Math.round((p.start_sec + Number(r.start || 0)) * 100) / 100, end: r.end == null ? null : Math.round((p.start_sec + Number(r.end)) * 100) / 100, text: String(r.text).trim() });++ }++ }++ if (segs.length) {++ vod.ai_transcript_json = JSON.stringify(segs);++ vod.ai_transcript = segs.map((x) => x.text).join(' ');++ if (live) vod.transcript_source = 'live';++ }++}++async function liveTranscript(vodId) {const rows = (await db.getTimelineByVod(vodId)).filter((r) => r.kind === 'speech' && String(r.text || '').trim());if (!rows.length) return [];@@@ -38,7 +38,7 @@ async function _cached(key, fn) {if (_mc.size > 300) _mc.delete(_mc.keys().next().value);return v;}--const _vodList = async (limit, offset = 0) => await _cached(`vl:${limit}:${offset}`, async () => (await media.listVods({ limit, offset }))?.vods || []);++const _vodList = async (limit, offset = 0) => await _cached(`vl:${limit}:${offset}`, async () => (await media.listVods({ limit, offset }, { whole: true }))?.vods || []);// People's clips and pastes: the AI's are listed on /moments (server/content/feed.js).const _clipList = async (limit, offset = 0) => await _cached(`cl:${limit}:${offset}`, async () => (await media.listClips({ limit, offset, auto_generated: 0 }))?.clips || []);const _pasteList = async (limit, offset = 0) => await _cached(`pl:${limit}:${offset}`, async () => (await require('../pastes-client').listPastes({ limit, offset, visibility: 'public', origin: 'user' }))?.pastes || []);@@@ -272,7 +272,7 @@ async function _channelMeta(username, page = 1) {const track = { partial: false };const offset = (page - 1) * CHANNEL_PAGE_SIZE;const [vr, cr, ar] = acct.banned ? [null, null, null] : await Promise.all([-- _listWithin(`chv:${acct.userId}:${offset}`, async () => await media.listVods({ user_id: acct.userId, order: 'newest', limit: CHANNEL_PAGE_SIZE, offset }, { timeoutMs: 5000 }), track),++ _listWithin(`chv:${acct.userId}:${offset}`, async () => await media.listVods({ user_id: acct.userId, order: 'newest', limit: CHANNEL_PAGE_SIZE, offset }, { timeoutMs: 5000, whole: true }), track),page === 1 ? _listWithin(`chc:${acct.userId}`, async () => await media.listClips({ channel_user_id: acct.userId, auto_generated: 0, limit: 8 }, { timeoutMs: 5000 }), track) : null,page === 1 ? _listWithin(`cha:${acct.userId}`, async () => await media.listClips({ channel_user_id: acct.userId, auto_generated: 1, limit: 6 }, { timeoutMs: 5000 }), track) : null,]);@@@ -660,7 +660,7 @@ async function _pasteMeta(slug) {/** The VOD recorded from a stream (the longest public one), for items that know only their stream. */const _vodOfStream = async (streamId) => await _cached(`vs:${streamId}`, async () => {-- const r = await media.listVods({ stream_id: streamId, limit: 3 });++ const r = await media.listVods({ stream_id: streamId, limit: 3 }, { whole: true });const rows = (r && r.vods) || [];return rows.filter((v) => v && !isPrivate(v) && Number(v.is_public) !== 0).sort((a, b) => (Number(b.duration_seconds ?? b.duration) || 0) - (Number(a.duration_seconds ?? a.duration) || 0))[0] || null;nnew file mode 100644@@@ -0,0 +1,123 @@++'use strict';++/**++ * A stream recorded in parts is one video (owner, 2026-10-27: "instead of 'The stream was recorded in 2 parts: Part 1 ·++ * Part 2' it should automatically just have them as a single vod"; OpenVibe.Media docs/recordings.md).++ *++ * The broadcaster dropping and coming back, or a recorder restart, makes a new VOD per session (stream 2795: VODs 6025++ * and 6026). Live knows which VODs are one stream's parts and where each starts in stream time (stream_history), so it++ * tells Media: PUT /recordings/stream:<id> with the parts in order, the gap before each and its stream offset. Media then++ * plays them as one video (one HLS playlist over the parts' segments), lists only the first part, which names the++ * recording, and lets a clip cross a join. A stream with one part sends nothing: that VOD is the video.++ *++ * sync(streamId, { close }) → Media's recording (or null): from the stream's history, idempotent; close: the stream ended++ * soon(streamId) sync a few seconds after a part started or ended (several changes, one call)++ * sweep() the streams of the last two days with two or more parts: synced, and closed once ended++ * (also the backfill of streams recorded in parts before this existed)++ * start() the sweep every SWEEP_MS (server/index.js's normal boot, so never under LIVE_DRILL)++ * toRecordingTime(parts, s) / toStreamTime(parts, s) stream time ↔ the video's time (gaps between parts skipped)++ * presentParts(recording) Media's parts as Live answers them: start_sec, duration_sec, stream_offset_sec++ *++ * Never throws: a recording that is not joined yet plays as its parts did before.++ */++const db = require('../db/database');++const history = require('./stream-history');++++const SOON_MS = 5000;++const SWEEP_MS = 10 * 60 * 1000;++const SWEEP_DAYS = 2;++const keyOf = (streamId) => `stream:${Number(streamId)}`;++const pending = new Map(); // streamId → timer++++/** The parts to send: the stream's recording parts with a VOD, in stream time order, each once. */++async function partsOf(streamId) {++ const { parts } = await history.forStream(streamId);++ const seen = new Set();++ const out = [];++ let prevEnd = null;++ for (const p of parts) {++ if (!p.vod_id || seen.has(p.vod_id)) continue;++ seen.add(p.vod_id);++ const gap = prevEnd != null && p.at > prevEnd ? Math.round((p.at - prevEnd) * 1000) : 0;++ out.push({ vod_id: p.vod_id, gap_before_ms: gap, stream_offset_ms: Math.round(p.at * 1000) });++ prevEnd = p.end != null ? p.end : prevEnd;++ }++ return out;++}++++async function sync(streamId, { close = false } = {}) {++ try {++ const parts = await partsOf(streamId);++ if (parts.length < 2) return null;++ const media = require('../media-client');++ const stream = await db.getStreamById(streamId).catch(() => null);++ const rec = await media.putRecording(keyOf(streamId), { parts, ...(stream && stream.title ? { title: String(stream.title).slice(0, 200) } : {}), ...(close ? { state: 'closed' } : {}) });++ return rec && rec.id ? rec : null;++ } catch (err) {++ console.warn(`[Recordings] stream ${streamId}: ${err.message}`);++ return null;++ }++}++++function soon(streamId) {++ if (!streamId || require('../drill').enabled) return;++ clearTimeout(pending.get(streamId));++ const t = setTimeout(() => { pending.delete(streamId); sync(streamId).catch(() => {}); }, SOON_MS);++ if (t.unref) t.unref();++ pending.set(streamId, t);++}++++async function sweep() {++ let rows = [];++ try {++ rows = await db.all(`SELECT h.stream_id, MAX(CASE WHEN s.is_live = 1 THEN 1 ELSE 0 END) AS live++ FROM stream_history h JOIN streams s ON s.id = h.stream_id++ WHERE h.kind = 'part' AND h.vod_id IS NOT NULL AND h.created_at >= ?++ GROUP BY h.stream_id HAVING COUNT(DISTINCT h.vod_id) >= 2 ORDER BY h.stream_id DESC LIMIT 200`,++ [new Date(Date.now() - SWEEP_DAYS * 86400000).toISOString().replace('T', ' ').slice(0, 19)]);++ } catch (err) { console.warn('[Recordings] sweep:', err.message); return 0; }++ let n = 0;++ for (const r of rows) if (await sync(r.stream_id, { close: !Number(r.live) })) n++;++ return n;++}++++// ── Time (the one conversion, Media docs/recordings.md §2) ──++// parts: [{ start_sec (recording time), duration_sec, stream_offset_sec }]. The gaps between parts are not in the video.++++/** Stream seconds → seconds into the recording, or null when that moment fell between parts (not recorded). */++function toRecordingTime(parts, streamSec) {++ const s = Number(streamSec);++ for (const p of parts || []) {++ if (p.stream_offset_sec == null) continue;++ if (s >= p.stream_offset_sec && s <= p.stream_offset_sec + p.duration_sec) return Math.round((p.start_sec + s - p.stream_offset_sec) * 100) / 100;++ }++ return null;++}++++/** Seconds into the recording → stream seconds (null when the part's place in the stream is unknown). */++function toStreamTime(parts, recSec) {++ const t = Number(recSec);++ const list = parts || [];++ const p = list.find((x) => t >= x.start_sec && t < x.start_sec + x.duration_sec) || list[list.length - 1];++ if (!p || p.stream_offset_sec == null) return null;++ return Math.round((p.stream_offset_sec + t - p.start_sec) * 100) / 100;++}++++/** Media's recording parts (ms) as Live answers them (s), each with its stream offset (Media's, else the history's). */++async function presentParts(rec) {++ const parts = (rec && Array.isArray(rec.parts) ? rec.parts : []);++ const offsets = await history.vodOffsets(parts.map((p) => p.vod_id));++ return parts.map((p) => ({++ vod_id: Number(p.vod_id), start_sec: Number(p.start_ms) / 1000, duration_sec: Number(p.duration_ms) / 1000,++ gap_before_sec: Number(p.gap_before_ms || 0) / 1000,++ stream_offset_sec: p.stream_offset_ms != null ? Number(p.stream_offset_ms) / 1000 : (offsets.has(Number(p.vod_id)) ? offsets.get(Number(p.vod_id)) : null),++ }));++}++++let started = false;++function start() {++ if (started || require('../drill').enabled) return;++ started = true;++ require('../utils/jobs').every('recordings-sweep', SWEEP_MS, sweep, { initialDelayMs: 90 * 1000, jitterMs: 30 * 1000 });++}++++module.exports = { sync, soon, sweep, start, partsOf, keyOf, toRecordingTime, toStreamTime, presentParts };@@@ -81,7 +81,10 @@ async function partStarted(stream, { vodId, part = 1, why = null, atMs = Date.noconst at = streamSeconds(stream, atMs);if (at == null) return false;entry(stream.id).started = epochMs(stream.started_at);-- return add({ stream_id: stream.id, kind: 'part', at_sec: at, vod_id: vodId, detail: { part, ...(why ? { why } : {}) } });++ const ok = await add({ stream_id: stream.id, kind: 'part', at_sec: at, vod_id: vodId, detail: { part, ...(why ? { why } : {}) } });++ // A second part: the stream's parts become one video on Media (recordings.js).++ if (ok && part >= 2) require('./recordings').soon(stream.id);++ return ok;}async function partEnded(streamId, vodId, atMs = Date.now()) {@@@ -91,6 +94,7 @@ async function partEnded(streamId, vodId, atMs = Date.now()) {try {await db.run(`UPDATE stream_history SET end_sec = ? WHERE stream_id = ? AND kind = 'part' AND vod_id = ? AND end_sec IS NULL`,[Math.max(0, Math.round((atMs - start) / 100) / 10), streamId, vodId]);++ require('./recordings').soon(streamId); // the gap before the next part is known nowreturn true;} catch { return false; }}nnew file mode 100644@@@ -0,0 +1,159 @@++/**++ * A stream recorded in parts is one video (owner, 2026-10-27: "instead of 'The stream was recorded in 2 parts: Part 1 ·++ * Part 2' it should automatically just have them as a single vod"; OpenVibe.Media docs/recordings.md).++ *++ * - streaming/recordings.js sends Media the stream's parts from its history (in order, each one's stream offset and the++ * gap before it) as PUT /recordings/stream:<id>; one part sends nothing; the sweep closes an ended stream's recording.++ * - toRecordingTime / toStreamTime: the video's time skips the gap between parts.++ * - GET /api/vods/:id for the first part of a joined recording answers the whole video: its length, the joined playlist,++ * the parts, every part's transcript in the video's time; its memories (chapters) are every part's, in the video's++ * time. A later part says where it starts in the whole. Before Media joined them, both answer as parts.++ * - Lists people see give the first part the whole video's length (media.listVods whole: true).++ *++ * node test/recordings-live.test.js++ */++'use strict';++const assert = require('assert');++const http = require('http');++++process.env.NODE_ENV = 'test';++const quiet = console.log;++console.log = (...a) => { if (!/^\[/.test(String(a[0]))) quiet(...a); };++console.warn = () => {};++++let failures = 0;++async function check(name, fn) {++ try { await fn(); quiet(' ✓', name); }++ catch (e) { failures++; quiet(' ✗', name, '\n ', e.stack || e.message); }++}++++(async () => {++ const db = require('../server/db/database');++ await db.initDb();++ const raw = db.getDb();++ const auth = require('../server/auth/auth');++ auth.optionalAuth = async (req, res, next) => next();++ await raw.prepare(`INSERT INTO users (id, username, display_name, email, password_hash, role, created_at) OVERRIDING SYSTEM VALUE++ VALUES (90, 'maticus', 'Maticus', 'm@x', 'x', 'user', '2025-01-01 00:00:00')`).run();++ await db.ensureChannel(90);++ const chan = await db.getChannelByUserId(90);++ const sid = Number((await db.createStream({ user_id: 90, channel_id: chan.id, title: 'BIRD DRAMA', protocol: 'webrtc' })).lastInsertRowid);++ const startMs = Math.floor(Date.now() / 1000) * 1000 - 2000 * 1000;++ const iso = (ms) => new Date(ms).toISOString().slice(0, 19).replace('T', ' ');++ await raw.prepare('UPDATE streams SET started_at = ?, ended_at = ?, is_live = 0 WHERE id = ?').run(iso(startMs), iso(startMs + 1930000), sid);++++ const media = require('../server/media-client');++ const puts = [];++ media.putRecording = async (key, body) => { puts.push({ key, body }); return { id: 'med_REC', key, parts: body.parts }; };++ const history = require('../server/streaming/stream-history');++ const recordings = require('../server/streaming/recordings');++ const stream = await db.getStreamById(sid);++ await history.partStarted(stream, { vodId: 7025, part: 1, atMs: startMs + 28000 });++ await history.partEnded(sid, 7025, startMs + 706000);++++ await check('one part: nothing is sent (that VOD is the video)', async () => {++ assert.strictEqual(await recordings.sync(sid), null);++ assert.strictEqual(puts.length, 0);++ });++++ await history.partStarted(stream, { vodId: 7026, part: 2, atMs: startMs + 709000 });++ await history.partEnded(sid, 7026, startMs + 1930000);++++ await check('two parts: PUT /recordings/stream:<id> with the parts in order, offsets and the gap', async () => {++ const rec = await recordings.sync(sid);++ assert.strictEqual(rec.id, 'med_REC');++ const put = puts.at(-1);++ assert.strictEqual(put.key, `stream:${sid}`);++ assert.deepStrictEqual(put.body.parts, [++ { vod_id: 7025, gap_before_ms: 0, stream_offset_ms: 28000 },++ { vod_id: 7026, gap_before_ms: 3000, stream_offset_ms: 709000 },++ ]);++ assert.strictEqual(put.body.title, 'BIRD DRAMA');++ assert.ok(!('state' in put.body), 'open while it may go on');++ await recordings.sweep();++ assert.strictEqual(puts.at(-1).body.state, 'closed', 'the sweep closes an ended stream\'s recording');++ });++++ const parts = [{ vod_id: 7025, start_sec: 0, duration_sec: 678, stream_offset_sec: 28 }, { vod_id: 7026, start_sec: 678, duration_sec: 1221, stream_offset_sec: 709 }];++ await check('stream time ↔ the video\'s time, the gap skipped', () => {++ assert.strictEqual(recordings.toRecordingTime(parts, 100), 72);++ assert.strictEqual(recordings.toRecordingTime(parts, 900), 869);++ assert.strictEqual(recordings.toRecordingTime(parts, 707.5), null, 'between parts: not recorded');++ assert.strictEqual(recordings.toStreamTime(parts, 869), 900);++ assert.strictEqual(recordings.toStreamTime(parts, 10), 38);++ });++++ // The VOD route over Media's answer.++ for (const [off, text] of [[100, 'birds in part one'], [900, 'robot in part two']]) {++ await raw.prepare('INSERT INTO stream_memories (stream_id, user_id, offset_seconds, description) VALUES (?, 90, ?, ?)').run(sid, off, text);++ }++ await raw.prepare(`INSERT INTO vod_ai_state (vod_id, ai_transcript_json) VALUES (7025, ?), (7026, ?)`)++ .run(JSON.stringify([{ start: 10, end: 12, text: 'hello from part one' }]), JSON.stringify([{ start: 5, end: 7, text: 'and part two' }]));++ let joined = true;++ const REC = () => ({ id: 'med_REC', key: `stream:${sid}`, vod_id: 7025, duration_ms: 1899000, ...(joined ? { hls_url: 'https://openvibe.media/o/med_REC/master.m3u8' } : {}),++ parts: [{ seq: 1, vod_id: 7025, start_ms: 0, duration_ms: 678000, gap_before_ms: 0, stream_offset_ms: 28000 },++ { seq: 2, vod_id: 7026, start_ms: 678000, duration_ms: 1221000, gap_before_ms: 3000, stream_offset_ms: 709000 }] });++ const VODS = {++ 7025: { id: 7025, user_id: 90, stream_id: sid, title: 'BIRD DRAMA', visibility: 'public', is_public: 1, status: 'ready', duration_seconds: 678 },++ 7026: { id: 7026, user_id: 90, stream_id: sid, title: 'BIRD DRAMA (Part 2)', visibility: 'public', is_public: 1, status: 'ready', duration_seconds: 1221 },++ };++ media.getVod = async (id) => ({ ...VODS[Number(id)], recording: { ...REC(), this_part: REC().parts.find((p) => p.vod_id === Number(id)) } });++ media.listClips = async () => ({ clips: [] });++ const express = require('express');++ const app = express();++ app.use(express.json());++ app.use('/api/vods', require('../server/media-proxy/vods'));++ const server = http.createServer(app).listen(0);++ if (!server.listening) await new Promise((r) => server.once('listening', r));++ let ip = 0;++ const call = (p) => new Promise((resolve, reject) => {++ http.get({ port: server.address().port, path: p, headers: { 'cf-connecting-ip': `10.3.0.${++ip}` } }, (res) => {++ let text = '';++ res.on('data', (c) => { text += c; });++ res.on('end', () => { let json = null; try { json = JSON.parse(text); } catch { /* */ } resolve({ status: res.statusCode, json, text }); });++ }).on('error', reject);++ });++++ await check('the first part of a joined recording answers the whole video', async () => {++ const r = await call('/api/vods/7025');++ assert.strictEqual(r.status, 200, r.text);++ const v = r.json.vod;++ assert.deepStrictEqual([v.duration_seconds, v.part_duration_seconds, v.hls_url], [1899, 678, 'https://openvibe.media/o/med_REC/master.m3u8']);++ assert.deepStrictEqual([v.recording.id, v.recording.joined, v.recording.vod_id, v.recording.parts.length], ['med_REC', true, 7025, 2]);++ assert.deepStrictEqual(v.recording.parts[1], { vod_id: 7026, start_sec: 678, duration_sec: 1221, gap_before_sec: 3, stream_offset_sec: 709 });++ assert.deepStrictEqual(JSON.parse(v.ai_transcript_json).map((s) => [s.start, s.text]), [[10, 'hello from part one'], [683, 'and part two']], 'every part, in the video\'s time');++ assert.ok(!('part' in v), 'not "part 1" any more');++ });++++ await check('its chapters are every part\'s, in the video\'s time', async () => {++ const m = await call('/api/vods/7025/memories');++ assert.deepStrictEqual(m.json.memories.map((x) => [x.offset_seconds, x.description]), [[72, 'birds in part one'], [869, 'robot in part two']]);++ });++++ await check('a later part says where it starts in the whole', async () => {++ const v = (await call('/api/vods/7026')).json.vod;++ assert.deepStrictEqual([v.recording.vod_id, v.recording.start_sec, v.duration_seconds], [7025, 678, 1221], 'its own length, and where to send people');++ });++++ await check('before Media joined them, the first part answers as a part', async () => {++ joined = false;++ const v = (await call('/api/vods/7025')).json.vod;++ assert.deepStrictEqual([v.duration_seconds, v.recording.joined, v.hls_url], [678, false, undefined]);++ joined = true;++ });++++ await check('lists people see give the first part the whole video\'s length', async () => {++ const rows = [{ id: 7025, duration_seconds: 678, recording: { recording_id: 'med_REC', parts: 2, duration_ms: 1899000, first_vod_id: 7025 } }, { id: 9, duration_seconds: 50 }];++ assert.deepStrictEqual(rows.map(media.wholeVideo).map((v) => [v.id, v.duration_seconds, v.part_duration_seconds]), [[7025, 1899, 678], [9, 50, undefined]]);++ const src = require('fs').readFileSync(require('path').join(__dirname, '..', 'server', 'media-client.js'), 'utf8');++ assert.ok(src.includes('if (whole && out && Array.isArray(out.vods)) out.vods.forEach(wholeVideo);'), 'only with whole: true (jobs inside a part keep the part\'s length)');++ for (const f of ['home/routes.js', 'content/feed.js', 'seo/pages.js', 'media-proxy/lookups.js', 'media-proxy/vods.js']) {++ assert.ok(require('fs').readFileSync(require('path').join(__dirname, '..', 'server', f), 'utf8').includes('whole: true'), f);++ }++ });++++ server.close();++ if (failures) { quiet(`recordings-live: ${failures} failed`); process.exit(1); }++ quiet('recordings-live: all passed');++ process.exit(0);++})().catch((e) => { console.error(e); process.exit(1); });
@@ -219,7 +219,7 @@ async function recapItem(r) {
@@ -219,7 +219,7 @@ async function recapItem(r) {
const SOURCES = {
const SOURCES = {
vods: {
vods: {
async load(offset, n, { sort, since }) {
async load(offset, n, { sort, since }) {
- const r = await media.listVods({ limit: n, offset, order: sort === 'top' ? 'views' : 'newest', since: since || undefined }, { timeoutMs: SOURCE_TIMEOUT_MS });
+ const r = await media.listVods({ limit: n, offset, order: sort === 'top' ? 'views' : 'newest', since: since || undefined }, { whole: true, timeoutMs: SOURCE_TIMEOUT_MS });
return { rows: mediaRows(r, 'vods'), total: mediaTotal(r) };
return { rows: mediaRows(r, 'vods'), total: mediaTotal(r) };
},
},
item: vodItem,
item: vodItem,
@@ -58,7 +58,7 @@ const MEDIA_POOL_TTL_MS = 30_000;
@@ -58,7 +58,7 @@ const MEDIA_POOL_TTL_MS = 30_000;
async function _mediaContent() {
async function _mediaContent() {
if (Date.now() - _mediaPool.at < MEDIA_POOL_TTL_MS) return _mediaPool;
if (Date.now() - _mediaPool.at < MEDIA_POOL_TTL_MS) return _mediaPool;
const [vods, clips, pastes] = await Promise.all([
const [vods, clips, pastes] = await Promise.all([
- media.listVods({ limit: 12 }).then(r => r?.vods || (Array.isArray(r) ? r : [])).catch(() => _mediaPool.vods),
+ media.listVods({ limit: 12 }, { whole: true }).then(r => r?.vods || (Array.isArray(r) ? r : [])).catch(() => _mediaPool.vods),
media.listClips({ limit: 12 }).then(r => r?.clips || (Array.isArray(r) ? r : [])).catch(() => _mediaPool.clips),
media.listClips({ limit: 12 }).then(r => r?.clips || (Array.isArray(r) ? r : [])).catch(() => _mediaPool.clips),
require('../pastes-client').listPastes({ limit: 12, visibility: 'public' }).then(r => r?.pastes || (Array.isArray(r) ? r : [])).catch(() => _mediaPool.pastes),
require('../pastes-client').listPastes({ limit: 12, visibility: 'public' }).then(r => r?.pastes || (Array.isArray(r) ? r : [])).catch(() => _mediaPool.pastes),
]);
]);
@@ -1458,6 +1458,8 @@ async function start() {
@@ -1458,6 +1458,8 @@ async function start() {
try { require('./ai/ai-moments-job').start(); } catch (e) { console.warn('[AI] moments job not started:', e.message); }
try { require('./ai/ai-moments-job').start(); } catch (e) { console.warn('[AI] moments job not started:', e.message); }
try { require('./ai/auto-clip-job').start(); } catch (e) { console.warn('[AI] auto-clip job not started:', e.message); }
try { require('./ai/auto-clip-job').start(); } catch (e) { console.warn('[AI] auto-clip job not started:', e.message); }
try { require('./ai/clip-title').start(); } catch (e) { console.warn('[AI] clip-title job not started:', e.message); }
try { require('./ai/clip-title').start(); } catch (e) { console.warn('[AI] clip-title job not started:', e.message); }
+ // A stream recorded in parts is one video on Media (streaming/recordings.js): synced, closed, backfilled.
+ try { require('./streaming/recordings').start(); } catch (e) { console.warn('[Recordings] sweep not started:', e.message); }
try { require('./ai/easter-egg-job').start(); } catch (e) { console.warn('[AI] easter-egg job not started:', e.message); }
try { require('./ai/easter-egg-job').start(); } catch (e) { console.warn('[AI] easter-egg job not started:', e.message); }
// The Rundown's desk replaced the Arena's jobs (the drama desk, the beef listener and judges, 2026-10-19).
// The Rundown's desk replaced the Arena's jobs (the drama desk, the beef listener and judges, 2026-10-19).
try { require('./rundown/desk').start(); } catch (e) { console.warn('[Rundown] desk not started:', e.message); }
try { require('./rundown/desk').start(); } catch (e) { console.warn('[Rundown] desk not started:', e.message); }
@@ -215,9 +215,27 @@ async function signedMediaUrl(kind, id, ttlS = 3600, opts = {}) {
@@ -215,9 +215,27 @@ async function signedMediaUrl(kind, id, ttlS = 3600, opts = {}) {
return await request('GET', `/${kind === 'clip' ? 'clips' : 'vods'}/${id}/signed-url`, { query: { ttl: ttlS }, ...opts });
return await request('GET', `/${kind === 'clip' ? 'clips' : 'vods'}/${id}/signed-url`, { query: { ttl: ttlS }, ...opts });
}
}
-/** GET /vods?limit&offset (+ pass-through filters like username/user_id/stream_id) */
+/**
+ * GET /vods?limit&offset (+ pass-through filters like username/user_id/stream_id). Media lists a stream recorded in parts
+ * once, by its first part, which names the recording; `whole: true` (lists people see) gives that card the whole
+ * video's length (`part_duration_seconds` keeps the part's). Jobs that work inside a part's file leave it off.
+ */
async function listVods(query = {}, opts = {}) {
async function listVods(query = {}, opts = {}) {
- return await request('GET', '/vods', { query, ...opts });
+ const { whole = false, ...rest } = opts;
+ const out = await request('GET', '/vods', { query, ...rest });
+ if (whole && out && Array.isArray(out.vods)) out.vods.forEach(wholeVideo);
+ return out;
+}
+
+/** A listed first part of a recording, made to read as the whole video (its length). */
+function wholeVideo(v) {
+ const r = v && v.recording;
+ if (r && Number(r.parts) > 1 && Number(r.duration_ms) > 0 && Number(r.first_vod_id) === Number(v.id)) {
+ v.part_duration_seconds = v.duration_seconds;
+ v.duration_seconds = Math.round(Number(r.duration_ms) / 1000);
+ v.duration = v.duration_seconds;
+ }
+ return v;
}
}
/** PUT /vods/:id { title?, is_public?, visibility?, ... } — metadata update (inherited shape). */
/** PUT /vods/:id { title?, is_public?, visibility?, ... } — metadata update (inherited shape). */
@@ -232,6 +250,25 @@ async function deleteVod(vodId, opts = {}) {
@@ -232,6 +250,25 @@ async function deleteVod(vodId, opts = {}) {
return await request('DELETE', `/vods/${vodId}`, opts);
return await request('DELETE', `/vods/${vodId}`, opts);
}
}
+// ── Recordings (OpenVibe.Media docs/recordings.md) ──────────────────────────
+// A stream recorded in parts is one video: Media joins the parts' timelines, lists the first part for the whole and
+// lets a clip cross a join. Live names the parts (server/streaming/recordings.js).
+
+/** PUT /recordings/:key { parts: [{ vod_id, gap_before_ms, stream_offset_ms }], title?, state? } → the recording, or { recording: null } */
+async function putRecording(key, body, opts = {}) {
+ return await request('PUT', `/recordings/${encodeURIComponent(key)}`, { body, ...opts });
+}
+
+/** GET /recordings/:key → { id, key, state, duration_ms, vod_id, parts[], hls_url? } (404 when none) */
+async function getRecording(key, opts = {}) {
+ return await request('GET', `/recordings/${encodeURIComponent(key)}`, opts);
+}
+
+/** POST /recordings/:key/close: the stream ended, no more parts. */
+async function closeRecording(key, opts = {}) {
+ return await request('POST', `/recordings/${encodeURIComponent(key)}/close`, opts);
+}
+
// ── Clips ────────────────────────────────────────────────────────────────────
// ── Clips ────────────────────────────────────────────────────────────────────
/** POST /clips { vod_id, start_s, end_s, title?, user_id? } → { id, status } */
/** POST /clips { vod_id, start_s, end_s, title?, user_id? } → { id, status } */
@@ -426,7 +463,9 @@ module.exports = {
@@ -426,7 +463,9 @@ module.exports = {
// vods
// vods
createVod, ingestRtmp, ingestRtpStart, ingestRtpStop,
createVod, ingestRtmp, ingestRtpStart, ingestRtpStop,
uploadVodChunk, finalizeVod,
uploadVodChunk, finalizeVod,
- getVod, listVods, updateVod, deleteVod,
+ getVod, listVods, wholeVideo, updateVod, deleteVod,
+ // recordings
+ putRecording, getRecording, closeRecording,
// clips
// clips
createClip, getClip, listClips, updateClip, deleteClip, recutClip, trimClip, getJob,
createClip, getClip, listClips, updateClip, deleteClip, recutClip, trimClip, getJob,
// files + thumbnails
// files + thumbnails
@@ -171,7 +171,7 @@ async function vodsForManagedStream(managedStreamId) {
@@ -171,7 +171,7 @@ async function vodsForManagedStream(managedStreamId) {
*/
*/
async function userVods(userId, { includePrivate = false, limit = 20 } = {}) {
async function userVods(userId, { includePrivate = false, limit = 20 } = {}) {
let rows;
let rows;
- try { rows = rowsOf(await media.listVods({ user_id: userId, include_private: includePrivate ? 1 : undefined, limit }, { timeoutMs: TIMEOUT_MS }), 'vods'); }
+ try { rows = rowsOf(await media.listVods({ user_id: userId, include_private: includePrivate ? 1 : undefined, limit }, { timeoutMs: TIMEOUT_MS, whole: true }), 'vods'); }
catch { return []; }
catch { return []; }
const keep = rows.filter((v) => finished(v) && (includePrivate || isPublic(v)));
const keep = rows.filter((v) => finished(v) && (includePrivate || isPublic(v)));
return Promise.all(keep.map(async (v) => withAiState(v, await aiState(db.getVodAiState, v.id))));
return Promise.all(keep.map(async (v) => withAiState(v, await aiState(db.getVodAiState, v.id))));
@@ -348,7 +348,7 @@ router.get('/:id/live-info', optionalAuth, async (req, res) => {
@@ -348,7 +348,7 @@ router.get('/:id/live-info', optionalAuth, async (req, res) => {
router.get('/', optionalAuth, async (req, res) => {
router.get('/', optionalAuth, async (req, res) => {
try {
try {
- const out = await media.listVods(await usernameToUserId(req.query));
+ const out = await media.listVods(await usernameToUserId(req.query), { whole: true });
const vods = (await Promise.all((out?.vods || (Array.isArray(out) ? out : [])).filter(v => !isGhostVod(v)).map(withUserFields)));
const vods = (await Promise.all((out?.vods || (Array.isArray(out) ? out : [])).filter(v => !isGhostVod(v)).map(withUserFields)));
res.json({
res.json({
vods,
vods,
@@ -431,6 +431,15 @@ router.get('/:id/memories', optionalAuth, async (req, res) => {
@@ -431,6 +431,15 @@ router.get('/:id/memories', optionalAuth, async (req, res) => {
description: db.publicMemoryText(m.description),
description: db.publicMemoryText(m.description),
tags: m.tags ? (() => { try { return JSON.parse(m.tags); } catch { return []; } })() : [],
tags: m.tags ? (() => { try { return JSON.parse(m.tags); } catch { return []; } })() : [],
}));
}));
+ // The first part of a joined recording answers for the whole video: every moment that was recorded, in the
+ // video's time (streaming/recordings.js; the gaps between parts were not).
+ if (vod.recording && Number(vod.recording.vod_id) === Number(vod.id) && vod.recording.hls_url && (vod.recording.parts || []).length > 1) {
+ const recordings = require('../streaming/recordings');
+ const parts = await recordings.presentParts(vod.recording);
+ memories = memories.map((m) => ({ ...m, offset_seconds: recordings.toRecordingTime(parts, m.offset_seconds) }))
+ .filter((m) => m.offset_seconds != null).map((m) => ({ ...m, offset_seconds: Math.round(m.offset_seconds) }));
+ return res.json({ memories });
+ }
// A VOD that is one part of its stream (the broadcaster reconnected): the moments inside it, in its own time.
// A VOD that is one part of its stream (the broadcaster reconnected): the moments inside it, in its own time.
// Its chapters (OpenVibe.Video) pointed eleven minutes late on part 2 of stream 2795.
// Its chapters (OpenVibe.Video) pointed eleven minutes late on part 2 of stream 2795.
const part = await require('../streaming/stream-history').partForVod(vod.id);
const part = await require('../streaming/stream-history').partForVod(vod.id);
@@ -577,6 +586,9 @@ router.get('/:id', optionalAuth, async (req, res) => {
@@ -577,6 +586,9 @@ router.get('/:id', optionalAuth, async (req, res) => {
}
}
} catch { /* */ }
} catch { /* */ }
+ // One video over a stream recorded in parts (streaming/recordings.js; OpenVibe.Media docs/recordings.md).
+ try { await presentRecording(vod); } catch (err) { console.warn('[VOD] recording:', err.message); }
+
const clips = co?.clips || (Array.isArray(co) ? co : []);
const clips = co?.clips || (Array.isArray(co) ? co : []);
try {
try {
if (inserted && inserted.changes > 0) {
if (inserted && inserted.changes > 0) {
@@ -872,6 +884,9 @@ router.post('/clips', requireAuth, memUpload.single('video'), async (req, res) =
@@ -872,6 +884,9 @@ router.post('/clips', requireAuth, memUpload.single('video'), async (req, res) =
user_id: req.user.id,
user_id: req.user.id,
stream_id: effStreamId || undefined,
stream_id: effStreamId || undefined,
visibility: effStream ? await db.resolveStreamClipVisibility(effStream) : 'public',
visibility: effStream ? await db.resolveStreamClipVisibility(effStream) : 'public',
+ // Times in the whole video of a stream recorded in parts (its watch page plays them as one): Media places
+ // the clip on the recording, across a join too (docs/recordings.md).
+ ...(['1', 'true', true, 1].includes(req.body && req.body.recording_time) ? { recording_time: 1 } : {}),
});
});
noteClip(req.user.id);
noteClip(req.user.id);
await _notifyStreamerOfClip(effStreamId, req.user, sanitizedTitle, clip?.id);
await _notifyStreamerOfClip(effStreamId, req.user, sanitizedTitle, clip?.id);
@@ -908,6 +923,52 @@ async function clipPlace(stream, startTime, endTime) {
@@ -908,6 +923,52 @@ async function clipPlace(stream, startTime, endTime) {
* The speech heard while a VOD's stream was live (the timeline rows stamped with this VOD), as transcript segments in
* The speech heard while a VOD's stream was live (the timeline rows stamped with this VOD), as transcript segments in
* the VOD's own time ([{ start, end, text }]); [] when there are none.
* the VOD's own time ([{ start, end, text }]); [] when there are none.
*/
*/
+/**
+ * A VOD that is part of a recording (Media's answer names it): its first part answers for the whole video once Media has
+ * joined the parts (`recording.joined`, an `hls_url` over all of them):
+ * - the whole length (`duration_seconds`; the part's own stays in `part_duration_seconds`) and the joined playlist;
+ * - `recording.parts` [{ vod_id, start_sec, duration_sec, gap_before_sec, stream_offset_sec }] for chat replay and links;
+ * - the transcript of every part in the video's time (each part's full pass, else its live captions).
+ * Until then, and for every later part, it answers as a part with `recording` naming the whole (`vod_id` of its first
+ * part and `start_sec`, where this part starts in it), so a page can send people to the whole video at that second.
+ */
+async function presentRecording(vod) {
+ const rec = vod.recording;
+ if (!rec || !Array.isArray(rec.parts) || rec.parts.length < 2) { delete vod.recording; return; }
+ const recordings = require('../streaming/recordings');
+ const parts = await recordings.presentParts(rec);
+ const mine = parts.find((p) => p.vod_id === Number(vod.id)) || null;
+ const joined = !!rec.hls_url;
+ const first = Number(rec.vod_id) === Number(vod.id);
+ vod.recording = { id: rec.id, vod_id: Number(rec.vod_id), joined, duration_seconds: Math.round(Number(rec.duration_ms) / 1000),
+ start_sec: mine ? mine.start_sec : 0, parts };
+ if (!first || !joined) return;
+ vod.part_duration_seconds = vod.duration_seconds;
+ vod.duration_seconds = vod.recording.duration_seconds;
+ vod.duration = vod.recording.duration_seconds;
+ vod.hls_url = rec.hls_url;
+ if (rec.hls_expires_at) vod.hls_expires_at = rec.hls_expires_at;
+ delete vod.part;
+ const segs = [];
+ let live = false;
+ for (const p of parts) {
+ let rows = [];
+ const st = Number(p.vod_id) === Number(vod.id) ? null : await db.getVodAiState(p.vod_id).catch(() => null);
+ const own = Number(p.vod_id) === Number(vod.id) && vod.transcript_source !== 'live' ? vod.ai_transcript_json : (st && st.ai_transcript_json);
+ try { rows = own ? JSON.parse(own) : []; } catch { rows = []; }
+ if (!rows.length) { rows = await liveTranscript(p.vod_id); if (rows.length) live = true; }
+ for (const r of rows) {
+ if (!r || !String(r.text || '').trim()) continue;
+ segs.push({ start: Math.round((p.start_sec + Number(r.start || 0)) * 100) / 100, end: r.end == null ? null : Math.round((p.start_sec + Number(r.end)) * 100) / 100, text: String(r.text).trim() });
+ }
+ }
+ if (segs.length) {
+ vod.ai_transcript_json = JSON.stringify(segs);
+ vod.ai_transcript = segs.map((x) => x.text).join(' ');
+ if (live) vod.transcript_source = 'live';
+ }
+}
+
async function liveTranscript(vodId) {
async function liveTranscript(vodId) {
const rows = (await db.getTimelineByVod(vodId)).filter((r) => r.kind === 'speech' && String(r.text || '').trim());
const rows = (await db.getTimelineByVod(vodId)).filter((r) => r.kind === 'speech' && String(r.text || '').trim());
if (!rows.length) return [];
if (!rows.length) return [];
@@ -38,7 +38,7 @@ async function _cached(key, fn) {
@@ -38,7 +38,7 @@ async function _cached(key, fn) {
if (_mc.size > 300) _mc.delete(_mc.keys().next().value);
if (_mc.size > 300) _mc.delete(_mc.keys().next().value);
return v;
return v;
}
}
-const _vodList = async (limit, offset = 0) => await _cached(`vl:${limit}:${offset}`, async () => (await media.listVods({ limit, offset }))?.vods || []);
+const _vodList = async (limit, offset = 0) => await _cached(`vl:${limit}:${offset}`, async () => (await media.listVods({ limit, offset }, { whole: true }))?.vods || []);
// People's clips and pastes: the AI's are listed on /moments (server/content/feed.js).
// People's clips and pastes: the AI's are listed on /moments (server/content/feed.js).
const _clipList = async (limit, offset = 0) => await _cached(`cl:${limit}:${offset}`, async () => (await media.listClips({ limit, offset, auto_generated: 0 }))?.clips || []);
const _clipList = async (limit, offset = 0) => await _cached(`cl:${limit}:${offset}`, async () => (await media.listClips({ limit, offset, auto_generated: 0 }))?.clips || []);
const _pasteList = async (limit, offset = 0) => await _cached(`pl:${limit}:${offset}`, async () => (await require('../pastes-client').listPastes({ limit, offset, visibility: 'public', origin: 'user' }))?.pastes || []);
const _pasteList = async (limit, offset = 0) => await _cached(`pl:${limit}:${offset}`, async () => (await require('../pastes-client').listPastes({ limit, offset, visibility: 'public', origin: 'user' }))?.pastes || []);
@@ -272,7 +272,7 @@ async function _channelMeta(username, page = 1) {
@@ -272,7 +272,7 @@ async function _channelMeta(username, page = 1) {
const track = { partial: false };
const track = { partial: false };
const offset = (page - 1) * CHANNEL_PAGE_SIZE;
const offset = (page - 1) * CHANNEL_PAGE_SIZE;
const [vr, cr, ar] = acct.banned ? [null, null, null] : await Promise.all([
const [vr, cr, ar] = acct.banned ? [null, null, null] : await Promise.all([
- _listWithin(`chv:${acct.userId}:${offset}`, async () => await media.listVods({ user_id: acct.userId, order: 'newest', limit: CHANNEL_PAGE_SIZE, offset }, { timeoutMs: 5000 }), track),
+ _listWithin(`chv:${acct.userId}:${offset}`, async () => await media.listVods({ user_id: acct.userId, order: 'newest', limit: CHANNEL_PAGE_SIZE, offset }, { timeoutMs: 5000, whole: true }), track),
page === 1 ? _listWithin(`chc:${acct.userId}`, async () => await media.listClips({ channel_user_id: acct.userId, auto_generated: 0, limit: 8 }, { timeoutMs: 5000 }), track) : null,
page === 1 ? _listWithin(`chc:${acct.userId}`, async () => await media.listClips({ channel_user_id: acct.userId, auto_generated: 0, limit: 8 }, { timeoutMs: 5000 }), track) : null,
page === 1 ? _listWithin(`cha:${acct.userId}`, async () => await media.listClips({ channel_user_id: acct.userId, auto_generated: 1, limit: 6 }, { timeoutMs: 5000 }), track) : null,
page === 1 ? _listWithin(`cha:${acct.userId}`, async () => await media.listClips({ channel_user_id: acct.userId, auto_generated: 1, limit: 6 }, { timeoutMs: 5000 }), track) : null,
]);
]);
@@ -660,7 +660,7 @@ async function _pasteMeta(slug) {
@@ -660,7 +660,7 @@ async function _pasteMeta(slug) {
/** The VOD recorded from a stream (the longest public one), for items that know only their stream. */
/** The VOD recorded from a stream (the longest public one), for items that know only their stream. */
const _vodOfStream = async (streamId) => await _cached(`vs:${streamId}`, async () => {
const _vodOfStream = async (streamId) => await _cached(`vs:${streamId}`, async () => {
- const r = await media.listVods({ stream_id: streamId, limit: 3 });
+ const r = await media.listVods({ stream_id: streamId, limit: 3 }, { whole: true });
const rows = (r && r.vods) || [];
const rows = (r && r.vods) || [];
return rows.filter((v) => v && !isPrivate(v) && Number(v.is_public) !== 0)
return rows.filter((v) => v && !isPrivate(v) && Number(v.is_public) !== 0)
.sort((a, b) => (Number(b.duration_seconds ?? b.duration) || 0) - (Number(a.duration_seconds ?? a.duration) || 0))[0] || null;
.sort((a, b) => (Number(b.duration_seconds ?? b.duration) || 0) - (Number(a.duration_seconds ?? a.duration) || 0))[0] || null;
new file mode 100644
new file mode 100644
@@ -0,0 +1,123 @@
@@ -0,0 +1,123 @@
+'use strict';
+/**
+ * A stream recorded in parts is one video (owner, 2026-10-27: "instead of 'The stream was recorded in 2 parts: Part 1 ·
+ * Part 2' it should automatically just have them as a single vod"; OpenVibe.Media docs/recordings.md).
+ *
+ * The broadcaster dropping and coming back, or a recorder restart, makes a new VOD per session (stream 2795: VODs 6025
+ * and 6026). Live knows which VODs are one stream's parts and where each starts in stream time (stream_history), so it
+ * tells Media: PUT /recordings/stream:<id> with the parts in order, the gap before each and its stream offset. Media then
+ * plays them as one video (one HLS playlist over the parts' segments), lists only the first part, which names the
+ * recording, and lets a clip cross a join. A stream with one part sends nothing: that VOD is the video.
+ *
+ * sync(streamId, { close }) → Media's recording (or null): from the stream's history, idempotent; close: the stream ended
+ * soon(streamId) sync a few seconds after a part started or ended (several changes, one call)
+ * sweep() the streams of the last two days with two or more parts: synced, and closed once ended
+ * (also the backfill of streams recorded in parts before this existed)
+ * start() the sweep every SWEEP_MS (server/index.js's normal boot, so never under LIVE_DRILL)
+ * toRecordingTime(parts, s) / toStreamTime(parts, s) stream time ↔ the video's time (gaps between parts skipped)
+ * presentParts(recording) Media's parts as Live answers them: start_sec, duration_sec, stream_offset_sec
+ *
+ * Never throws: a recording that is not joined yet plays as its parts did before.
+ */
+const db = require('../db/database');
+const history = require('./stream-history');
+
+const SOON_MS = 5000;
+const SWEEP_MS = 10 * 60 * 1000;
+const SWEEP_DAYS = 2;
+const keyOf = (streamId) => `stream:${Number(streamId)}`;
+const pending = new Map(); // streamId → timer
+
+/** The parts to send: the stream's recording parts with a VOD, in stream time order, each once. */
+async function partsOf(streamId) {
+ const { parts } = await history.forStream(streamId);
+ const seen = new Set();
+ const out = [];
+ let prevEnd = null;
+ for (const p of parts) {
+ if (!p.vod_id || seen.has(p.vod_id)) continue;
+ seen.add(p.vod_id);
+ const gap = prevEnd != null && p.at > prevEnd ? Math.round((p.at - prevEnd) * 1000) : 0;
+ out.push({ vod_id: p.vod_id, gap_before_ms: gap, stream_offset_ms: Math.round(p.at * 1000) });
+ prevEnd = p.end != null ? p.end : prevEnd;
+ }
+ return out;
+}
+
+async function sync(streamId, { close = false } = {}) {
+ try {
+ const parts = await partsOf(streamId);
+ if (parts.length < 2) return null;
+ const media = require('../media-client');
+ const stream = await db.getStreamById(streamId).catch(() => null);
+ const rec = await media.putRecording(keyOf(streamId), { parts, ...(stream && stream.title ? { title: String(stream.title).slice(0, 200) } : {}), ...(close ? { state: 'closed' } : {}) });
+ return rec && rec.id ? rec : null;
+ } catch (err) {
+ console.warn(`[Recordings] stream ${streamId}: ${err.message}`);
+ return null;
+ }
+}
+
+function soon(streamId) {
+ if (!streamId || require('../drill').enabled) return;
+ clearTimeout(pending.get(streamId));
+ const t = setTimeout(() => { pending.delete(streamId); sync(streamId).catch(() => {}); }, SOON_MS);
+ if (t.unref) t.unref();
+ pending.set(streamId, t);
+}
+
+async function sweep() {
+ let rows = [];
+ try {
+ rows = await db.all(`SELECT h.stream_id, MAX(CASE WHEN s.is_live = 1 THEN 1 ELSE 0 END) AS live
+ FROM stream_history h JOIN streams s ON s.id = h.stream_id
+ WHERE h.kind = 'part' AND h.vod_id IS NOT NULL AND h.created_at >= ?
+ GROUP BY h.stream_id HAVING COUNT(DISTINCT h.vod_id) >= 2 ORDER BY h.stream_id DESC LIMIT 200`,
+ [new Date(Date.now() - SWEEP_DAYS * 86400000).toISOString().replace('T', ' ').slice(0, 19)]);
+ } catch (err) { console.warn('[Recordings] sweep:', err.message); return 0; }
+ let n = 0;
+ for (const r of rows) if (await sync(r.stream_id, { close: !Number(r.live) })) n++;
+ return n;
+}
+
+// ── Time (the one conversion, Media docs/recordings.md §2) ──
+// parts: [{ start_sec (recording time), duration_sec, stream_offset_sec }]. The gaps between parts are not in the video.
+
+/** Stream seconds → seconds into the recording, or null when that moment fell between parts (not recorded). */
+function toRecordingTime(parts, streamSec) {
+ const s = Number(streamSec);
+ for (const p of parts || []) {
+ if (p.stream_offset_sec == null) continue;
+ if (s >= p.stream_offset_sec && s <= p.stream_offset_sec + p.duration_sec) return Math.round((p.start_sec + s - p.stream_offset_sec) * 100) / 100;
+ }
+ return null;
+}
+
+/** Seconds into the recording → stream seconds (null when the part's place in the stream is unknown). */
+function toStreamTime(parts, recSec) {
+ const t = Number(recSec);
+ const list = parts || [];
+ const p = list.find((x) => t >= x.start_sec && t < x.start_sec + x.duration_sec) || list[list.length - 1];
+ if (!p || p.stream_offset_sec == null) return null;
+ return Math.round((p.stream_offset_sec + t - p.start_sec) * 100) / 100;
+}
+
+/** Media's recording parts (ms) as Live answers them (s), each with its stream offset (Media's, else the history's). */
+async function presentParts(rec) {
+ const parts = (rec && Array.isArray(rec.parts) ? rec.parts : []);
+ const offsets = await history.vodOffsets(parts.map((p) => p.vod_id));
+ return parts.map((p) => ({
+ vod_id: Number(p.vod_id), start_sec: Number(p.start_ms) / 1000, duration_sec: Number(p.duration_ms) / 1000,
+ gap_before_sec: Number(p.gap_before_ms || 0) / 1000,
+ stream_offset_sec: p.stream_offset_ms != null ? Number(p.stream_offset_ms) / 1000 : (offsets.has(Number(p.vod_id)) ? offsets.get(Number(p.vod_id)) : null),
+ }));
+}
+
+let started = false;
+function start() {
+ if (started || require('../drill').enabled) return;
+ started = true;
+ require('../utils/jobs').every('recordings-sweep', SWEEP_MS, sweep, { initialDelayMs: 90 * 1000, jitterMs: 30 * 1000 });
+}
+
+module.exports = { sync, soon, sweep, start, partsOf, keyOf, toRecordingTime, toStreamTime, presentParts };
@@ -81,7 +81,10 @@ async function partStarted(stream, { vodId, part = 1, why = null, atMs = Date.no
@@ -81,7 +81,10 @@ async function partStarted(stream, { vodId, part = 1, why = null, atMs = Date.no
const at = streamSeconds(stream, atMs);
const at = streamSeconds(stream, atMs);
if (at == null) return false;
if (at == null) return false;
entry(stream.id).started = epochMs(stream.started_at);
entry(stream.id).started = epochMs(stream.started_at);
- return add({ stream_id: stream.id, kind: 'part', at_sec: at, vod_id: vodId, detail: { part, ...(why ? { why } : {}) } });
+ const ok = await add({ stream_id: stream.id, kind: 'part', at_sec: at, vod_id: vodId, detail: { part, ...(why ? { why } : {}) } });
+ // A second part: the stream's parts become one video on Media (recordings.js).
+ if (ok && part >= 2) require('./recordings').soon(stream.id);
+ return ok;
}
}
async function partEnded(streamId, vodId, atMs = Date.now()) {
async function partEnded(streamId, vodId, atMs = Date.now()) {
@@ -91,6 +94,7 @@ async function partEnded(streamId, vodId, atMs = Date.now()) {
@@ -91,6 +94,7 @@ async function partEnded(streamId, vodId, atMs = Date.now()) {
try {
try {
await db.run(`UPDATE stream_history SET end_sec = ? WHERE stream_id = ? AND kind = 'part' AND vod_id = ? AND end_sec IS NULL`,
await db.run(`UPDATE stream_history SET end_sec = ? WHERE stream_id = ? AND kind = 'part' AND vod_id = ? AND end_sec IS NULL`,
[Math.max(0, Math.round((atMs - start) / 100) / 10), streamId, vodId]);
[Math.max(0, Math.round((atMs - start) / 100) / 10), streamId, vodId]);
+ require('./recordings').soon(streamId); // the gap before the next part is known now
return true;
return true;
} catch { return false; }
} catch { return false; }
}
}
new file mode 100644
new file mode 100644
@@ -0,0 +1,159 @@
@@ -0,0 +1,159 @@
+/**
+ * A stream recorded in parts is one video (owner, 2026-10-27: "instead of 'The stream was recorded in 2 parts: Part 1 ·
+ * Part 2' it should automatically just have them as a single vod"; OpenVibe.Media docs/recordings.md).
+ *
+ * - streaming/recordings.js sends Media the stream's parts from its history (in order, each one's stream offset and the
+ * gap before it) as PUT /recordings/stream:<id>; one part sends nothing; the sweep closes an ended stream's recording.
+ * - toRecordingTime / toStreamTime: the video's time skips the gap between parts.
+ * - GET /api/vods/:id for the first part of a joined recording answers the whole video: its length, the joined playlist,
+ * the parts, every part's transcript in the video's time; its memories (chapters) are every part's, in the video's
+ * time. A later part says where it starts in the whole. Before Media joined them, both answer as parts.
+ * - Lists people see give the first part the whole video's length (media.listVods whole: true).
+ *
+ * node test/recordings-live.test.js
+ */
+'use strict';
+const assert = require('assert');
+const http = require('http');
+
+process.env.NODE_ENV = 'test';
+const quiet = console.log;
+console.log = (...a) => { if (!/^\[/.test(String(a[0]))) quiet(...a); };
+console.warn = () => {};
+
+let failures = 0;
+async function check(name, fn) {
+ try { await fn(); quiet(' ✓', name); }
+ catch (e) { failures++; quiet(' ✗', name, '\n ', e.stack || e.message); }
+}
+
+(async () => {
+ const db = require('../server/db/database');
+ await db.initDb();
+ const raw = db.getDb();
+ const auth = require('../server/auth/auth');
+ auth.optionalAuth = async (req, res, next) => next();
+ await raw.prepare(`INSERT INTO users (id, username, display_name, email, password_hash, role, created_at) OVERRIDING SYSTEM VALUE
+ VALUES (90, 'maticus', 'Maticus', 'm@x', 'x', 'user', '2025-01-01 00:00:00')`).run();
+ await db.ensureChannel(90);
+ const chan = await db.getChannelByUserId(90);
+ const sid = Number((await db.createStream({ user_id: 90, channel_id: chan.id, title: 'BIRD DRAMA', protocol: 'webrtc' })).lastInsertRowid);
+ const startMs = Math.floor(Date.now() / 1000) * 1000 - 2000 * 1000;
+ const iso = (ms) => new Date(ms).toISOString().slice(0, 19).replace('T', ' ');
+ await raw.prepare('UPDATE streams SET started_at = ?, ended_at = ?, is_live = 0 WHERE id = ?').run(iso(startMs), iso(startMs + 1930000), sid);
+
+ const media = require('../server/media-client');
+ const puts = [];
+ media.putRecording = async (key, body) => { puts.push({ key, body }); return { id: 'med_REC', key, parts: body.parts }; };
+ const history = require('../server/streaming/stream-history');
+ const recordings = require('../server/streaming/recordings');
+ const stream = await db.getStreamById(sid);
+ await history.partStarted(stream, { vodId: 7025, part: 1, atMs: startMs + 28000 });
+ await history.partEnded(sid, 7025, startMs + 706000);
+
+ await check('one part: nothing is sent (that VOD is the video)', async () => {
+ assert.strictEqual(await recordings.sync(sid), null);
+ assert.strictEqual(puts.length, 0);
+ });
+
+ await history.partStarted(stream, { vodId: 7026, part: 2, atMs: startMs + 709000 });
+ await history.partEnded(sid, 7026, startMs + 1930000);
+
+ await check('two parts: PUT /recordings/stream:<id> with the parts in order, offsets and the gap', async () => {
+ const rec = await recordings.sync(sid);
+ assert.strictEqual(rec.id, 'med_REC');
+ const put = puts.at(-1);
+ assert.strictEqual(put.key, `stream:${sid}`);
+ assert.deepStrictEqual(put.body.parts, [
+ { vod_id: 7025, gap_before_ms: 0, stream_offset_ms: 28000 },
+ { vod_id: 7026, gap_before_ms: 3000, stream_offset_ms: 709000 },
+ ]);
+ assert.strictEqual(put.body.title, 'BIRD DRAMA');
+ assert.ok(!('state' in put.body), 'open while it may go on');
+ await recordings.sweep();
+ assert.strictEqual(puts.at(-1).body.state, 'closed', 'the sweep closes an ended stream\'s recording');
+ });
+
+ const parts = [{ vod_id: 7025, start_sec: 0, duration_sec: 678, stream_offset_sec: 28 }, { vod_id: 7026, start_sec: 678, duration_sec: 1221, stream_offset_sec: 709 }];
+ await check('stream time ↔ the video\'s time, the gap skipped', () => {
+ assert.strictEqual(recordings.toRecordingTime(parts, 100), 72);
+ assert.strictEqual(recordings.toRecordingTime(parts, 900), 869);
+ assert.strictEqual(recordings.toRecordingTime(parts, 707.5), null, 'between parts: not recorded');
+ assert.strictEqual(recordings.toStreamTime(parts, 869), 900);
+ assert.strictEqual(recordings.toStreamTime(parts, 10), 38);
+ });
+
+ // The VOD route over Media's answer.
+ for (const [off, text] of [[100, 'birds in part one'], [900, 'robot in part two']]) {
+ await raw.prepare('INSERT INTO stream_memories (stream_id, user_id, offset_seconds, description) VALUES (?, 90, ?, ?)').run(sid, off, text);
+ }
+ await raw.prepare(`INSERT INTO vod_ai_state (vod_id, ai_transcript_json) VALUES (7025, ?), (7026, ?)`)
+ .run(JSON.stringify([{ start: 10, end: 12, text: 'hello from part one' }]), JSON.stringify([{ start: 5, end: 7, text: 'and part two' }]));
+ let joined = true;
+ const REC = () => ({ id: 'med_REC', key: `stream:${sid}`, vod_id: 7025, duration_ms: 1899000, ...(joined ? { hls_url: 'https://openvibe.media/o/med_REC/master.m3u8' } : {}),
+ parts: [{ seq: 1, vod_id: 7025, start_ms: 0, duration_ms: 678000, gap_before_ms: 0, stream_offset_ms: 28000 },
+ { seq: 2, vod_id: 7026, start_ms: 678000, duration_ms: 1221000, gap_before_ms: 3000, stream_offset_ms: 709000 }] });
+ const VODS = {
+ 7025: { id: 7025, user_id: 90, stream_id: sid, title: 'BIRD DRAMA', visibility: 'public', is_public: 1, status: 'ready', duration_seconds: 678 },
+ 7026: { id: 7026, user_id: 90, stream_id: sid, title: 'BIRD DRAMA (Part 2)', visibility: 'public', is_public: 1, status: 'ready', duration_seconds: 1221 },
+ };
+ media.getVod = async (id) => ({ ...VODS[Number(id)], recording: { ...REC(), this_part: REC().parts.find((p) => p.vod_id === Number(id)) } });
+ media.listClips = async () => ({ clips: [] });
+ const express = require('express');
+ const app = express();
+ app.use(express.json());
+ app.use('/api/vods', require('../server/media-proxy/vods'));
+ const server = http.createServer(app).listen(0);
+ if (!server.listening) await new Promise((r) => server.once('listening', r));
+ let ip = 0;
+ const call = (p) => new Promise((resolve, reject) => {
+ http.get({ port: server.address().port, path: p, headers: { 'cf-connecting-ip': `10.3.0.${++ip}` } }, (res) => {
+ let text = '';
+ res.on('data', (c) => { text += c; });
+ res.on('end', () => { let json = null; try { json = JSON.parse(text); } catch { /* */ } resolve({ status: res.statusCode, json, text }); });
+ }).on('error', reject);
+ });
+
+ await check('the first part of a joined recording answers the whole video', async () => {
+ const r = await call('/api/vods/7025');
+ assert.strictEqual(r.status, 200, r.text);
+ const v = r.json.vod;
+ assert.deepStrictEqual([v.duration_seconds, v.part_duration_seconds, v.hls_url], [1899, 678, 'https://openvibe.media/o/med_REC/master.m3u8']);
+ assert.deepStrictEqual([v.recording.id, v.recording.joined, v.recording.vod_id, v.recording.parts.length], ['med_REC', true, 7025, 2]);
+ assert.deepStrictEqual(v.recording.parts[1], { vod_id: 7026, start_sec: 678, duration_sec: 1221, gap_before_sec: 3, stream_offset_sec: 709 });
+ assert.deepStrictEqual(JSON.parse(v.ai_transcript_json).map((s) => [s.start, s.text]), [[10, 'hello from part one'], [683, 'and part two']], 'every part, in the video\'s time');
+ assert.ok(!('part' in v), 'not "part 1" any more');
+ });
+
+ await check('its chapters are every part\'s, in the video\'s time', async () => {
+ const m = await call('/api/vods/7025/memories');
+ assert.deepStrictEqual(m.json.memories.map((x) => [x.offset_seconds, x.description]), [[72, 'birds in part one'], [869, 'robot in part two']]);
+ });
+
+ await check('a later part says where it starts in the whole', async () => {
+ const v = (await call('/api/vods/7026')).json.vod;
+ assert.deepStrictEqual([v.recording.vod_id, v.recording.start_sec, v.duration_seconds], [7025, 678, 1221], 'its own length, and where to send people');
+ });
+
+ await check('before Media joined them, the first part answers as a part', async () => {
+ joined = false;
+ const v = (await call('/api/vods/7025')).json.vod;
+ assert.deepStrictEqual([v.duration_seconds, v.recording.joined, v.hls_url], [678, false, undefined]);
+ joined = true;
+ });
+
+ await check('lists people see give the first part the whole video\'s length', async () => {
+ const rows = [{ id: 7025, duration_seconds: 678, recording: { recording_id: 'med_REC', parts: 2, duration_ms: 1899000, first_vod_id: 7025 } }, { id: 9, duration_seconds: 50 }];
+ assert.deepStrictEqual(rows.map(media.wholeVideo).map((v) => [v.id, v.duration_seconds, v.part_duration_seconds]), [[7025, 1899, 678], [9, 50, undefined]]);
+ const src = require('fs').readFileSync(require('path').join(__dirname, '..', 'server', 'media-client.js'), 'utf8');
+ assert.ok(src.includes('if (whole && out && Array.isArray(out.vods)) out.vods.forEach(wholeVideo);'), 'only with whole: true (jobs inside a part keep the part\'s length)');
+ for (const f of ['home/routes.js', 'content/feed.js', 'seo/pages.js', 'media-proxy/lookups.js', 'media-proxy/vods.js']) {
+ assert.ok(require('fs').readFileSync(require('path').join(__dirname, '..', 'server', f), 'utf8').includes('whole: true'), f);
+ }
+ });
+
+ server.close();
+ if (failures) { quiet(`recordings-live: ${failures} failed`); process.exit(1); }
+ quiet('recordings-live: all passed');
+ process.exit(0);
+})().catch((e) => { console.error(e); process.exit(1); });