openvibers/openvibe.live Commits

Download links for VODs and clips (Media's bytes as a named attachment, signed when private); a part's offset and its own moments for OpenVibe.Video (#381)

OP OpenVibers committed 18 days ago · 67092b6517 · 5 files, +233 −1

Changes

ddiff --git a/docs/vods-and-clips.md b/docs/vods-and-clips.md
iindex 3c46a6f2..4f7bc553 100644
---- a/docs/vods-and-clips.md
++++ b/docs/vods-and-clips.md
@@@ -169,6 +169,11 @@ lists) carries three URLs, set by `setPlaybackUrls()` in `server/media-client.js
The watch pages play `hls_url` when there is one and fall back to the file. The server-rendered pages name no
playback URL at all: their schema.org `contentUrl` is the page and their `og:video` tags carry only the duration.
++To save the file, `GET /api/vods/:id/download-url?name=…` and `/api/clips/:id/download-url?name=…` answer
++`{ url, expires_at }`: the byte route with `?download=1&name=…`, which Media answers as an attachment named after
++`name` (its real extension added), so a browser saves it instead of playing it. A private item (for whoever may see it)
++gets a signed URL valid for 15 minutes. OpenVibe.Video's Download menu uses these.
++
## Views
A view is one `content_views` row per item and address: Live's detail read (`GET /api/vods/:id`, `GET /api/clips/:id`)
ddiff --git a/server/media-proxy/clips.js b/server/media-proxy/clips.js
iindex 04c2367c..7c1e2285 100644
---- a/server/media-proxy/clips.js
++++ b/server/media-proxy/clips.js
@@@ -218,6 +218,25 @@ router.get('/:id/file', (req, res) => {
res.redirect(302, media.clipUrl(id));
});
++// The URL its file downloads from (OpenVibe.Video's Download menu): Media's bytes as an attachment named ?name, signed
++// for a private clip (its owner, the clipped streamer, staff). See ./download-url.js.
++router.get('/:id/download-url', optionalAuth, async (req, res) => {
++ try {
++ if (!/^\d+$/.test(req.params.id)) return clipNotFound(res);
++ let clip;
++ try { clip = await media.getClip(req.params.id); }
++ catch (err) {
++ if (err && err.name === 'MediaApiError' && err.status === 404) return clipNotFound(res);
++ return mediaErr(res, err, 'Failed to get clip');
++ }
++ if (!clip || !await access.canView(req.user, clip)) return clipNotFound(res);
++ res.set('Cache-Control', 'private, no-store');
++ res.json(await require('./download-url').downloadUrl('clip', clip, req.query.name));
++ } catch (err) {
++ mediaErr(res, err, 'Failed to get the download link');
++ }
++});
++
router.get('/:id', optionalAuth, async (req, res) => {
try {
if (!/^\d+$/.test(req.params.id)) return clipNotFound(res);
ddiff --git a/server/media-proxy/download-url.js b/server/media-proxy/download-url.js
nnew file mode 100644
iindex 00000000..496b6956
---- /dev/null
++++ b/server/media-proxy/download-url.js
@@@ -0,0 +1,37 @@
++'use strict';
++/**
++ * The URL a person saves a recording or clip from (OpenVibe.Video's Download menu): Media's bytes route with ?download,
++ * which Media answers as an attachment named `name` (Media's server/public/routes.js), so the browser saves the file
++ * instead of playing it. A public or unlisted item is its plain URL; a private one (its owner, or staff) is a
++ * short-lived signed URL, since the browser that follows it carries no key.
++ */
++const media = require('../media-client');
++const access = require('./access');
++
++const TTL_S = 900;
++
++/** A file name as the caller asked for it, without its extension (Media adds the real one), at most 150 characters. */
++function cleanName(name) {
++ const s = String(name || '').replace(/[\u0000-\u001f\u007f/\\]+/g, ' ').replace(/\s+/g, ' ').trim();
++ return s.slice(0, 150);
++}
++
++/** → { url, expires_at } for `kind` ('vod' | 'clip') item `row`, already checked visible to the reader. */
++async function downloadUrl(kind, row, name) {
++ let base;
++ let expires = null;
++ if (access.isPrivate(row)) {
++ const signed = await media.signedMediaUrl(kind, row.id, TTL_S);
++ if (!signed || !signed.url) throw new Error('Media signed no URL');
++ base = new URL(media.publicUrl(signed.url));
++ expires = signed.expires_at || null;
++ } else {
++ base = new URL(kind === 'clip' ? media.clipUrl(row.id) : media.vodPlaybackUrl(row.id));
++ }
++ base.searchParams.set('download', '1');
++ const n = cleanName(name);
++ if (n) base.searchParams.set('name', n);
++ return { url: base.toString(), expires_at: expires };
++}
++
++module.exports = { downloadUrl, cleanName };
ddiff --git a/server/media-proxy/vods.js b/server/media-proxy/vods.js
iindex a6ff32a2..8581edd0 100644
---- a/server/media-proxy/vods.js
++++ b/server/media-proxy/vods.js
@@@ -426,11 +426,20 @@ router.get('/:id/memories', optionalAuth, async (req, res) => {
if (!vod) return;
const streamId = vod.stream_id || null;
if (!streamId) return res.json({ memories: [] });
-- const memories = (await db.getStreamMemories(streamId) || []).map(m => ({
++ let memories = (await db.getStreamMemories(streamId) || []).map(m => ({
offset_seconds: m.offset_seconds,
description: db.publicMemoryText(m.description),
tags: m.tags ? (() => { try { return JSON.parse(m.tags); } catch { return []; } })() : [],
}));
++ // 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);
++ if (part) {
++ const len = part.end != null ? part.end - part.at : Infinity;
++ memories = memories
++ .filter((m) => Number(m.offset_seconds) >= part.at && Number(m.offset_seconds) - part.at <= len)
++ .map((m) => ({ ...m, offset_seconds: Math.round(Number(m.offset_seconds) - part.at) }));
++ }
res.json({ memories });
} catch {
res.status(500).json({ error: 'Failed to load AI timeline' });
@@@ -532,6 +541,10 @@ router.get('/:id', optionalAuth, async (req, res) => {
// Enrich with local stream details for chat replay.
if (vod.stream_id) {
++ // One part of a stream recorded in parts: where it starts in the stream (seconds since stream_started_at),
++ // so chat replay and other stream-time things land at the right place in it.
++ const part = await require('../streaming/stream-history').partForVod(vod.id).catch(() => null);
++ if (part) { vod.stream_offset_sec = part.at; vod.part = part.part; }
if (stream) {
vod.stream_started_at = stream.started_at;
vod.stream_ended_at = stream.ended_at;
@@@ -579,6 +592,19 @@ router.get('/:id', optionalAuth, async (req, res) => {
}
});
++// The URL its file downloads from (OpenVibe.Video's Download menu): Media's bytes as an attachment named ?name, signed
++// for a private VOD (its owner, staff). See ./download-url.js.
++router.get('/:id/download-url', optionalAuth, async (req, res) => {
++ try {
++ const vod = await loadVisibleVod(req, res, req.params.id);
++ if (!vod) return;
++ res.set('Cache-Control', 'private, no-store');
++ res.json(await require('./download-url').downloadUrl('vod', vod, req.query.name));
++ } catch (err) {
++ mediaErr(res, err, 'Failed to get the download link');
++ }
++});
++
router.put('/:id', requireAuth, async (req, res) => {
const vod = await ownVodOrModerator(req, res);
if (!vod) return;
ddiff --git a/test/vod-downloads.test.js b/test/vod-downloads.test.js
nnew file mode 100644
iindex 00000000..ac2b152f
---- /dev/null
++++ b/test/vod-downloads.test.js
@@@ -0,0 +1,145 @@
++/**
++ * What OpenVibe.Video's Download menu reads from Live.
++ *
++ * - GET /api/vods/:id/download-url and /api/clips/:id/download-url: Media's bytes route with ?download=1&name=… (Media
++ * answers it as an attachment); a private item only for who may see it, as a short-lived signed URL; anyone else gets
++ * the missing-item answer.
++ * - A VOD that is one part of its stream (the broadcaster reconnected) says where it starts (stream_offset_sec, part),
++ * and its moments (chapters) are the ones inside it, in its own time: part 2 of stream 2795 pointed 11 minutes late.
++ *
++ * node test/vod-downloads.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');
++ const signIn = async (req) => {
++ const id = Number(req.headers['x-test-user'] || 0);
++ const u = id ? await db.getUserById(id) : null;
++ if (u) { req.user = u; req.authSource = 'network'; }
++ return u;
++ };
++ auth.requireAuth = async (req, res, next) => ((await signIn(req)) ? next() : res.status(401).json({ error: 'Authentication required' }));
++ auth.optionalAuth = async (req, res, next) => { await signIn(req); next(); };
++
++ for (const [id, name] of [[80, 'maticus'], [81, 'someone']]) {
++ await raw.prepare(`INSERT INTO users (id, username, display_name, email, password_hash, role, created_at) OVERRIDING SYSTEM VALUE
++ VALUES (?, ?, ?, ?, 'x', 'user', '2025-01-01 00:00:00')`).run(id, name, name, `${name}@x`);
++ }
++ await db.ensureChannel(80);
++ const chan = await db.getChannelByUserId(80);
++ const sid = Number((await db.createStream({ user_id: 80, channel_id: chan.id, title: 'BIRD DRAMA', protocol: 'webrtc' })).lastInsertRowid);
++ const startMs = Math.floor(Date.now() / 1000) * 1000 - 2000 * 1000;
++ await raw.prepare('UPDATE streams SET started_at = ?, ended_at = ? WHERE id = ?')
++ .run(new Date(startMs).toISOString().slice(0, 19).replace('T', ' '), new Date(startMs + 1930000).toISOString().slice(0, 19).replace('T', ' '), sid);
++ const stream = await db.getStreamById(sid);
++ const history = require('../server/streaming/stream-history');
++ await history.partStarted(stream, { vodId: 6025, part: 1, atMs: startMs + 28000 });
++ await history.partEnded(sid, 6025, startMs + 706000);
++ await history.partStarted(stream, { vodId: 6026, part: 2, atMs: startMs + 709000 });
++ await history.partEnded(sid, 6026, startMs + 1930000);
++ for (const [off, text] of [[100, 'birds in part one'], [900, 'robot in part two'], [1500, 'squirrels in part two']]) {
++ await raw.prepare('INSERT INTO stream_memories (stream_id, user_id, offset_seconds, description) VALUES (?, 80, ?, ?)').run(sid, off, text);
++ }
++
++ const media = require('../server/media-client');
++ const VODS = {
++ 6025: { id: 6025, user_id: 80, stream_id: sid, title: 'BIRD DRAMA', visibility: 'public', is_public: 1, status: 'ready' },
++ 6026: { id: 6026, user_id: 80, stream_id: sid, title: 'BIRD DRAMA (Part 2)', visibility: 'public', is_public: 1, status: 'ready' },
++ 6030: { id: 6030, user_id: 80, title: 'Private one', visibility: 'private', is_public: 0, status: 'ready' },
++ };
++ const CLIPS = {
++ 505: { id: 505, user_id: 81, vod_id: 6026, stream_id: sid, title: 'Clip', visibility: 'public', is_public: 1, status: 'ready' },
++ 506: { id: 506, user_id: 81, channel_user_id: 80, vod_id: 6030, title: 'Hidden clip', visibility: 'private', is_public: 0, status: 'ready' },
++ };
++ const missing = (what) => new media.MediaApiError(`${what} not found`, 404, { error: `${what} not found` });
++ media.getVod = async (id) => { const v = VODS[Number(id)]; if (!v) throw missing('VOD'); return { ...v }; };
++ media.getClip = async (id) => { const c = CLIPS[Number(id)]; if (!c) throw missing('Clip'); return { ...c }; };
++ media.listClips = async () => ({ clips: [] });
++ const signedFor = [];
++ media.signedMediaUrl = async (kind, id, ttl) => { signedFor.push([kind, id, ttl]); return { url: `/${kind === 'clip' ? 'c' : 'v'}/${id}?exp=99&sig=abc`, expires_at: '2026-10-27T00:00:00Z' }; };
++
++ const express = require('express');
++ const app = express();
++ app.use(express.json());
++ app.use('/api/vods', require('../server/media-proxy/vods'));
++ app.use('/api/clips', require('../server/media-proxy/clips'));
++ const server = http.createServer(app).listen(0);
++ if (!server.listening) await new Promise((r) => server.once('listening', r));
++ let ip = 0;
++ const call = (p, user) => new Promise((resolve, reject) => {
++ const headers = { 'cf-connecting-ip': `10.2.0.${++ip}` };
++ if (user) headers['x-test-user'] = String(user);
++ http.get({ port: server.address().port, path: p, headers }, (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, headers: res.headers }); });
++ }).on('error', reject);
++ });
++ const media0 = media.vodPlaybackUrl(1).replace(/\/v\/1$/, '');
++
++ await check('a public VOD: Media\'s bytes route as an attachment with the asked-for name', async () => {
++ const r = await call('/api/vods/6026/download-url?name=' + encodeURIComponent('BIRD DRAMA (Part 2) - Maticus/2026-10-26'));
++ assert.strictEqual(r.status, 200, r.text);
++ const u = new URL(r.json.url);
++ assert.strictEqual(`${u.origin}${u.pathname}`, `${media0}/v/6026`);
++ assert.strictEqual(u.searchParams.get('download'), '1');
++ assert.strictEqual(u.searchParams.get('name'), 'BIRD DRAMA (Part 2) - Maticus 2026-10-26', 'no path separators in a name');
++ assert.strictEqual(r.headers['cache-control'], 'private, no-store');
++ assert.strictEqual(signedFor.length, 0, 'nothing signed for a public VOD');
++ });
++
++ await check('a private VOD: missing to others, a signed attachment URL for its owner', async () => {
++ assert.strictEqual((await call('/api/vods/6030/download-url')).status, 404);
++ assert.strictEqual((await call('/api/vods/6030/download-url', 81)).status, 404);
++ const r = await call('/api/vods/6030/download-url?name=Private', 80);
++ assert.strictEqual(r.status, 200, r.text);
++ const u = new URL(r.json.url);
++ assert.deepStrictEqual([u.pathname, u.searchParams.get('exp'), u.searchParams.get('sig'), u.searchParams.get('download'), u.searchParams.get('name')], ['/v/6030', '99', 'abc', '1', 'Private']);
++ assert.deepStrictEqual(signedFor.pop(), ['vod', 6030, 900]);
++ assert.strictEqual(r.json.expires_at, '2026-10-27T00:00:00Z');
++ });
++
++ await check('clips: the same, a private one for the clipped streamer', async () => {
++ const pub = await call('/api/clips/505/download-url?name=Clip');
++ assert.strictEqual(pub.status, 200, pub.text);
++ assert.ok(/\/c\/505\?download=1&name=Clip$/.test(pub.json.url), pub.json.url);
++ assert.strictEqual((await call('/api/clips/506/download-url')).status, 404);
++ const own = await call('/api/clips/506/download-url', 80);
++ assert.strictEqual(own.status, 200, own.text);
++ assert.ok(/\/c\/506\?exp=99&sig=abc&download=1$/.test(own.json.url), own.json.url);
++ assert.strictEqual((await call('/api/clips/99999/download-url')).status, 404);
++ });
++
++ await check('a part of a stream says where it starts; its moments are its own, in its time', async () => {
++ const v = await call('/api/vods/6026');
++ assert.strictEqual(v.status, 200, v.text);
++ assert.deepStrictEqual([v.json.vod.stream_offset_sec, v.json.vod.part], [709, 2]);
++ const m2 = await call('/api/vods/6026/memories');
++ assert.deepStrictEqual(m2.json.memories.map((m) => [m.offset_seconds, m.description]), [[191, 'robot in part two'], [791, 'squirrels in part two']]);
++ const m1 = await call('/api/vods/6025/memories');
++ assert.deepStrictEqual(m1.json.memories.map((m) => [m.offset_seconds, m.description]), [[72, 'birds in part one']]);
++ });
++
++ server.close();
++ if (failures) { quiet(`vod downloads: ${failures} failed`); process.exit(1); }
++ quiet('vod downloads: ok');
++ process.exit(0);
++})().catch((err) => { quiet(err); process.exit(1); });