openvibers/openvibe.mediahub Commits

Video: playlists hold uploads too (kind upload), with Save to playlist and Up next on an upload's page (#74)

OP OpenVibers committed 29 days ago · 7031c296b0 · 7 files, +101 −33

A playlist item is a kind and a number; uploads get a stable number of their own (video_uploads.seq,
migration 0010, filled for the uploads that exist) and kind 'upload' joins vod and clip. An upload's
watch page has the Save to playlist form (ready, not private) and the Up next panel; the playlist page
and Play all resolve uploads from video_uploads. A private, hidden or removed upload drops out of
playlists (its owner sees No longer available). README: the 'not built yet: uploads' line is gone.

Changes

ddiff --git a/README.md b/README.md
iindex 1de4274..d553197 100644
---- a/README.md
++++ b/README.md
@@@ -277,13 +277,16 @@ never answer there. Only https URLs from Live (player source, thumbnails, the th
else is dropped.
Playlists: 100 per owner and 500 items per playlist, from `MEDIAHUB_VIDEO_PLAYLISTS_MAX` and
--`MEDIAHUB_VIDEO_PLAYLIST_ITEMS_MAX`. A playlist holds Live ids only (`vod` or `clip` plus the numeric id); the
++`MEDIAHUB_VIDEO_PLAYLIST_ITEMS_MAX`. A playlist holds a kind and a number: a Live id (`vod` or `clip`) or an upload's number (`upload`, the `seq` of
++`video_uploads`, migration 0010); the
watch pages fetch the cards through the cached Live client, and an item Live no longer answers is "No longer
available" to the owner and hidden for everyone else. "Play all" starts the first item with `?list=<id>`, and each
watch page opened with `?list=` shows "Up next" and advances when the video ends. A playlist may also be owned by
an app, agent or service through the API below; its public page attributes it "Curated by an app".
--A channel page (`/@user`) lists that person's public playlists (newest twelve). Not built yet: uploads.
++A channel page (`/@user`) lists that person's public playlists (newest twelve). An upload's watch page has the same
++"Save to playlist" form (a ready video that is not private) and the same "Up next" panel; an upload that is made
++private, hidden or removed drops out of everyone's playlists (its owner's list shows "No longer available").
## Pages
@@@ -323,7 +326,7 @@ session, an app/agent/service token needs the capability the route names, and pu
| `GET /api/v1/video/playlists/:id` | owner (private too); anyone for public/unlisted; app with `video.playlist.read` for others' public/unlisted | one playlist and its items |
| `PATCH /api/v1/video/playlists/:id` | owner, or app with `video.playlist.manage` | rename / describe / change visibility |
| `DELETE /api/v1/video/playlists/:id` | owner, or app with `video.playlist.manage` | delete it (its items go with it) |
--| `POST /api/v1/video/playlists/:id/items` | owner, or app with `video.playlist.manage` | add one `{ kind: 'vod'|'clip', id }` |
++| `POST /api/v1/video/playlists/:id/items` | owner, or app with `video.playlist.manage` | add one `{ kind: 'vod'|'clip'|'upload', id }` |
| `PATCH /api/v1/video/playlists/:id/items` | owner, or app with `video.playlist.manage` | reorder: the full ordered list `{ items: [{ kind, id }, …] }` |
| `DELETE /api/v1/video/playlists/:id/items/:kind/:itemId` | owner, or app with `video.playlist.manage` | remove one |
| `GET /api/v1/video/users/:username/playlists` | anyone | that person's public playlists, newest first, `limit` (default 20, max 50) and an opaque `cursor` |
@@@ -362,7 +365,8 @@ curl -sS https://openvibe.video/api/v1/video/playlists/<id>
```
`owner` is `{ kind: 'user', username }` for a person, or `{ kind: 'app' | 'agent' | 'service' }` otherwise. Items
--are Live ids only; the watch pages resolve their display fields lazily through the cached Live client.
++are Live ids (`vod`, `clip`) or upload numbers (`upload`); the watch pages resolve their display fields lazily, through
++the cached Live client for Live's and from `video_uploads` for uploads.
## Account export and deletion (ADR-033)
ddiff --git a/migrations/0010_playlist_uploads.sql b/migrations/0010_playlist_uploads.sql
nnew file mode 100644
iindex 0000000..666abca
---- /dev/null
++++ b/migrations/0010_playlist_uploads.sql
@@@ -0,0 +1,8 @@
++-- phase: expand
++-- Playlists hold uploads too (kind 'upload'). A playlist item is a kind and a number; an upload's address is its
++-- vup_ id, so each upload gets a stable number of its own (seq, filled for the uploads that exist) and a playlist
++-- item of kind 'upload' carries it.
++ALTER TABLE video_uploads ADD COLUMN seq bigint GENERATED ALWAYS AS IDENTITY;
++CREATE UNIQUE INDEX video_uploads_seq ON video_uploads (seq);
++ALTER TABLE video_playlist_items DROP CONSTRAINT video_playlist_items_kind_check;
++ALTER TABLE video_playlist_items ADD CONSTRAINT video_playlist_items_kind_check CHECK (kind IN ('vod', 'clip', 'upload'));
ddiff --git a/server/video/pages.js b/server/video/pages.js
iindex d25b017..b5898cc 100644
---- a/server/video/pages.js
++++ b/server/video/pages.js
@@@ -533,7 +533,7 @@ ${kind === 'vod' ? html`<label class="ovv-field"><span>Description</span><textar
r.use(express.urlencoded({ extended: false, limit: '64kb' }));
// ── 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 });
++ 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 });
// ── Playlist page pieces ───────────────────────────────
async function playlistForm(req, kind, id) {
@@@ -552,6 +552,41 @@ ${kind === 'vod' ? html`<label class="ovv-field"><span>Description</span><textar
</form></details>`;
}
++ /**
++ * What a playlist item shows and where it plays: a Live recording or clip through the cached Live client, an upload
++ * from video_uploads (by its seq). Null when it is gone or nobody but its owner may watch it any more.
++ */
++ async function itemInfo(kind, itemId) {
++ if (kind === 'upload') {
++ const row = await ctx.videoUploads.bySeq(itemId);
++ if (!row || row.status !== 'ready' || row.state !== 'active' || row.visibility === 'private') return null;
++ return { href: `/u/${row.id}`, title: row.title, thumb: httpsUrl(row.thumbnail_url), channel: row.owner_username || null, duration: row.duration_seconds, icon: 'fa-film' };
++ }
++ let info = null;
++ try {
++ const data = kind === 'vod' ? await live.vod(itemId) : await live.clip(itemId);
++ info = data && (kind === 'vod' ? data.vod : data.clip);
++ } catch { info = null; }
++ if (!info) return null;
++ return {
++ href: kind === 'vod' ? `/v/${itemId}` : `/c/${itemId}`,
++ title: info.title || (kind === 'clip' ? 'Clip' : 'Stream recording'),
++ thumb: httpsUrl(info.thumbnail_url),
++ channel: info.username || info.display_name || null,
++ duration: info.duration_seconds != null ? info.duration_seconds : info.duration,
++ icon: kind === 'clip' ? 'fa-scissors' : 'fa-video',
++ };
++ }
++
++ /** Where an item plays, without asking anyone (the up-next link and "Play all"). */
++ async function itemHref(kind, itemId) {
++ if (kind === 'upload') {
++ const row = await ctx.videoUploads.bySeq(itemId);
++ return row ? `/u/${row.id}` : null;
++ }
++ return kind === 'vod' ? `/v/${itemId}` : `/c/${itemId}`;
++ }
++
async function upNext(req, kind, id) {
const listId = String(req.query.list || '');
if (!playlists.isPlaylistId(listId)) return { html: '', nextUrl: null };
@@@ -562,13 +597,11 @@ ${kind === 'vod' ? html`<label class="ovv-field"><span>Description</span><textar
const next = idx >= 0 ? items[idx + 1] : items[0];
if (!next) return { html: '', nextUrl: null };
const nextId = Number(next.item_id);
-- const nextUrl = `${next.kind === 'vod' ? '/v/' : '/c/'}${nextId}?list=${encodeURIComponent(pl.id)}`;
-- let label = `${next.kind === 'vod' ? 'VOD' : 'Clip'} ${nextId}`;
-- try {
-- const data = next.kind === 'vod' ? await live.vod(nextId) : await live.clip(nextId);
-- const info = data && (next.kind === 'vod' ? data.vod : data.clip);
-- if (info && info.title) label = info.title;
-- } catch { /* Live unavailable: keep the id label */ }
++ const nextHref = await itemHref(next.kind, nextId);
++ if (!nextHref) return { html: '', nextUrl: null };
++ const nextUrl = `${nextHref}?list=${encodeURIComponent(pl.id)}`;
++ const info = await itemInfo(next.kind, nextId);
++ const label = (info && info.title) || `${next.kind === 'vod' ? 'VOD' : next.kind === 'clip' ? 'Clip' : 'Video'} ${nextId}`;
return {
html: html`<section class="ovv-panel ovv-upnext" aria-label="Up next"><p class="ovv-upnext-in">Up next in <a href="/playlists/${pl.id}">${pl.title}</a></p><a class="ovv-upnext-link" href="${nextUrl}"><i class="fa-solid fa-play" aria-hidden="true"></i> <span>${label}</span></a><p class="ovv-fine">Plays on its own when this one ends.</p></section>`,
nextUrl,
@@@ -1078,30 +1111,23 @@ ${rows.length ? html`<ul class="ovv-playlist-list">${rows.map(playlistRow)}</ul>
const listLd = [];
for (const it of items) {
const itemId = Number(it.item_id);
-- let info = null;
-- try {
-- const data = it.kind === 'vod' ? await live.vod(itemId) : await live.clip(itemId);
-- info = data && (it.kind === 'vod' ? data.vod : data.clip);
-- } catch { info = null; }
++ const info = await itemInfo(it.kind, itemId);
if (!info) {
if (isOwner) rows.push(html`<li class="ovv-item-row ovv-item-row--gone"><div class="ovv-playlist-main"><span class="ovv-item-title">No longer available</span><span class="ovv-meta">${it.kind} ${itemId}</span></div>${itemControls(pl.id, it)}</li>`);
continue;
}
-- const href = it.kind === 'vod' ? `/v/${itemId}` : `/c/${itemId}`;
-- listLd.push({ '@type': 'ListItem', position: listLd.length + 1, url: `${base}${href}`, name: String(info.title || (it.kind === 'vod' ? 'Stream recording' : 'Clip')).slice(0, 110) });
-- const thumb = httpsUrl(info.thumbnail_url);
-- const title = info.title || (it.kind === 'clip' ? 'Clip' : 'Stream recording');
-- const channel = info.username || info.display_name || null;
-- const dur = info.duration_seconds != null ? info.duration_seconds : info.duration;
++ const { href, thumb, title, channel, duration: dur } = info;
++ listLd.push({ '@type': 'ListItem', position: listLd.length + 1, url: `${base}${href}`, name: String(title).slice(0, 110) });
rows.push(html`<li class="ovv-item-row">
--<a class="ovv-item-media" href="${href}" tabindex="-1" aria-hidden="true">${thumb ? thumbImg(thumb, '132px') : html`<span class="ovv-card-placeholder"><i class="fa-solid ${it.kind === 'clip' ? 'fa-scissors' : 'fa-video'}"></i></span>`}${dur != null ? html`<span class="ovv-dur">${durationLabel(dur)}</span>` : ''}</a>
++<a class="ovv-item-media" href="${href}" tabindex="-1" aria-hidden="true">${thumb ? thumbImg(thumb, '132px') : html`<span class="ovv-card-placeholder"><i class="fa-solid ${info.icon}"></i></span>`}${dur != null ? html`<span class="ovv-dur">${durationLabel(dur)}</span>` : ''}</a>
<div class="ovv-playlist-main"><a class="ovv-item-title" href="${href}">${title}</a>${channel ? html`<span class="ovv-meta">@${channel}</span>` : ''}</div>
${isOwner ? itemControls(pl.id, it) : ''}
</li>`);
}
const first = items[0];
-- const playAllHref = first ? `${first.kind === 'vod' ? '/v/' : '/c/'}${Number(first.item_id)}?list=${encodeURIComponent(pl.id)}` : null;
++ const firstHref = first ? await itemHref(first.kind, Number(first.item_id)) : null;
++ const playAllHref = firstHref ? `${firstHref}?list=${encodeURIComponent(pl.id)}` : null;
return page(req, res, {
index: pl.visibility === 'public',
ddiff --git a/server/video/playlists.js b/server/video/playlists.js
iindex 4de3b90..1e5250d 100644
---- a/server/video/playlists.js
++++ b/server/video/playlists.js
@@@ -1,9 +1,10 @@
'use strict';
/**
-- * Playlists on openvibe.video (step C): a signed-in person's named lists of OpenVibe.Live recordings and clips.
-- * The rows hold only Live's public ids (kind + numeric id), never the media bytes — the watch pages ask Live for
-- * the card and player details through the existing, cached Live client.
++ * Playlists on openvibe.video (step C): a signed-in person's named lists of OpenVibe.Live recordings and clips and of
++ * videos uploaded here. The rows hold only a kind and a number, never the media bytes: Live's public id for a vod or
++ * a clip (the watch pages ask Live for the card through the cached Live client), an upload's seq for an upload
++ * (migration 0010; the card comes from video_uploads).
*
* The rules here are shared by the pages (server/video/pages.js) and the API (server/video/api.js) so a form and
* a POST enforce the same numbers: 100 playlists per owner, 500 items per playlist (both from the environment),
@@@ -17,7 +18,7 @@
const TITLE_MAX = 120;
const DESCRIPTION_MAX = 2000;
const VISIBILITIES = ['public', 'unlisted', 'private'];
--const KINDS = ['vod', 'clip'];
++const KINDS = ['vod', 'clip', 'upload'];
const PLAYLIST_ID_RE = /^vpl_[0-9A-HJKMNP-TV-Z]{26}$/;
/** Live's ids: positive safe integers (what /v/:id and /c/:id accept). */
@@@ -173,7 +174,7 @@ function createPlaylists({ s, config, log = console }) {
async function addItem(owner, playlistId, { kind, id }) {
const row = await own(owner, playlistId);
if (!row) return { ok: false, code: 'playlist.not_found', detail: 'No such playlist.' };
-- if (!KINDS.includes(kind)) return { ok: false, code: 'playlist.invalid_kind', detail: 'kind is vod or clip.' };
++ if (!KINDS.includes(kind)) return { ok: false, code: 'playlist.invalid_kind', detail: 'kind is vod, clip or upload.' };
if (!isItemId(id)) return { ok: false, code: 'playlist.invalid_item', detail: 'item id must be a positive whole number.' };
const itemId = Number(id);
const n = await s.db.value('SELECT count(*) FROM video_playlist_items WHERE playlist_id = $1', [playlistId]).then(Number);
ddiff --git a/server/video/upload-routes.js b/server/video/upload-routes.js
iindex 988d89e..0009fb6 100644
---- a/server/video/upload-routes.js
++++ b/server/video/upload-routes.js
@@@ -105,7 +105,7 @@ function createUploadApi({ ctx, config }) {
/** The pages; `h` carries the video site's helpers (page, notFound, signedIn, ownerOf, mustSignIn, sameSite, isStaff, cards). */
function mountUploadPages(r, h) {
-- const { ctx, page, notFound, signedIn, ownerOf, mustSignIn, sameSite, isStaff, visibilityOptions, visibilityLabel, durationLabel, dateLabel, httpsUrl, fileOf, hlsAttrs, grid, card, renderCaptionsVtt, renderChaptersVtt, vttTime, sendVtt, notFoundVtt } = h;
++ const { ctx, page, notFound, signedIn, ownerOf, mustSignIn, sameSite, isStaff, visibilityOptions, visibilityLabel, durationLabel, dateLabel, httpsUrl, fileOf, hlsAttrs, grid, card, renderCaptionsVtt, renderChaptersVtt, vttTime, sendVtt, notFoundVtt, playlistForm, upNext } = h;
const up = ctx.videoUploads;
const cfg = ctx.config.videoUploads;
const whoOf = (req) => ({ requester: signedIn(req) ? ownerOf(req) : null, staff: isStaff(req) });
@@@ -193,6 +193,10 @@ ${fresh.map((x) => html`<tr><td><a href="/u/${x.id}">${x.title}</a></td><td>${st
const metaParts = [row.ready_at ? html`<time datetime="${row.ready_at}">${dateLabel(row.ready_at)}</time>` : null, row.duration_seconds ? durationLabel(row.duration_seconds) : null, `${Number(row.views) || 0} views`].filter(Boolean);
const thumb = httpsUrl(row.thumbnail_url);
if (row.status === 'processing') res.set('Refresh', '15'); // the page reloads itself until Media is done
++ // Playlists (kind upload, by seq): a ready video anyone else may watch can be saved, and ?list= plays the next one.
++ const saveForm = ready && row.state === 'active' && row.visibility !== 'private' && playlistForm ? await playlistForm(req, 'upload', row.seq) : '';
++ const inList = upNext ? await upNext(req, 'upload', row.seq) : { html: '', nextUrl: null };
++ const nextAttr = inList.nextUrl ? html` data-ovv-next="${inList.nextUrl}"` : '';
return page(req, res, {
index: listed, noindex: !listed,
...(listed ? {} : { cache: cache.htmlHeaders({ private: true }) }),
@@@ -205,7 +209,7 @@ ${fresh.map((x) => html`<tr><td><a href="/u/${x.id}">${x.title}</a></td><td>${st
${req.query.saved === '1' ? notice('Saved.', 'ok') : ''}${req.query.reported === '1' ? notice('Thanks: the report reached OpenVibe staff.', 'ok') : ''}
${row.state === 'hidden' ? notice('Hidden after reports: only you and OpenVibe staff can see it until staff decide.', 'warn') : ''}
${row.visibility === 'private' && ready ? notice('Private: only you (and OpenVibe staff) can see this video.', 'info') : ''}
--${ready && vod ? html`<div class="ovv-player-wrap"><video class="ovv-player" controls preload="metadata" playsinline${thumb ? html` poster="${`${thumb}?w=1280&fmt=webp`}"` : ''} src="${fileOf(vod) || ''}" data-ovv-video${hlsAttrs(vod)}>${tracks}</video></div>`
++${ready && vod ? html`<div class="ovv-player-wrap"><video class="ovv-player" controls preload="metadata" playsinline${thumb ? html` poster="${`${thumb}?w=1280&fmt=webp`}"` : ''} src="${fileOf(vod) || ''}" data-ovv-video${nextAttr}${hlsAttrs(vod)}>${tracks}</video></div>`
: html`<div class="ovv-player-wrap ovv-processing" role="status">${row.status === 'processing' ? html`<p><i class="fa-solid fa-gear fa-spin" aria-hidden="true"></i> Processing: OpenVibe.Media is making it playable. This page reloads by itself.</p>`
: row.status === 'failed' ? html`<p>This upload could not be processed. ${row.error || ''}</p>`
: html`<p>The upload stopped before the end (${row.parts_received} of ${row.parts_expected} parts). Upload the file again from <a href="/upload">Upload</a>.</p>`}</div>`}
@@@ -214,6 +218,7 @@ ${ready && vod ? html`<div class="ovv-player-wrap"><video class="ovv-player" con
${channel ? html`<a class="ovv-avatar" href="/@${channel}" aria-hidden="true" tabindex="-1">${channel.slice(0, 1).toUpperCase()}</a>` : ''}
<div class="ovv-byline-text">${channel ? html`<a class="ovv-channel-name" href="/@${channel}">@${channel}</a>` : ''}
${metaParts.length ? html`<span class="ovv-meta">${metaParts.map((p, i) => (i === 0 ? p : [html` · `, p]))}</span>` : ''}</div>
++${saveForm ? html`<div class="ovv-byline-actions">${saveForm}</div>` : ''}
</div>
${row.description ? html`<section class="ovv-panel ovv-description"><p>${row.description}</p></section>` : ''}
${aiDone && ai.overview ? html`<section class="ovv-panel" aria-labelledby="h-ai"><h2 id="h-ai">About this video</h2><p>${ai.overview}</p><p class="ovv-fine">Written by AI from the video's sound and pictures (OpenVibe.AI, run on OpenVibe's own servers).</p>
@@@ -222,6 +227,7 @@ ${aiDone && ai.segments.length ? html`<details class="ovv-panel ovv-transcript">
${manage && ready && ai && (ai.status === 'queued' || ai.status === 'running') ? html`<p class="ovv-fine">Captions, chapters and a summary are being made by OpenVibe.AI.</p>` : ''}
</div>
<aside class="ovv-side">
++${inList.html}
${manage ? html`<section class="ovv-panel ovv-owner" aria-labelledby="h-owner"><h2 id="h-owner">Manage this video</h2>
<form class="ovv-owner-form" method="post" action="/u/${row.id}/manage">
<label class="ovv-field"><span>Title</span><input name="title" type="text" maxlength="120" value="${row.title}" required></label>
ddiff --git a/server/video/uploads.js b/server/video/uploads.js
iindex fa5a3a1..55ced05 100644
---- a/server/video/uploads.js
++++ b/server/video/uploads.js
@@@ -50,6 +50,8 @@ function createUploads({ s, media, config, log = console }) {
const running = new Map(); // upload id → the completion in flight in this process
const byId = (id) => (/^vup_[0-9A-HJKMNP-TV-Z]{26}$/.test(String(id)) ? s.db.maybe('SELECT * FROM video_uploads WHERE id = $1', [String(id)]) : null);
++ /** An upload by its playlist number (seq, migration 0010). */
++ const bySeq = (seq) => (/^[1-9]\d{0,15}$/.test(String(seq)) ? s.db.maybe('SELECT * FROM video_uploads WHERE seq = $1', [Number(seq)]) : null);
const patchRow = async (id, patch) => {
const cols = Object.keys(patch);
await s.db.query(`UPDATE video_uploads SET ${cols.map((c, i) => `${c} = $${i + 3}`).join(', ')}, updated_at = $2 WHERE id = $1`, [id, s.iso(), ...cols.map((c) => patch[c])]);
@@@ -208,7 +210,7 @@ function createUploads({ s, media, config, log = console }) {
return {
PART_SIZE, REASONS, UploadError, kindOf, addressId, canSee,
-- byId, start, part, complete, refresh, update, remove, report, startRecording, chunk,
++ byId, bySeq, 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-uploads.test.js b/test/video-uploads.test.js
iindex c1e5ee9..6f79209 100644
---- a/test/video-uploads.test.js
++++ b/test/video-uploads.test.js
@@@ -111,6 +111,27 @@ function vreq(base, path, { method = 'GET', cookie, form, json, body, same = tru
assert.ok(mine.text.includes('Trip to the coast') && mine.text.includes('Public'));
});
++ await check('a ready upload goes in a playlist (kind upload, by its number): listed, played, and next in line', async () => {
++ const watch = await vreq(t.base, `/u/${id}`, { cookie: samCookie });
++ assert.ok(watch.text.includes('Save to playlist'), 'a signed-in viewer can save it');
++ assert.ok(watch.text.includes('<input type="hidden" name="kind" value="upload">'));
++ const seq = Number((/<input type="hidden" name="id" value="(\d+)">/.exec(watch.text) || [])[1]);
++ assert.ok(seq > 0, 'the form carries the upload\'s number');
++ const saved = await vreq(t.base, '/playlists/add', { method: 'POST', cookie: samCookie, form: { kind: 'upload', id: String(seq), title: 'Road trips' } });
++ assert.strictEqual(saved.status, 303, saved.text.slice(0, 200));
++ const listUrl = saved.headers.location;
++ const listId = listUrl.split('/').pop();
++ const list = await vreq(t.base, listUrl, { cookie: samCookie });
++ assert.ok(list.text.includes(`href="/u/${id}"`) && list.text.includes('Trip to the coast'), 'the playlist shows the upload');
++ assert.ok(list.text.includes(`href="/u/${id}?list=${listId}"`), 'Play all starts it');
++ await vreq(t.base, `/playlists/${listId}/visibility`, { method: 'POST', cookie: samCookie, form: { visibility: 'public' } });
++ await vreq(t.base, '/playlists/add', { method: 'POST', cookie: samCookie, form: { kind: 'vod', id: '101', playlist: listId } });
++ const inList = await vreq(t.base, `/u/${id}?list=${listId}`);
++ assert.ok(inList.text.includes('Up next in') && inList.text.includes(`data-ovv-next="/v/101?list=${listId}"`), 'the next item plays after it');
++ const api = await vreq(t.base, `/api/v1/video/playlists/${listId}`, { cookie: samCookie });
++ assert.ok(api.text.includes('"kind":"upload"'), api.text.slice(0, 300));
++ });
++
await check('the owner edits (Media follows) and makes it private: gone for everyone else, still the owner\'s', async () => {
const r = await vreq(t.base, `/u/${id}/manage`, { method: 'POST', cookie: kimCookie, form: { title: 'Coast trip', description: 'x', visibility: 'private' } });
assert.strictEqual(r.status, 303);