openvibers/openvibe.mediahub Commits

Download button on recordings and clips: the video, transcript (TXT/SRT/VTT/JSON), chat replay (TXT/CSV/JSON), chapters (YouTube TXT/VTT/JSON), cover picture, details and everything as a .zip; each panel has its own links (#141)

OP OpenVibers committed 18 days ago · cb9518d232 · 8 files, +841 −15

Changes

ddiff --git a/README.md b/README.md
iindex c840d6f..e2357fe 100644
---- a/README.md
++++ b/README.md
@@@ -304,6 +304,8 @@ does not sign in on the other.
| `/playlists/:id` | anyone with the link | one playlist: public and unlisted are open by link (unlisted noindex), private only to the owner |
| `/v/:id/captions.vtt` | anyone | the AI transcript as WebVTT captions |
| `/v/:id/chapters.vtt` | anyone | the AI moments as WebVTT chapters |
++| `/v/:id/download/:file` | whoever may see the recording (a private one: its owner) | the Download menu's files: `video` (OpenVibe.Media's file as an attachment, through Live's download link), `thumbnail`, `transcript.{txt,srt,vtt,json}`, `chat.{txt,csv,json}` (every message, in the recording's own time), `chapters.{txt,vtt,json}` (TXT is the lines a YouTube description takes), `info.json`, `all.zip` (everything but the video); named `<title> - <channel> - <date>.<what>.<ext>` ([server/video/downloads.js](server/video/downloads.js)) |
++| `/c/:id/download/:file` | whoever may see the clip | `video`, `thumbnail`, `transcript.{txt,srt,vtt,json}`, `info.json`, `all.zip` |
| `/sitemap.xml`, `/robots.txt`, `/llms.txt` | anyone | crawl artifacts for the video host |
| `/<INDEXNOW_KEY>.txt` | anyone | IndexNow's key file, only with a key: every 10 minutes a sweep of Live's public feed sends new watch pages, their channel pages and the sitemap to search engines ([server/video/indexnow.js](server/video/indexnow.js)) |
ddiff --git a/public/css/video.css b/public/css/video.css
iindex 18542e9..2e2c2d8 100644
---- a/public/css/video.css
++++ b/public/css/video.css
@@@ -192,6 +192,47 @@ a.ovv-channel-name:hover { text-decoration: underline; }
.ovv-person { flex-direction: column; align-items: flex-start; gap: 8px; padding: 12px; }
}
++/* Downloads (server/video/downloads.js): the Download button's menu, a sheet on phones, and each panel's own links */
++.ovv-dl { position: relative; }
++.ovv-dl > summary { list-style: none; cursor: pointer; }
++.ovv-dl > summary::-webkit-details-marker { display: none; }
++.ovv-dl[open] > summary { border-color: var(--ovv-accent); background: var(--bg-hover, #1c2a44); }
++.ovv-dl-menu { position: absolute; z-index: 40; top: calc(100% + 8px); left: 0; width: min(430px, calc(100vw - 32px)); box-sizing: border-box; padding: 6px; border: 1px solid var(--ovv-line-strong); border-radius: 14px; background: var(--bg-secondary, #0f1729); box-shadow: 0 18px 48px rgba(0, 0, 0, .45); }
++.ovv-dl-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; padding: 6px 8px 6px 10px; font-size: .9rem; font-weight: 700; }
++.ovv-dl-close { all: unset; display: none; place-items: center; width: 34px; height: 34px; border-radius: 8px; cursor: pointer; color: var(--ovv-dim); }
++.ovv-dl--js .ovv-dl-close { display: grid; }
++.ovv-dl-close:hover, .ovv-dl-close:focus-visible { background: var(--bg-hover, #1c2a44); color: var(--text-primary, #e6edf7); }
++.ovv-dl-list { list-style: none; margin: 0; padding: 0; display: grid; gap: 2px; }
++.ovv-dl-row { display: grid; grid-template-columns: 26px minmax(0, 1fr) auto; align-items: center; gap: 10px; padding: 8px 10px; border-radius: 10px; }
++.ovv-dl-row:hover { background: color-mix(in srgb, var(--ovv-accent) 8%, transparent); }
++.ovv-dl-row > i { text-align: center; color: var(--accent-light, var(--ovv-accent)); }
++.ovv-dl-what { display: grid; min-width: 0; }
++.ovv-dl-label { font-size: .92rem; font-weight: 700; }
++.ovv-dl-about { font-size: .78rem; color: var(--ovv-dim); overflow-wrap: anywhere; }
++.ovv-dl-formats { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 4px; }
++.ovv-dl-format { display: inline-flex; align-items: center; min-height: 30px; padding: 2px 10px; border-radius: 8px; border: 1px solid var(--ovv-line-strong); color: inherit !important; font-size: .78rem; font-weight: 700; letter-spacing: .3px; text-decoration: none; }
++.ovv-dl-format:hover, .ovv-dl-format:focus-visible { border-color: var(--ovv-accent); background: var(--bg-hover, #1c2a44); }
++.ovv-dl-format--main { border-color: var(--accent-strong, #2563eb); background: var(--accent-strong, #2563eb); color: var(--on-accent-strong, #fff) !important; }
++.ovv-dl-format--main:hover, .ovv-dl-format--main:focus-visible { background: var(--accent-dark, #1d4ed8); border-color: var(--accent-light, #60a5fa); }
++.ovv-dl--up .ovv-dl-menu { top: auto; bottom: calc(100% + 8px); } /* no room below the button: it opens upward (video.js) */
++.ovv-dl-row--all { margin-top: 4px; padding-top: 10px; border-top: 1px solid var(--ovv-line); border-radius: 0 0 10px 10px; }
++.ovv-dl-hint { margin: 4px 10px 6px; font-size: .74rem; color: var(--ovv-faint); }
++.ovv-panel-dl { margin: .7rem 0 0; font-size: .82rem; color: var(--ovv-dim); }
++.ovv-panel .ovv-panel-dl a { display: inline; padding: 0; border-radius: 0; font-size: inherit; font-weight: 700; color: var(--accent-light, var(--ovv-accent)); text-decoration: underline; } /* not a chapter row (.ovv-chapters a) */
++.ovv-panel-dl i { margin-right: 4px; color: var(--ovv-faint); }
++.ovv-transcript .ovv-panel-dl { margin: 0 0 .6rem; }
++.ovv-btn--small { min-height: 32px; padding: 4px 10px; font-size: .82rem; }
++@media (max-width: 600px) {
++ /* A sheet from the bottom, over a dimmed page (the dim and the close button come with video.js: without it, the
++ Download button above the sheet closes it). */
++ .ovv-dl--js[open]::before { content: ''; position: fixed; inset: 0; z-index: 39; background: rgba(0, 0, 0, .55); }
++ .ovv-dl-menu { position: fixed; top: auto; left: 8px; right: 8px; bottom: 8px; width: auto; max-height: 82vh; max-height: 82dvh; overflow-y: auto; border-radius: 16px; padding-bottom: calc(6px + env(safe-area-inset-bottom, 0px)); }
++ .ovv-dl-row { grid-template-columns: 24px minmax(0, 1fr); row-gap: 6px; padding: 7px 8px; }
++ .ovv-dl-formats { grid-column: 2; justify-content: flex-start; }
++ .ovv-dl-format { min-height: 36px; padding: 4px 13px; font-size: .82rem; }
++ .ovv-dl-hint { display: none; }
++}
++
/* Playlists */
.ovv-save { position: relative; }
.ovv-save > summary { list-style: none; cursor: pointer; }
ddiff --git a/public/js/video.js b/public/js/video.js
iindex 39d6967..e34cc6f 100644
---- a/public/js/video.js
++++ b/public/js/video.js
@@@ -104,6 +104,66 @@
return h ? h + ':' + String(m).padStart(2, '0') + ':' + s : m + ':' + s;
}
++ // The Download menu (a <details>, usable without this): it closes on a click elsewhere, on Escape, on its close
++ // button and once a format is chosen, and stays inside the window; on a phone it is a sheet over a dimmed page.
++ // "Copy for a YouTube description" copies the chapters' text where the browser lets a page write the clipboard.
++ function downloads() {
++ const menus = Array.prototype.slice.call(document.querySelectorAll('[data-ovv-dl]'));
++ const close = function (dl, focus) {
++ if (!dl.open) return;
++ dl.open = false;
++ if (focus) dl.querySelector('summary').focus();
++ };
++ menus.forEach(function (dl) {
++ dl.classList.add('ovv-dl--js');
++ const menu = dl.querySelector('.ovv-dl-menu');
++ dl.addEventListener('toggle', function () {
++ menu.style.left = '';
++ dl.classList.remove('ovv-dl--up');
++ if (!dl.open || window.innerWidth <= 600) return;
++ const over = menu.getBoundingClientRect().right - (document.documentElement.clientWidth - 12);
++ if (over > 0) menu.style.left = -over + 'px';
++ // Below the button when it fits; above when only that fits; else below, scrolled into view.
++ const button = dl.querySelector('summary').getBoundingClientRect();
++ const tall = menu.getBoundingClientRect().height + 16;
++ const below = window.innerHeight - button.bottom;
++ if (tall > below && button.top > below && button.top >= tall) dl.classList.add('ovv-dl--up');
++ else if (tall > below) menu.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
++ });
++ const x = dl.querySelector('[data-ovv-dl-close]');
++ if (x) x.addEventListener('click', function () { close(dl, true); });
++ menu.addEventListener('click', function (e) {
++ if (e.target.closest('a[download]')) setTimeout(function () { close(dl, false); }, 150);
++ });
++ });
++ if (menus.length) {
++ document.addEventListener('click', function (e) {
++ menus.forEach(function (dl) {
++ if (dl.open && !dl.querySelector('.ovv-dl-menu').contains(e.target) && !dl.querySelector('summary').contains(e.target)) close(dl, false);
++ });
++ });
++ document.addEventListener('keydown', function (e) {
++ if (e.key === 'Escape') menus.forEach(function (dl) { close(dl, true); });
++ });
++ }
++ if (!navigator.clipboard || !window.fetch) return;
++ document.querySelectorAll('[data-ovv-copy]').forEach(function (b) {
++ const wrap = b.closest('[data-ovv-copy-wrap]');
++ if (wrap) wrap.hidden = false;
++ const label = b.innerHTML;
++ const say = function (text) {
++ b.textContent = text;
++ setTimeout(function () { b.innerHTML = label; }, 2000);
++ };
++ b.addEventListener('click', function () {
++ fetch(b.getAttribute('data-ovv-copy'), { credentials: 'same-origin' })
++ .then(function (r) { return r.ok ? r.text() : Promise.reject(new Error('HTTP ' + r.status)); })
++ .then(function (text) { return navigator.clipboard.writeText(text); })
++ .then(function () { say('Copied'); }, function () { say('Could not copy: use the TXT download'); });
++ });
++ });
++ }
++
document.addEventListener('DOMContentLoaded', function () {
// A clip still being cut: look again in a few seconds (the server answers once it is ready).
const cutting = document.querySelector('[data-ovv-reload]');
@@@ -112,6 +172,8 @@
setTimeout(function () { window.location.replace(window.location.pathname); }, wait);
}
++ downloads();
++
const video = document.querySelector('video[data-ovv-video]');
if (!video) return;
ddiff --git a/server/video/downloads.js b/server/video/downloads.js
nnew file mode 100644
iindex 0000000..71a40aa
---- /dev/null
++++ b/server/video/downloads.js
@@@ -0,0 +1,430 @@
++'use strict';
++
++/**
++ * Downloads on openvibe.video: everything a recording's or clip's page shows, as files to keep (owner, 2026-10-26: "add
++ * easy and intuitive ways for downloading stuff like the transcripts, chat replay, chapters, etc … in a user friendly
++ * way across the different file types and maybe have a generic download button").
++ *
++ * /v/:id/download/<file> video · thumbnail · transcript.{txt,srt,vtt,json} · chat.{txt,csv,json}
++ * · chapters.{txt,vtt,json} · info.json · all.zip
++ * /c/:id/download/<file> video · thumbnail · transcript.{txt,srt,vtt,json} · info.json · all.zip
++ *
++ * The watch page's Download button lists what this item has, each in the formats people use it in (text to read, SRT
++ * and WebVTT for video editors and players, CSV for spreadsheets, JSON for programs, chapters as the lines a YouTube
++ * description takes), and each panel (transcript, chapters, chat replay) carries its own links. Every file is named
++ * "<title> - <channel> - <date>.<what>.<ext>". The video itself is OpenVibe.Media's file, sent as an attachment
++ * (Live's download-url: signed for a private item's owner); "Everything" is one .zip of the rest (not the video).
++ *
++ * Who may download is who may see the page: a public or unlisted item for anyone, a private one for its owner (and
++ * staff), read from Live as them exactly like the page.
++ */
++const zlib = require('zlib');
++const { html } = require('../render/html');
++
++const FILE_NAME_MAX = 120;
++const THUMB_MAX_BYTES = 10 * 1024 * 1024;
++const FETCH_MS = 8000;
++
++// ── Names and times ────────────────────────────────────────
++/** A file name part: no control, quote or path characters, spaces collapsed. */
++function cleanPart(s) {
++ return String(s == null ? '' : s).replace(/[\u0000-\u001f\u007f"\\/:*?<>|]+/g, ' ').replace(/\s+/g, ' ').trim();
++}
++
++/** "<title> - <channel> - <YYYY-MM-DD>", at most FILE_NAME_MAX characters (the title gives way first). */
++function fileBase(item, kind) {
++ const title = cleanPart(item && item.title) || (kind === 'clip' ? 'Clip' : 'Stream recording');
++ const who = cleanPart(item && (kind === 'clip' ? (item.source_streamer_username || item.username) : (item.display_name || item.username)));
++ const day = /^\d{4}-\d{2}-\d{2}/.test(String(item && item.created_at || '')) ? String(item.created_at).slice(0, 10) : '';
++ const tail = [who, day].filter(Boolean).join(' - ');
++ const room = Math.max(10, FILE_NAME_MAX - (tail ? tail.length + 3 : 0));
++ const t = title.length > room ? `${title.slice(0, room - 1).trim()}…` : title;
++ return tail ? `${t} - ${tail}` : t;
++}
++
++/** Content-Disposition for a download: an ASCII name and the UTF-8 one. */
++function disposition(file) {
++ const ascii = file.replace(/[^\x20-\x7e]|["\\]/g, '_');
++ const utf8 = encodeURIComponent(file).replace(/['()*]/g, (c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}`);
++ return `attachment; filename="${ascii}"; filename*=UTF-8''${utf8}`;
++}
++
++/** 75 → "1:15", 3725 → "1:02:05" (hours when `long`, or when there are any). */
++function clockLabel(seconds, long = false) {
++ const n = Math.max(0, Math.floor(Number(seconds) || 0));
++ const h = Math.floor(n / 3600);
++ const m = Math.floor((n % 3600) / 60);
++ const s = n % 60;
++ return h || long ? `${h}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}` : `${m}:${String(s).padStart(2, '0')}`;
++}
++
++/** SubRip time: 00:01:02,345. */
++function srtTime(seconds) {
++ const ms = Math.max(0, Math.round((Number(seconds) || 0) * 1000));
++ const h = Math.floor(ms / 3600000);
++ const m = Math.floor((ms % 3600000) / 60000);
++ const s = Math.floor((ms % 60000) / 1000);
++ return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')},${String(ms % 1000).padStart(3, '0')}`;
++}
++
++const oneLine = (s) => String(s == null ? '' : s).replace(/\s+/g, ' ').trim();
++
++/** The header every text file starts with: what it is, from where, and how to get back to it. */
++function textHeader(meta, what) {
++ return [meta.title, [meta.channel, meta.date, meta.length].filter(Boolean).join(' · '), meta.url, what, ''].filter((l, i) => l || i === 4).join('\n');
++}
++
++// ── Formats ────────────────────────────────────────────────
++const transcriptFormats = {
++ txt: (meta, segs) => `${textHeader(meta, 'Transcript (AI)')}\n${segs.map((s) => `[${clockLabel(s.start, meta.long)}] ${oneLine(s.text)}`).join('\n')}\n`,
++ srt: (_meta, segs) => `${segs.map((s, i) => `${i + 1}\n${srtTime(s.start)} --> ${srtTime(Math.max(s.end, s.start + 0.5))}\n${oneLine(s.text)}\n`).join('\n')}`,
++ json: (meta, segs) => `${JSON.stringify({ ...meta.ref, title: meta.title, url: meta.url, transcript: segs.map((s) => ({ start: s.start, end: s.end, text: oneLine(s.text) })) }, null, 2)}\n`,
++};
++
++/** A CSV cell: quoted when it must be, and a leading = + - @ made inert so a spreadsheet never runs it. */
++function csvCell(v) {
++ let s = String(v == null ? '' : v);
++ if (/^[=+\-@\t\r]/.test(s)) s = `'${s}`;
++ return /[",\r\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
++}
++
++const chatFormats = {
++ txt: (meta, rows) => `${textHeader(meta, `Chat replay (${rows.length} messages)`)}\n${rows.map((r) => `[${clockLabel(r.offset, meta.long)}] ${r.name || r.username || '?'}: ${oneLine(r.text)}`).join('\n')}\n`,
++ csv: (meta, rows) => `${['time', 'seconds', 'sent_at', 'username', 'display_name', 'message'].join(',')}\r\n${rows.map((r) => [clockLabel(r.offset, meta.long), r.offset, r.sentAt || '', r.username || '', r.name || '', oneLine(r.text)].map(csvCell).join(',')).join('\r\n')}\r\n`,
++ json: (meta, rows) => `${JSON.stringify({ ...meta.ref, title: meta.title, url: meta.url, messages: rows.map((r) => ({ seconds: r.offset, sent_at: r.sentAt || null, username: r.username || null, display_name: r.name || null, message: r.text })) }, null, 2)}\n`,
++};
++
++/**
++ * Chapters as a YouTube description takes them: one "m:ss Title" line each, the first at 0:00 (YouTube needs that,
++ * so a first chapter later than the start gets a "Start" line before it).
++ */
++const chapterFormats = {
++ txt: (meta, chs) => {
++ const lines = chs.map((c) => `${clockLabel(c.offset_seconds, meta.long)} ${oneLine(c.description)}`);
++ if (chs.length && chs[0].offset_seconds >= 1) lines.unshift(`${clockLabel(0, meta.long)} Start`);
++ return `${lines.join('\n')}\n`;
++ },
++ json: (meta, chs) => `${JSON.stringify({ ...meta.ref, title: meta.title, url: meta.url, chapters: chs.map((c, i) => ({ start: c.offset_seconds, end: i + 1 < chs.length ? chs[i + 1].offset_seconds : (meta.seconds || null), title: oneLine(c.description) })) }, null, 2)}\n`,
++};
++
++/** What the item is, as one JSON file: the page's facts and where each of its files is. */
++function infoJson(meta, item, kind, files) {
++ const o = {
++ ...meta.ref,
++ title: meta.title,
++ url: meta.url,
++ channel: meta.channel || null,
++ created_at: item.created_at || null,
++ duration_seconds: item.duration_seconds == null ? null : Number(item.duration_seconds),
++ description: item.description || null,
++ summary: item.ai_overview || null,
++ visibility: item.visibility || null,
++ views: item.view_count == null ? null : Number(item.view_count),
++ };
++ if (kind === 'vod') {
++ Object.assign(o, {
++ category: item.stream_category || null,
++ stream: item.stream_id ? { id: Number(item.stream_id), started_at: item.stream_started_at || null, ended_at: item.stream_ended_at || null, part: item.part || null, starts_at_second: item.stream_offset_sec == null ? null : Number(item.stream_offset_sec) } : null,
++ });
++ } else {
++ Object.assign(o, {
++ recording: item.vod_id ? { id: Number(item.vod_id), from_second: item.start_time == null ? null : Number(item.start_time), url: meta.recordingUrl || null } : null,
++ made_by: item.username || null,
++ ai_clip: Boolean(item.auto_generated),
++ });
++ }
++ o.files = files;
++ return `${JSON.stringify(o, null, 2)}\n`;
++}
++
++// ── Zip ────────────────────────────────────────────────────
++const CRC_TABLE = (() => {
++ const t = new Uint32Array(256);
++ for (let n = 0; n < 256; n++) { let c = n; for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1; t[n] = c >>> 0; }
++ return t;
++})();
++function crc32(buf) {
++ if (typeof zlib.crc32 === 'function') return zlib.crc32(buf) >>> 0;
++ let c = 0xffffffff;
++ for (let i = 0; i < buf.length; i++) c = CRC_TABLE[(c ^ buf[i]) & 0xff] ^ (c >>> 8);
++ return (c ^ 0xffffffff) >>> 0;
++}
++
++/** A .zip of [{ name, data }] (deflated, UTF-8 names, one timestamp). Small text files: built in memory. */
++function zip(files, when = new Date()) {
++ const dosTime = ((when.getHours() << 11) | (when.getMinutes() << 5) | (when.getSeconds() >> 1)) & 0xffff;
++ const dosDate = (((when.getFullYear() - 1980) << 9) | ((when.getMonth() + 1) << 5) | when.getDate()) & 0xffff;
++ const local = [];
++ const central = [];
++ let offset = 0;
++ for (const f of files) {
++ const data = Buffer.isBuffer(f.data) ? f.data : Buffer.from(String(f.data), 'utf8');
++ const name = Buffer.from(f.name, 'utf8');
++ const packed = zlib.deflateRawSync(data, { level: 9 });
++ const stored = packed.length >= data.length;
++ const body = stored ? data : packed;
++ const crc = crc32(data);
++ const head = Buffer.alloc(30);
++ head.writeUInt32LE(0x04034b50, 0); head.writeUInt16LE(20, 4); head.writeUInt16LE(0x0800, 6); head.writeUInt16LE(stored ? 0 : 8, 8);
++ head.writeUInt16LE(dosTime, 10); head.writeUInt16LE(dosDate, 12); head.writeUInt32LE(crc, 14);
++ head.writeUInt32LE(body.length, 18); head.writeUInt32LE(data.length, 22); head.writeUInt16LE(name.length, 26); head.writeUInt16LE(0, 28);
++ local.push(head, name, body);
++ const dir = Buffer.alloc(46);
++ dir.writeUInt32LE(0x02014b50, 0); dir.writeUInt16LE(20, 4); dir.writeUInt16LE(20, 6); dir.writeUInt16LE(0x0800, 8); dir.writeUInt16LE(stored ? 0 : 8, 10);
++ dir.writeUInt16LE(dosTime, 12); dir.writeUInt16LE(dosDate, 14); dir.writeUInt32LE(crc, 16); dir.writeUInt32LE(body.length, 20); dir.writeUInt32LE(data.length, 24);
++ dir.writeUInt16LE(name.length, 28); dir.writeUInt32LE(offset, 42);
++ central.push(dir, name);
++ offset += head.length + name.length + body.length;
++ }
++ const dirBuf = Buffer.concat(central);
++ const end = Buffer.alloc(22);
++ end.writeUInt32LE(0x06054b50, 0); end.writeUInt16LE(files.length, 8); end.writeUInt16LE(files.length, 10);
++ end.writeUInt32LE(dirBuf.length, 12); end.writeUInt32LE(offset, 16);
++ return Buffer.concat([...local, dirBuf, end]);
++}
++
++// ── The menu ───────────────────────────────────────────────
++const FORMAT_HELP = {
++ mp4: 'The video file', webm: 'The video file', jpg: 'The cover picture', png: 'The cover picture', webp: 'The cover picture',
++ txt: 'Plain text, with times', srt: 'Subtitles for video editors and players (SubRip)', vtt: 'Web captions (WebVTT)',
++ csv: 'A table for spreadsheets', json: 'Data for programs', zip: 'Everything in one file',
++};
++const CHAPTER_TXT_HELP = 'Times and titles, ready to paste into a YouTube description';
++
++/** One row of the menu: an icon, what it is (and a line about it), and a link per format. */
++function row({ icon, label, about, links }) {
++ return html`<li class="ovv-dl-row"><i class="${icon}" aria-hidden="true"></i><span class="ovv-dl-what"><span class="ovv-dl-label">${label}</span>${about ? html`<span class="ovv-dl-about">${about}</span>` : ''}</span><span class="ovv-dl-formats">${links.map((l) => html`<a class="ovv-dl-format${l.main ? ' ovv-dl-format--main' : ''}" href="${l.href}" download rel="nofollow" title="${l.help || FORMAT_HELP[l.ext] || ''}" aria-label="${label}: ${l.text || l.ext.toUpperCase()} (${l.help || FORMAT_HELP[l.ext] || ''})">${l.text || l.ext.toUpperCase()}</a>`)}</span></li>`;
++}
++
++/** "570.7 MB" for a byte count (null when unknown). */
++function sizeLabel(bytes) {
++ const n = Number(bytes);
++ if (!Number.isFinite(n) || n <= 0) return null;
++ const units = ['bytes', 'KB', 'MB', 'GB', 'TB'];
++ let v = n; let u = 0;
++ while (v >= 1000 && u < units.length - 1) { v /= 1000; u++; }
++ return `${u ? v.toFixed(v < 10 ? 1 : 0) : v} ${units[u]}`;
++}
++
++/** The file extension a video download will have (Media adds the real one; MP4 unless the file says otherwise). */
++function videoExt(item) {
++ const p = String((item && (item.file_path || item.file_url)) || '');
++ return /\.webm(\?|$)/i.test(p) ? 'webm' : 'mp4';
++}
++
++/**
++ * The Download button and its menu: what this item has, each in its formats. `has` = { transcript: n, chat: n,
++ * chapters: n } (counts; 0 = left out). A <details> menu: it opens and closes without JavaScript; video.js only closes
++ * it on a click elsewhere or Escape and keeps it on screen.
++ */
++function downloadMenu({ kind, id, item, has }) {
++ const at = `/${kind === 'clip' ? 'c' : 'v'}/${id}/download`;
++ const ready = !item.is_recording && (kind === 'vod' || !item.status || item.status === 'ready');
++ const rows = [];
++ if (ready) {
++ const size = sizeLabel(item.file_size);
++ const ext = videoExt(item);
++ rows.push(row({ icon: 'fa-solid fa-film', label: 'Video', about: [kind === 'clip' ? 'The clip' : 'The whole recording', size].filter(Boolean).join(' · '), links: [{ href: `${at}/video`, ext, main: true }] }));
++ }
++ if (has.transcript) rows.push(row({ icon: 'fa-solid fa-closed-captioning', label: 'Transcript', about: `${has.transcript} lines, by AI`, links: ['txt', 'srt', 'vtt', 'json'].map((ext) => ({ href: `${at}/transcript.${ext}`, ext })) }));
++ if (has.chat) rows.push(row({ icon: 'fa-solid fa-comments', label: 'Chat replay', about: `${has.chat} messages`, links: ['txt', 'csv', 'json'].map((ext) => ({ href: `${at}/chat.${ext}`, ext })) }));
++ if (has.chapters) rows.push(row({ icon: 'fa-solid fa-list-ol', label: 'Chapters', about: `${has.chapters} chapters`, links: [{ href: `${at}/chapters.txt`, ext: 'txt', help: CHAPTER_TXT_HELP }, { href: `${at}/chapters.vtt`, ext: 'vtt' }, { href: `${at}/chapters.json`, ext: 'json' }] }));
++ if (item.thumbnail_url) rows.push(row({ icon: 'fa-regular fa-image', label: 'Cover picture', about: null, links: [{ href: `${at}/thumbnail`, ext: 'jpg', text: 'Image' }] }));
++ rows.push(row({ icon: 'fa-solid fa-circle-info', label: 'Details', about: 'Title, channel, dates, summary', links: [{ href: `${at}/info.json`, ext: 'json' }] }));
++ const everything = html`<li class="ovv-dl-row ovv-dl-row--all"><i class="fa-solid fa-file-zipper" aria-hidden="true"></i><span class="ovv-dl-what"><span class="ovv-dl-label">Everything</span><span class="ovv-dl-about">All of the above but the video, in one .zip</span></span><span class="ovv-dl-formats"><a class="ovv-dl-format ovv-dl-format--main" href="${at}/all.zip" download rel="nofollow" title="${FORMAT_HELP.zip}" aria-label="Everything: ZIP (${FORMAT_HELP.zip})">ZIP</a></span></li>`;
++ return html`<details class="ovv-dl" data-ovv-dl><summary class="ovv-btn"><i class="fa-solid fa-download" aria-hidden="true"></i> Download</summary>
++<div class="ovv-dl-menu" role="group" aria-label="Download this ${kind === 'clip' ? 'clip' : 'recording'}">
++<div class="ovv-dl-head"><span>Download this ${kind === 'clip' ? 'clip' : 'recording'}</span><button type="button" class="ovv-dl-close" data-ovv-dl-close aria-label="Close"><i class="fa-solid fa-xmark" aria-hidden="true"></i></button></div>
++<ul class="ovv-dl-list">${rows}${everything}</ul>
++<p class="ovv-dl-hint">Hover or long-press a format to see what it is for.</p>
++</div></details>`;
++}
++
++/** A panel's own links ("Download: TXT · SRT …"), for the transcript, chapters and chat replay panels. */
++function panelLinks(kind, id, what, exts, helps = {}) {
++ const at = `/${kind === 'clip' ? 'c' : 'v'}/${id}/download/${what}`;
++ return html`<p class="ovv-panel-dl"><i class="fa-solid fa-download" aria-hidden="true"></i> Download ${exts.map((ext, i) => html`${i ? ' · ' : ''}<a href="${at}.${ext}" download rel="nofollow" title="${helps[ext] || FORMAT_HELP[ext] || ''}">${ext.toUpperCase()}</a>`)}</p>`;
++}
++
++// ── Routes ─────────────────────────────────────────────────
++const TYPES = {
++ txt: 'text/plain; charset=utf-8', srt: 'application/x-subrip; charset=utf-8', vtt: 'text/vtt; charset=utf-8',
++ json: 'application/json; charset=utf-8', csv: 'text/csv; charset=utf-8', zip: 'application/zip',
++};
++const VOD_FILES = new Set(['video', 'thumbnail', 'info.json', 'all.zip',
++ 'transcript.txt', 'transcript.srt', 'transcript.vtt', 'transcript.json', 'chat.txt', 'chat.csv', 'chat.json',
++ 'chapters.txt', 'chapters.vtt', 'chapters.json']);
++const CLIP_FILES = new Set(['video', 'thumbnail', 'info.json', 'all.zip', 'transcript.txt', 'transcript.srt', 'transcript.vtt', 'transcript.json']);
++
++/**
++ * Mount /v/:id/download/:file and /c/:id/download/:file. `deps` are the watch pages' own readers, so a file holds
++ * exactly what the page shows.
++ */
++function mountDownloads(r, deps) {
++ const { live, chat, ownView, asPerson, notFound, base, httpsUrl, fetchImpl, log,
++ transcriptSegments, moments, chapters, chatMessageRows, renderCaptionsVtt, renderChaptersVtt } = deps;
++
++ /** The item as the page shows it to this reader (public, or their own private one) → { item, own } or null. */
++ async function load(req, res, kind, id) {
++ let data = kind === 'vod' ? await live.vod(id) : await live.clip(id);
++ let own = false;
++ if (!data || !(kind === 'vod' ? data.vod : data.clip)) {
++ data = await ownView(req, res, kind, id);
++ own = true;
++ }
++ const item = data && (kind === 'vod' ? data.vod : data.clip);
++ return item ? { item, own } : null;
++ }
++
++ function metaOf(kind, id, item) {
++ const seconds = Number(item.duration_seconds) || 0;
++ return {
++ ref: { [kind === 'clip' ? 'clip_id' : 'recording_id']: Number(id) },
++ title: item.title || (kind === 'clip' ? 'Clip' : 'Stream recording'),
++ channel: kind === 'clip' ? (item.source_streamer_display_name || item.source_streamer_username || null) : (item.display_name || item.username || null),
++ date: /^\d{4}-\d{2}-\d{2}/.test(String(item.created_at || '')) ? String(item.created_at).slice(0, 10) : null,
++ length: seconds ? clockLabel(seconds) : null,
++ seconds: seconds || null,
++ long: seconds >= 3600,
++ url: `${base}/${kind === 'clip' ? 'c' : 'v'}/${id}`,
++ recordingUrl: kind === 'clip' && item.vod_id ? `${base}/v/${item.vod_id}` : null,
++ };
++ }
++
++ /** The memories as chapters (as the reader: a private recording's are only Live's answer to its owner). */
++ async function chapterList(req, res, id, item, own) {
++ try {
++ const m = own ? await asPerson(req, res, (token) => live.vodMemoriesAs(token, id)) : await live.vodMemories(id);
++ return chapters(moments(m && m.memories), item.duration_seconds);
++ } catch { return []; }
++ }
++ async function chatRows(item) {
++ try {
++ const replay = await chat.chatReplay(item.stream_id);
++ const out = chatMessageRows(item, replay && Array.isArray(replay.messages) ? replay.messages : [], Infinity);
++ return out ? out.rows : [];
++ } catch { return []; }
++ }
++
++ function sendText(res, own, file, ext, body) {
++ res.set({
++ 'Content-Type': TYPES[ext], 'Content-Disposition': disposition(file), 'X-Robots-Tag': 'noindex',
++ 'Cache-Control': own ? 'private, no-store' : 'public, max-age=300', 'X-Content-Type-Options': 'nosniff',
++ });
++ return res.send(body);
++ }
++ const missing = (res) => res.status(404).set({ 'Content-Type': 'text/plain; charset=utf-8', 'X-Robots-Tag': 'noindex' }).send('Not found');
++
++ /** The cover picture's bytes (Media's thumbnail), or null. Only an https URL from Live is fetched. */
++ async function thumbnail(item) {
++ const url = httpsUrl(item.thumbnail_url);
++ if (!url) return null;
++ try {
++ const up = await fetchImpl(url, { headers: { Accept: 'image/*' }, credentials: 'omit', signal: AbortSignal.timeout(FETCH_MS) });
++ if (!up.ok) return null;
++ const type = String(up.headers.get('content-type') || '').split(';')[0].trim();
++ if (!/^image\/(jpeg|png|webp)$/.test(type)) return null;
++ const body = Buffer.from(await up.arrayBuffer());
++ if (!body.length || body.length > THUMB_MAX_BYTES) return null;
++ return { body, type, ext: type === 'image/jpeg' ? 'jpg' : type.slice(6) };
++ } catch { return null; }
++ }
++
++ /** Every text file of the item: [{ name, data }] (what "Everything" zips). */
++ async function textFiles(req, res, kind, id, item, own, meta, base_) {
++ const files = [];
++ const segs = transcriptSegments(item);
++ if (segs.length) {
++ files.push({ name: `${base_}.transcript.txt`, data: transcriptFormats.txt(meta, segs) },
++ { name: `${base_}.transcript.srt`, data: transcriptFormats.srt(meta, segs) },
++ { name: `${base_}.transcript.vtt`, data: renderCaptionsVtt(segs) },
++ { name: `${base_}.transcript.json`, data: transcriptFormats.json(meta, segs) });
++ }
++ if (kind === 'vod') {
++ const [rows, chs] = await Promise.all([chatRows(item), chapterList(req, res, id, item, own)]);
++ if (rows.length) {
++ files.push({ name: `${base_}.chat.txt`, data: chatFormats.txt(meta, rows) },
++ { name: `${base_}.chat.csv`, data: chatFormats.csv(meta, rows) },
++ { name: `${base_}.chat.json`, data: chatFormats.json(meta, rows) });
++ }
++ if (chs.length) {
++ files.push({ name: `${base_}.chapters.txt`, data: chapterFormats.txt(meta, chs) },
++ { name: `${base_}.chapters.vtt`, data: renderChaptersVtt(chs, item.duration_seconds) },
++ { name: `${base_}.chapters.json`, data: chapterFormats.json(meta, chs) });
++ }
++ }
++ return files;
++ }
++
++ function route(kind) {
++ const allowed = kind === 'vod' ? VOD_FILES : CLIP_FILES;
++ return async (req, res) => {
++ const id = String(req.params.id || '');
++ const file = String(req.params.file || '');
++ if (!live.isId(id) || !allowed.has(file)) return notFound(req, res);
++ const found = await load(req, res, kind, id);
++ if (!found) return notFound(req, res);
++ const { item, own } = found;
++ const meta = metaOf(kind, id, item);
++ const name = fileBase(item, kind);
++
++ if (file === 'video') {
++ // OpenVibe.Media's file as an attachment: Live hands out its URL (a signed one for an owner's private item).
++ try {
++ const got = own
++ ? await asPerson(req, res, (token) => live.downloadUrlAs(token, kind, id, name))
++ : await live.downloadUrl(kind, id, name);
++ const url = got && httpsUrl(got.url);
++ if (!url) return notFound(req, res);
++ res.set({ 'Cache-Control': 'private, no-store', 'X-Robots-Tag': 'noindex' });
++ return res.redirect(302, url);
++ } catch (err) {
++ log.warn('[OpenVibe.Video] download link failed', err && err.message);
++ return res.status(502).set('Content-Type', 'text/plain; charset=utf-8').send('OpenVibe.Live did not give a download link; try again in a minute.');
++ }
++ }
++ if (file === 'thumbnail') {
++ const t = await thumbnail(item);
++ if (!t) return missing(res);
++ res.set({ 'Content-Type': t.type, 'Content-Disposition': disposition(`${name}.${t.ext}`), 'X-Robots-Tag': 'noindex', 'Cache-Control': own ? 'private, no-store' : 'public, max-age=3600' });
++ return res.send(t.body);
++ }
++
++ const [what, ext] = file.split('.');
++ if (what === 'transcript') {
++ const segs = transcriptSegments(item);
++ if (!segs.length) return missing(res);
++ const body = ext === 'vtt' ? renderCaptionsVtt(segs) : transcriptFormats[ext](meta, segs);
++ return sendText(res, own, `${name}.transcript.${ext}`, ext, body);
++ }
++ if (what === 'chat') {
++ const rows = await chatRows(item);
++ if (!rows.length) return missing(res);
++ return sendText(res, own, `${name}.chat.${ext}`, ext, chatFormats[ext](meta, rows));
++ }
++ if (what === 'chapters') {
++ const chs = await chapterList(req, res, id, item, own);
++ if (!chs.length) return missing(res);
++ const body = ext === 'vtt' ? renderChaptersVtt(chs, item.duration_seconds) : chapterFormats[ext](meta, chs);
++ return sendText(res, own, `${name}.chapters.${ext}`, ext, body);
++ }
++ const texts = await textFiles(req, res, kind, id, item, own, meta, name);
++ const listing = Object.fromEntries(texts.map((f) => [f.name.slice(name.length + 1), f.name]));
++ const info = infoJson(meta, item, kind, { video: `${meta.url}/download/video`, ...listing });
++ if (file === 'info.json') return sendText(res, own, `${name}.info.json`, 'json', info);
++ // all.zip: a folder named after the item, every text file, the details and the cover picture.
++ const t = await thumbnail(item);
++ const inZip = [{ name: `${name}/${name}.info.json`, data: info }, ...texts.map((f) => ({ name: `${name}/${f.name}`, data: f.data }))];
++ if (t) inZip.push({ name: `${name}/${name}.${t.ext}`, data: t.body });
++ return sendText(res, own, `${name}.zip`, 'zip', zip(inZip));
++ };
++ }
++
++ r.get('/v/:id/download/:file', route('vod'));
++ r.get('/c/:id/download/:file', route('clip'));
++}
++
++module.exports = {
++ mountDownloads, downloadMenu, panelLinks, fileBase, disposition, clockLabel, srtTime, csvCell, zip, crc32, sizeLabel,
++ transcriptFormats, chatFormats, chapterFormats, infoJson, CHAPTER_TXT_HELP,
++};
ddiff --git a/server/video/live-client.js b/server/video/live-client.js
iindex b9c2943..37018f2 100644
---- a/server/video/live-client.js
++++ b/server/video/live-client.js
@@@ -165,6 +165,20 @@ function createLiveClient({ config, fetchImpl = globalThis.fetch, log = console
async clipAs(token, id) {
try { return await asPerson(token, 'GET', `/api/clips/${id}`); } catch (err) { if (err.status === 404) return null; throw err; }
},
++ /** A private recording's moments, as its owner (the public read answers 404 for it). */
++ async vodMemoriesAs(token, id) {
++ try { return await asPerson(token, 'GET', `/api/vods/${id}/memories`); } catch (err) { if (err.status === 404) return null; throw err; }
++ },
++ /**
++ * Where the file of a recording or clip downloads from: { url } (OpenVibe.Media's bytes as an attachment named
++ * `name`), null when Live has no such item. As the person for a private item of theirs (a signed URL).
++ */
++ downloadUrl(kind, id, name) {
++ return getJson(`/api/${kind === 'clip' ? 'clips' : 'vods'}/${id}/download-url?name=${encodeURIComponent(String(name || ''))}`);
++ },
++ async downloadUrlAs(token, kind, id, name) {
++ try { return await asPerson(token, 'GET', `/api/${kind === 'clip' ? 'clips' : 'vods'}/${id}/download-url?name=${encodeURIComponent(String(name || ''))}`); } catch (err) { if (err.status === 404) return null; throw err; }
++ },
updateVod: (token, id, fields) => asPerson(token, 'PUT', `/api/vods/${id}`, fields),
deleteVod: (token, id) => asPerson(token, 'DELETE', `/api/vods/${id}`),
setClipTitle: (token, id, title) => asPerson(token, 'PUT', `/api/clips/${id}/title`, { title }),
ddiff --git a/server/video/pages.js b/server/video/pages.js
iindex 309b6e6..5f322b9 100644
---- a/server/video/pages.js
++++ b/server/video/pages.js
@@@ -13,6 +13,8 @@
* /@:username a channel's recordings (and clips), newest first
* /v/:id/captions.vtt the AI transcript as WebVTT captions
* /v/:id/chapters.vtt the AI moments as WebVTT chapters
++ * /v/:id/download/:file the recording, its transcript, chat replay, chapters, details or all of it as files
++ * /c/:id/download/:file the clip, its transcript and details (server/video/downloads.js)
* /sitemap.xml /robots.txt /llms.txt for the video host
*
* An unlisted item (visibility 'unlisted') is reachable by id but noindex and never in a list or
@@@ -39,6 +41,7 @@ const formatBytes = (b) => rules.formatBytes(b).replace(/\.0 /, ' ');
const { createVideoApiRoutes } = require('./api');
const { createUploadApi, mountUploadPages } = require('./upload-routes');
const { ACCESS_COOKIE, REFRESH_COOKIE } = require('../auth/sso');
++const { mountDownloads, downloadMenu, panelLinks, CHAPTER_TXT_HELP } = require('./downloads');
const PUBLIC_DIR = path.join(__dirname, '..', '..', 'public');
const HOME_LIMIT = 10;
@@@ -277,11 +280,18 @@ function relativeDate(iso) {
return `${y} year${y === 1 ? '' : 's'} ago`;
}
--/** The replay messages that belong in the chat panel: oldest first, at most 1,000, null when none. */
--function chatMessageRows(vod, messages) {
-- const start = utcSeconds(vod && vod.stream_started_at);
-- if (!Number.isFinite(start) || !Array.isArray(messages)) return null;
-- const end = utcSeconds(vod.stream_ended_at);
++/**
++ * The replay messages that belong to this recording, oldest first: at most `limit` (the panel shows 1,000; a download
++ * takes them all), null when none. Times are seconds into the recording: a part of a stream recorded in parts (Live's
++ * stream_offset_sec: its broadcaster reconnected) starts that much after the stream and lasts its own length.
++ */
++function chatMessageRows(vod, messages, limit = 1000) {
++ const streamStart = utcSeconds(vod && vod.stream_started_at);
++ if (!Number.isFinite(streamStart) || !Array.isArray(messages)) return null;
++ const partAt = Number(vod.stream_offset_sec);
++ const inPart = vod.stream_offset_sec != null && Number.isFinite(partAt) && partAt >= 0;
++ const start = streamStart + (inPart ? partAt : 0);
++ const end = inPart && Number(vod.duration_seconds) > 0 ? start + Number(vod.duration_seconds) : utcSeconds(vod.stream_ended_at);
const rows = [];
for (const m of messages) {
if (!m || typeof m !== 'object') continue;
@@@ -292,12 +302,12 @@ function chatMessageRows(vod, messages) {
const offset = at - start;
if (offset < 0) continue;
if (Number.isFinite(end) && offset > end - start + 60) continue;
-- rows.push({ offset: Math.floor(offset), name: m.display_name || m.username || '', text: m.message == null ? '' : String(m.message) });
++ rows.push({ offset: Math.floor(offset), name: m.display_name || m.username || '', username: m.username || '', sentAt: new Date(at * 1000).toISOString(), text: m.message == null ? '' : String(m.message) });
}
rows.sort((a, b) => a.offset - b.offset);
if (!rows.length) return null;
-- const leftOut = rows.length > 1000 ? rows.length - 1000 : 0;
-- return { rows: rows.slice(0, 1000), leftOut };
++ const leftOut = rows.length > limit ? rows.length - limit : 0;
++ return { rows: rows.slice(0, limit), leftOut };
}
function commentItem(c) {
@@@ -636,6 +646,10 @@ ${kind === 'vod' ? html`<label class="ovv-field"><span>Description</span><textar
// ── Uploads: /upload, /u/:id, /me/videos (server/video/upload-routes.js) ──
const uploadCard = mountUploadPages(r, { ctx, page, notFound, signedIn, ownerOf, mustSignIn, sameSite, isStaff, visibilityOptions, visibilityLabel, durationLabel, dateLabel, httpsUrl, fileOf, hlsAttrs, grid, card, renderCaptionsVtt, renderChaptersVtt, vttTime, sendVtt, notFoundVtt, playlistForm, upNext });
++ // ── Downloads: /v/:id/download/:file and /c/:id/download/:file (server/video/downloads.js) ──
++ mountDownloads(r, { live, chat, ownView, asPerson, notFound, base, httpsUrl, log, fetchImpl: ctx.fetchImpl || globalThis.fetch,
++ transcriptSegments, moments, chapters, chatMessageRows, renderCaptionsVtt, renderChaptersVtt });
++
// ── Playlist page pieces ───────────────────────────────
async function playlistForm(req, kind, id) {
if (!signedIn(req)) {
@@@ -1030,7 +1044,8 @@ ${found ? sections.map((x) => (x.cards.length || kind ? html`<section class="ovv
// (one after another, five services' answers in turn, the page's p95 was 581 ms on 2026-10-17).
const fail = Symbol('failed');
const [m, cm, replay, saveForm, up] = await Promise.all([
-- Promise.resolve().then(() => live.vodMemories(id)).catch(() => fail),
++ // A private recording's moments are Live's answer to its owner only.
++ Promise.resolve().then(() => (ownOnly ? asPerson(req, res, (token) => live.vodMemoriesAs(token, id)) : live.vodMemories(id))).catch(() => fail),
Promise.resolve().then(() => live.comments('vod', id)).catch(() => fail),
Promise.resolve().then(() => chat.chatReplay(vod.stream_id)).catch(() => fail),
playlistForm(req, 'vod', id),
@@@ -1092,19 +1107,19 @@ ${savedNotice(req)}${ownOnly ? notice('Private: only you (and OpenVibe staff) ca
${channelName ? html`<a class="ovv-avatar" href="${localChannel || liveChannel}" aria-hidden="true" tabindex="-1">${String(channelName).slice(0, 1).toUpperCase()}${live.isUsername(String(vod.username || '')) ? html`<img src="${config.networkUrl}/avatar/${vod.username}" alt="" loading="lazy">` : ''}</a>` : ''}
<div class="ovv-byline-text">${channelName ? (localChannel ? html`<a class="ovv-channel-name" href="${localChannel}">@${channelName}</a>` : html`<span class="ovv-channel-name">@${channelName}</span>`) : ''}
${metaParts.length ? html`<span class="ovv-meta">${metaParts.map((p, i) => (i === 0 ? p : [html` · `, p]))}</span>` : ''}</div>
--<div class="ovv-byline-actions">${saveForm}${channelName ? html`<a class="ovv-btn" href="${liveChannel}"><i class="fa-solid fa-tower-broadcast" aria-hidden="true"></i> Channel on OpenVibe.Live</a>` : ''}${threadUrl ? html`<a class="ovv-btn" href="${threadUrl}"><i class="fa-regular fa-comments" aria-hidden="true"></i> Discuss on OpenVibe.Community</a>` : ''}</div>
++<div class="ovv-byline-actions">${saveForm}${downloadMenu({ kind: 'vod', id, item: vod, has: { transcript: segs.length, chat: chatRows ? chatRows.rows.length + chatRows.leftOut : 0, chapters: mems.length } })}${channelName ? html`<a class="ovv-btn" href="${liveChannel}"><i class="fa-solid fa-tower-broadcast" aria-hidden="true"></i> Channel on OpenVibe.Live</a>` : ''}${threadUrl ? html`<a class="ovv-btn" href="${threadUrl}"><i class="fa-regular fa-comments" aria-hidden="true"></i> Discuss on OpenVibe.Community</a>` : ''}</div>
</div>
${vod.is_recording ? html`<p class="ovv-recording" role="status"><i class="fa-solid fa-circle" aria-hidden="true"></i> This recording is still in progress. <a href="${liveChannel}">Watch the live channel</a>.</p>` : ''}
${vod.ai_overview ? html`<section class="ovv-panel" aria-labelledby="h-overview"><h2 id="h-overview">About this stream</h2><p>${vod.ai_overview}</p><p class="ovv-fine">Written by AI from the stream.</p></section>` : ''}
--${segs.length ? html`<details class="ovv-panel ovv-transcript"><summary>Transcript <span class="ovv-fine">(AI, ${segs.length} lines)</span></summary><div class="ovv-transcript-body">${segs.map((s) => html`<p><button type="button" class="ovv-seek" data-ovv-seek="${s.start}">${vttTime(s.start).slice(0, 8)}</button> <span>${s.text}</span></p>`)}</div></details>` : ''}
++${segs.length ? html`<details class="ovv-panel ovv-transcript"><summary>Transcript <span class="ovv-fine">(AI, ${segs.length} lines)</span></summary>${panelLinks('vod', id, 'transcript', ['txt', 'srt', 'vtt', 'json'])}<div class="ovv-transcript-body">${segs.map((s) => html`<p><button type="button" class="ovv-seek" data-ovv-seek="${s.start}">${vttTime(s.start).slice(0, 8)}</button> <span>${s.text}</span></p>`)}</div></details>` : ''}
${commentsOk ? html`<section class="ovv-panel ovv-comments" aria-labelledby="h-comments"><h2 id="h-comments">Comments</h2>${commentRows.length ? commentRows.map(commentItem) : html`<p class="ovv-comments-empty">No comments yet.</p>`}${threadUrl ? html`<p class="ovv-comments-link"><a class="ovv-btn" href="${threadUrl}">Join the discussion on OpenVibe.Community</a></p>` : ''}</section>` : ''}
</div>
<aside class="ovv-side">
${manageable ? ownerPanel('vod', id, vod) : ''}
${ownOnly ? '' : clipPanel(req, id, vod)}
${up.html}
--${mems.length ? html`<section class="ovv-panel ovv-chapters" aria-labelledby="h-chapters"><h2 id="h-chapters">Chapters</h2><ol>${mems.map((m) => html`<li><a href="/v/${id}?t=${m.offset_seconds}" data-ovv-chapter="${m.offset_seconds}"><span class="ovv-time">${vttTime(m.offset_seconds).slice(0, 8)}</span><span>${m.description}</span></a></li>`)}</ol><p class="ovv-fine">From the AI moments of the stream.</p></section>` : ''}
--${chatRows ? html`<section class="ovv-panel ovv-chat" aria-labelledby="h-chat"><h2 id="h-chat">Chat replay</h2><div class="ovv-chat-body" data-ovv-chat>${chatRows.rows.map((r) => html`<div class="ovv-chat-row" data-ovv-at="${r.offset}"><button type="button" class="ovv-seek" data-ovv-seek="${r.offset}">${streamTime(r.offset)}</button><span class="ovv-chat-msg">${r.name ? html`<b class="ovv-chat-name">${r.name}</b> ` : ''}${r.text}</span></div>`)}</div>${chatRows.leftOut ? html`<p class="ovv-fine">Showing the first 1,000 messages; ${chatRows.leftOut} more not shown.</p>` : ''}</section>` : ''}
++${mems.length ? html`<section class="ovv-panel ovv-chapters" aria-labelledby="h-chapters"><h2 id="h-chapters">Chapters</h2><ol>${mems.map((m) => html`<li><a href="/v/${id}?t=${m.offset_seconds}" data-ovv-chapter="${m.offset_seconds}"><span class="ovv-time">${vttTime(m.offset_seconds).slice(0, 8)}</span><span>${m.description}</span></a></li>`)}</ol><p class="ovv-fine">From the AI moments of the stream.</p>${panelLinks('vod', id, 'chapters', ['txt', 'vtt', 'json'], { txt: CHAPTER_TXT_HELP })}<p class="ovv-panel-dl" hidden data-ovv-copy-wrap><button type="button" class="ovv-btn ovv-btn--small" data-ovv-copy="/v/${id}/download/chapters.txt"><i class="fa-regular fa-copy" aria-hidden="true"></i> Copy for a YouTube description</button></p></section>` : ''}
++${chatRows ? html`<section class="ovv-panel ovv-chat" aria-labelledby="h-chat"><h2 id="h-chat">Chat replay</h2><div class="ovv-chat-body" data-ovv-chat>${chatRows.rows.map((r) => html`<div class="ovv-chat-row" data-ovv-at="${r.offset}"><button type="button" class="ovv-seek" data-ovv-seek="${r.offset}">${streamTime(r.offset)}</button><span class="ovv-chat-msg">${r.name ? html`<b class="ovv-chat-name">${r.name}</b> ` : ''}${r.text}</span></div>`)}</div>${chatRows.leftOut ? html`<p class="ovv-fine">Showing the first 1,000 messages; ${chatRows.leftOut} more not shown (the download has them all).</p>` : ''}${panelLinks('vod', id, 'chat', ['txt', 'csv', 'json'])}</section>` : ''}
${clips.length ? html`<section class="ovv-panel" aria-labelledby="h-clips"><h2 id="h-clips">Clips from this stream</h2><div class="ovv-grid ovv-grid--side">${clips.map(clipCard)}</div></section>` : ''}
</aside>
</div>`,
@@@ -1172,7 +1187,7 @@ ${cutting ? html`<div class="ovv-player-wrap ovv-cutting" role="status" data-ovv
<div class="ovv-byline">
<div class="ovv-byline-text"><span class="ovv-channel-name">${clip.auto_generated ? html`<span class="ovv-tag ovv-tag--inline">AI clip</span> ` : ''}${attribution}</span>
${clip.created_at ? html`<span class="ovv-meta"><time datetime="${clip.created_at}">${dateLabel(clip.created_at)}</time>${clip.duration_seconds != null ? html` · ${durationLabel(clip.duration_seconds)}` : ''}</span>` : ''}</div>
--<div class="ovv-byline-actions">${saveForm}${fullRecording ? html`<a class="ovv-btn ovv-btn--primary" href="${fullRecording}"><i class="fa-solid fa-film" aria-hidden="true"></i> Watch in the full recording</a>` : ''}${threadUrl ? html`<a class="ovv-btn" href="${threadUrl}"><i class="fa-regular fa-comments" aria-hidden="true"></i> Discuss on OpenVibe.Community</a>` : ''}</div>
++<div class="ovv-byline-actions">${saveForm}${cutting ? '' : downloadMenu({ kind: 'clip', id, item: clip, has: { transcript: transcriptSegments(clip).length } })}${fullRecording ? html`<a class="ovv-btn ovv-btn--primary" href="${fullRecording}"><i class="fa-solid fa-film" aria-hidden="true"></i> Watch in the full recording</a>` : ''}${threadUrl ? html`<a class="ovv-btn" href="${threadUrl}"><i class="fa-regular fa-comments" aria-hidden="true"></i> Discuss on OpenVibe.Community</a>` : ''}</div>
</div>
${up.html}
${clip.ai_overview ? html`<section class="ovv-panel"><p>${clip.ai_overview}</p><p class="ovv-fine">Written by AI from the clip.</p></section>` : ''}
ddiff --git a/test/video-downloads.test.js b/test/video-downloads.test.js
nnew file mode 100644
iindex 0000000..07955ff
---- /dev/null
++++ b/test/video-downloads.test.js
@@@ -0,0 +1,261 @@
++'use strict';
++/**
++ * Downloads on openvibe.video (server/video/downloads.js; owner, 2026-10-26: "easy and intuitive ways for downloading
++ * stuff like the transcripts, chat replay, chapters, etc … a generic download button").
++ *
++ * - The watch page has a Download button whose menu lists what the item has (video with its size, transcript, chat
++ * replay, chapters, cover picture, details, everything), each in its formats; the panels carry their own links.
++ * - Each file: the right type, an attachment named "<title> - <channel> - <date>.<what>.<ext>", noindex.
++ * Transcript TXT/SRT/VTT/JSON; chat TXT/CSV (formula-safe)/JSON with every message, in the recording's own time
++ * (a part of a stream starts later); chapters as YouTube lines (from 0:00), VTT and JSON; details JSON; a .zip of all.
++ * - The video is Live's download link (Media's attachment), as the person for their private recording; a private
++ * item is missing to anyone else, its files included.
++ */
++const assert = require('assert');
++const http = require('http');
++const zlib = require('zlib');
++const { boot, check, done } = require('./helpers/boot');
++const dl = require('../server/video/downloads');
++
++const usernameOf = (auth) => {
++ const tok = String(auth || '').replace(/^Bearer /, '');
++ try { return JSON.parse(Buffer.from(tok.split('.')[1], 'base64url').toString('utf8')).username || null; } catch { return null; }
++};
++
++const JPEG = Buffer.from('ffd8ffe000104a46494600010100000100010000ffd9', 'hex');
++const realFetch = globalThis.fetch;
++globalThis.fetch = async (url, opts) => {
++ if (String(url).startsWith('https://media.test/t/')) return new Response(JPEG, { status: 200, headers: { 'content-type': 'image/jpeg' } });
++ return realFetch(url, opts);
++};
++
++function startLive() {
++ const transcript = JSON.stringify([{ start: 11, end: 14.5, text: 'What is up, "guys"' }, { start: 20, end: 22, text: 'Bird drama' }]);
++ const vods = {
++ 6026: { id: 6026, stream_id: 2795, title: 'BIRD DRAMA (Part 2)', duration_seconds: 1921, file_size: 570688809, created_at: '2026-10-26 21:05:28',
++ stream_started_at: '2026-10-26 20:53:39', stream_ended_at: '2026-10-26 21:37:29', stream_offset_sec: 709, part: 2,
++ playback_url: 'https://openvibe.media/v/6026', file_url: 'https://openvibe.media/v/6026', thumbnail_url: 'https://media.test/t/vod-6026.jpg',
++ visibility: 'public', username: 'Maticus', display_name: 'Maticus', ai_transcript_json: transcript, stream_category: 'outdoors' },
++ 6030: { id: 6030, title: 'Kept to myself', duration_seconds: 60, created_at: '2026-10-25 10:00:00', visibility: 'private', username: 'Maticus', display_name: 'Maticus',
++ playback_url: 'https://openvibe.media/v/6030', ai_transcript_json: JSON.stringify([{ start: 1, end: 2, text: 'secret' }]) },
++ };
++ const clips = {
++ 505: { id: 505, vod_id: 6026, title: 'Clip from stream', start_time: 871.5, duration_seconds: 30, created_at: '2026-10-26 21:20:31', status: 'ready',
++ playback_url: 'https://openvibe.media/c/505', visibility: 'public', username: 'goosely', source_streamer_username: 'Maticus', thumbnail_url: 'https://media.test/t/clip-505.jpg' },
++ };
++ const memories = { 6026: [{ offset_seconds: 30, description: 'Birds at the feeder — heard: "look"' }, { offset_seconds: 900, description: 'A robot drives by' }], 6030: [{ offset_seconds: 5, description: 'Private moment' }] };
++ // Chat replay for stream 2795: stream time 709 s + 12 s is 12 s into part 2.
++ const at = (sec) => new Date(Date.parse('2026-10-26T20:53:39Z') + sec * 1000).toISOString();
++ const messages = [
++ { message_type: 'chat', timestamp: at(100), username: 'early', display_name: 'Early', message: 'in part one' },
++ { message_type: 'chat', timestamp: at(721), username: 'viewer1', display_name: 'Viewer One', message: 'hello, "world"' },
++ { message_type: 'chat', timestamp: at(735), username: 'mallory', display_name: 'Mallory', message: '=HYPERLINK("http://x")' },
++ { message_type: 'chat', timestamp: at(740), username: 'gone', message: 'deleted', is_deleted: 1 },
++ ];
++ const calls = [];
++ const server = http.createServer((req, res) => {
++ const url = new URL(req.url, 'http://127.0.0.1');
++ const who = usernameOf(req.headers.authorization);
++ calls.push({ path: url.pathname + url.search, who });
++ const json = (status, body) => { res.writeHead(status, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(body)); };
++ let m = /^\/api\/chat\/(\d+)\/replay$/.exec(url.pathname);
++ if (m) return json(200, { messages: m[1] === '2795' ? messages : [] });
++ m = /^\/api\/(vods|clips)\/(\d+)(\/memories|\/download-url)?$/.exec(url.pathname);
++ if (!m) return json(404, { error: 'not found' });
++ const item = (m[1] === 'vods' ? vods : clips)[m[2]];
++ if (!item || (item.visibility === 'private' && who !== item.username)) return json(404, { error: 'not found' });
++ if (m[3] === '/memories') return json(200, { memories: memories[m[2]] || [] });
++ if (m[3] === '/download-url') {
++ const signed = item.visibility === 'private' ? 'exp=1&sig=s&' : '';
++ return json(200, { url: `https://openvibe.media/${m[1] === 'vods' ? 'v' : 'c'}/${m[2]}?${signed}download=1&name=${encodeURIComponent(url.searchParams.get('name'))}` });
++ }
++ return json(200, m[1] === 'vods' ? { vod: item, clips: [] } : { clip: item });
++ });
++ return new Promise((resolve) => server.listen(0, '127.0.0.1', () => resolve({ url: `http://127.0.0.1:${server.address().port}`, calls, close: () => new Promise((r) => server.close(r)) })));
++}
++
++/** Files of a .zip (stored or deflated), checked against their CRC. */
++function unzip(buf) {
++ const out = {};
++ let o = 0;
++ while (buf.readUInt32LE(o) === 0x04034b50) {
++ const method = buf.readUInt16LE(o + 8);
++ const crc = buf.readUInt32LE(o + 14);
++ const size = buf.readUInt32LE(o + 18);
++ const nameLen = buf.readUInt16LE(o + 26);
++ const extra = buf.readUInt16LE(o + 28);
++ const name = buf.toString('utf8', o + 30, o + 30 + nameLen);
++ const body = buf.subarray(o + 30 + nameLen + extra, o + 30 + nameLen + extra + size);
++ const data = method === 8 ? zlib.inflateRawSync(body) : body;
++ assert.strictEqual(dl.crc32(data), crc, `${name}: CRC`);
++ out[name] = data;
++ o += 30 + nameLen + extra + size;
++ }
++ assert.strictEqual(buf.readUInt32LE(buf.length - 22), 0x06054b50, 'an end of central directory');
++ return out;
++}
++
++(async () => {
++ // Pure pieces.
++ await check('names, times and CSV cells', async () => {
++ assert.strictEqual(dl.fileBase({ title: 'BIRD DRAMA (Part 2)', display_name: 'Maticus', created_at: '2026-10-26 21:05:28' }, 'vod'), 'BIRD DRAMA (Part 2) - Maticus - 2026-10-26');
++ assert.strictEqual(dl.fileBase({ title: 'a/b: "c"?', username: 'x' }, 'clip'), 'a b c - x');
++ assert.ok(dl.fileBase({ title: 'w'.repeat(300), display_name: 'M', created_at: '2026-10-26' }, 'vod').length <= 120);
++ assert.strictEqual(dl.srtTime(3725.5), '01:02:05,500');
++ assert.strictEqual(dl.clockLabel(75), '1:15');
++ assert.strictEqual(dl.clockLabel(75, true), '0:01:15');
++ assert.strictEqual(dl.csvCell('=SUM(A1)'), "'=SUM(A1)");
++ assert.strictEqual(dl.csvCell('a "b", c'), '"a ""b"", c"');
++ assert.strictEqual(dl.sizeLabel(570688809), '571 MB');
++ assert.strictEqual(dl.disposition('Ünïcode "x".txt'), 'attachment; filename="_n_code _x_.txt"; filename*=UTF-8\'\'%C3%9Cn%C3%AFcode%20%22x%22.txt');
++ });
++
++ const live = await startLive();
++ const t = await boot({ env: { MEDIAHUB_VIDEO_URL: 'https://openvibe.video', MEDIAHUB_LIVE_URL: live.url, MEDIAHUB_LIVE_PUBLIC_URL: 'https://openvibe.live', MEDIAHUB_CHAT_URL: live.url } });
++ const maticus = t.network.addUser('Maticus');
++ const sam = t.network.addUser('sam');
++ // On the video host (its pages answer only there), signed in with `as`.
++ const vget = (path, { as } = {}) => new Promise((resolve, reject) => {
++ const u = new URL(t.base + path);
++ const headers = { Host: 'openvibe.video' };
++ if (as) headers.cookie = t.signIn(as);
++ const req = http.request({ hostname: u.hostname, port: u.port, path: u.pathname + u.search, method: 'GET', headers }, (res) => {
++ const chunks = [];
++ res.on('data', (c) => chunks.push(c));
++ res.on('end', () => {
++ const buffer = Buffer.concat(chunks);
++ const text = buffer.toString('utf8');
++ resolve({ status: res.statusCode, headers: { get: (k) => res.headers[k.toLowerCase()] ?? null }, buffer, text, json: () => JSON.parse(text) });
++ });
++ });
++ req.on('error', reject);
++ req.end();
++ });
++ t.get = vget;
++ const base = 'BIRD DRAMA (Part 2) - Maticus - 2026-10-26';
++ const named = (res) => decodeURIComponent((/filename\*=UTF-8''(.+)$/.exec(res.headers.get('content-disposition') || '') || [])[1] || '');
++
++ await check('the watch page: a Download menu of what the recording has, and each panel\'s links', async () => {
++ const r = await t.get('/v/6026');
++ assert.strictEqual(r.status, 200);
++ const menu = r.text.slice(r.text.indexOf('<details class="ovv-dl"'), r.text.indexOf('</details>', r.text.indexOf('<details class="ovv-dl"')));
++ assert.ok(menu.includes('> Download</summary>'));
++ for (const href of ['/v/6026/download/video', '/v/6026/download/transcript.txt', '/v/6026/download/transcript.srt', '/v/6026/download/transcript.vtt',
++ '/v/6026/download/transcript.json', '/v/6026/download/chat.txt', '/v/6026/download/chat.csv', '/v/6026/download/chat.json',
++ '/v/6026/download/chapters.txt', '/v/6026/download/chapters.vtt', '/v/6026/download/chapters.json', '/v/6026/download/thumbnail',
++ '/v/6026/download/info.json', '/v/6026/download/all.zip']) assert.ok(menu.includes(`href="${href}"`), href);
++ assert.ok(menu.includes('The whole recording · 571 MB'), 'the video with its size');
++ assert.ok(menu.includes('2 lines, by AI') && menu.includes('2 messages') && menu.includes('2 chapters'), 'what each holds');
++ assert.ok(/rel="nofollow"/.test(menu) && /download rel/.test(menu), 'download links, not followed by crawlers');
++ assert.ok(r.text.includes('class="ovv-panel-dl"') && r.text.includes('data-ovv-copy="/v/6026/download/chapters.txt"'), 'panel links and the copy button');
++ // Chat replay sits in the part's own time: 12 s in, not 721 s.
++ assert.ok(r.text.includes('data-ovv-at="12"') && !r.text.includes('in part one'), 'chat in part 2\'s time');
++ });
++
++ await check('transcript: TXT, SRT, VTT and JSON', async () => {
++ const txt = await t.get('/v/6026/download/transcript.txt');
++ assert.strictEqual(txt.status, 200);
++ assert.strictEqual(txt.headers.get('content-type'), 'text/plain; charset=utf-8');
++ assert.strictEqual(named(txt), `${base}.transcript.txt`);
++ assert.ok(/^attachment; filename="BIRD DRAMA \(Part 2\) - Maticus - 2026-10-26.transcript.txt"/.test(txt.headers.get('content-disposition')));
++ assert.strictEqual(txt.headers.get('x-robots-tag'), 'noindex');
++ assert.ok(txt.text.startsWith('BIRD DRAMA (Part 2)\nMaticus · 2026-10-26 · 32:01\nhttps://openvibe.video/v/6026\nTranscript (AI)\n\n[0:11] What is up, "guys"\n[0:20] Bird drama\n'), txt.text);
++ const srt = await t.get('/v/6026/download/transcript.srt');
++ assert.strictEqual(srt.headers.get('content-type'), 'application/x-subrip; charset=utf-8');
++ assert.strictEqual(srt.text, '1\n00:00:11,000 --> 00:00:14,500\nWhat is up, "guys"\n\n2\n00:00:20,000 --> 00:00:22,000\nBird drama\n');
++ const vtt = await t.get('/v/6026/download/transcript.vtt');
++ assert.strictEqual(vtt.headers.get('content-type'), 'text/vtt; charset=utf-8');
++ assert.ok(vtt.text.startsWith('WEBVTT\n\n00:00:11.000 --> 00:00:14.500\nWhat is up, "guys"\n'), vtt.text.slice(0, 80));
++ const json = (await t.get('/v/6026/download/transcript.json')).json();
++ assert.deepStrictEqual(json, { recording_id: 6026, title: 'BIRD DRAMA (Part 2)', url: 'https://openvibe.video/v/6026', transcript: [{ start: 11, end: 14.5, text: 'What is up, "guys"' }, { start: 20, end: 22, text: 'Bird drama' }] });
++ });
++
++ await check('chat replay: every message in the recording\'s time; CSV is safe for spreadsheets', async () => {
++ const txt = await t.get('/v/6026/download/chat.txt');
++ assert.ok(txt.text.endsWith('[0:12] Viewer One: hello, "world"\n[0:26] Mallory: =HYPERLINK("http://x")\n'), txt.text);
++ assert.ok(!txt.text.includes('deleted') && !txt.text.includes('in part one'));
++ const csv = await t.get('/v/6026/download/chat.csv');
++ assert.strictEqual(csv.headers.get('content-type'), 'text/csv; charset=utf-8');
++ const lines = csv.text.trim().split('\r\n');
++ assert.strictEqual(lines[0], 'time,seconds,sent_at,username,display_name,message');
++ assert.strictEqual(lines[1], '0:12,12,2026-10-26T21:05:40.000Z,viewer1,Viewer One,"hello, ""world"""');
++ assert.strictEqual(lines[2], '0:26,26,2026-10-26T21:05:54.000Z,mallory,Mallory,"\'=HYPERLINK(""http://x"")"');
++ const json = (await t.get('/v/6026/download/chat.json')).json();
++ assert.deepStrictEqual(json.messages.map((m) => [m.seconds, m.username, m.message]), [[12, 'viewer1', 'hello, "world"'], [26, 'mallory', '=HYPERLINK("http://x")']]);
++ });
++
++ await check('chapters: YouTube lines from 0:00, VTT, JSON', async () => {
++ const txt = await t.get('/v/6026/download/chapters.txt');
++ assert.strictEqual(txt.text, '0:00 Start\n0:30 Birds at the feeder\n15:00 A robot drives by\n');
++ const vtt = await t.get('/v/6026/download/chapters.vtt');
++ assert.ok(vtt.text.includes('00:00:30.000 --> 00:15:00.000\nBirds at the feeder'), vtt.text);
++ const json = (await t.get('/v/6026/download/chapters.json')).json();
++ assert.deepStrictEqual(json.chapters, [{ start: 30, end: 900, title: 'Birds at the feeder' }, { start: 900, end: 1921, title: 'A robot drives by' }]);
++ });
++
++ await check('details, the cover picture and everything in a .zip', async () => {
++ const info = (await t.get('/v/6026/download/info.json')).json();
++ assert.strictEqual(info.recording_id, 6026);
++ assert.deepStrictEqual(info.stream, { id: 2795, started_at: '2026-10-26 20:53:39', ended_at: '2026-10-26 21:37:29', part: 2, starts_at_second: 709 });
++ assert.strictEqual(info.files.video, 'https://openvibe.video/v/6026/download/video');
++ assert.strictEqual(info.files['chat.csv'], `${base}.chat.csv`);
++ const pic = await t.get('/v/6026/download/thumbnail');
++ assert.strictEqual(pic.status, 200);
++ assert.strictEqual(pic.headers.get('content-type'), 'image/jpeg');
++ assert.strictEqual(named(pic), `${base}.jpg`);
++ assert.ok(pic.buffer.equals(JPEG));
++ const z = await t.get('/v/6026/download/all.zip');
++ assert.strictEqual(z.headers.get('content-type'), 'application/zip');
++ assert.strictEqual(named(z), `${base}.zip`);
++ const files = unzip(z.buffer);
++ assert.deepStrictEqual(Object.keys(files).sort(), [
++ 'chapters.json', 'chapters.txt', 'chapters.vtt', 'chat.csv', 'chat.json', 'chat.txt', 'info.json', 'jpg',
++ 'transcript.json', 'transcript.srt', 'transcript.txt', 'transcript.vtt',
++ ].map((x) => (x === 'jpg' ? `${base}/${base}.jpg` : `${base}/${base}.${x}`)).sort());
++ assert.strictEqual(files[`${base}/${base}.chapters.txt`].toString(), '0:00 Start\n0:30 Birds at the feeder\n15:00 A robot drives by\n');
++ });
++
++ await check('the video: Live\'s download link (Media\'s attachment), named', async () => {
++ const v = await t.get('/v/6026/download/video');
++ assert.strictEqual(v.status, 302);
++ assert.strictEqual(v.headers.get('location'), `https://openvibe.media/v/6026?download=1&name=${encodeURIComponent(base)}`);
++ assert.strictEqual(v.headers.get('cache-control'), 'private, no-store');
++ });
++
++ await check('a clip: its menu and files', async () => {
++ const page = await t.get('/c/505');
++ assert.ok(page.text.includes('href="/c/505/download/video"') && page.text.includes('href="/c/505/download/all.zip"'));
++ assert.ok(!page.text.includes('/c/505/download/transcript.txt'), 'no transcript row without one');
++ const info = (await t.get('/c/505/download/info.json')).json();
++ assert.deepStrictEqual([info.clip_id, info.recording.id, info.recording.from_second, info.recording.url], [505, 6026, 871.5, 'https://openvibe.video/v/6026']);
++ assert.strictEqual((await t.get('/c/505/download/chat.txt')).status, 404, 'no chat file for a clip');
++ assert.strictEqual((await t.get('/c/505/download/transcript.txt')).status, 404, 'nothing to transcribe');
++ const v = await t.get('/c/505/download/video');
++ assert.strictEqual(v.headers.get('location'), `https://openvibe.media/c/505?download=1&name=${encodeURIComponent('Clip from stream - Maticus - 2026-10-26')}`);
++ });
++
++ await check('a private recording: its owner downloads it (a signed link), anyone else finds nothing', async () => {
++ assert.strictEqual((await t.get('/v/6030/download/transcript.txt')).status, 404);
++ assert.strictEqual((await t.get('/v/6030/download/video', { as: sam })).status, 404);
++ const mine = await t.get('/v/6030/download/transcript.txt', { as: maticus });
++ assert.strictEqual(mine.status, 200);
++ assert.strictEqual(mine.headers.get('cache-control'), 'private, no-store');
++ assert.ok(mine.text.includes('[0:01] secret'));
++ const chs = await t.get('/v/6030/download/chapters.txt', { as: maticus });
++ assert.strictEqual(chs.text, '0:00 Start\n0:05 Private moment\n', 'its chapters, read from Live as the owner');
++ const v = await t.get('/v/6030/download/video', { as: maticus });
++ assert.ok(/^https:\/\/openvibe\.media\/v\/6030\?exp=1&sig=s&download=1/.test(v.headers.get('location')), v.headers.get('location'));
++ const page = await t.get('/v/6030', { as: maticus });
++ assert.ok(page.text.includes('Private moment'), 'the owner\'s page shows its chapters too');
++ });
++
++ await check('an unknown file or id is a 404', async () => {
++ assert.strictEqual((await t.get('/v/6026/download/secrets.txt')).status, 404);
++ assert.strictEqual((await t.get('/v/6026/download/..%2Fetc')).status, 404);
++ assert.strictEqual((await t.get('/v/99999/download/info.json')).status, 404);
++ });
++
++ await t.close();
++ await live.close();
++ done();
++})();
ddiff --git a/test/video-pages.test.js b/test/video-pages.test.js
iindex c80d536..d556ec5 100644
---- a/test/video-pages.test.js
++++ b/test/video-pages.test.js
@@@ -603,8 +603,9 @@ function countOccurrences(haystack, needle) {
await check('the chat replay caps at 1,000 messages and says how many were left out', async () => {
const r = await getVideo('/v/104');
assert.strictEqual(r.status, 200);
-- assert.ok(r.text.includes('Showing the first 1,000 messages; 5 more not shown.'), 'the cap note names the count');
++ assert.ok(r.text.includes('Showing the first 1,000 messages; 5 more not shown (the download has them all).'), 'the cap note names the count');
assert.strictEqual(countOccurrences(r.text, 'class="ovv-chat-row"'), 1000, 'exactly 1,000 rows render');
++ assert.ok(r.text.includes('1005 messages'), 'the Download menu counts every message');
});
await check('the VOD page shows the newest comments and the Community link', async () => {