openvibers/openvibe.mediahub Commits
Video: playlists hold uploads too (kind upload), with Save to playlist and Up next on an upload's page (#74)
Changes
@@@ -277,13 +277,16 @@ never answer there. Only https URLs from Live (player source, thumbnails, the thelse 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); thewatch pages fetch the cards through the cached Live client, and an item Live no longer answers is "No longeravailable" to the owner and hidden for everyone else. "Play all" starts the first item with `?list=<id>`, and eachwatch page opened with `?list=` shows "Up next" and advances when the video ends. A playlist may also be owned byan 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)nnew file mode 100644@@@ -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'));@@@ -533,7 +533,7 @@ ${kind === 'vod' ? html`<label class="ovv-field"><span>Description</span><textarr.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><textarconst 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',@@@ -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);@@@ -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>${stconst 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>@@@ -50,6 +50,8 @@ function createUploads({ s, media, config, log = console }) {const running = new Map(); // upload id → the completion in flight in this processconst 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]),@@@ -111,6 +111,27 @@ function vreq(base, path, { method = 'GET', cookie, form, json, body, same = truassert.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);
@@ -277,13 +277,16 @@ never answer there. Only https URLs from Live (player source, thumbnails, the th
@@ -277,13 +277,16 @@ never answer there. Only https URLs from Live (player source, thumbnails, the th
else is dropped.
else is dropped.
Playlists: 100 per owner and 500 items per playlist, from `MEDIAHUB_VIDEO_PLAYLISTS_MAX` and
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
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
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
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".
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
## Pages
@@ -323,7 +326,7 @@ session, an app/agent/service token needs the capability the route names, and pu
@@ -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 |
| `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 |
| `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) |
| `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 }, …] }` |
| `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 |
| `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` |
| `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>
@@ -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
`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)
## Account export and deletion (ADR-033)
new file mode 100644
new file mode 100644
@@ -0,0 +1,8 @@
@@ -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'));
@@ -533,7 +533,7 @@ ${kind === 'vod' ? html`<label class="ovv-field"><span>Description</span><textar
@@ -533,7 +533,7 @@ ${kind === 'vod' ? html`<label class="ovv-field"><span>Description</span><textar
r.use(express.urlencoded({ extended: false, limit: '64kb' }));
r.use(express.urlencoded({ extended: false, limit: '64kb' }));
// ── Uploads: /upload, /u/:id, /me/videos (server/video/upload-routes.js) ──
// ── Uploads: /upload, /u/:id, /me/videos (server/video/upload-routes.js) ──
- const uploadCard = mountUploadPages(r, { ctx, page, notFound, signedIn, ownerOf, mustSignIn, sameSite, isStaff, visibilityOptions, visibilityLabel, durationLabel, dateLabel, httpsUrl, fileOf, hlsAttrs, grid, card, renderCaptionsVtt, renderChaptersVtt, vttTime, sendVtt, notFoundVtt });
+ 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 ───────────────────────────────
// ── Playlist page pieces ───────────────────────────────
async function playlistForm(req, kind, id) {
async function playlistForm(req, kind, id) {
@@ -552,6 +552,41 @@ ${kind === 'vod' ? html`<label class="ovv-field"><span>Description</span><textar
@@ -552,6 +552,41 @@ ${kind === 'vod' ? html`<label class="ovv-field"><span>Description</span><textar
</form></details>`;
</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) {
async function upNext(req, kind, id) {
const listId = String(req.query.list || '');
const listId = String(req.query.list || '');
if (!playlists.isPlaylistId(listId)) return { html: '', nextUrl: null };
if (!playlists.isPlaylistId(listId)) return { html: '', nextUrl: null };
@@ -562,13 +597,11 @@ ${kind === 'vod' ? html`<label class="ovv-field"><span>Description</span><textar
@@ -562,13 +597,11 @@ ${kind === 'vod' ? html`<label class="ovv-field"><span>Description</span><textar
const next = idx >= 0 ? items[idx + 1] : items[0];
const next = idx >= 0 ? items[idx + 1] : items[0];
if (!next) return { html: '', nextUrl: null };
if (!next) return { html: '', nextUrl: null };
const nextId = Number(next.item_id);
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 {
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>`,
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,
nextUrl,
@@ -1078,30 +1111,23 @@ ${rows.length ? html`<ul class="ovv-playlist-list">${rows.map(playlistRow)}</ul>
@@ -1078,30 +1111,23 @@ ${rows.length ? html`<ul class="ovv-playlist-list">${rows.map(playlistRow)}</ul>
const listLd = [];
const listLd = [];
for (const it of items) {
for (const it of items) {
const itemId = Number(it.item_id);
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 (!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>`);
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;
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">
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>
<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) : ''}
${isOwner ? itemControls(pl.id, it) : ''}
</li>`);
</li>`);
}
}
const first = items[0];
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, {
return page(req, res, {
index: pl.visibility === 'public',
index: pl.visibility === 'public',
@@ -1,9 +1,10 @@
@@ -1,9 +1,10 @@
'use strict';
'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
* 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),
* a POST enforce the same numbers: 100 playlists per owner, 500 items per playlist (both from the environment),
@@ -17,7 +18,7 @@
@@ -17,7 +18,7 @@
const TITLE_MAX = 120;
const TITLE_MAX = 120;
const DESCRIPTION_MAX = 2000;
const DESCRIPTION_MAX = 2000;
const VISIBILITIES = ['public', 'unlisted', 'private'];
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}$/;
const PLAYLIST_ID_RE = /^vpl_[0-9A-HJKMNP-TV-Z]{26}$/;
/** Live's ids: positive safe integers (what /v/:id and /c/:id accept). */
/** Live's ids: positive safe integers (what /v/:id and /c/:id accept). */
@@ -173,7 +174,7 @@ function createPlaylists({ s, config, log = console }) {
@@ -173,7 +174,7 @@ function createPlaylists({ s, config, log = console }) {
async function addItem(owner, playlistId, { kind, id }) {
async function addItem(owner, playlistId, { kind, id }) {
const row = await own(owner, playlistId);
const row = await own(owner, playlistId);
if (!row) return { ok: false, code: 'playlist.not_found', detail: 'No such playlist.' };
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.' };
if (!isItemId(id)) return { ok: false, code: 'playlist.invalid_item', detail: 'item id must be a positive whole number.' };
const itemId = Number(id);
const itemId = Number(id);
const n = await s.db.value('SELECT count(*) FROM video_playlist_items WHERE playlist_id = $1', [playlistId]).then(Number);
const n = await s.db.value('SELECT count(*) FROM video_playlist_items WHERE playlist_id = $1', [playlistId]).then(Number);
@@ -105,7 +105,7 @@ function createUploadApi({ ctx, config }) {
@@ -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). */
/** The pages; `h` carries the video site's helpers (page, notFound, signedIn, ownerOf, mustSignIn, sameSite, isStaff, cards). */
function mountUploadPages(r, h) {
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 up = ctx.videoUploads;
const cfg = ctx.config.videoUploads;
const cfg = ctx.config.videoUploads;
const whoOf = (req) => ({ requester: signedIn(req) ? ownerOf(req) : null, staff: isStaff(req) });
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
@@ -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 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);
const thumb = httpsUrl(row.thumbnail_url);
if (row.status === 'processing') res.set('Refresh', '15'); // the page reloads itself until Media is done
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, {
return page(req, res, {
index: listed, noindex: !listed,
index: listed, noindex: !listed,
...(listed ? {} : { cache: cache.htmlHeaders({ private: true }) }),
...(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
@@ -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') : ''}
${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.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') : ''}
${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>`
: 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>`
: 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>`}
: 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
@@ -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>` : ''}
${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>` : ''}
<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>
${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>
</div>
${row.description ? html`<section class="ovv-panel ovv-description"><p>${row.description}</p></section>` : ''}
${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>
${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">
@@ -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>` : ''}
${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>
</div>
<aside class="ovv-side">
<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>
${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">
<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>
<label class="ovv-field"><span>Title</span><input name="title" type="text" maxlength="120" value="${row.title}" required></label>
@@ -50,6 +50,8 @@ function createUploads({ s, media, config, log = console }) {
@@ -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 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);
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 patchRow = async (id, patch) => {
const cols = Object.keys(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])]);
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 }) {
@@ -208,7 +210,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, 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]),
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]),
@@ -111,6 +111,27 @@ function vreq(base, path, { method = 'GET', cookie, form, json, body, same = tru
@@ -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'));
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 () => {
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' } });
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);
assert.strictEqual(r.status, 303);