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)
Changes
@@@ -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)) |@@@ -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; }@@@ -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;nnew file mode 100644@@@ -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,++};@@@ -165,6 +165,20 @@ function createLiveClient({ config, fetchImpl = globalThis.fetch, log = consoleasync 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 }),@@@ -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>` : ''}nnew file mode 100644@@@ -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();++})();@@@ -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 () => {
@@ -304,6 +304,8 @@ does not sign in on the other.
@@ -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 |
| `/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/captions.vtt` | anyone | the AI transcript as WebVTT captions |
| `/v/:id/chapters.vtt` | anyone | the AI moments as WebVTT chapters |
| `/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 |
| `/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)) |
| `/<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)) |
@@ -192,6 +192,47 @@ a.ovv-channel-name:hover { text-decoration: underline; }
@@ -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; }
.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 */
/* Playlists */
.ovv-save { position: relative; }
.ovv-save { position: relative; }
.ovv-save > summary { list-style: none; cursor: pointer; }
.ovv-save > summary { list-style: none; cursor: pointer; }
@@ -104,6 +104,66 @@
@@ -104,6 +104,66 @@
return h ? h + ':' + String(m).padStart(2, '0') + ':' + s : m + ':' + s;
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 () {
document.addEventListener('DOMContentLoaded', function () {
// A clip still being cut: look again in a few seconds (the server answers once it is ready).
// 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]');
const cutting = document.querySelector('[data-ovv-reload]');
@@ -112,6 +172,8 @@
@@ -112,6 +172,8 @@
setTimeout(function () { window.location.replace(window.location.pathname); }, wait);
setTimeout(function () { window.location.replace(window.location.pathname); }, wait);
}
}
+ downloads();
+
const video = document.querySelector('video[data-ovv-video]');
const video = document.querySelector('video[data-ovv-video]');
if (!video) return;
if (!video) return;
new file mode 100644
new file mode 100644
@@ -0,0 +1,430 @@
@@ -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,
+};
@@ -165,6 +165,20 @@ function createLiveClient({ config, fetchImpl = globalThis.fetch, log = console
@@ -165,6 +165,20 @@ function createLiveClient({ config, fetchImpl = globalThis.fetch, log = console
async clipAs(token, id) {
async clipAs(token, id) {
try { return await asPerson(token, 'GET', `/api/clips/${id}`); } catch (err) { if (err.status === 404) return null; throw err; }
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),
updateVod: (token, id, fields) => asPerson(token, 'PUT', `/api/vods/${id}`, fields),
deleteVod: (token, id) => asPerson(token, 'DELETE', `/api/vods/${id}`),
deleteVod: (token, id) => asPerson(token, 'DELETE', `/api/vods/${id}`),
setClipTitle: (token, id, title) => asPerson(token, 'PUT', `/api/clips/${id}/title`, { title }),
setClipTitle: (token, id, title) => asPerson(token, 'PUT', `/api/clips/${id}/title`, { title }),
@@ -13,6 +13,8 @@
@@ -13,6 +13,8 @@
* /@:username a channel's recordings (and clips), newest first
* /@:username a channel's recordings (and clips), newest first
* /v/:id/captions.vtt the AI transcript as WebVTT captions
* /v/:id/captions.vtt the AI transcript as WebVTT captions
* /v/:id/chapters.vtt the AI moments as WebVTT chapters
* /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
* /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
* 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 /, ' ');
@@ -39,6 +41,7 @@ const formatBytes = (b) => rules.formatBytes(b).replace(/\.0 /, ' ');
const { createVideoApiRoutes } = require('./api');
const { createVideoApiRoutes } = require('./api');
const { createUploadApi, mountUploadPages } = require('./upload-routes');
const { createUploadApi, mountUploadPages } = require('./upload-routes');
const { ACCESS_COOKIE, REFRESH_COOKIE } = require('../auth/sso');
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 PUBLIC_DIR = path.join(__dirname, '..', '..', 'public');
const HOME_LIMIT = 10;
const HOME_LIMIT = 10;
@@ -277,11 +280,18 @@ function relativeDate(iso) {
@@ -277,11 +280,18 @@ function relativeDate(iso) {
return `${y} year${y === 1 ? '' : 's'} ago`;
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 = [];
const rows = [];
for (const m of messages) {
for (const m of messages) {
if (!m || typeof m !== 'object') continue;
if (!m || typeof m !== 'object') continue;
@@ -292,12 +302,12 @@ function chatMessageRows(vod, messages) {
@@ -292,12 +302,12 @@ function chatMessageRows(vod, messages) {
const offset = at - start;
const offset = at - start;
if (offset < 0) continue;
if (offset < 0) continue;
if (Number.isFinite(end) && offset > end - start + 60) 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);
rows.sort((a, b) => a.offset - b.offset);
if (!rows.length) return null;
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) {
function commentItem(c) {
@@ -636,6 +646,10 @@ ${kind === 'vod' ? html`<label class="ovv-field"><span>Description</span><textar
@@ -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) ──
// ── 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 });
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 ───────────────────────────────
// ── Playlist page pieces ───────────────────────────────
async function playlistForm(req, kind, id) {
async function playlistForm(req, kind, id) {
if (!signedIn(req)) {
if (!signedIn(req)) {
@@ -1030,7 +1044,8 @@ ${found ? sections.map((x) => (x.cards.length || kind ? html`<section class="ovv
@@ -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).
// (one after another, five services' answers in turn, the page's p95 was 581 ms on 2026-10-17).
const fail = Symbol('failed');
const fail = Symbol('failed');
const [m, cm, replay, saveForm, up] = await Promise.all([
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(() => live.comments('vod', id)).catch(() => fail),
Promise.resolve().then(() => chat.chatReplay(vod.stream_id)).catch(() => fail),
Promise.resolve().then(() => chat.chatReplay(vod.stream_id)).catch(() => fail),
playlistForm(req, 'vod', id),
playlistForm(req, 'vod', id),
@@ -1092,19 +1107,19 @@ ${savedNotice(req)}${ownOnly ? notice('Private: only you (and OpenVibe staff) ca
@@ -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>` : ''}
${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>`) : ''}
<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>
${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>
</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.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>` : ''}
${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>` : ''}
${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>
</div>
<aside class="ovv-side">
<aside class="ovv-side">
${manageable ? ownerPanel('vod', id, vod) : ''}
${manageable ? ownerPanel('vod', id, vod) : ''}
${ownOnly ? '' : clipPanel(req, id, vod)}
${ownOnly ? '' : clipPanel(req, id, vod)}
${up.html}
${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>` : ''}
${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>
</aside>
</div>`,
</div>`,
@@ -1172,7 +1187,7 @@ ${cutting ? html`<div class="ovv-player-wrap ovv-cutting" role="status" data-ovv
@@ -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">
<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>
<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>
${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>
</div>
${up.html}
${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>` : ''}
${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>` : ''}
new file mode 100644
new file mode 100644
@@ -0,0 +1,261 @@
@@ -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();
+})();
@@ -603,8 +603,9 @@ function countOccurrences(haystack, needle) {
@@ -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 () => {
await check('the chat replay caps at 1,000 messages and says how many were left out', async () => {
const r = await getVideo('/v/104');
const r = await getVideo('/v/104');
assert.strictEqual(r.status, 200);
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.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 () => {
await check('the VOD page shows the newest comments and the Community link', async () => {