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)
Changes
@@@ -169,6 +169,11 @@ lists) carries three URLs, set by `setPlaybackUrls()` in `server/media-client.jsThe watch pages play `hls_url` when there is one and fall back to the file. The server-rendered pages name noplayback 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.++## ViewsA view is one `content_views` row per item and address: Live's detail read (`GET /api/vods/:id`, `GET /api/clips/:id`)@@@ -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);nnew file mode 100644@@@ -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 };@@@ -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;nnew file mode 100644@@@ -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); });
@@ -169,6 +169,11 @@ lists) carries three URLs, set by `setPlaybackUrls()` in `server/media-client.js
@@ -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
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.
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
## Views
A view is one `content_views` row per item and address: Live's detail read (`GET /api/vods/:id`, `GET /api/clips/:id`)
A view is one `content_views` row per item and address: Live's detail read (`GET /api/vods/:id`, `GET /api/clips/:id`)
@@ -218,6 +218,25 @@ router.get('/:id/file', (req, res) => {
@@ -218,6 +218,25 @@ router.get('/:id/file', (req, res) => {
res.redirect(302, media.clipUrl(id));
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) => {
router.get('/:id', optionalAuth, async (req, res) => {
try {
try {
if (!/^\d+$/.test(req.params.id)) return clipNotFound(res);
if (!/^\d+$/.test(req.params.id)) return clipNotFound(res);
new file mode 100644
new file mode 100644
@@ -0,0 +1,37 @@
@@ -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 };
@@ -426,11 +426,20 @@ router.get('/:id/memories', optionalAuth, async (req, res) => {
@@ -426,11 +426,20 @@ router.get('/:id/memories', optionalAuth, async (req, res) => {
if (!vod) return;
if (!vod) return;
const streamId = vod.stream_id || null;
const streamId = vod.stream_id || null;
if (!streamId) return res.json({ memories: [] });
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,
offset_seconds: m.offset_seconds,
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 []; } })() : [],
}));
}));
+ // 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 });
res.json({ memories });
} catch {
} catch {
res.status(500).json({ error: 'Failed to load AI timeline' });
res.status(500).json({ error: 'Failed to load AI timeline' });
@@ -532,6 +541,10 @@ router.get('/:id', optionalAuth, async (req, res) => {
@@ -532,6 +541,10 @@ router.get('/:id', optionalAuth, async (req, res) => {
// Enrich with local stream details for chat replay.
// Enrich with local stream details for chat replay.
if (vod.stream_id) {
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) {
if (stream) {
vod.stream_started_at = stream.started_at;
vod.stream_started_at = stream.started_at;
vod.stream_ended_at = stream.ended_at;
vod.stream_ended_at = stream.ended_at;
@@ -579,6 +592,19 @@ router.get('/:id', optionalAuth, async (req, res) => {
@@ -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) => {
router.put('/:id', requireAuth, async (req, res) => {
const vod = await ownVodOrModerator(req, res);
const vod = await ownVodOrModerator(req, res);
if (!vod) return;
if (!vod) return;
new file mode 100644
new file mode 100644
@@ -0,0 +1,145 @@
@@ -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); });