openvibers/openvibe.mediahub Commits

Video: record from the camera or the screen in the browser, never live (#69)

OP OpenVibers committed 29 days ago · 6ed90424b3 · 9 files, +235 −8

Changes

ddiff --git a/README.md b/README.md
iindex e047add..24b95e7 100644
---- a/README.md
++++ b/README.md
@@@ -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
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.
++- **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
plays HLS, otherwise through hls.js, self-hosted at `public/vendor/hls.light.min.js` (hls.js 1.7.3, Apache-2.0, loaded
ddiff --git a/deploy/nginx/openvibe.download.conf b/deploy/nginx/openvibe.download.conf
iindex 86b0073..54bfa7b 100644
---- a/deploy/nginx/openvibe.download.conf
++++ b/deploy/nginx/openvibe.download.conf
@@@ -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;
ddiff --git a/public/css/video.css b/public/css/video.css
iindex 3553f29..a560b53 100644
---- a/public/css/video.css
++++ b/public/css/video.css
@@@ -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; }
ddiff --git a/public/js/video-record.js b/public/js/video-record.js
nnew file mode 100644
iindex 0000000..5cda606
---- /dev/null
++++ b/public/js/video-record.js
@@@ -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 = ''; } });
++})();
ddiff --git a/server/video/layout.js b/server/video/layout.js
iindex 0ad214c..b6ddcbb 100644
---- a/server/video/layout.js
++++ b/server/video/layout.js
@@@ -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) {
ddiff --git a/server/video/upload-routes.js b/server/video/upload-routes.js
iindex 2b2283a..5387323 100644
---- a/server/video/upload-routes.js
++++ b/server/video/upload-routes.js
@@@ -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.
ddiff --git a/server/video/uploads.js b/server/video/uploads.js
iindex 540e498..fa5a3a1 100644
---- a/server/video/uploads.js
++++ b/server/video/uploads.js
@@@ -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]),
ddiff --git a/test/video-pages.test.js b/test/video-pages.test.js
iindex bda5897..54958d5 100644
---- a/test/video-pages.test.js
++++ b/test/video-pages.test.js
@@@ -330,9 +330,9 @@ function countOccurrences(haystack, needle) {
assert.strictEqual(r.text, 'WEBVTT\n\n00:00:10.000 --> 00:00:30.000\nIntro &amp; setup\n\n00:00:30.000 --> 00:00:45.000\nThe &lt;main&gt; 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');
});
ddiff --git a/test/video-uploads.test.js b/test/video-uploads.test.js
iindex 04c7ff8..c1e5ee9 100644
---- a/test/video-uploads.test.js
++++ b/test/video-uploads.test.js
@@@ -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');
});
++ 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);