openvibers/openvibe.mediahub Commits
Video: record from the camera or the screen in the browser, never live (#69)
Changes
@@@ -203,6 +203,12 @@ pages read Live's public JSON API server-side, signed out (Live already applies- **Reports:** anyone who can see a video may report it, once per person or per keyed address. A child-safety reporthides it at once, and three reports hide it until staff decide; the owner still sees it, labelled.- **Account deletion:** erases the rows and deletes the VODs in Media.++- **Recording** (`/record`, `public/js/video-record.js`):++ - Record from the camera or the screen (with the microphone) in the browser. It is never live.++ - MediaRecorder hands over a piece every 4 seconds, and each piece goes up in order++ (`POST /api/v1/videos/recordings`, then `PUT /api/v1/videos/:id/chunks/:n`, up to 9 MB each) to the same kind of++ Media VOD.++ - Stop completes it like an upload. Size and quota are checked as it grows, and a recording is private by default.The player plays Media's segment timeline (`hls_url`, HLS) when the recording has one: natively where the browserplays HLS, otherwise through hls.js, self-hosted at `public/vendor/hls.light.min.js` (hls.js 1.7.3, Apache-2.0, loaded@@@ -320,7 +320,7 @@ server {location ~* ^/metrics(/|$) { return 404; }# Uploads (server/video/upload-routes.js): one 8 MB part per request, in order, unbuffered to MediaHub.-- location ~ ^/api/v1/videos/[^/]+/parts/[0-9]+$ {++ location ~ ^/api/v1/videos/[^/]+/(parts|chunks)/[0-9]+$ {client_max_body_size 9m;proxy_request_buffering off;proxy_read_timeout 180s;@@@ -215,3 +215,11 @@ a.ovv-channel-name:hover { text-decoration: underline; }.ovv-radio { display: flex; gap: 8px; align-items: baseline; }.ovv-table { width: 100%; border-collapse: collapse; }.ovv-table th, .ovv-table td { text-align: left; padding: 8px 10px; border-bottom: 1px solid var(--ovv-line); }++/* Recording (/record) */++.ovv-record { display: grid; gap: 12px; max-width: 760px; }++.ovv-record-source { border: 0; padding: 0; margin: 0; display: flex; gap: 18px; flex-wrap: wrap; }++.ovv-record-source legend { font-weight: 700; margin-bottom: 6px; }++.ovv-record-stage { position: relative; }++.ovv-record-preview { width: 100%; max-height: 420px; border-radius: 12px; background: #000; display: block; }++.ovv-record-clock { position: absolute; top: 10px; left: 10px; margin: 0; padding: 2px 10px; border-radius: 999px; background: rgba(220, 38, 38, .9); color: #fff; font-weight: 700; font-variant-numeric: tabular-nums; }++.ovv-record-actions { display: flex; gap: 8px; flex-wrap: wrap; margin: 0; }nnew file mode 100644@@@ -0,0 +1,102 @@++/* OpenVibe.Video — /record: record the camera or the screen with MediaRecorder and send it as it records. Every few++ * seconds the recorder hands over a piece, which goes up in order (PUT /api/v1/videos/:id/chunks/:n) to the video++ * started with POST /api/v1/videos/recordings; Stop sends the last piece and completes it. Nothing is ever live, and++ * nothing is kept in the browser beyond the pieces still waiting to go up. */++(function () {++ 'use strict';++ var form = document.querySelector('form[data-video-record]');++ if (!form) return;++ var preview = form.querySelector('.ovv-record-preview');++ var clock = form.querySelector('.ovv-record-clock');++ var status = form.querySelector('[role=status]');++ var bPreview = form.querySelector('[data-record-preview]');++ var bStart = form.querySelector('[data-record-start]');++ var bStop = form.querySelector('[data-record-stop]');++ function say(t) { status.textContent = t; }++ if (!window.MediaRecorder || !navigator.mediaDevices || !window.fetch) { say('This browser cannot record video. Upload a file instead.'); bPreview.disabled = true; return; }++ var TYPES = ['video/webm;codecs=vp9,opus', 'video/webm;codecs=vp8,opus', 'video/webm', 'video/mp4'];++ var mime = TYPES.filter(function (t) { return MediaRecorder.isTypeSupported(t); })[0] || '';++ var stream = null, recorder = null, id = null, n = 0, queue = Promise.resolve(), failed = null, started = 0, tick = null, sentBytes = 0;++ function source() { var c = form.querySelector('input[name=source]:checked'); return c ? c.value : 'camera'; }++ function stopTracks() { if (stream) stream.getTracks().forEach(function (t) { t.stop(); }); stream = null; }++ function request(url, opts) {++ return fetch(url, opts).then(function (res) {++ if (res.ok) return res.json().catch(function () { return null; });++ return res.json().then(function (p) { throw new Error((p && p.detail) || ('Refused (' + res.status + ').')); }, function () { throw new Error('Refused (' + res.status + ').'); });++ });++ }++ function send(blob) {++ var k = ++n;++ queue = queue.then(function () {++ if (failed) return null;++ var tries = 0;++ var once = function () {++ return request('/api/v1/videos/' + id + '/chunks/' + k, { method: 'PUT', headers: { 'Content-Type': 'application/octet-stream' }, body: blob }).then(function () {++ sentBytes += blob.size;++ say('Recording… ' + (sentBytes / 1048576).toFixed(1) + ' MB saved');++ }, function (err) {++ if (++tries < 3 && !/Refused \(4|already finished|Send piece|at most|pass/.test(err.message)) return new Promise(function (r) { setTimeout(r, 1500); }).then(once);++ failed = err; throw err;++ });++ };++ return once();++ }).catch(function (err) {++ say('Saving stopped: ' + err.message);++ if (recorder && recorder.state !== 'inactive') recorder.stop();++ });++ }++ function getStream() {++ if (source() === 'screen') {++ return navigator.mediaDevices.getDisplayMedia({ video: true, audio: true }).then(function (screen) {++ return navigator.mediaDevices.getUserMedia({ audio: true }).then(function (mic) {++ mic.getAudioTracks().forEach(function (t) { screen.addTrack(t); });++ return screen;++ }, function () { return screen; });++ });++ }++ return navigator.mediaDevices.getUserMedia({ video: { width: { ideal: 1280 }, height: { ideal: 720 } }, audio: true });++ }++ bPreview.addEventListener('click', function () {++ stopTracks();++ getStream().then(function (s) {++ stream = s;++ preview.srcObject = s; preview.hidden = false; preview.play().catch(function () {});++ bStart.disabled = false;++ bPreview.textContent = source() === 'screen' ? 'Choose another screen' : 'Restart the camera';++ say('Ready. Press Start recording.');++ }, function () { say(source() === 'screen' ? 'Screen sharing was not allowed.' : 'The camera or microphone was not allowed.'); });++ });++ form.addEventListener('change', function (e) { if (e.target.name === 'source') { bPreview.textContent = e.target.value === 'screen' ? 'Choose the screen' : 'Turn on the camera'; } });++ bStart.addEventListener('click', function () {++ if (!stream) return;++ bStart.disabled = true; bPreview.disabled = true;++ say('Starting…');++ request('/api/v1/videos/recordings', { method: 'POST', headers: { 'Content-Type': 'application/json' },++ body: JSON.stringify({ title: form.elements.title.value, visibility: form.elements.visibility.value, content_type: (mime || 'video/webm').split(';')[0] }) })++ .then(function (out) {++ id = out.upload.id;++ recorder = new MediaRecorder(stream, mime ? { mimeType: mime, videoBitsPerSecond: 2500000 } : { videoBitsPerSecond: 2500000 });++ recorder.ondataavailable = function (e) { if (e.data && e.data.size) send(e.data); };++ recorder.onstop = function () {++ stopTracks(); clearInterval(tick); clock.hidden = true;++ queue.then(function () {++ if (failed && n <= 1) throw failed;++ say('Saving…');++ return request('/api/v1/videos/' + id + '/complete', { method: 'POST' });++ }).then(function (done) {++ say('Saved. OpenVibe.Media is processing it; opening its page…');++ window.location.assign(done.video.url);++ }, function (err) { say('The recording could not be saved: ' + err.message); bPreview.disabled = false; });++ };++ // A stream that ends by itself (the screen share stopped) stops the recording too.++ stream.getVideoTracks().forEach(function (t) { t.addEventListener('ended', function () { if (recorder.state !== 'inactive') recorder.stop(); }); });++ recorder.start(4000);++ started = Date.now(); clock.hidden = false;++ tick = setInterval(function () { var s = Math.floor((Date.now() - started) / 1000); clock.textContent = Math.floor(s / 60) + ':' + String(s % 60).padStart(2, '0'); }, 500);++ bStop.disabled = false;++ say('Recording…');++ }, function (err) { say(err.message); bStart.disabled = false; bPreview.disabled = false; });++ });++ bStop.addEventListener('click', function () { bStop.disabled = true; if (recorder && recorder.state !== 'inactive') recorder.stop(); });++ window.addEventListener('beforeunload', function (e) { if (recorder && recorder.state === 'recording') { e.preventDefault(); e.returnValue = ''; } });++})();@@@ -91,7 +91,7 @@ ${render(o.body)}}// The site's own places in the navbar: the videos, and your playlists (signed in; the page asks you to sign in).--const NAV_LINKS = [{ label: 'Videos', href: '/' }, { label: 'Upload', href: '/upload' }, { label: 'Your videos', href: '/me/videos' }, { label: 'Playlists', href: '/playlists' }];++const NAV_LINKS = [{ label: 'Videos', href: '/' }, { label: 'Upload', href: '/upload' }, { label: 'Record', href: '/record' }, { label: 'Your videos', href: '/me/videos' }, { label: 'Playlists', href: '/playlists' }];/** Send a video page. Signed-out pages are public; a session cookie makes a page private, no-store. */function send(res, status, o) {@@@ -10,6 +10,9 @@* GET /u/:id the watch page (processing: it says so and reloads itself)* POST /u/:id/manage | /u/:id/delete | /u/:id/report* GET /me/videos your uploads, with their state++ * GET /record record from the camera or the screen in the browser (public/js/video-record.js)++ * POST /api/v1/videos/recordings start: { title, description, visibility, content_type } → { upload }++ * PUT /api/v1/videos/:id/chunks/:n the recording's next piece (up to 9 MB), in order, while it records** Every write is a signed-in person's (a session on openvibe.video) from openvibe.video itself (Sec-Fetch-Site or* Origin). Reports are open to anyone who can see the video, one per person (or per keyed address) per video.@@@ -68,6 +71,25 @@ function createUploadApi({ ctx, config }) {} catch (err) { return problem(res, err); }});++ r.post('/recordings', express.json({ limit: '16kb' }), async (req, res) => {++ if (!guard(req, res)) return undefined;++ try {++ const b = req.body || {};++ const row = await up.startRecording(who(req), { title: b.title, description: b.description, visibility: b.visibility, contentType: b.content_type });++ return res.status(201).json({ upload: { id: row.id } });++ } catch (err) { return problem(res, err); }++ });++++ r.put('/:id/chunks/:n', express.raw({ type: () => true, limit: '9mb' }), async (req, res) => {++ if (!guard(req, res)) return undefined;++ const row = await own(req, res);++ if (!row) return undefined;++ try {++ const out = await up.chunk(row, Number(req.params.n), Buffer.isBuffer(req.body) ? req.body : null);++ return res.json({ pieces: out.parts_received, size_bytes: Number(out.size_bytes) });++ } catch (err) { return problem(res, err); }++ });++r.post('/:id/complete', async (req, res) => {if (!guard(req, res)) return undefined;const row = await own(req, res);@@@ -109,7 +131,27 @@ function mountUploadPages(r, h) {<button class="ovv-btn ovv-btn--primary" type="submit">Upload</button><noscript><p class="ovv-fine">Uploading needs JavaScript: a video goes up in parts so a big file and a dropped connection both work.</p></noscript></form>--<p><a href="/me/videos">Your videos</a></p>`,++<p><a href="/record">Record from your camera or screen instead</a> · <a href="/me/videos">Your videos</a></p>`,++ }));++++ r.get('/record', mustSignIn('/record', 'Recordings belong to your OpenVibe account: sign in to record a video.'), async (req, res) => page(req, res, {++ title: 'Record a video',++ crumbs: [{ label: 'Home', href: '/' }, { label: 'Record' }],++ scripts: ['js/video-record.js'],++ body: html`<h1>Record a video</h1>++<p class="ovv-lede">Record from your camera or your screen, right here. It is never live: each few seconds go up to your OpenVibe.Video account as you record, and when you stop it becomes a video you can keep private, share by link or publish.</p>++<form class="ovv-record ovv-panel" data-video-record>++<fieldset class="ovv-record-source"><legend>Record</legend>++<label class="ovv-radio"><input type="radio" name="source" value="camera" checked> Camera and microphone</label>++<label class="ovv-radio"><input type="radio" name="source" value="screen"> Screen, with your microphone</label></fieldset>++<label class="ovv-field"><span>Title</span><input name="title" type="text" maxlength="120" placeholder="Untitled recording"></label>++<label class="ovv-field"><span>Who can see it</span><select name="visibility">${visibilityOptions('private')}</select></label>++<div class="ovv-record-stage"><video class="ovv-record-preview" muted playsinline hidden></video><p class="ovv-record-clock" aria-live="off" hidden>0:00</p></div>++<p class="ovv-upload-status" role="status" aria-live="polite"></p>++<p class="ovv-record-actions"><button class="ovv-btn" type="button" data-record-preview>Turn on the camera</button> <button class="ovv-btn ovv-btn--primary" type="button" data-record-start disabled>Start recording</button> <button class="ovv-btn ovv-btn--danger" type="button" data-record-stop disabled>Stop and save</button></p>++<p class="ovv-fine">Up to ${Math.round(cfg.maxBytes / GB)} GB a video. Keep this tab open while recording; a few seconds are lost at most if the connection drops. Recording needs a browser that records video (Chrome, Edge, Firefox, Safari 14.1+) and JavaScript.</p>++</form>++<p><a href="/upload">Upload a file instead</a> · <a href="/me/videos">Your videos</a></p>`,}));// Without JavaScript the form posts here: a video goes up in parts, which needs the page's script.@@@ -9,6 +9,10 @@* complete(row) closes the file; Media finalizes in the background, the row goes processing → ready* refresh(row) reads Media again for a row still processing (after a restart it is how it finishes)* update(row, patch) / remove(row) / report(row, …)++ * startRecording(who, { title, description, visibility, contentType }) a video recorded in the browser (/record):++ * its size is not known ahead, so parts_expected stays 0 until complete()++ * chunk(row, n, buffer) the n-th piece of a recording (any size up to 9 MB), in order; the size and the person's++ * quota are checked as it grows** Rules: MP4 (QuickTime and M4V are the same container and go to Media as MP4), WebM and Matroska; at most* VIDEO_UPLOAD_MAX_BYTES (2 GB) a file, VIDEO_UPLOAD_QUOTA_BYTES (20 GB) a person, VIDEO_UPLOADS_PER_DAY (10) a day.@@@ -86,7 +90,45 @@ function createUploads({ s, media, config, log = console }) {return await byId(id);}++ async function startRecording(who, input) {++ if (!who || !who.requester) throw new UploadError(401, 'auth.required', 'Sign in to record a video.');++ const title = cleanLine(input.title, 120) || `Recording, ${s.iso().slice(0, 16).replace('T', ' ')} UTC`;++ const description = cleanText(input.description, 5000);++ const visibility = VISIBILITY.includes(input.visibility) ? input.visibility : 'private';++ const t = String(input.contentType || '').split(';')[0].trim().toLowerCase();++ const kind = t === 'video/webm' || t === 'video/mp4' ? t : null;++ if (!kind) throw new UploadError(415, 'upload.type', 'This browser records in a format OpenVibe.Video does not take (WebM or MP4).');++ const today = s.iso().slice(0, 10);++ const usage = await s.db.maybe(`SELECT count(*) FILTER (WHERE created_at >= $2)::int AS today, coalesce(sum(size_bytes) FILTER (WHERE state <> 'removed'), 0)::bigint AS bytes++ FROM video_uploads WHERE owner = $1`, [who.requester, today]);++ if (usage && usage.today >= cfg.perDay) throw new UploadError(429, 'upload.daily_limit', `That is ${cfg.perDay} uploads today. Try again tomorrow.`);++ if (usage && Number(usage.bytes) >= cfg.quotaBytes) throw new UploadError(413, 'upload.quota', `Your videos already fill ${Math.round(cfg.quotaBytes / 1024 ** 3)} GB. Delete one first.`);++ const vod = await media.vodCreate({ title, visibility, ownerSubject: who.requester });++ if (!vod.ok || !vod.data || vod.data.id == null) throw new UploadError(502, 'upload.media', `OpenVibe.Media could not start the recording (${vod.code || vod.status}).`);++ const id = s.newId('vup');++ const now = s.iso();++ await s.db.query(`INSERT INTO video_uploads (id, owner, owner_username, title, description, visibility, media_vod_id, content_type, size_bytes, part_size, parts_expected, created_at, updated_at)++ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, 0, 0, 0, $9, $9)`,++ [id, who.requester, who.username || null, title, description, visibility, Number(vod.data.id), kind, now]);++ return await byId(id);++ }++++ /** A recording's next piece. The total may not pass the per-video limit or the person's quota. */++ async function chunk(row, n, buffer) {++ if (row.status !== 'uploading' || row.parts_expected !== 0) throw new UploadError(409, 'upload.closed', 'This recording is already finished.');++ if (n !== row.parts_received + 1) throw new UploadError(409, 'upload.out_of_order', `Send piece ${row.parts_received + 1} next.`);++ if (!buffer || !buffer.length) throw new UploadError(422, 'upload.empty', 'An empty piece.');++ const size = Number(row.size_bytes) + buffer.length;++ if (size > cfg.maxBytes) throw new UploadError(413, 'upload.too_big', `A video can be at most ${Math.round(cfg.maxBytes / 1024 ** 3)} GB. Stop the recording.`);++ const used = Number(await s.db.value("SELECT coalesce(sum(size_bytes), 0)::bigint FROM video_uploads WHERE owner = $1 AND state <> 'removed' AND id <> $2", [row.owner, row.id]));++ if (used + size > cfg.quotaBytes) throw new UploadError(413, 'upload.quota', `Your videos would pass ${Math.round(cfg.quotaBytes / 1024 ** 3)} GB. Stop the recording.`);++ const out = await media.vodChunk(row.media_vod_id, buffer, row.content_type);++ if (!out.ok) throw new UploadError(502, 'upload.media', `OpenVibe.Media did not take piece ${n} (${out.code || out.status}). Try again.`);++ return await patchRow(row.id, { parts_received: n, size_bytes: size });++ }++async function part(row, n, buffer) {++ if (row.parts_expected === 0) throw new UploadError(409, 'upload.recording', 'This is a recording: send its pieces to …/chunks/:n.');if (row.status !== 'uploading') throw new UploadError(409, 'upload.closed', 'This upload is already finished.');if (n !== row.parts_received + 1) throw new UploadError(409, 'upload.out_of_order', `Send part ${row.parts_received + 1} next.`);const last = n === row.parts_expected;@@@ -109,8 +151,10 @@ function createUploads({ s, media, config, log = console }) {async function complete(row) {if (row.status === 'ready' || row.status === 'processing') return row;-- if (row.parts_received !== row.parts_expected) throw new UploadError(409, 'upload.incomplete', `${row.parts_expected - row.parts_received} parts are still missing.`);-- const now = await patchRow(row.id, { status: 'processing' });++ const recording = row.parts_expected === 0;++ if (recording && row.parts_received < 1) throw new UploadError(409, 'upload.incomplete', 'Nothing was recorded.');++ if (!recording && row.parts_received !== row.parts_expected) throw new UploadError(409, 'upload.incomplete', `${row.parts_expected - row.parts_received} parts are still missing.`);++ const now = await patchRow(row.id, { status: 'processing', ...(recording ? { parts_expected: row.parts_received } : {}) });// Media can take minutes on a big file: answer now, finish in the background (refresh() finishes it after a restart).const job = media.vodComplete(row.media_vod_id).then(async (out) => {if (out.ok) return await absorb(await byId(row.id), out.data);@@@ -164,7 +208,7 @@ function createUploads({ s, media, config, log = console }) {return {PART_SIZE, REASONS, UploadError, kindOf, addressId, canSee,-- byId, start, part, complete, refresh, update, remove, report,++ byId, start, part, complete, refresh, update, remove, report, startRecording, chunk,mine: (owner, limit = 60) => s.db.many("SELECT * FROM video_uploads WHERE owner = $1 AND state <> 'removed' ORDER BY id DESC LIMIT $2", [owner, limit]),latest: (limit = 12) => s.db.many("SELECT * FROM video_uploads WHERE visibility = 'public' AND status = 'ready' AND state = 'active' ORDER BY id DESC LIMIT $1", [limit]),channel: (username, limit = 48) => s.db.many("SELECT * FROM video_uploads WHERE lower(owner_username) = lower($1) AND visibility = 'public' AND status = 'ready' AND state = 'active' ORDER BY id DESC LIMIT $2", [String(username), limit]),@@@ -330,9 +330,9 @@ function countOccurrences(haystack, needle) {assert.strictEqual(r.text, 'WEBVTT\n\n00:00:10.000 --> 00:00:30.000\nIntro & setup\n\n00:00:30.000 --> 00:00:45.000\nThe <main> part\n');});-- await check('the navbar names the site\'s places: Videos, Upload, Your videos and Playlists, the current one active', async () => {++ await check('the navbar names the site\'s places: Videos, Upload, Record, Your videos and Playlists, the current one active', async () => {const home = (await getVideo('/')).text;-- assert.match(home, /"links":\[\{"label":"Videos","href":"\/","active":true\},\{"label":"Upload","href":"\/upload","active":false\},\{"label":"Your videos","href":"\/me\/videos","active":false\},\{"label":"Playlists","href":"\/playlists","active":false\}\]/);++ assert.match(home, /"links":\[\{"label":"Videos","href":"\/","active":true\},\{"label":"Upload","href":"\/upload","active":false\},\{"label":"Record","href":"\/record","active":false\},\{"label":"Your videos","href":"\/me\/videos","active":false\},\{"label":"Playlists","href":"\/playlists","active":false\}\]/);assert.ok(home.includes('href="/playlists"'), 'the no-JS nav links Playlists too');});@@@ -133,6 +133,31 @@ function vreq(base, path, { method = 'GET', cookie, form, json, body, same = truassert.strictEqual((await vreq(t.base, `/u/${id}/report`, { method: 'POST', cookie: samCookie, form: { reason: 'nonsense' } })).status, 404, 'hidden: not reportable by others');});++ await check('a recording: pieces of any size go up in order while it records, then it completes and plays', async () => {++ const startR = await vreq(t.base, '/api/v1/videos/recordings', { method: 'POST', json: { title: '', visibility: 'unlisted', content_type: 'video/webm;codecs=vp9,opus' }, cookie: samCookie });++ assert.strictEqual(startR.status, 201, startR.text);++ const rid = startR.json().upload.id;++ const pieces = [Buffer.from('webm-header+cluster-1'), Buffer.alloc(300 * 1024, 2), Buffer.alloc(17, 3)];++ assert.strictEqual((await vreq(t.base, `/api/v1/videos/${rid}/chunks/2`, { method: 'PUT', body: pieces[1], cookie: samCookie })).status, 409, 'in order');++ assert.strictEqual((await vreq(t.base, `/api/v1/videos/${rid}/parts/1`, { method: 'PUT', body: pieces[0], cookie: samCookie })).status, 409, 'a recording takes pieces, not parts');++ for (const [i, p] of pieces.entries()) {++ const r = await vreq(t.base, `/api/v1/videos/${rid}/chunks/${i + 1}`, { method: 'PUT', body: p, cookie: samCookie });++ assert.strictEqual(r.status, 200, r.text);++ }++ const vod = [...t.media.vods.values()].find((v) => v.visibility === 'unlisted');++ assert.ok(vod.bytes.equals(Buffer.concat(pieces)), 'Media has every piece, in order');++ assert.deepStrictEqual([...new Set(vod.types)], ['video/webm']);++ assert.strictEqual((await vreq(t.base, `/api/v1/videos/${rid}/complete`, { method: 'POST', cookie: samCookie })).status, 202);++ let page = null;++ for (let i = 0; i < 20; i++) { page = await vreq(t.base, `/u/${rid}`, { cookie: samCookie }); if (page.text.includes('data-ovv-video')) break; await new Promise((r) => setTimeout(r, 50)); }++ assert.ok(page.text.includes('data-ovv-video'), 'it plays');++ assert.match(page.text, /<h1 class="ovv-title">Recording, \d{4}-\d\d-\d\d \d\d:\d\d UTC<\/h1>/, 'an untitled recording is named by its time');++ assert.strictEqual((await vreq(t.base, `/api/v1/videos/${rid}/chunks/4`, { method: 'PUT', body: pieces[2], cookie: samCookie })).status, 409, 'closed once complete');++ const rec = await vreq(t.base, '/record', { cookie: samCookie });++ assert.ok(rec.text.includes('data-video-record') && rec.text.includes('js/video-record.js'), 'the recorder page');++ assert.strictEqual((await vreq(t.base, '/record')).status, 401, 'signed out: sign in first');++ });++await check('delete removes the VOD in Media; the daily limit holds', async () => {const r = await vreq(t.base, `/u/${id}/delete`, { method: 'POST', cookie: kimCookie, form: { confirm: '1' } });assert.strictEqual(r.status, 303);
@@ -203,6 +203,12 @@ pages read Live's public JSON API server-side, signed out (Live already applies
@@ -203,6 +203,12 @@ pages read Live's public JSON API server-side, signed out (Live already applies
- **Reports:** anyone who can see a video may report it, once per person or per keyed address. A child-safety report
- **Reports:** anyone who can see a video may report it, once per person or per keyed address. A child-safety report
hides it at once, and three reports hide it until staff decide; the owner still sees it, labelled.
hides it at once, and three reports hide it until staff decide; the owner still sees it, labelled.
- **Account deletion:** erases the rows and deletes the VODs in Media.
- **Account deletion:** erases the rows and deletes the VODs in Media.
+- **Recording** (`/record`, `public/js/video-record.js`):
+ - Record from the camera or the screen (with the microphone) in the browser. It is never live.
+ - MediaRecorder hands over a piece every 4 seconds, and each piece goes up in order
+ (`POST /api/v1/videos/recordings`, then `PUT /api/v1/videos/:id/chunks/:n`, up to 9 MB each) to the same kind of
+ Media VOD.
+ - Stop completes it like an upload. Size and quota are checked as it grows, and a recording is private by default.
The player plays Media's segment timeline (`hls_url`, HLS) when the recording has one: natively where the browser
The player plays Media's segment timeline (`hls_url`, HLS) when the recording has one: natively where the browser
plays HLS, otherwise through hls.js, self-hosted at `public/vendor/hls.light.min.js` (hls.js 1.7.3, Apache-2.0, loaded
plays HLS, otherwise through hls.js, self-hosted at `public/vendor/hls.light.min.js` (hls.js 1.7.3, Apache-2.0, loaded
@@ -320,7 +320,7 @@ server {
@@ -320,7 +320,7 @@ server {
location ~* ^/metrics(/|$) { return 404; }
location ~* ^/metrics(/|$) { return 404; }
# Uploads (server/video/upload-routes.js): one 8 MB part per request, in order, unbuffered to MediaHub.
# Uploads (server/video/upload-routes.js): one 8 MB part per request, in order, unbuffered to MediaHub.
- location ~ ^/api/v1/videos/[^/]+/parts/[0-9]+$ {
+ location ~ ^/api/v1/videos/[^/]+/(parts|chunks)/[0-9]+$ {
client_max_body_size 9m;
client_max_body_size 9m;
proxy_request_buffering off;
proxy_request_buffering off;
proxy_read_timeout 180s;
proxy_read_timeout 180s;
@@ -215,3 +215,11 @@ a.ovv-channel-name:hover { text-decoration: underline; }
@@ -215,3 +215,11 @@ a.ovv-channel-name:hover { text-decoration: underline; }
.ovv-radio { display: flex; gap: 8px; align-items: baseline; }
.ovv-radio { display: flex; gap: 8px; align-items: baseline; }
.ovv-table { width: 100%; border-collapse: collapse; }
.ovv-table { width: 100%; border-collapse: collapse; }
.ovv-table th, .ovv-table td { text-align: left; padding: 8px 10px; border-bottom: 1px solid var(--ovv-line); }
.ovv-table th, .ovv-table td { text-align: left; padding: 8px 10px; border-bottom: 1px solid var(--ovv-line); }
+/* Recording (/record) */
+.ovv-record { display: grid; gap: 12px; max-width: 760px; }
+.ovv-record-source { border: 0; padding: 0; margin: 0; display: flex; gap: 18px; flex-wrap: wrap; }
+.ovv-record-source legend { font-weight: 700; margin-bottom: 6px; }
+.ovv-record-stage { position: relative; }
+.ovv-record-preview { width: 100%; max-height: 420px; border-radius: 12px; background: #000; display: block; }
+.ovv-record-clock { position: absolute; top: 10px; left: 10px; margin: 0; padding: 2px 10px; border-radius: 999px; background: rgba(220, 38, 38, .9); color: #fff; font-weight: 700; font-variant-numeric: tabular-nums; }
+.ovv-record-actions { display: flex; gap: 8px; flex-wrap: wrap; margin: 0; }
new file mode 100644
new file mode 100644
@@ -0,0 +1,102 @@
@@ -0,0 +1,102 @@
+/* OpenVibe.Video — /record: record the camera or the screen with MediaRecorder and send it as it records. Every few
+ * seconds the recorder hands over a piece, which goes up in order (PUT /api/v1/videos/:id/chunks/:n) to the video
+ * started with POST /api/v1/videos/recordings; Stop sends the last piece and completes it. Nothing is ever live, and
+ * nothing is kept in the browser beyond the pieces still waiting to go up. */
+(function () {
+ 'use strict';
+ var form = document.querySelector('form[data-video-record]');
+ if (!form) return;
+ var preview = form.querySelector('.ovv-record-preview');
+ var clock = form.querySelector('.ovv-record-clock');
+ var status = form.querySelector('[role=status]');
+ var bPreview = form.querySelector('[data-record-preview]');
+ var bStart = form.querySelector('[data-record-start]');
+ var bStop = form.querySelector('[data-record-stop]');
+ function say(t) { status.textContent = t; }
+ if (!window.MediaRecorder || !navigator.mediaDevices || !window.fetch) { say('This browser cannot record video. Upload a file instead.'); bPreview.disabled = true; return; }
+ var TYPES = ['video/webm;codecs=vp9,opus', 'video/webm;codecs=vp8,opus', 'video/webm', 'video/mp4'];
+ var mime = TYPES.filter(function (t) { return MediaRecorder.isTypeSupported(t); })[0] || '';
+ var stream = null, recorder = null, id = null, n = 0, queue = Promise.resolve(), failed = null, started = 0, tick = null, sentBytes = 0;
+ function source() { var c = form.querySelector('input[name=source]:checked'); return c ? c.value : 'camera'; }
+ function stopTracks() { if (stream) stream.getTracks().forEach(function (t) { t.stop(); }); stream = null; }
+ function request(url, opts) {
+ return fetch(url, opts).then(function (res) {
+ if (res.ok) return res.json().catch(function () { return null; });
+ return res.json().then(function (p) { throw new Error((p && p.detail) || ('Refused (' + res.status + ').')); }, function () { throw new Error('Refused (' + res.status + ').'); });
+ });
+ }
+ function send(blob) {
+ var k = ++n;
+ queue = queue.then(function () {
+ if (failed) return null;
+ var tries = 0;
+ var once = function () {
+ return request('/api/v1/videos/' + id + '/chunks/' + k, { method: 'PUT', headers: { 'Content-Type': 'application/octet-stream' }, body: blob }).then(function () {
+ sentBytes += blob.size;
+ say('Recording… ' + (sentBytes / 1048576).toFixed(1) + ' MB saved');
+ }, function (err) {
+ if (++tries < 3 && !/Refused \(4|already finished|Send piece|at most|pass/.test(err.message)) return new Promise(function (r) { setTimeout(r, 1500); }).then(once);
+ failed = err; throw err;
+ });
+ };
+ return once();
+ }).catch(function (err) {
+ say('Saving stopped: ' + err.message);
+ if (recorder && recorder.state !== 'inactive') recorder.stop();
+ });
+ }
+ function getStream() {
+ if (source() === 'screen') {
+ return navigator.mediaDevices.getDisplayMedia({ video: true, audio: true }).then(function (screen) {
+ return navigator.mediaDevices.getUserMedia({ audio: true }).then(function (mic) {
+ mic.getAudioTracks().forEach(function (t) { screen.addTrack(t); });
+ return screen;
+ }, function () { return screen; });
+ });
+ }
+ return navigator.mediaDevices.getUserMedia({ video: { width: { ideal: 1280 }, height: { ideal: 720 } }, audio: true });
+ }
+ bPreview.addEventListener('click', function () {
+ stopTracks();
+ getStream().then(function (s) {
+ stream = s;
+ preview.srcObject = s; preview.hidden = false; preview.play().catch(function () {});
+ bStart.disabled = false;
+ bPreview.textContent = source() === 'screen' ? 'Choose another screen' : 'Restart the camera';
+ say('Ready. Press Start recording.');
+ }, function () { say(source() === 'screen' ? 'Screen sharing was not allowed.' : 'The camera or microphone was not allowed.'); });
+ });
+ form.addEventListener('change', function (e) { if (e.target.name === 'source') { bPreview.textContent = e.target.value === 'screen' ? 'Choose the screen' : 'Turn on the camera'; } });
+ bStart.addEventListener('click', function () {
+ if (!stream) return;
+ bStart.disabled = true; bPreview.disabled = true;
+ say('Starting…');
+ request('/api/v1/videos/recordings', { method: 'POST', headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ title: form.elements.title.value, visibility: form.elements.visibility.value, content_type: (mime || 'video/webm').split(';')[0] }) })
+ .then(function (out) {
+ id = out.upload.id;
+ recorder = new MediaRecorder(stream, mime ? { mimeType: mime, videoBitsPerSecond: 2500000 } : { videoBitsPerSecond: 2500000 });
+ recorder.ondataavailable = function (e) { if (e.data && e.data.size) send(e.data); };
+ recorder.onstop = function () {
+ stopTracks(); clearInterval(tick); clock.hidden = true;
+ queue.then(function () {
+ if (failed && n <= 1) throw failed;
+ say('Saving…');
+ return request('/api/v1/videos/' + id + '/complete', { method: 'POST' });
+ }).then(function (done) {
+ say('Saved. OpenVibe.Media is processing it; opening its page…');
+ window.location.assign(done.video.url);
+ }, function (err) { say('The recording could not be saved: ' + err.message); bPreview.disabled = false; });
+ };
+ // A stream that ends by itself (the screen share stopped) stops the recording too.
+ stream.getVideoTracks().forEach(function (t) { t.addEventListener('ended', function () { if (recorder.state !== 'inactive') recorder.stop(); }); });
+ recorder.start(4000);
+ started = Date.now(); clock.hidden = false;
+ tick = setInterval(function () { var s = Math.floor((Date.now() - started) / 1000); clock.textContent = Math.floor(s / 60) + ':' + String(s % 60).padStart(2, '0'); }, 500);
+ bStop.disabled = false;
+ say('Recording…');
+ }, function (err) { say(err.message); bStart.disabled = false; bPreview.disabled = false; });
+ });
+ bStop.addEventListener('click', function () { bStop.disabled = true; if (recorder && recorder.state !== 'inactive') recorder.stop(); });
+ window.addEventListener('beforeunload', function (e) { if (recorder && recorder.state === 'recording') { e.preventDefault(); e.returnValue = ''; } });
+})();
@@ -91,7 +91,7 @@ ${render(o.body)}
@@ -91,7 +91,7 @@ ${render(o.body)}
}
}
// The site's own places in the navbar: the videos, and your playlists (signed in; the page asks you to sign in).
// The site's own places in the navbar: the videos, and your playlists (signed in; the page asks you to sign in).
-const NAV_LINKS = [{ label: 'Videos', href: '/' }, { label: 'Upload', href: '/upload' }, { label: 'Your videos', href: '/me/videos' }, { label: 'Playlists', href: '/playlists' }];
+const NAV_LINKS = [{ label: 'Videos', href: '/' }, { label: 'Upload', href: '/upload' }, { label: 'Record', href: '/record' }, { label: 'Your videos', href: '/me/videos' }, { label: 'Playlists', href: '/playlists' }];
/** Send a video page. Signed-out pages are public; a session cookie makes a page private, no-store. */
/** Send a video page. Signed-out pages are public; a session cookie makes a page private, no-store. */
function send(res, status, o) {
function send(res, status, o) {
@@ -10,6 +10,9 @@
@@ -10,6 +10,9 @@
* GET /u/:id the watch page (processing: it says so and reloads itself)
* GET /u/:id the watch page (processing: it says so and reloads itself)
* POST /u/:id/manage | /u/:id/delete | /u/:id/report
* POST /u/:id/manage | /u/:id/delete | /u/:id/report
* GET /me/videos your uploads, with their state
* GET /me/videos your uploads, with their state
+ * GET /record record from the camera or the screen in the browser (public/js/video-record.js)
+ * POST /api/v1/videos/recordings start: { title, description, visibility, content_type } → { upload }
+ * PUT /api/v1/videos/:id/chunks/:n the recording's next piece (up to 9 MB), in order, while it records
*
*
* Every write is a signed-in person's (a session on openvibe.video) from openvibe.video itself (Sec-Fetch-Site or
* Every write is a signed-in person's (a session on openvibe.video) from openvibe.video itself (Sec-Fetch-Site or
* Origin). Reports are open to anyone who can see the video, one per person (or per keyed address) per video.
* Origin). Reports are open to anyone who can see the video, one per person (or per keyed address) per video.
@@ -68,6 +71,25 @@ function createUploadApi({ ctx, config }) {
@@ -68,6 +71,25 @@ function createUploadApi({ ctx, config }) {
} catch (err) { return problem(res, err); }
} catch (err) { return problem(res, err); }
});
});
+ r.post('/recordings', express.json({ limit: '16kb' }), async (req, res) => {
+ if (!guard(req, res)) return undefined;
+ try {
+ const b = req.body || {};
+ const row = await up.startRecording(who(req), { title: b.title, description: b.description, visibility: b.visibility, contentType: b.content_type });
+ return res.status(201).json({ upload: { id: row.id } });
+ } catch (err) { return problem(res, err); }
+ });
+
+ r.put('/:id/chunks/:n', express.raw({ type: () => true, limit: '9mb' }), async (req, res) => {
+ if (!guard(req, res)) return undefined;
+ const row = await own(req, res);
+ if (!row) return undefined;
+ try {
+ const out = await up.chunk(row, Number(req.params.n), Buffer.isBuffer(req.body) ? req.body : null);
+ return res.json({ pieces: out.parts_received, size_bytes: Number(out.size_bytes) });
+ } catch (err) { return problem(res, err); }
+ });
+
r.post('/:id/complete', async (req, res) => {
r.post('/:id/complete', async (req, res) => {
if (!guard(req, res)) return undefined;
if (!guard(req, res)) return undefined;
const row = await own(req, res);
const row = await own(req, res);
@@ -109,7 +131,27 @@ function mountUploadPages(r, h) {
@@ -109,7 +131,27 @@ function mountUploadPages(r, h) {
<button class="ovv-btn ovv-btn--primary" type="submit">Upload</button>
<button class="ovv-btn ovv-btn--primary" type="submit">Upload</button>
<noscript><p class="ovv-fine">Uploading needs JavaScript: a video goes up in parts so a big file and a dropped connection both work.</p></noscript>
<noscript><p class="ovv-fine">Uploading needs JavaScript: a video goes up in parts so a big file and a dropped connection both work.</p></noscript>
</form>
</form>
-<p><a href="/me/videos">Your videos</a></p>`,
+<p><a href="/record">Record from your camera or screen instead</a> · <a href="/me/videos">Your videos</a></p>`,
+ }));
+
+ r.get('/record', mustSignIn('/record', 'Recordings belong to your OpenVibe account: sign in to record a video.'), async (req, res) => page(req, res, {
+ title: 'Record a video',
+ crumbs: [{ label: 'Home', href: '/' }, { label: 'Record' }],
+ scripts: ['js/video-record.js'],
+ body: html`<h1>Record a video</h1>
+<p class="ovv-lede">Record from your camera or your screen, right here. It is never live: each few seconds go up to your OpenVibe.Video account as you record, and when you stop it becomes a video you can keep private, share by link or publish.</p>
+<form class="ovv-record ovv-panel" data-video-record>
+<fieldset class="ovv-record-source"><legend>Record</legend>
+<label class="ovv-radio"><input type="radio" name="source" value="camera" checked> Camera and microphone</label>
+<label class="ovv-radio"><input type="radio" name="source" value="screen"> Screen, with your microphone</label></fieldset>
+<label class="ovv-field"><span>Title</span><input name="title" type="text" maxlength="120" placeholder="Untitled recording"></label>
+<label class="ovv-field"><span>Who can see it</span><select name="visibility">${visibilityOptions('private')}</select></label>
+<div class="ovv-record-stage"><video class="ovv-record-preview" muted playsinline hidden></video><p class="ovv-record-clock" aria-live="off" hidden>0:00</p></div>
+<p class="ovv-upload-status" role="status" aria-live="polite"></p>
+<p class="ovv-record-actions"><button class="ovv-btn" type="button" data-record-preview>Turn on the camera</button> <button class="ovv-btn ovv-btn--primary" type="button" data-record-start disabled>Start recording</button> <button class="ovv-btn ovv-btn--danger" type="button" data-record-stop disabled>Stop and save</button></p>
+<p class="ovv-fine">Up to ${Math.round(cfg.maxBytes / GB)} GB a video. Keep this tab open while recording; a few seconds are lost at most if the connection drops. Recording needs a browser that records video (Chrome, Edge, Firefox, Safari 14.1+) and JavaScript.</p>
+</form>
+<p><a href="/upload">Upload a file instead</a> · <a href="/me/videos">Your videos</a></p>`,
}));
}));
// Without JavaScript the form posts here: a video goes up in parts, which needs the page's script.
// Without JavaScript the form posts here: a video goes up in parts, which needs the page's script.
@@ -9,6 +9,10 @@
@@ -9,6 +9,10 @@
* complete(row) closes the file; Media finalizes in the background, the row goes processing → ready
* complete(row) closes the file; Media finalizes in the background, the row goes processing → ready
* refresh(row) reads Media again for a row still processing (after a restart it is how it finishes)
* refresh(row) reads Media again for a row still processing (after a restart it is how it finishes)
* update(row, patch) / remove(row) / report(row, …)
* update(row, patch) / remove(row) / report(row, …)
+ * startRecording(who, { title, description, visibility, contentType }) a video recorded in the browser (/record):
+ * its size is not known ahead, so parts_expected stays 0 until complete()
+ * chunk(row, n, buffer) the n-th piece of a recording (any size up to 9 MB), in order; the size and the person's
+ * quota are checked as it grows
*
*
* Rules: MP4 (QuickTime and M4V are the same container and go to Media as MP4), WebM and Matroska; at most
* Rules: MP4 (QuickTime and M4V are the same container and go to Media as MP4), WebM and Matroska; at most
* VIDEO_UPLOAD_MAX_BYTES (2 GB) a file, VIDEO_UPLOAD_QUOTA_BYTES (20 GB) a person, VIDEO_UPLOADS_PER_DAY (10) a day.
* VIDEO_UPLOAD_MAX_BYTES (2 GB) a file, VIDEO_UPLOAD_QUOTA_BYTES (20 GB) a person, VIDEO_UPLOADS_PER_DAY (10) a day.
@@ -86,7 +90,45 @@ function createUploads({ s, media, config, log = console }) {
@@ -86,7 +90,45 @@ function createUploads({ s, media, config, log = console }) {
return await byId(id);
return await byId(id);
}
}
+ async function startRecording(who, input) {
+ if (!who || !who.requester) throw new UploadError(401, 'auth.required', 'Sign in to record a video.');
+ const title = cleanLine(input.title, 120) || `Recording, ${s.iso().slice(0, 16).replace('T', ' ')} UTC`;
+ const description = cleanText(input.description, 5000);
+ const visibility = VISIBILITY.includes(input.visibility) ? input.visibility : 'private';
+ const t = String(input.contentType || '').split(';')[0].trim().toLowerCase();
+ const kind = t === 'video/webm' || t === 'video/mp4' ? t : null;
+ if (!kind) throw new UploadError(415, 'upload.type', 'This browser records in a format OpenVibe.Video does not take (WebM or MP4).');
+ const today = s.iso().slice(0, 10);
+ const usage = await s.db.maybe(`SELECT count(*) FILTER (WHERE created_at >= $2)::int AS today, coalesce(sum(size_bytes) FILTER (WHERE state <> 'removed'), 0)::bigint AS bytes
+ FROM video_uploads WHERE owner = $1`, [who.requester, today]);
+ if (usage && usage.today >= cfg.perDay) throw new UploadError(429, 'upload.daily_limit', `That is ${cfg.perDay} uploads today. Try again tomorrow.`);
+ if (usage && Number(usage.bytes) >= cfg.quotaBytes) throw new UploadError(413, 'upload.quota', `Your videos already fill ${Math.round(cfg.quotaBytes / 1024 ** 3)} GB. Delete one first.`);
+ const vod = await media.vodCreate({ title, visibility, ownerSubject: who.requester });
+ if (!vod.ok || !vod.data || vod.data.id == null) throw new UploadError(502, 'upload.media', `OpenVibe.Media could not start the recording (${vod.code || vod.status}).`);
+ const id = s.newId('vup');
+ const now = s.iso();
+ await s.db.query(`INSERT INTO video_uploads (id, owner, owner_username, title, description, visibility, media_vod_id, content_type, size_bytes, part_size, parts_expected, created_at, updated_at)
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, 0, 0, 0, $9, $9)`,
+ [id, who.requester, who.username || null, title, description, visibility, Number(vod.data.id), kind, now]);
+ return await byId(id);
+ }
+
+ /** A recording's next piece. The total may not pass the per-video limit or the person's quota. */
+ async function chunk(row, n, buffer) {
+ if (row.status !== 'uploading' || row.parts_expected !== 0) throw new UploadError(409, 'upload.closed', 'This recording is already finished.');
+ if (n !== row.parts_received + 1) throw new UploadError(409, 'upload.out_of_order', `Send piece ${row.parts_received + 1} next.`);
+ if (!buffer || !buffer.length) throw new UploadError(422, 'upload.empty', 'An empty piece.');
+ const size = Number(row.size_bytes) + buffer.length;
+ if (size > cfg.maxBytes) throw new UploadError(413, 'upload.too_big', `A video can be at most ${Math.round(cfg.maxBytes / 1024 ** 3)} GB. Stop the recording.`);
+ const used = Number(await s.db.value("SELECT coalesce(sum(size_bytes), 0)::bigint FROM video_uploads WHERE owner = $1 AND state <> 'removed' AND id <> $2", [row.owner, row.id]));
+ if (used + size > cfg.quotaBytes) throw new UploadError(413, 'upload.quota', `Your videos would pass ${Math.round(cfg.quotaBytes / 1024 ** 3)} GB. Stop the recording.`);
+ const out = await media.vodChunk(row.media_vod_id, buffer, row.content_type);
+ if (!out.ok) throw new UploadError(502, 'upload.media', `OpenVibe.Media did not take piece ${n} (${out.code || out.status}). Try again.`);
+ return await patchRow(row.id, { parts_received: n, size_bytes: size });
+ }
+
async function part(row, n, buffer) {
async function part(row, n, buffer) {
+ if (row.parts_expected === 0) throw new UploadError(409, 'upload.recording', 'This is a recording: send its pieces to …/chunks/:n.');
if (row.status !== 'uploading') throw new UploadError(409, 'upload.closed', 'This upload is already finished.');
if (row.status !== 'uploading') throw new UploadError(409, 'upload.closed', 'This upload is already finished.');
if (n !== row.parts_received + 1) throw new UploadError(409, 'upload.out_of_order', `Send part ${row.parts_received + 1} next.`);
if (n !== row.parts_received + 1) throw new UploadError(409, 'upload.out_of_order', `Send part ${row.parts_received + 1} next.`);
const last = n === row.parts_expected;
const last = n === row.parts_expected;
@@ -109,8 +151,10 @@ function createUploads({ s, media, config, log = console }) {
@@ -109,8 +151,10 @@ function createUploads({ s, media, config, log = console }) {
async function complete(row) {
async function complete(row) {
if (row.status === 'ready' || row.status === 'processing') return row;
if (row.status === 'ready' || row.status === 'processing') return row;
- if (row.parts_received !== row.parts_expected) throw new UploadError(409, 'upload.incomplete', `${row.parts_expected - row.parts_received} parts are still missing.`);
- const now = await patchRow(row.id, { status: 'processing' });
+ const recording = row.parts_expected === 0;
+ if (recording && row.parts_received < 1) throw new UploadError(409, 'upload.incomplete', 'Nothing was recorded.');
+ if (!recording && row.parts_received !== row.parts_expected) throw new UploadError(409, 'upload.incomplete', `${row.parts_expected - row.parts_received} parts are still missing.`);
+ const now = await patchRow(row.id, { status: 'processing', ...(recording ? { parts_expected: row.parts_received } : {}) });
// Media can take minutes on a big file: answer now, finish in the background (refresh() finishes it after a restart).
// Media can take minutes on a big file: answer now, finish in the background (refresh() finishes it after a restart).
const job = media.vodComplete(row.media_vod_id).then(async (out) => {
const job = media.vodComplete(row.media_vod_id).then(async (out) => {
if (out.ok) return await absorb(await byId(row.id), out.data);
if (out.ok) return await absorb(await byId(row.id), out.data);
@@ -164,7 +208,7 @@ function createUploads({ s, media, config, log = console }) {
@@ -164,7 +208,7 @@ function createUploads({ s, media, config, log = console }) {
return {
return {
PART_SIZE, REASONS, UploadError, kindOf, addressId, canSee,
PART_SIZE, REASONS, UploadError, kindOf, addressId, canSee,
- byId, start, part, complete, refresh, update, remove, report,
+ byId, start, part, complete, refresh, update, remove, report, startRecording, chunk,
mine: (owner, limit = 60) => s.db.many("SELECT * FROM video_uploads WHERE owner = $1 AND state <> 'removed' ORDER BY id DESC LIMIT $2", [owner, limit]),
mine: (owner, limit = 60) => s.db.many("SELECT * FROM video_uploads WHERE owner = $1 AND state <> 'removed' ORDER BY id DESC LIMIT $2", [owner, limit]),
latest: (limit = 12) => s.db.many("SELECT * FROM video_uploads WHERE visibility = 'public' AND status = 'ready' AND state = 'active' ORDER BY id DESC LIMIT $1", [limit]),
latest: (limit = 12) => s.db.many("SELECT * FROM video_uploads WHERE visibility = 'public' AND status = 'ready' AND state = 'active' ORDER BY id DESC LIMIT $1", [limit]),
channel: (username, limit = 48) => s.db.many("SELECT * FROM video_uploads WHERE lower(owner_username) = lower($1) AND visibility = 'public' AND status = 'ready' AND state = 'active' ORDER BY id DESC LIMIT $2", [String(username), limit]),
channel: (username, limit = 48) => s.db.many("SELECT * FROM video_uploads WHERE lower(owner_username) = lower($1) AND visibility = 'public' AND status = 'ready' AND state = 'active' ORDER BY id DESC LIMIT $2", [String(username), limit]),
@@ -330,9 +330,9 @@ function countOccurrences(haystack, needle) {
@@ -330,9 +330,9 @@ function countOccurrences(haystack, needle) {
assert.strictEqual(r.text, 'WEBVTT\n\n00:00:10.000 --> 00:00:30.000\nIntro & setup\n\n00:00:30.000 --> 00:00:45.000\nThe <main> part\n');
assert.strictEqual(r.text, 'WEBVTT\n\n00:00:10.000 --> 00:00:30.000\nIntro & setup\n\n00:00:30.000 --> 00:00:45.000\nThe <main> part\n');
});
});
- await check('the navbar names the site\'s places: Videos, Upload, Your videos and Playlists, the current one active', async () => {
+ await check('the navbar names the site\'s places: Videos, Upload, Record, Your videos and Playlists, the current one active', async () => {
const home = (await getVideo('/')).text;
const home = (await getVideo('/')).text;
- assert.match(home, /"links":\[\{"label":"Videos","href":"\/","active":true\},\{"label":"Upload","href":"\/upload","active":false\},\{"label":"Your videos","href":"\/me\/videos","active":false\},\{"label":"Playlists","href":"\/playlists","active":false\}\]/);
+ assert.match(home, /"links":\[\{"label":"Videos","href":"\/","active":true\},\{"label":"Upload","href":"\/upload","active":false\},\{"label":"Record","href":"\/record","active":false\},\{"label":"Your videos","href":"\/me\/videos","active":false\},\{"label":"Playlists","href":"\/playlists","active":false\}\]/);
assert.ok(home.includes('href="/playlists"'), 'the no-JS nav links Playlists too');
assert.ok(home.includes('href="/playlists"'), 'the no-JS nav links Playlists too');
});
});
@@ -133,6 +133,31 @@ function vreq(base, path, { method = 'GET', cookie, form, json, body, same = tru
@@ -133,6 +133,31 @@ function vreq(base, path, { method = 'GET', cookie, form, json, body, same = tru
assert.strictEqual((await vreq(t.base, `/u/${id}/report`, { method: 'POST', cookie: samCookie, form: { reason: 'nonsense' } })).status, 404, 'hidden: not reportable by others');
assert.strictEqual((await vreq(t.base, `/u/${id}/report`, { method: 'POST', cookie: samCookie, form: { reason: 'nonsense' } })).status, 404, 'hidden: not reportable by others');
});
});
+ await check('a recording: pieces of any size go up in order while it records, then it completes and plays', async () => {
+ const startR = await vreq(t.base, '/api/v1/videos/recordings', { method: 'POST', json: { title: '', visibility: 'unlisted', content_type: 'video/webm;codecs=vp9,opus' }, cookie: samCookie });
+ assert.strictEqual(startR.status, 201, startR.text);
+ const rid = startR.json().upload.id;
+ const pieces = [Buffer.from('webm-header+cluster-1'), Buffer.alloc(300 * 1024, 2), Buffer.alloc(17, 3)];
+ assert.strictEqual((await vreq(t.base, `/api/v1/videos/${rid}/chunks/2`, { method: 'PUT', body: pieces[1], cookie: samCookie })).status, 409, 'in order');
+ assert.strictEqual((await vreq(t.base, `/api/v1/videos/${rid}/parts/1`, { method: 'PUT', body: pieces[0], cookie: samCookie })).status, 409, 'a recording takes pieces, not parts');
+ for (const [i, p] of pieces.entries()) {
+ const r = await vreq(t.base, `/api/v1/videos/${rid}/chunks/${i + 1}`, { method: 'PUT', body: p, cookie: samCookie });
+ assert.strictEqual(r.status, 200, r.text);
+ }
+ const vod = [...t.media.vods.values()].find((v) => v.visibility === 'unlisted');
+ assert.ok(vod.bytes.equals(Buffer.concat(pieces)), 'Media has every piece, in order');
+ assert.deepStrictEqual([...new Set(vod.types)], ['video/webm']);
+ assert.strictEqual((await vreq(t.base, `/api/v1/videos/${rid}/complete`, { method: 'POST', cookie: samCookie })).status, 202);
+ let page = null;
+ for (let i = 0; i < 20; i++) { page = await vreq(t.base, `/u/${rid}`, { cookie: samCookie }); if (page.text.includes('data-ovv-video')) break; await new Promise((r) => setTimeout(r, 50)); }
+ assert.ok(page.text.includes('data-ovv-video'), 'it plays');
+ assert.match(page.text, /<h1 class="ovv-title">Recording, \d{4}-\d\d-\d\d \d\d:\d\d UTC<\/h1>/, 'an untitled recording is named by its time');
+ assert.strictEqual((await vreq(t.base, `/api/v1/videos/${rid}/chunks/4`, { method: 'PUT', body: pieces[2], cookie: samCookie })).status, 409, 'closed once complete');
+ const rec = await vreq(t.base, '/record', { cookie: samCookie });
+ assert.ok(rec.text.includes('data-video-record') && rec.text.includes('js/video-record.js'), 'the recorder page');
+ assert.strictEqual((await vreq(t.base, '/record')).status, 401, 'signed out: sign in first');
+ });
+
await check('delete removes the VOD in Media; the daily limit holds', async () => {
await check('delete removes the VOD in Media; the daily limit holds', async () => {
const r = await vreq(t.base, `/u/${id}/delete`, { method: 'POST', cookie: kimCookie, form: { confirm: '1' } });
const r = await vreq(t.base, `/u/${id}/delete`, { method: 'POST', cookie: kimCookie, form: { confirm: '1' } });
assert.strictEqual(r.status, 303);
assert.strictEqual(r.status, 303);