openvibers/openvibe.mediahub Commits

MediaHub: OpenVibe.Video playlists, with sign-in on openvibe.video (#31)

OP OpenVibers committed 12 Oct 2026 · 1b5f06ea45 · 15 files, +1054 −49

OpenVibe.Video step C: **playlists**. A signed-in person saves recordings and clips from openvibe.video into named lists, shares them, and plays them in order.

- **Sign-in on the video host:** the same Network client (`media-hub`), with openvibe.video's own callback. Network#162 registered it, and `createSso` picks between the two fixed callback URIs by an exact hostname match. Cookies are host-only, so each host keeps its own session. Signed-in pages are `Cache-Control: private`; signed-out pages stay cacheable and unchanged.
- **Data:** migration `0003_video_playlists.sql` (expand) adds `video_playlists` (`vpl_` ids; title, description, visibility public/unlisted/private) and `video_playlist_items` (vod or clip, Live's id, position). Limits: 100 playlists per person, 500 items per playlist.
- **Pages:**
  - "Save to playlist" on every watch page (signed out: "Sign in to save to a playlist").
  - `/playlists` to create, rename, change visibility and delete.
  - `/playlists/:id`, with item cards, reorder and remove for the owner. Unlisted playlists are noindex; a private one is a 404 to anyone but its owner, the same answer as a missing id.
  - "Play all" with `?list=`: an "Up next" card, and the next item plays on its own when one ends.
- **API** (browser session, same-origin writes): `/api/v1/video/playlists` and its items, on the video host only.
- **Account data (ADR-033):** playlists and items are in the export and erased with the account.
- **Design pass:**
  - "Save to playlist" is a disclosure among the byline actions, so the byline stays under the title.
  - "Up next" is the side column's first card.
  - Each playlist's rename, visibility and delete fold behind "Edit", and the new-playlist form asks who can see it.
  - No horizontal scroll at 390 px.

Built by a DeepSeek job and reviewed. Checked against the real Live API with a signed-in preview (a public playlist of three VODs and a clip). Tests: the full suite, 25 files; the new `video-playlists` has 11 checks: sign-in callback, add form or sign-in link, create/add/duplicate/reorder/remove/rename/visibility, private 404 and unlisted noindex, same-origin refusal, `?list=` up next, cache headers, account export and erasure, and limits.

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex 9bdebbd..8868f53 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -13,6 +13,10 @@ What changed in OpenVibe.MediaHub, newest first. Each site also publishes its pa
- OpenVibe.Video step B: the VOD watch page now shows a chat replay of the stream (read from OpenVibe.Chat,
oldest first, the first 1,000 messages) and read-only comments with the OpenVibe.Community thread link, plus the
`MEDIAHUB_CHAT_URL` setting.
++- OpenVibe.Video step C: playlists. Signed-in people can save recordings and clips into playlists (100 playlists per
++ person, 500 items each), share them by link (public/unlisted), and watch them back with the `?list=` up-next flow.
++ Sign-in now works on the video host with its own callback URI, and the playlists are part of account export and
++ deletion.
- openvibe.pics is still parked: its vhost answers 302 to https://openvibe.network/ and proxies nothing. The app
no longer has "coming" pages or host-based brand handling.
ddiff --git a/README.md b/README.md
iindex 201a86d..6c9bbec 100644
---- a/README.md
++++ b/README.md
@@@ -3,7 +3,7 @@
> Your files, shared on your terms.
**Status:** alpha. One process serves **openvibe.download** (a private drive, live in v1) and
--**openvibe.video** (public watch pages for OpenVibe.Live's stream recordings and clips, step A), chosen by Host
++**openvibe.video** (public watch pages and playlists for OpenVibe.Live's stream recordings and clips), chosen by Host
header. **openvibe.pics** is still parked: it redirects to `https://openvibe.network/` until Pics is built.
**Port:** 4990 · **Service id:** `media-hub` · **Env prefix:** `MEDIAHUB` · **License:** AGPL-3.0.
@@@ -31,6 +31,8 @@ creates none):
- `mh_uploads` — an in-flight chunked upload session (Media's multipart id, the parts expected, the expiry).
- `account_data_events` — the once-per-event record of applied account export and deletion deliveries
(openvibe-sdk/account-data, ADR-033).
++- `video_playlists` and `video_playlist_items` — the person's playlists and their ordered Live items
++ (`migrations/0003_video_playlists.sql`); the items cascade from their playlist.
## Does not own
@@@ -110,33 +112,43 @@ MediaHub owns the drive: `mh_files`, `mh_folders`, `mh_shares`, `mh_reports`, `m
## OpenVibe.Video (openvibe.video)
--OpenVibe.Video v1 is public and read-only: it serves watch pages for the recordings and clips people already made
--public on OpenVibe.Live. There are no uploads and no new storage — the media files stay on OpenVibe.Media
--(`playback_url`), and the pages read Live's public JSON API server-side, signed out (Live already applies
--visibility: a private item answers 404, an unlisted one is reachable by id but noindex and never listed).
++OpenVibe.Video is public and read-only: it serves watch pages for the recordings and clips people already made
++public on OpenVibe.Live, plus playlists. There are no uploads and no new storage — the media files stay on
++OpenVibe.Media (`playback_url`), and the pages read Live's public JSON API server-side, signed out (Live already
++applies visibility: a private item answers 404, an unlisted one is reachable by id but noindex and never listed).
The VOD page also shows a chat replay of the stream (from OpenVibe.Chat) and read-only comments with a link to
--the OpenVibe.Community thread.
++the OpenVibe.Community thread. The video host signs in through the same Network SSO as the drive, with its own
++callback URI (`https://openvibe.video/auth/callback`); each host's session cookie is host-only, so signing in on one
++does not sign in on the other.
--| Path | What |
--|---|---|
--| `/` | what OpenVibe.Video is, then the latest recordings and clips |
--| `/v/:id` | a VOD watch page: player, AI chapters and captions, chat replay, comments, its clips |
--| `/c/:id` | a clip watch page: player, attribution, the full-recording link, the Community thread link |
--| `/@:username` | a channel's recordings (and clips), newest first, 24 per page |
--| `/v/:id/captions.vtt` | the AI transcript as WebVTT captions |
--| `/v/:id/chapters.vtt` | the AI moments as WebVTT chapters: at most twelve, a twelfth of the stream apart, titled by what was seen |
--| `/sitemap.xml`, `/robots.txt`, `/llms.txt` | crawl artifacts for the video host |
++| Path | Who | What |
++|---|---|---|
++| `/` | anyone | what OpenVibe.Video is, then the latest recordings and clips |
++| `/v/:id` | anyone | a VOD watch page: player, AI chapters and captions, chat replay, comments, its clips, add-to-playlist |
++| `/c/:id` | anyone | a clip watch page: player, attribution, the full-recording link, add-to-playlist |
++| `/@:username` | anyone | a channel's recordings (and clips), newest first, 24 per page |
++| `/playlists` | the owner | their playlists: create, rename, change visibility, delete |
++| `/playlists/:id` | anyone with the link | one playlist: public and unlisted are open by link (unlisted noindex), private only to the owner |
++| `/v/:id/captions.vtt` | anyone | the AI transcript as WebVTT captions |
++| `/v/:id/chapters.vtt` | anyone | the AI moments as WebVTT chapters |
++| `/sitemap.xml`, `/robots.txt`, `/llms.txt` | anyone | crawl artifacts for the video host |
Configuration: `MEDIAHUB_VIDEO_URL` (default `https://openvibe.video`; its hostname is the video host),
`MEDIAHUB_LIVE_URL` (default `https://openvibe.live`; production points it at the loopback address),
`MEDIAHUB_LIVE_PUBLIC_URL` (default `https://openvibe.live`; links people click) and `MEDIAHUB_CHAT_URL`
(default `https://openvibe.live`, which proxies `/api/chat/` to OpenVibe.Chat; production points it at the loopback
--address). The video host is chosen by `Host` header in `server/app.js`, so the drive's pages, API and `/auth` never
--answer there. Only https URLs from Live (player source, thumbnails, the thread link) reach a page; anything else is
--dropped.
++address). The video host is chosen by `Host` header in `server/app.js`, so the drive's pages, API and legal pages
++never answer there. Only https URLs from Live (player source, thumbnails, the thread link) reach a page; anything
++else is dropped.
++
++Playlists: 100 per person 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
++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.
--Not built yet: playlists and uploads.
++Not built yet: uploads, and public playlists of a channel.
## Pages
@@@ -159,6 +171,17 @@ Not built yet: playlists and uploads.
| `GET /uploads/:id`, `PUT /uploads/:id/parts/:n`, `POST /uploads/:id/complete`, `DELETE /uploads/:id` | resumable uploads |
| `GET /usage` | your usage against the limits |
++On the video host (openvibe.video), the playlist API is browser-session only — the owner is the signed-in person,
++never an app, agent or service token:
++
++| Route | |
++|---|---|
++| `GET /api/v1/video/playlists`, `POST /api/v1/video/playlists` | list and create playlists |
++| `GET /api/v1/video/playlists/:id`, `PATCH /api/v1/video/playlists/:id`, `DELETE /api/v1/video/playlists/:id` | one playlist: read, rename/describe/change visibility, delete |
++| `POST /api/v1/video/playlists/:id/items` | add one `{ kind: 'vod'|'clip', id }` |
++| `PATCH /api/v1/video/playlists/:id/items` | reorder: the full ordered list `{ items: [{ kind, id }, …] }` |
++| `DELETE /api/v1/video/playlists/:id/items/:kind/:itemId` | remove one |
++
Session-cookie writes must be same-origin. Errors are RFC 9457 problem+json with a stable `code`.
## Account export and deletion (ADR-033)
ddiff --git a/STATUS.json b/STATUS.json
iindex 54ad0f0..b459dab 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -3,7 +3,7 @@
"stage": "alpha",
"code": true,
"wave": "W20",
-- "plan": "plan T10/T19: media-hub serves openvibe.download as a private drive in v1; openvibe.video serves public read-only watch pages for OpenVibe.Live recordings and clips (Video v1 step A); openvibe.pics is parked (302 to openvibe.network) until scanning and abuse intake exist",
++ "plan": "plan T10/T19: media-hub serves openvibe.download as a private drive in v1; openvibe.video serves public read-only watch pages and playlists for OpenVibe.Live recordings and clips (Video v1 step C); openvibe.pics is parked (302 to openvibe.network) until scanning and abuse intake exist",
"runtime": "node 22, express 4, PostgreSQL 18 (openvibe-sdk/db, async) + Valkey 9",
"port": "4990",
"domain": "openvibe.download and openvibe.video (openvibe.pics is parked: it redirects to openvibe.network)",
@@@ -12,7 +12,7 @@
"openvibe-sdk v0.44.0",
"openvibe-shared v4.0.2"
],
-- "summary": "OpenVibe.MediaHub: openvibe.download as a private drive. Account-only uploads stream into OpenVibe.Media (a plain form up to 256 MB, resumable parts beyond). Files are private. Share links expire in 1 hour to 7 days, need a signed-in downloader and can be limited to named accounts (matched by subject). Reports suspend a share for staff. Downloads are always sandboxed attachments. Per-person quotas, and account export and deletion (ADR-033). openvibe.video serves public watch pages for Live's recordings and clips (no uploads); openvibe.pics is parked and redirects to openvibe.network.",
++ "summary": "OpenVibe.MediaHub: openvibe.download as a private drive. Account-only uploads stream into OpenVibe.Media (a plain form up to 256 MB, resumable parts beyond). Files are private. Share links expire in 1 hour to 7 days, need a signed-in downloader and can be limited to named accounts (matched by subject). Reports suspend a share for staff. Downloads are always sandboxed attachments. Per-person quotas, and account export and deletion (ADR-033). openvibe.video serves public watch pages and playlists for Live's recordings and clips (no uploads); openvibe.pics is parked and redirects to openvibe.network.",
"works": [
"Sign-in with OpenVibe.Network (PKCE S256, httpOnly session cookies); pages server-rendered, complete without JavaScript",
"Uploads: a plain form streamed to disk with its sha256 (up to 256 MB), and resumable chunked uploads (8 MB parts) through Media multipart",
@@@ -21,13 +21,14 @@
"Reports: 3 distinct people or one report of illegal content suspend a share; staff restore or remove (Media object deleted)",
"Downloads: attachment, nosniff, sandbox CSP, real type only for a safe list, filename* for any script, Range resume",
"OpenVibe.Video step A: public watch pages on openvibe.video (/v/:id, /c/:id, /@:username) reading Live's public API, with WebVTT captions/chapters and no uploads",
++ "OpenVibe.Video playlists: a signed-in person's named lists of Live recordings and clips (/playlists, /playlists/:id), with a browser-session API, the ?list= watch flow, and sign-in on the video host",
"Parked domain: openvibe.pics redirects to openvibe.network in nginx; no request reaches the app",
"Account export and deletion (ADR-033) through openvibe-sdk/account-data; subscriptions created at boot",
"Per-caller and per-address limits; readiness (database required); metrics on loopback"
],
"notYet": [
"Uploads on openvibe.pics (images, albums, embeds): parked until scanning (hash lists, malware) and abuse intake shared with Media holds exist",
-- "OpenVibe.Video chat replay, comments on the page, playlists and uploads",
++ "OpenVibe.Video uploads and public playlists of a channel",
"Public (signed-out) share links: deliberately not offered while there is no scanning",
"Previews and thumbnails of files"
],
ddiff --git a/migrations/0003_video_playlists.sql b/migrations/0003_video_playlists.sql
nnew file mode 100644
iindex 0000000..8f53f5a
---- /dev/null
++++ b/migrations/0003_video_playlists.sql
@@@ -0,0 +1,32 @@
++-- phase: expand
++-- OpenVibe.Video playlists (step C): a signed-in person's named lists of OpenVibe.Live recordings and clips,
++-- shown on openvibe.video. Nothing here is ever a credential, a key or a token.
++--
++-- A playlist holds only Live's public ids (kind + numeric id), never the media bytes, so deleting a person's
++-- account deletes the lists here and nothing else: the recordings and clips themselves stay on Live.
++
++CREATE TABLE video_playlists (
++ id text COLLATE "C" PRIMARY KEY, -- vpl_<ULID>
++ owner text COLLATE "C" NOT NULL, -- user:usr_… (a playlist belongs to a person, never to an app)
++ title text NOT NULL, -- <= 120 characters (enforced in code)
++ description text NOT NULL DEFAULT '', -- <= 2,000 characters (enforced in code)
++ visibility text COLLATE "C" NOT NULL DEFAULT 'private', -- public | unlisted | private
++ created_at text COLLATE "C" NOT NULL,
++ updated_at text COLLATE "C" NOT NULL,
++ CHECK (visibility IN ('public', 'unlisted', 'private'))
++);
++
++-- The playlist page and the person's list are read as "this person's playlists, newest first".
++CREATE INDEX video_playlists_owner ON video_playlists (owner, id DESC);
++
++CREATE TABLE video_playlist_items (
++ playlist_id text COLLATE "C" NOT NULL REFERENCES video_playlists (id) ON DELETE CASCADE,
++ kind text COLLATE "C" NOT NULL, -- vod | clip
++ item_id bigint NOT NULL, -- Live's numeric id (the id /v/:id and /c/:id use)
++ position integer NOT NULL, -- 0-based order within the playlist
++ added_at text COLLATE "C" NOT NULL,
++ CHECK (kind IN ('vod', 'clip')),
++ UNIQUE (playlist_id, kind, item_id)
++);
++
++CREATE INDEX video_playlist_items_playlist ON video_playlist_items (playlist_id, position);
ddiff --git a/public/css/video.css b/public/css/video.css
iindex 4e94d49..8657775 100644
---- a/public/css/video.css
++++ b/public/css/video.css
@@@ -125,3 +125,50 @@ a.ovv-channel-name:hover { text-decoration: underline; }
.ovv-player-wrap { border-radius: 10px; margin: 0 -4px; }
.ovv-btn { white-space: normal; }
}
++
++/* Playlists */
++.ovv-save { position: relative; }
++.ovv-save > summary { list-style: none; cursor: pointer; }
++.ovv-save > summary::-webkit-details-marker { display: none; }
++.ovv-save[open] > summary { border-color: var(--ovv-accent); }
++.ovv-save-form { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin: 12px 0 0; padding: 12px 14px; border: 1px solid var(--ovv-line); border-radius: 12px; background: var(--ovv-card); }
++.ovv-save[open] > .ovv-save-form { position: absolute; z-index: 5; top: 100%; left: 0; width: min(420px, 86vw); margin-top: 8px; box-shadow: 0 14px 34px rgba(0, 0, 0, .45); }
++.ovv-save-label { font-size: .85rem; font-weight: 700; color: var(--ovv-dim); flex-basis: 100%; }
++.ovv-save-form input[type=text], .ovv-save-form select,
++.ovv-playlist-actions input[type=text], .ovv-playlist-actions select { font: inherit; min-height: 38px; padding: 7px 10px; border-radius: 9px; border: 1px solid var(--ovv-line-strong); background: var(--bg-input, var(--bg-secondary, #0f1729)); color: inherit; }
++.ovv-save[open] .ovv-save-form select, .ovv-save[open] .ovv-save-form input[type=text] { flex: 1 1 160px; min-width: 0; }
++.ovv-inline { display: inline-flex; align-items: center; gap: 6px; }
++.ovv-btn--danger { border-color: color-mix(in srgb, var(--danger, #ef4444) 55%, transparent); color: var(--danger, #ef4444) !important; background: transparent; }
++.ovv-btn--danger:hover, .ovv-btn--danger:focus-visible { background: color-mix(in srgb, var(--danger, #ef4444) 12%, transparent); border-color: var(--danger, #ef4444); }
++
++/* Up next (a playlist playing): the side column's first card */
++.ovv-upnext { display: grid; gap: 8px; }
++.ovv-upnext-in { margin: 0; font-size: .85rem; color: var(--ovv-dim); }
++.ovv-upnext-in a { font-weight: 700; }
++.ovv-upnext-link { display: flex; gap: 10px; align-items: center; padding: 10px 12px; border-radius: 10px; background: var(--bg-tertiary, #16223a); border: 1px solid var(--ovv-line-strong); color: var(--text-primary, #e6edf7) !important; font-weight: 700; text-decoration: none; }
++.ovv-upnext-link:hover, .ovv-upnext-link:focus-visible { border-color: var(--ovv-accent); }
++.ovv-upnext-link i { color: var(--accent-light, var(--ovv-accent)); }
++.ovv-upnext-link span { overflow-wrap: anywhere; }
++.ovv-watch--clip .ovv-upnext { margin-top: 18px; }
++
++/* Your playlists: a row's edit controls fold behind Edit */
++.ovv-pl-edit > summary { list-style: none; cursor: pointer; }
++.ovv-pl-edit > summary::-webkit-details-marker { display: none; }
++.ovv-pl-edit[open] { flex-basis: 100%; }
++.ovv-pl-edit[open] > summary { margin-bottom: 10px; border-color: var(--ovv-accent); }
++.ovv-pl-edit .ovv-playlist-actions { padding-top: 10px; border-top: 1px solid var(--ovv-line); }
++
++.ovv-playlist-list, .ovv-item-list { list-style: none; margin: 0; padding: 0; display: grid; gap: 10px; }
++.ovv-playlist-row, .ovv-item-row { display: flex; flex-wrap: wrap; align-items: center; gap: 12px; padding: 12px 14px; border: 1px solid var(--ovv-line); border-radius: 12px; background: var(--ovv-card); }
++.ovv-playlist-main { display: grid; gap: 2px; min-width: 0; flex: 1 1 220px; }
++.ovv-playlist-title, .ovv-item-title { font-weight: 700; overflow-wrap: anywhere; color: inherit; text-decoration: none; }
++.ovv-playlist-title:hover, .ovv-item-title:hover { text-decoration: underline; }
++.ovv-playlist-actions { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
++.ovv-confirm { display: inline-flex; align-items: center; gap: 5px; font-size: .85rem; color: var(--ovv-dim); }
++
++.ovv-item-media { position: relative; display: block; flex: none; width: 132px; aspect-ratio: 16 / 9; border-radius: 8px; overflow: hidden; background: var(--ovv-card); border: 1px solid var(--ovv-line); }
++.ovv-item-media img { width: 100%; height: 100%; object-fit: cover; display: block; }
++.ovv-item-media .ovv-card-placeholder { width: 100%; height: 100%; }
++.ovv-item-controls { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; }
++.ovv-item-row--gone .ovv-item-title { color: var(--ovv-faint); font-weight: 500; }
++.ovv-playlist-head { padding: 8px 0 4px; }
ddiff --git a/public/js/video.js b/public/js/video.js
iindex 7797481..f48a6cc 100644
---- a/public/js/video.js
++++ b/public/js/video.js
@@@ -29,6 +29,14 @@
const t = startSeconds();
if (t != null) seek(video, t);
++ // A playlist's watch page (?list=…) carries the next item on the player; move to it when this ends.
++ const nextUrl = video.getAttribute('data-ovv-next');
++ if (nextUrl) {
++ video.addEventListener('ended', function () {
++ window.location.href = nextUrl;
++ });
++ }
++
document.querySelectorAll('[data-ovv-seek]').forEach(function (el) {
el.addEventListener('click', function () {
seek(video, Number(el.getAttribute('data-ovv-seek')));
ddiff --git a/server/account.js b/server/account.js
iindex 83a4fa5..dd56911 100644
---- a/server/account.js
++++ b/server/account.js
@@@ -29,6 +29,9 @@ const TABLES = [
{ table: 'mh_usage', subject: 'owner', value: AS_REQUESTER, file: 'usage.json' },
{ table: 'mh_files', subject: 'owner', value: AS_REQUESTER, file: 'files.json' },
{ table: 'mh_folders', subject: 'owner', value: AS_REQUESTER, file: 'folders.json' },
++ // A playlist's items have no owner column of their own; they are exported with extraExport below and erased
++ // by the playlists' ON DELETE CASCADE, so only the playlists table is listed here.
++ { table: 'video_playlists', subject: 'owner', value: AS_REQUESTER, file: 'playlists.json' },
// An upload that never finished is this service's own state, not something the person made: it is erased with
// the account but is not part of the export.
{ table: 'mh_uploads', subject: 'owner', value: AS_REQUESTER, file: null },
@@@ -42,7 +45,16 @@ function createAccount({ db, config, media, log = console }) {
db,
service: 'media-hub',
tables: TABLES,
-- note: 'OpenVibe.MediaHub: your files, folders, share links, the reports you filed and your upload counts. The bytes of your files live in OpenVibe.Media.',
++ note: 'OpenVibe.MediaHub: your files, folders, share links, the reports you filed, your upload counts and your playlists. The bytes of your files live in OpenVibe.Media.',
++ extraExport: async (db, subject) => {
++ const rows = await db.many(
++ `SELECT i.playlist_id, i.kind, i.item_id, i.position, i.added_at
++ FROM video_playlist_items i
++ JOIN video_playlists p ON p.id = i.playlist_id
++ WHERE p.owner = $1
++ ORDER BY i.playlist_id, i.position ASC`, [`user:${subject}`]);
++ return rows.length ? [{ name: 'playlist_items.json', content: rows }] : [];
++ },
extraErase: async () => {
const ids = pendingObjects;
pendingObjects = [];
ddiff --git a/server/app.js b/server/app.js
iindex 86cc6e8..986f0d3 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -39,6 +39,7 @@ const { createIdentity } = require('./network/identity');
const { createService } = require('./drive/service');
const { createUploads } = require('./drive/upload');
const { createAccount, createSender } = require('./account');
++const { createPlaylists } = require('./video/playlists');
const PUBLIC_DIR = path.join(__dirname, '..', 'public');
const VERSION = require('../package.json').version;
@@@ -62,7 +63,8 @@ async function createApp(opts = {}) {
const drive = createService({ s, media, identity, config, log });
const uploads = createUploads({ s, media, config, service: drive, log });
const account = createAccount({ db: s.db, config, media, log });
-- const ctx = { config, s, store, keys, sso, principal, media, identity, drive, uploads, account, fetchImpl, log };
++ const playlists = createPlaylists({ s, config, log });
++ const ctx = { config, s, store, keys, sso, principal, media, identity, drive, uploads, account, playlists, fetchImpl, log };
const app = express();
app.disable('x-powered-by');
@@@ -103,8 +105,19 @@ async function createApp(opts = {}) {
}));
app.use(cookieParser());
++ // ── Who is asking (verified offline; refreshed when expired) ──
++ // Mounted before the host routing so both openvibe.download and openvibe.video see req.viewer; the video
++ // pages use it for the signed-in navbar and their private, no-store cache rule.
++ app.use(sso.middleware());
++
++ // ── Sign-in (OAuth2 + PKCE client of OpenVibe.Network) ──
++ // Mounted before the host routing too: both hosts sign in through the same routes, and createSso picks the
++ // callback URI from the Host header (the drive's, or the video host's).
++ app.use('/auth/', rateLimit({ windowMs: 15 * 60_000, limit: 60, standardHeaders: true, legacyHeaders: false }));
++ app.use('/auth', sso.routes());
++
// ── Host routing: openvibe.video is OpenVibe.Video; every other host (openvibe.download) stays
-- // the drive. On the video host the drive's pages, API and /auth routes never answer.
++ // the drive. On the video host the drive's pages, API and legal pages never answer.
const videoHost = (() => { try { return new URL(config.video.url).hostname; } catch { return null; } })();
const videoRoutes = createVideoRoutes(ctx);
app.use((req, res, next) => {
@@@ -123,12 +136,6 @@ async function createApp(opts = {}) {
// through a proxy, so only loopback can reach it even though nginx proxies this path.
app.use('/internal/events', account.consumer({ send: createSender({ config, fetchImpl }) }));
-- // ── Who is asking (verified offline; refreshed when expired) ──
-- app.use(sso.middleware());
--
-- // ── Sign-in (OAuth2 + PKCE client of OpenVibe.Network) ──
-- app.use('/auth/', rateLimit({ windowMs: 15 * 60_000, limit: 60, standardHeaders: true, legacyHeaders: false }));
-- app.use('/auth', sso.routes());
{ const legal = require('openvibe-shared/legal'); app.get(legal.PATHS, legal.handler({ id: 'media-hub', service: 'media-hub', host: 'openvibe.download', name: 'OpenVibe.MediaHub', profile: 'ugc' })); }
// ── Static assets (content-hashed ?v= → immutable) ──────
ddiff --git a/server/auth/sso.js b/server/auth/sso.js
iindex cb033ad..98b23b2 100644
---- a/server/auth/sso.js
++++ b/server/auth/sso.js
@@@ -37,11 +37,11 @@ function sanitizeNext(next) {
return next;
}
--function authorizeUrl(config, { state, challenge, silent = false }) {
++function authorizeUrl(config, { state, challenge, silent = false, redirectUri = null }) {
const q = new URLSearchParams({
response_type: 'code',
client_id: config.oauth.clientId,
-- redirect_uri: config.oauth.redirectUri,
++ redirect_uri: redirectUri || config.oauth.redirectUri,
scope: config.oauth.scope,
state,
code_challenge: challenge,
@@@ -54,6 +54,15 @@ function authorizeUrl(config, { state, challenge, silent = false }) {
function createSso({ config, keys, fetchImpl = globalThis.fetch, now = () => Date.now(), log = console }) {
const cookieBase = () => ({ sameSite: 'lax', secure: config.cookies.secure, httpOnly: true, path: '/' });
++ // openvibe.video signs in through the same Network client, but with its own callback URI: the Network
++ // registered https://openvibe.video/auth/callback for client media-hub next to the drive's. Cookies are
++ // host-only (no Domain), so each host keeps its own session.
++ const videoRedirectUri = (() => { try { return `${String(config.video.url).replace(/\/+$/, '')}/auth/callback`; } catch { return null; } })();
++ const videoHostname = (() => { try { return new URL(config.video.url).hostname; } catch { return null; } })();
++ const redirectUriFor = (req) => (videoRedirectUri && videoHostname && req && req.hostname === videoHostname
++ ? videoRedirectUri
++ : config.oauth.redirectUri);
++
function setSession(res, data) {
res.cookie(ACCESS_COOKIE, data.access_token, { ...cookieBase(), maxAge: 24 * 60 * 60 * 1000 });
if (data.refresh_token) res.cookie(REFRESH_COOKIE, data.refresh_token, { ...cookieBase(), maxAge: 30 * 24 * 60 * 60 * 1000 });
@@@ -175,7 +184,7 @@ function createSso({ config, keys, fetchImpl = globalThis.fetch, now = () => Dat
const { verifier, challenge } = pkcePair();
res.cookie(FLOW_COOKIE, JSON.stringify({ state, verifier, next: sanitizeNext(req.query.next), silent }), flowOpts());
res.set('Cache-Control', cache.htmlHeaders({ private: true }));
-- res.redirect(authorizeUrl(config, { state, challenge, silent }));
++ res.redirect(authorizeUrl(config, { state, challenge, silent, redirectUri: redirectUriFor(req) }));
});
r.get('/callback', async (req, res) => {
@@@ -195,7 +204,7 @@ function createSso({ config, keys, fetchImpl = globalThis.fetch, now = () => Dat
}
if (!code) return res.status(400).type('text/plain').send('The Network sent no authorization code.');
try {
-- const data = await tokenGrant({ grant_type: 'authorization_code', code: String(code), redirect_uri: config.oauth.redirectUri, code_verifier: flow.verifier });
++ const data = await tokenGrant({ grant_type: 'authorization_code', code: String(code), redirect_uri: redirectUriFor(req), code_verifier: flow.verifier });
setSession(res, data);
return res.redirect(next);
} catch (err) {
ddiff --git a/server/config.js b/server/config.js
iindex bc8d1cc..20d1439 100644
---- a/server/config.js
++++ b/server/config.js
@@@ -63,6 +63,9 @@ function load(env = process.env) {
liveUrl: trim(env.MEDIAHUB_LIVE_URL || 'https://openvibe.live'),
livePublicUrl: trim(env.MEDIAHUB_LIVE_PUBLIC_URL || 'https://openvibe.live'),
chatUrl: trim(env.MEDIAHUB_CHAT_URL || 'https://openvibe.live'),
++ // Playlists (step C): the per-person caps the pages and the API enforce together.
++ playlistsMax: size(env.MEDIAHUB_VIDEO_PLAYLISTS_MAX, 100),
++ playlistItemsMax: size(env.MEDIAHUB_VIDEO_PLAYLIST_ITEMS_MAX, 500),
},
// What one person may keep and do here. Media's own quotas are per tenant (this whole service is one
ddiff --git a/server/video/api.js b/server/video/api.js
nnew file mode 100644
iindex 0000000..029ea6f
---- /dev/null
++++ b/server/video/api.js
@@@ -0,0 +1,123 @@
++'use strict';
++
++/**
++ * /api/v1/video/playlists — the playlist API on openvibe.video (step C). It is mounted only on the video host,
++ * inside createVideoRoutes, so the drive's /api/v1 stays exactly as it is. For now it is a browser-session API:
++ * the owner is the signed-in person (req.viewer), never an app, agent or service token, and a write made with the
++ * session cookie must come from openvibe.video itself (same-origin), exactly as in the drive.
++ *
++ * GET /api/v1/video/playlists the person's playlists
++ * POST /api/v1/video/playlists make one: { title, description?, visibility? }
++ * GET /api/v1/video/playlists/:id one playlist and its items (owner only)
++ * PATCH /api/v1/video/playlists/:id rename / describe / change visibility
++ * DELETE /api/v1/video/playlists/:id delete it (its items go with it)
++ * POST /api/v1/video/playlists/:id/items add one: { kind: 'vod'|'clip', id }
++ * PATCH /api/v1/video/playlists/:id/items reorder: the full ordered list { items: [{ kind, id }, …] }
++ * DELETE /api/v1/video/playlists/:id/items/:kind/:itemId remove one
++ *
++ * A refusal is an RFC 9457 problem+json with a stable code, in the repo's own style.
++ */
++const express = require('express');
++const contracts = require('openvibe-contracts');
++const { asyncRouter } = require('../http/router');
++const { sameOrigin } = require('../http/principal');
++
++function createVideoApiRoutes(ctx) {
++ const { config, playlists } = ctx;
++ const r = asyncRouter();
++ const problem = (req, res, status, code, detail) => contracts.http.sendProblem(res, status, code, { detail, ctx: req.ov });
++
++ r.use((req, res, next) => { res.set('Cache-Control', 'no-store'); next(); });
++ r.use(express.json({ limit: '64kb' }));
++
++ const ownerOf = (req) => (req.viewer && req.viewer.kind === 'user' && req.viewer.subject ? `user:${req.viewer.subject}` : null);
++
++ /** A signed-in person; a write by session cookie must be same-origin. */
++ function person(req, res, next) {
++ const owner = ownerOf(req);
++ if (!owner) {
++ problem(req, res, 401, 'token.required', 'Sign in to openvibe.video to manage your playlists.');
++ return undefined;
++ }
++ if (req.method !== 'GET' && !sameOrigin(req, config.video.url)) {
++ problem(req, res, 403, 'request.cross_site', 'A signed-in request that changes something must come from openvibe.video itself.');
++ return undefined;
++ }
++ req.playlistOwner = owner;
++ return next();
++ }
++
++ function refused(code) {
++ if (code === 'playlist.not_found' || code === 'playlist.item_not_found') return 404;
++ if (code === 'playlist.item_duplicate') return 409;
++ return 422;
++ }
++
++ const wirePlaylist = (row, itemCount = null) => ({
++ id: row.id,
++ title: row.title,
++ description: row.description,
++ visibility: row.visibility,
++ item_count: Number(itemCount == null ? (row.item_count != null ? row.item_count : 0) : itemCount),
++ created_at: row.created_at,
++ updated_at: row.updated_at,
++ });
++ const wireItem = (row) => ({ kind: row.kind, id: Number(row.item_id), position: Number(row.position), added_at: row.added_at });
++
++ r.get('/', person, async (req, res) => {
++ const rows = await playlists.list(req.playlistOwner);
++ return res.json({ playlists: rows.map(wirePlaylist) });
++ });
++
++ r.post('/', person, async (req, res) => {
++ const b = req.body || {};
++ const out = await playlists.create(req.playlistOwner, { title: b.title, description: b.description, visibility: b.visibility });
++ if (!out.ok) return problem(req, res, refused(out.code), out.code, out.detail);
++ res.set('Location', `/api/v1/video/playlists/${out.playlist.id}`);
++ return res.status(201).json({ playlist: wirePlaylist(out.playlist, 0) });
++ });
++
++ r.get('/:id', person, async (req, res) => {
++ const row = await playlists.own(req.playlistOwner, String(req.params.id || ''));
++ if (!row) return problem(req, res, 404, 'playlist.not_found', 'No such playlist.');
++ const items = await playlists.items(row.id);
++ return res.json({ playlist: wirePlaylist(row, items.length), items: items.map(wireItem) });
++ });
++
++ r.patch('/:id', person, async (req, res) => {
++ const out = await playlists.update(req.playlistOwner, String(req.params.id || ''), req.body || {});
++ if (!out.ok) return problem(req, res, refused(out.code), out.code, out.detail);
++ return res.json({ playlist: wirePlaylist(out.playlist) });
++ });
++
++ r.delete('/:id', person, async (req, res) => {
++ const out = await playlists.remove(req.playlistOwner, String(req.params.id || ''));
++ if (!out.ok) return problem(req, res, 404, 'playlist.not_found', 'No such playlist.');
++ return res.status(204).end();
++ });
++
++ r.post('/:id/items', person, async (req, res) => {
++ const b = req.body || {};
++ const out = await playlists.addItem(req.playlistOwner, String(req.params.id || ''), { kind: b.kind, id: b.id });
++ if (!out.ok) return problem(req, res, refused(out.code), out.code, out.detail);
++ res.set('Location', `/api/v1/video/playlists/${req.params.id}/items/${out.item.kind}/${out.item.item_id}`);
++ return res.status(201).json({ item: wireItem(out.item) });
++ });
++
++ r.patch('/:id/items', person, async (req, res) => {
++ const list = Array.isArray(req.body) ? req.body : (req.body && req.body.items);
++ const out = await playlists.reorder(req.playlistOwner, String(req.params.id || ''), list);
++ if (!out.ok) return problem(req, res, refused(out.code), out.code, out.detail);
++ return res.json({ items: out.items.map(wireItem) });
++ });
++
++ r.delete('/:id/items/:kind/:itemId', person, async (req, res) => {
++ const out = await playlists.removeItem(req.playlistOwner, String(req.params.id || ''), String(req.params.kind || ''), req.params.itemId);
++ if (!out.ok) return problem(req, res, refused(out.code), out.code, out.detail);
++ return res.status(204).end();
++ });
++
++ return r;
++}
++
++module.exports = { createVideoApiRoutes };
ddiff --git a/server/video/layout.js b/server/video/layout.js
iindex 128c0e9..b4e6b71 100644
---- a/server/video/layout.js
++++ b/server/video/layout.js
@@@ -2,9 +2,12 @@
/**
* The OpenVibe.Video page shell. Same composition as the drive's layout (openvibe-shared/shell,
-- * the OpenVibe Frame navbar and footer, the shared app icon), but public and read-only: no sign-in
-- * bar, no drive navigation, and every canonical URL is on the video host. The brand here is
-- * OpenVibe.Video.
++ * the OpenVibe Frame navbar and footer, the shared app icon), but public and read-only: no drive
++ * navigation, and every canonical URL is on the video host. The brand here is OpenVibe.Video.
++ *
++ * A page rendered for a signed-in person names them (and a session cookie is present), so it is
++ * private and never cached; every other page stays public. Cloudflare ignores Vary, so the cache
++ * decision is made from the session cookie's presence rather than Vary.
*/
const crypto = require('crypto');
const fs = require('fs');
@@@ -29,9 +32,11 @@ function assetVersion(rel) {
}
const asset = (rel) => `/${rel}?v=${assetVersion(rel)}`;
--/** o: title, description, body (html), config, path, index, noindex, crumbs [{label, href}], scripts [rel] */
++/** o: title, description, body (html), config, path, index, noindex, viewer, private, crumbs [{label, href}], scripts [rel] */
function renderPage(o) {
const title = o.title || SITE_NAME;
++ const viewer = o.viewer || { kind: 'anonymous' };
++ const signedIn = viewer.kind === 'user';
const pathNow = o.path || '/';
const canonical = `${String(o.baseUrl).replace(/\/+$/, '')}${pathNow.split('?')[0]}`;
const crumbs = (o.crumbs || []).length
@@@ -43,8 +48,12 @@ ${appIcon.headTags({ site: 'media-hub' })}
<link rel="stylesheet" href="${asset('css/video.css')}">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css" crossorigin="anonymous" referrerpolicy="no-referrer">
${(o.scripts || []).map((rel) => `<script src="${asset(rel)}" defer></script>`).join('\n')}`;
++ const account = signedIn
++ ? html`Signed in as <strong>${viewer.displayName || viewer.username || 'you'}</strong> · <a href="/auth/logout?next=${encodeURIComponent(pathNow)}">Sign out</a>`
++ : html`<a href="/auth/login?next=${encodeURIComponent(pathNow)}">Sign in with OpenVibe</a>`;
const body = `<a class="skip" href="#main">Skip to content</a>
<div id="navbar-mount"></div>
++<noscript><div class="account-bar" role="navigation" aria-label="Account">${render(account)}</div></noscript>
<main id="main" class="page ovv-page">
${render(crumbs)}
${render(o.body)}
@@@ -60,16 +69,24 @@ ${render(o.body)}
robots: o.noindex ? 'noindex' : (o.index ? 'index, follow' : 'noindex, nofollow'),
home: '/',
navLinks: [],
-- navbar: { service: 'media-hub', apiBase: NETWORK_URL, history: { type: 'page', title } },
++ navbar: {
++ service: 'media-hub',
++ apiBase: NETWORK_URL,
++ history: { type: 'page', title },
++ sessionUrl: '/auth/me',
++ loginUrl: '/auth/login?next={path}',
++ logoutUrl: '/auth/logout?next={path}',
++ silentLogin: `${String(o.baseUrl).replace(/\/+$/, '')}/auth/login?silent=1&next={url}`,
++ },
footer: { service: 'media-hub', variant: 'compact', brandName: SITE_NAME },
head,
body,
});
}
--/** Send a video page. Video pages are public; unlisted pages are noindex but still cached. */
++/** Send a video page. Signed-out pages are public; a session cookie makes a page private, no-store. */
function send(res, status, o) {
-- if (!res.get('Cache-Control')) res.set('Cache-Control', o.cache || cache.htmlHeaders({ maxAge: 300 }));
++ if (!res.get('Cache-Control')) res.set('Cache-Control', o.cache || cache.htmlHeaders({ private: o.private, maxAge: 300 }));
res.set('Vary', 'Host');
res.status(status).type('html').send(renderPage(o));
}
ddiff --git a/server/video/pages.js b/server/video/pages.js
iindex 8df6dde..d171fb3 100644
---- a/server/video/pages.js
++++ b/server/video/pages.js
@@@ -22,10 +22,13 @@ const cache = require('openvibe-shared/cache-policy');
const seo = require('openvibe-shared/seo');
const ovServe = require('openvibe-shared/serve');
const { asyncRouter } = require('../http/router');
--const { html } = require('../render/html');
++const { sameOrigin } = require('../http/principal');
++const { html, notice } = require('../render/html');
const { send, assetVersion, SITE_NAME } = require('./layout');
const { createLiveClient } = require('./live-client');
const { createChatClient } = require('./chat-client');
++const { createVideoApiRoutes } = require('./api');
++const { ACCESS_COOKIE, REFRESH_COOKIE } = require('../auth/sso');
const PUBLIC_DIR = path.join(__dirname, '..', '..', 'public');
const HOME_LIMIT = 10;
@@@ -287,7 +290,7 @@ function clipCard(c) {
const grid = (cards, empty = 'Nothing here yet.') => (cards && cards.length ? html`<div class="ovv-grid">${cards}</div>` : html`<p class="muted">${empty}</p>`);
function createVideoRoutes(ctx) {
-- const { config, log } = ctx;
++ const { config, log, playlists } = ctx;
const live = createLiveClient({ config, fetchImpl: ctx.fetchImpl || globalThis.fetch, log });
const chat = createChatClient({ config, fetchImpl: ctx.fetchImpl || globalThis.fetch, log });
const r = asyncRouter();
@@@ -295,7 +298,14 @@ function createVideoRoutes(ctx) {
const livePublic = String(config.video.livePublicUrl).replace(/\/+$/, '');
const videoHostname = (() => { try { return new URL(config.video.url).hostname; } catch { return 'openvibe.video'; } })();
-- const page = (req, res, o, status = 200) => send(res, status, { ...o, baseUrl: base, path: req.originalUrl });
++ const hasSessionCookie = (req) => Boolean(req.cookies && (req.cookies[ACCESS_COOKIE] || req.cookies[REFRESH_COOKIE]));
++ const page = (req, res, o, status = 200) => send(res, status, {
++ ...o,
++ viewer: req.viewer,
++ private: o.private !== false && hasSessionCookie(req),
++ baseUrl: base,
++ path: req.originalUrl,
++ });
const notFound = (req, res) => page(req, res, {
title: 'Not found',
cache: cache.htmlHeaders({ private: true }),
@@@ -304,6 +314,23 @@ function createVideoRoutes(ctx) {
const notFoundVtt = (_req, res) => res.status(404).set('Content-Type', 'text/plain; charset=utf-8').send('Not found');
const sendVtt = (res, body) => res.set('Content-Type', 'text/vtt; charset=utf-8').set('Cache-Control', cache.htmlHeaders({ maxAge: 300 })).send(body);
++ const signedIn = (req) => Boolean(req.viewer && req.viewer.kind === 'user' && req.viewer.subject);
++ const ownerOf = (req) => `user:${req.viewer.subject}`;
++ const signInPrompt = (what, next) => html`<div class="notice">${what} <a href="/auth/login?next=${encodeURIComponent(next)}">Sign in with OpenVibe</a>.</div>`;
++ const mustSignIn = (back, why) => (req, res, next) => (signedIn(req) ? next() : page(req, res, {
++ title: 'Sign in',
++ body: html`<h1>Sign in first</h1>${signInPrompt(why, back === 'here' ? req.originalUrl : back)}`,
++ }, 401));
++ function sameSite(req, res, back) {
++ if (sameOrigin(req, config.video.url)) return true;
++ page(req, res, {
++ title: 'Not from this site',
++ body: html`<h1>That came from somewhere else</h1><p class="muted">A signed-in page that changes something has to come from openvibe.video itself. <a href="${back}">Go back</a> and try again.</p>`,
++ }, 403);
++ return false;
++ }
++ const visibilityLabel = (v) => (v === 'public' ? 'Public' : (v === 'unlisted' ? 'Unlisted' : 'Private'));
++
// The shell's navbar/footer scripts come from the site's own /shared copy, and the one
// product stylesheet/script live in public/.
r.use('/shared', ovServe.handler());
@@@ -316,6 +343,100 @@ function createVideoRoutes(ctx) {
},
}));
++ // ── Playlist API (openvibe.video only; the drive's /api/v1 is untouched) ──
++ r.use('/api/v1/video/playlists', createVideoApiRoutes(ctx));
++ // The playlist forms are web forms (no JavaScript): urlencoded text. The API router above has its own JSON
++ // parser, so the two never read each other's bodies.
++ r.use(express.urlencoded({ extended: false, limit: '64kb' }));
++
++ // ── Playlist page pieces ───────────────────────────────
++ async function playlistForm(req, kind, id) {
++ if (!signedIn(req)) {
++ return html`<a class="ovv-btn" href="/auth/login?next=${encodeURIComponent(req.originalUrl)}"><i class="fa-solid fa-plus" aria-hidden="true"></i> Sign in to save to a playlist</a>`;
++ }
++ const lists = await playlists.list(ownerOf(req));
++ return html`<details class="ovv-save"><summary class="ovv-btn"><i class="fa-solid fa-plus" aria-hidden="true"></i> Save to playlist</summary>
++<form class="ovv-save-form" method="post" action="/playlists/add">
++<input type="hidden" name="kind" value="${kind}">
++<input type="hidden" name="id" value="${id}">
++<span class="ovv-save-label">Add to playlist</span>
++<select name="playlist" aria-label="Choose a playlist or make a new one"><option value="">New playlist…</option>${lists.map((p) => html`<option value="${p.id}">${p.title}</option>`)}</select>
++<input name="title" type="text" maxlength="${playlists.TITLE_MAX}" placeholder="New playlist title" aria-label="New playlist title">
++<button class="ovv-btn ovv-btn--primary" type="submit">Save</button>
++</form></details>`;
++ }
++
++ async function upNext(req, kind, id) {
++ const listId = String(req.query.list || '');
++ if (!playlists.isPlaylistId(listId)) return { html: '', nextUrl: null };
++ const pl = await playlists.get(listId);
++ if (!pl) return { html: '', nextUrl: null };
++ const items = await playlists.items(pl.id);
++ const idx = items.findIndex((x) => x.kind === kind && Number(x.item_id) === Number(id));
++ 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 */ }
++ 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,
++ };
++ }
++
++ const itemControls = (plId, it) => html`<div class="ovv-item-controls">
++<form class="ovv-inline" method="post" action="/playlists/${plId}/items/${it.kind}/${Number(it.item_id)}/up"><button class="ovv-btn" type="submit" aria-label="Move up">↑</button></form>
++<form class="ovv-inline" method="post" action="/playlists/${plId}/items/${it.kind}/${Number(it.item_id)}/down"><button class="ovv-btn" type="submit" aria-label="Move down">↓</button></form>
++<form class="ovv-inline" method="post" action="/playlists/${plId}/items/${it.kind}/${Number(it.item_id)}/remove"><button class="ovv-btn" type="submit">Remove</button></form>
++</div>`;
++
++ function playlistRow(p) {
++ const n = Number(p.item_count) || 0;
++ return html`<li class="ovv-playlist-row">
++<div class="ovv-playlist-main"><a class="ovv-playlist-title" href="/playlists/${p.id}">${p.title}</a>
++<span class="ovv-meta">${visibilityLabel(p.visibility)} · ${n} item${n === 1 ? '' : 's'} · updated ${dateLabel(p.updated_at)}</span></div>
++<details class="ovv-pl-edit"><summary class="ovv-btn"><i class="fa-solid fa-pen" aria-hidden="true"></i> Edit</summary>
++<div class="ovv-playlist-actions">
++<form class="ovv-inline" method="post" action="/playlists/${p.id}/rename"><input name="title" type="text" maxlength="${playlists.TITLE_MAX}" value="${p.title}" aria-label="New title" required> <button class="ovv-btn" type="submit">Rename</button></form>
++<form class="ovv-inline" method="post" action="/playlists/${p.id}/visibility"><select name="visibility" aria-label="Visibility">${playlists.VISIBILITIES.map((v) => html`<option value="${v}"${v === p.visibility ? ' selected' : ''}>${visibilityLabel(v)}</option>`)}</select> <button class="ovv-btn" type="submit">Change</button></form>
++<form class="ovv-inline" method="post" action="/playlists/${p.id}/delete"><label class="ovv-confirm"><input type="checkbox" name="confirm" value="1" required> I want to delete it</label> <button class="ovv-btn ovv-btn--danger" type="submit">Delete</button></form>
++</div></details>
++</li>`;
++ }
++
++ function playlistError(req, res, out) {
++ const code = out && out.code;
++ const status = code === 'playlist.not_found' || code === 'playlist.item_not_found' ? 404 : (code === 'playlist.item_duplicate' ? 409 : 422);
++ return page(req, res, {
++ title: 'Playlist',
++ body: html`<h1>That did not go through</h1>${notice((out && out.detail) || 'Something went wrong.', 'warn')}<p><a class="ovv-btn" href="/playlists">Back to your playlists</a></p>`,
++ }, status);
++ }
++
++ async function handleMove(req, res, dir) {
++ if (!sameSite(req, res, `/playlists/${req.params.id}`)) return undefined;
++ const owner = ownerOf(req);
++ const pl = await playlists.own(owner, String(req.params.id || ''));
++ if (!pl) return playlistError(req, res, { code: 'playlist.not_found', detail: 'No such playlist.' });
++ const kind = String(req.params.kind || '');
++ const itemId = req.params.itemId;
++ const items = await playlists.items(pl.id);
++ const idx = items.findIndex((x) => x.kind === kind && Number(x.item_id) === Number(itemId));
++ if (idx < 0) return playlistError(req, res, { code: 'playlist.item_not_found', detail: 'No such item.' });
++ const other = dir === 'up' ? idx - 1 : idx + 1;
++ if (other < 0 || other >= items.length) return res.redirect(303, `/playlists/${pl.id}`);
++ const list = items.map((x) => ({ kind: x.kind, id: Number(x.item_id) }));
++ const tmp = list[idx]; list[idx] = list[other]; list[other] = tmp;
++ const out = await playlists.reorder(owner, pl.id, list);
++ if (!out.ok) return playlistError(req, res, out);
++ return res.redirect(303, `/playlists/${pl.id}`);
++ }
++
// ── Home ────────────────────────────────────────────────
r.get('/', async (req, res) => {
const [vods, clips] = await Promise.all([
@@@ -405,6 +526,10 @@ function createVideoRoutes(ctx) {
if (vod.stream_category) metaParts.push(vod.stream_category);
if (vod.view_count != null) metaParts.push(`${Number(vod.view_count) || 0} views`);
++ const saveForm = await playlistForm(req, 'vod', id);
++ const up = await upNext(req, 'vod', id);
++ const nextAttr = up.nextUrl ? html` data-ovv-next="${up.nextUrl}"` : '';
++
return page(req, res, {
index: !unlisted,
noindex: unlisted,
@@@ -413,13 +538,13 @@ function createVideoRoutes(ctx) {
scripts: ['js/video.js'],
body: html`<div class="ovv-watch">
<div class="ovv-main">
--<div class="ovv-player-wrap"><video class="ovv-player" controls preload="metadata" playsinline src="${httpsUrl(vod.playback_url) || ''}" data-ovv-video>${segs.length ? html`<track kind="captions" label="Transcript (AI)" src="/v/${id}/captions.vtt" default>` : ''}${mems.length ? html`<track kind="chapters" src="/v/${id}/chapters.vtt">` : ''}</video></div>
++<div class="ovv-player-wrap"><video class="ovv-player" controls preload="metadata" playsinline src="${httpsUrl(vod.playback_url) || ''}" data-ovv-video${nextAttr}>${segs.length ? html`<track kind="captions" label="Transcript (AI)" src="/v/${id}/captions.vtt" default>` : ''}${mems.length ? html`<track kind="chapters" src="/v/${id}/chapters.vtt">` : ''}</video></div>
<h1 class="ovv-title">${vod.title || 'Stream recording'}</h1>
<div class="ovv-byline">
${channelName ? html`<a class="ovv-avatar" href="${localChannel || liveChannel}" aria-hidden="true" tabindex="-1">${String(channelName).slice(0, 1).toUpperCase()}</a>` : ''}
<div class="ovv-byline-text">${channelName ? (localChannel ? html`<a class="ovv-channel-name" href="${localChannel}">@${channelName}</a>` : html`<span class="ovv-channel-name">@${channelName}</span>`) : ''}
${metaParts.length ? html`<span class="ovv-meta">${metaParts.map((p, i) => (i === 0 ? p : [html` · `, p]))}</span>` : ''}</div>
--<div class="ovv-byline-actions">${channelName ? html`<a class="ovv-btn" href="${liveChannel}"><i class="fa-solid fa-tower-broadcast" aria-hidden="true"></i> Channel on OpenVibe.Live</a>` : ''}${threadUrl ? html`<a class="ovv-btn" href="${threadUrl}"><i class="fa-regular fa-comments" aria-hidden="true"></i> Discuss on OpenVibe.Community</a>` : ''}</div>
++<div class="ovv-byline-actions">${saveForm}${channelName ? html`<a class="ovv-btn" href="${liveChannel}"><i class="fa-solid fa-tower-broadcast" aria-hidden="true"></i> Channel on OpenVibe.Live</a>` : ''}${threadUrl ? html`<a class="ovv-btn" href="${threadUrl}"><i class="fa-regular fa-comments" aria-hidden="true"></i> Discuss on OpenVibe.Community</a>` : ''}</div>
</div>
${vod.is_recording ? html`<p class="ovv-recording" role="status"><i class="fa-solid fa-circle" aria-hidden="true"></i> This recording is still in progress. <a href="${liveChannel}">Watch the live channel</a>.</p>` : ''}
${vod.ai_overview ? html`<section class="ovv-panel" aria-labelledby="h-overview"><h2 id="h-overview">About this stream</h2><p>${vod.ai_overview}</p><p class="ovv-fine">Written by AI from the stream.</p></section>` : ''}
@@@ -427,6 +552,7 @@ ${segs.length ? html`<details class="ovv-panel ovv-transcript"><summary>Transcri
${commentsOk ? html`<section class="ovv-panel ovv-comments" aria-labelledby="h-comments"><h2 id="h-comments">Comments</h2>${commentRows.length ? commentRows.map(commentItem) : html`<p class="ovv-comments-empty">No comments yet.</p>`}${threadUrl ? html`<p class="ovv-comments-link"><a class="ovv-btn" href="${threadUrl}">Join the discussion on OpenVibe.Community</a></p>` : ''}</section>` : ''}
</div>
<aside class="ovv-side">
++${up.html}
${mems.length ? html`<section class="ovv-panel ovv-chapters" aria-labelledby="h-chapters"><h2 id="h-chapters">Chapters</h2><ol>${mems.map((m) => html`<li><a href="/v/${id}?t=${m.offset_seconds}" data-ovv-chapter="${m.offset_seconds}"><span class="ovv-time">${vttTime(m.offset_seconds).slice(0, 8)}</span><span>${m.description}</span></a></li>`)}</ol><p class="ovv-fine">From the AI moments of the stream.</p></section>` : ''}
${chatRows ? html`<section class="ovv-panel ovv-chat" aria-labelledby="h-chat"><h2 id="h-chat">Chat replay</h2><div class="ovv-chat-body" data-ovv-chat>${chatRows.rows.map((r) => html`<div class="ovv-chat-row" data-ovv-at="${r.offset}"><button type="button" class="ovv-seek" data-ovv-seek="${r.offset}">${streamTime(r.offset)}</button><span class="ovv-chat-msg">${r.name ? html`<b class="ovv-chat-name">${r.name}</b> ` : ''}${r.text}</span></div>`)}</div>${chatRows.leftOut ? html`<p class="ovv-fine">Showing the first 1,000 messages; ${chatRows.leftOut} more not shown.</p>` : ''}</section>` : ''}
${clips.length ? html`<section class="ovv-panel" aria-labelledby="h-clips"><h2 id="h-clips">Clips from this stream</h2><div class="ovv-grid ovv-grid--side">${clips.map(clipCard)}</div></section>` : ''}
@@@ -457,6 +583,10 @@ ${clips.length ? html`<section class="ovv-panel" aria-labelledby="h-clips"><h2 i
const madeBy = clip.display_name || clip.username || 'an OpenVibe member';
const attribution = clip.auto_generated ? `AI clip from ${sourceName}'s stream` : `Made by ${madeBy}`;
++ const saveForm = await playlistForm(req, 'clip', id);
++ const up = await upNext(req, 'clip', id);
++ const nextAttr = up.nextUrl ? html` data-ovv-next="${up.nextUrl}"` : '';
++
return page(req, res, {
index: !unlisted,
noindex: unlisted,
@@@ -465,13 +595,14 @@ ${clips.length ? html`<section class="ovv-panel" aria-labelledby="h-clips"><h2 i
scripts: ['js/video.js'],
body: html`<div class="ovv-watch ovv-watch--clip">
<div class="ovv-main">
--<div class="ovv-player-wrap"><video class="ovv-player" controls preload="metadata" playsinline src="${httpsUrl(clip.playback_url) || ''}" data-ovv-video></video></div>
++<div class="ovv-player-wrap"><video class="ovv-player" controls preload="metadata" playsinline src="${httpsUrl(clip.playback_url) || ''}" data-ovv-video${nextAttr}></video></div>
<h1 class="ovv-title">${clip.title || 'Clip'}</h1>
<div class="ovv-byline">
<div class="ovv-byline-text"><span class="ovv-channel-name">${clip.auto_generated ? html`<span class="ovv-tag ovv-tag--inline">AI clip</span> ` : ''}${attribution}</span>
${clip.created_at ? html`<span class="ovv-meta"><time datetime="${clip.created_at}">${dateLabel(clip.created_at)}</time>${clip.duration_seconds != null ? html` · ${durationLabel(clip.duration_seconds)}` : ''}</span>` : ''}</div>
--<div class="ovv-byline-actions">${fullRecording ? html`<a class="ovv-btn ovv-btn--primary" href="${fullRecording}"><i class="fa-solid fa-film" aria-hidden="true"></i> Watch in the full recording</a>` : ''}${threadUrl ? html`<a class="ovv-btn" href="${threadUrl}"><i class="fa-regular fa-comments" aria-hidden="true"></i> Discuss on OpenVibe.Community</a>` : ''}</div>
++<div class="ovv-byline-actions">${saveForm}${fullRecording ? html`<a class="ovv-btn ovv-btn--primary" href="${fullRecording}"><i class="fa-solid fa-film" aria-hidden="true"></i> Watch in the full recording</a>` : ''}${threadUrl ? html`<a class="ovv-btn" href="${threadUrl}"><i class="fa-regular fa-comments" aria-hidden="true"></i> Discuss on OpenVibe.Community</a>` : ''}</div>
</div>
++${up.html}
${clip.ai_overview ? html`<section class="ovv-panel"><p>${clip.ai_overview}</p><p class="ovv-fine">Written by AI from the clip.</p></section>` : ''}
<p class="ovv-manage"><a href="${livePublic}/clip/${id}">Manage on OpenVibe.Live</a></p>
</div>
@@@ -515,6 +646,132 @@ ${clips.length ? html`<section class="ovv-section" aria-labelledby="h-clips"><h2
});
});
++ // ── Playlists ──────────────────────────────────────────
++ r.get('/playlists', mustSignIn('/playlists', 'A playlist belongs to your OpenVibe account, so you can keep and share your saved recordings and clips.'), async (req, res) => {
++ const rows = await playlists.list(ownerOf(req));
++ return page(req, res, {
++ title: 'Your playlists',
++ crumbs: [{ label: 'Home', href: '/' }, { label: 'Playlists' }],
++ body: html`<h1>Your playlists</h1>
++<section class="ovv-section" aria-labelledby="h-create"><h2 id="h-create">New playlist</h2>
++<form class="ovv-save-form ovv-panel" method="post" action="/playlists"><label for="new-title">Title</label> <input id="new-title" name="title" type="text" maxlength="${playlists.TITLE_MAX}" required placeholder="Watch later"> <label for="new-vis">Who can see it</label> <select id="new-vis" name="visibility">${playlists.VISIBILITIES.map((v) => html`<option value="${v}"${v === 'private' ? ' selected' : ''}>${visibilityLabel(v)}</option>`)}</select> <button class="ovv-btn ovv-btn--primary" type="submit"><i class="fa-solid fa-plus" aria-hidden="true"></i> Create</button></form></section>
++<section class="ovv-section" aria-labelledby="h-lists"><h2 id="h-lists">Playlists</h2>
++${rows.length ? html`<ul class="ovv-playlist-list">${rows.map(playlistRow)}</ul>` : html`<p class="muted">No playlists yet. Save a recording or clip from its watch page.</p>`}</section>`,
++ });
++ });
++
++ r.post('/playlists', mustSignIn('/playlists'), async (req, res) => {
++ if (!sameSite(req, res, '/playlists')) return undefined;
++ const out = await playlists.create(ownerOf(req), { title: (req.body || {}).title, visibility: (req.body || {}).visibility || 'private' });
++ if (!out.ok) return playlistError(req, res, out);
++ return res.redirect(303, `/playlists/${out.playlist.id}`);
++ });
++
++ r.post('/playlists/:id/rename', mustSignIn('/playlists'), async (req, res) => {
++ if (!sameSite(req, res, '/playlists')) return undefined;
++ const out = await playlists.update(ownerOf(req), String(req.params.id || ''), { title: (req.body || {}).title });
++ if (!out.ok) return playlistError(req, res, out);
++ return res.redirect(303, '/playlists');
++ });
++
++ r.post('/playlists/:id/visibility', mustSignIn('/playlists'), async (req, res) => {
++ if (!sameSite(req, res, '/playlists')) return undefined;
++ const out = await playlists.update(ownerOf(req), String(req.params.id || ''), { visibility: (req.body || {}).visibility });
++ if (!out.ok) return playlistError(req, res, out);
++ return res.redirect(303, '/playlists');
++ });
++
++ r.post('/playlists/:id/delete', mustSignIn('/playlists'), async (req, res) => {
++ if (!sameSite(req, res, '/playlists')) return undefined;
++ if (String((req.body || {}).confirm || '') !== '1') {
++ return page(req, res, {
++ title: 'Delete playlist',
++ body: html`<h1>Confirm the delete</h1>${notice('Tick the delete box to confirm.', 'warn')}<p><a class="ovv-btn" href="/playlists">Back to your playlists</a></p>`,
++ }, 422);
++ }
++ const out = await playlists.remove(ownerOf(req), String(req.params.id || ''));
++ if (!out.ok) return playlistError(req, res, out);
++ return res.redirect(303, '/playlists');
++ });
++
++ r.post('/playlists/add', mustSignIn('/playlists'), async (req, res) => {
++ if (!sameSite(req, res, '/playlists')) return undefined;
++ const b = req.body || {};
++ const kind = String(b.kind || '');
++ const id = String(b.id || '');
++ const title = String(b.title || '').trim();
++ const owner = ownerOf(req);
++ let target = String(b.playlist || '');
++ if (title) {
++ const created = await playlists.create(owner, { title });
++ if (!created.ok) return playlistError(req, res, created);
++ target = created.playlist.id;
++ }
++ const out = await playlists.addItem(owner, target, { kind, id });
++ if (!out.ok) return playlistError(req, res, out);
++ return res.redirect(303, `/playlists/${target}`);
++ });
++
++ r.get('/playlists/:id', async (req, res) => {
++ const id = String(req.params.id || '');
++ const pl = await playlists.get(id);
++ if (!pl) return notFound(req, res);
++ const isOwner = signedIn(req) && ownerOf(req) === pl.owner;
++ if (pl.visibility === 'private' && !isOwner) return notFound(req, res);
++
++ const items = await playlists.items(pl.id);
++ const rows = [];
++ 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; }
++ 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}`;
++ 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;
++ rows.push(html`<li class="ovv-item-row">
++<a class="ovv-item-media" href="${href}" tabindex="-1" aria-hidden="true">${thumb ? html`<img src="${thumb}" alt="" loading="lazy">` : 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>
++<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;
++
++ return page(req, res, {
++ index: pl.visibility === 'public',
++ noindex: pl.visibility === 'unlisted',
++ title: pl.title,
++ description: pl.description || undefined,
++ crumbs: [{ label: 'Home', href: '/' }, { label: 'Playlists', href: '/playlists' }, { label: pl.title }],
++ body: html`<header class="ovv-playlist-head"><h1>${pl.title}</h1>
++${pl.description ? html`<p class="ovv-lede">${pl.description}</p>` : ''}
++<p class="ovv-meta">${visibilityLabel(pl.visibility)} · ${items.length} item${items.length === 1 ? '' : 's'} · updated ${dateLabel(pl.updated_at)}</p>
++${playAllHref ? html`<p><a class="ovv-btn ovv-btn--primary" href="${playAllHref}"><i class="fa-solid fa-play" aria-hidden="true"></i> Play all</a></p>` : ''}</header>
++${items.length ? html`<ol class="ovv-item-list">${rows}</ol>` : html`<p class="muted">This playlist is empty.</p>`}
++${isOwner ? html`<p class="ovv-fine">You can reorder items with the up and down buttons, or remove them. <a href="/playlists">Back to your playlists</a>.</p>` : ''}`,
++ });
++ });
++
++ r.post('/playlists/:id/items/:kind/:itemId/up', mustSignIn('/playlists'), (req, res) => handleMove(req, res, 'up'));
++ r.post('/playlists/:id/items/:kind/:itemId/down', mustSignIn('/playlists'), (req, res) => handleMove(req, res, 'down'));
++
++ r.post('/playlists/:id/items/:kind/:itemId/remove', mustSignIn('/playlists'), async (req, res) => {
++ if (!sameSite(req, res, `/playlists/${req.params.id}`)) return undefined;
++ const out = await playlists.removeItem(ownerOf(req), String(req.params.id || ''), String(req.params.kind || ''), req.params.itemId);
++ if (!out.ok) return playlistError(req, res, out);
++ return res.redirect(303, `/playlists/${req.params.id}`);
++ });
++
// ── Discovery: robots.txt, sitemap.xml, llms.txt ────────
/** Up to SITEMAP_PAGES pages of one feed, following Live's cursor; a page that fails ends the walk. */
async function feedItems(type) {
ddiff --git a/server/video/playlists.js b/server/video/playlists.js
nnew file mode 100644
iindex 0000000..fb4b384
---- /dev/null
++++ b/server/video/playlists.js
@@@ -0,0 +1,191 @@
++'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.
++ *
++ * 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 person, 500 items per playlist (both from the environment),
++ * a title of 1-120 characters, a description of at most 2,000, and a visibility of public | unlisted | private.
++ *
++ * Nothing here trusts a caller's identity: the caller passes the requester it already established, and this module
++ * only ever compares it with what the rows say.
++ */
++
++const TITLE_MAX = 120;
++const DESCRIPTION_MAX = 2000;
++const VISIBILITIES = ['public', 'unlisted', 'private'];
++const KINDS = ['vod', 'clip'];
++const PLAYLIST_ID_RE = /^vpl_[0-9A-HJKMNP-TV-Z]{26}$/;
++
++/** Live's ids: positive safe integers (what /v/:id and /c/:id accept). */
++function isItemId(v) {
++ return /^[1-9]\d*$/.test(String(v)) && Number.isSafeInteger(Number(v)) && Number(v) > 0;
++}
++
++function createPlaylists({ s, config, log = console }) {
++ const nowIso = () => new Date(s.now()).toISOString();
++ const max = () => Number(config.video.playlistsMax) || 100;
++ const itemsMax = () => Number(config.video.playlistItemsMax) || 500;
++
++ const cleanTitle = (v) => String(v == null ? '' : v).trim();
++ const cleanDescription = (v) => String(v == null ? '' : v).trim();
++
++ function titleError(title) {
++ if (!title) return 'A playlist needs a title.';
++ if (title.length > TITLE_MAX) return `A title is at most ${TITLE_MAX} characters.`;
++ return null;
++ }
++ function descriptionError(desc) {
++ if (desc.length > DESCRIPTION_MAX) return `A description is at most ${DESCRIPTION_MAX} characters.`;
++ return null;
++ }
++
++ const get = async (id) => {
++ if (!PLAYLIST_ID_RE.test(String(id || ''))) return null;
++ return s.db.maybe('SELECT * FROM video_playlists WHERE id = $1', [id]);
++ };
++
++ const own = async (owner, id) => {
++ if (!PLAYLIST_ID_RE.test(String(id || ''))) return null;
++ const row = await s.db.maybe('SELECT * FROM video_playlists WHERE id = $1', [id]);
++ return row && row.owner === owner ? row : null;
++ };
++
++ /** The person's playlists, newest-updated first, each with its item count. */
++ const list = (owner) => s.db.many(
++ `SELECT p.*, (SELECT count(*) FROM video_playlist_items i WHERE i.playlist_id = p.id)::int AS item_count
++ FROM video_playlists p
++ WHERE p.owner = $1
++ ORDER BY p.updated_at DESC, p.id DESC`, [owner]);
++
++ const count = (owner) => s.db.value('SELECT count(*) FROM video_playlists WHERE owner = $1', [owner]).then(Number);
++
++ async function create(owner, { title, description = '', visibility = 'private' }) {
++ const t = cleanTitle(title);
++ const terr = titleError(t);
++ if (terr) return { ok: false, code: 'playlist.invalid_title', detail: terr };
++ const d = cleanDescription(description);
++ const derr = descriptionError(d);
++ if (derr) return { ok: false, code: 'playlist.invalid_description', detail: derr };
++ if (!VISIBILITIES.includes(visibility)) return { ok: false, code: 'playlist.invalid_visibility', detail: 'Visibility is public, unlisted or private.' };
++ if (await count(owner) >= max()) {
++ return { ok: false, code: 'playlist.limit', detail: `You have ${max()} playlists (the most one person may have). Delete one first.` };
++ }
++ const now = nowIso();
++ const row = { id: s.newId('vpl'), owner, title: t, description: d, visibility, created_at: now, updated_at: now };
++ await s.db.query(
++ `INSERT INTO video_playlists (id, owner, title, description, visibility, created_at, updated_at)
++ VALUES ($1, $2, $3, $4, $5, $6, $7)`,
++ [row.id, row.owner, row.title, row.description, row.visibility, row.created_at, row.updated_at]);
++ return { ok: true, playlist: row };
++ }
++
++ /** PATCH { title?, description?, visibility? }: each field present is validated and replaced. */
++ async function update(owner, id, patch) {
++ const row = await own(owner, id);
++ if (!row) return { ok: false, code: 'playlist.not_found', detail: 'No such playlist.' };
++ const next = { title: row.title, description: row.description, visibility: row.visibility };
++ if ('title' in patch) {
++ const t = cleanTitle(patch.title);
++ const err = titleError(t);
++ if (err) return { ok: false, code: 'playlist.invalid_title', detail: err };
++ next.title = t;
++ }
++ if ('description' in patch) {
++ const d = cleanDescription(patch.description);
++ const err = descriptionError(d);
++ if (err) return { ok: false, code: 'playlist.invalid_description', detail: err };
++ next.description = d;
++ }
++ if ('visibility' in patch) {
++ if (!VISIBILITIES.includes(patch.visibility)) return { ok: false, code: 'playlist.invalid_visibility', detail: 'Visibility is public, unlisted or private.' };
++ next.visibility = patch.visibility;
++ }
++ const updated = nowIso();
++ await s.db.exec('UPDATE video_playlists SET title = $2, description = $3, visibility = $4, updated_at = $5 WHERE id = $1',
++ [id, next.title, next.description, next.visibility, updated]);
++ return { ok: true, playlist: { ...row, ...next, updated_at: updated } };
++ }
++
++ async function remove(owner, id) {
++ const row = await own(owner, id);
++ if (!row) return { ok: false, code: 'playlist.not_found', detail: 'No such playlist.' };
++ await s.db.exec('DELETE FROM video_playlists WHERE id = $1', [id]);
++ return { ok: true };
++ }
++
++ const items = (playlistId) => s.db.many(
++ 'SELECT * FROM video_playlist_items WHERE playlist_id = $1 ORDER BY position ASC, added_at ASC', [playlistId]);
++
++ const getItem = (playlistId, kind, itemId) => s.db.maybe(
++ 'SELECT * FROM video_playlist_items WHERE playlist_id = $1 AND kind = $2 AND item_id = $3', [playlistId, kind, itemId]);
++
++ const touch = (playlistId) => s.db.exec('UPDATE video_playlists SET updated_at = $2 WHERE id = $1', [playlistId, nowIso()]);
++
++ 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 (!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);
++ if (n >= itemsMax()) return { ok: false, code: 'playlist.items_limit', detail: `A playlist holds at most ${itemsMax()} items.` };
++ const pos = await s.db.value('SELECT coalesce(max(position), -1) FROM video_playlist_items WHERE playlist_id = $1', [playlistId]).then((v) => Number(v) + 1);
++ try {
++ await s.db.query('INSERT INTO video_playlist_items (playlist_id, kind, item_id, position, added_at) VALUES ($1, $2, $3, $4, $5)',
++ [playlistId, kind, itemId, pos, nowIso()]);
++ } catch (err) {
++ if (err && err.code === '23505') return { ok: false, code: 'playlist.item_duplicate', detail: 'That item is already in the playlist.' };
++ throw err;
++ }
++ await touch(playlistId);
++ return { ok: true, item: await getItem(playlistId, kind, itemId) };
++ }
++
++ async function removeItem(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) || !isItemId(id)) return { ok: false, code: 'playlist.invalid_item', detail: 'That item is not valid.' };
++ const removed = await s.db.exec('DELETE FROM video_playlist_items WHERE playlist_id = $1 AND kind = $2 AND item_id = $3',
++ [playlistId, kind, Number(id)]);
++ if (!removed) return { ok: false, code: 'playlist.item_not_found', detail: 'No such item in this playlist.' };
++ await touch(playlistId);
++ return { ok: true };
++ }
++
++ /** Reorder: the full playlist as an ordered list of { kind, id }; it must be exactly the current items. */
++ async function reorder(owner, playlistId, list) {
++ const row = await own(owner, playlistId);
++ if (!row) return { ok: false, code: 'playlist.not_found', detail: 'No such playlist.' };
++ if (!Array.isArray(list) || list.length > itemsMax()) return { ok: false, code: 'playlist.invalid_items', detail: 'items must be a list of { kind, id }.' };
++ const wanted = list.map((x, i) => {
++ if (!x || !KINDS.includes(x.kind) || !isItemId(x.id)) return null;
++ return { kind: x.kind, id: Number(x.id), position: i };
++ });
++ if (wanted.some((x) => x === null)) return { ok: false, code: 'playlist.invalid_items', detail: 'items must be a list of { kind, id }.' };
++ const key = (x) => `${x.kind}:${x.id}`;
++ const wantKeys = new Set(wanted.map(key));
++ if (wantKeys.size !== wanted.length) return { ok: false, code: 'playlist.invalid_items', detail: 'items must not repeat.' };
++ const existing = await items(playlistId);
++ if (wantKeys.size !== existing.length || existing.some((x) => !wantKeys.has(key({ kind: x.kind, id: Number(x.item_id) })))) {
++ return { ok: false, code: 'playlist.invalid_items', detail: 'items must be exactly the playlist items, in a new order.' };
++ }
++ await s.db.tx(async (t) => {
++ for (const w of wanted) {
++ await t.exec('UPDATE video_playlist_items SET position = $3 WHERE playlist_id = $1 AND kind = $2 AND item_id = $4',
++ [playlistId, w.kind, w.position, w.id]);
++ }
++ });
++ await touch(playlistId);
++ return { ok: true, items: await items(playlistId) };
++ }
++
++ return {
++ TITLE_MAX, DESCRIPTION_MAX, VISIBILITIES, KINDS, isItemId, isPlaylistId: (id) => PLAYLIST_ID_RE.test(String(id || '')),
++ list, get, own, create, update, remove, items, getItem, addItem, removeItem, reorder,
++ };
++}
++
++module.exports = { createPlaylists, TITLE_MAX, DESCRIPTION_MAX, VISIBILITIES, KINDS, isItemId };
ddiff --git a/test/video-playlists.test.js b/test/video-playlists.test.js
nnew file mode 100644
iindex 0000000..d44e49d
---- /dev/null
++++ b/test/video-playlists.test.js
@@@ -0,0 +1,271 @@
++'use strict';
++/**
++ * OpenVibe.Video step C: playlists on openvibe.video. Covers sign-in on the video host, the add-to-playlist form on
++ * the watch pages, the playlist pages and the playlist API (create, add vod/clip, duplicate, reorder, remove,
++ * rename, visibility), private/unlisted access, limits, CSRF, account export/erasure, the ?list= up-next panel and
++ * the signed-in cache rule.
++ */
++const assert = require('assert');
++const http = require('http');
++const { signDeliveryHeaders } = require('openvibe-sdk/events');
++const { boot, check, done } = require('./helpers/boot');
++
++const SECRET = 'm'.repeat(48);
++const EXPORT = 'exp_01JABCDEFGHJKMNPQRSTVWXYZ0';
++const DELETION = 'del_01JABCDEFGHJKMNPQRSTVWXYZ0';
++
++function startLive() {
++ const publicVod = {
++ id: 101, title: 'A public recording', description: 'A public stream', duration_seconds: 45,
++ created_at: '2026-10-12T10:00:00.000Z', thumbnail_url: 'https://openvibe.media/t/101.jpg',
++ playback_url: 'https://openvibe.media/v/101', visibility: 'public', username: 'streamer', display_name: 'Streamer',
++ };
++ const secondVod = {
++ id: 104, title: 'A long recording', description: '', duration_seconds: 300,
++ created_at: '2026-10-12T09:30:00.000Z', thumbnail_url: null,
++ playback_url: 'https://openvibe.media/v/104', visibility: 'public', username: 'streamer', display_name: 'Streamer',
++ };
++ const publicClip = {
++ id: 201, vod_id: 101, title: 'A clip', start_time: 5, end_time: 15, duration_seconds: 10,
++ playback_url: 'https://openvibe.media/c/201', thumbnail_url: null, visibility: 'public',
++ username: 'clipper', display_name: 'Clipper',
++ };
++ const server = http.createServer((req, res) => {
++ const url = new URL(req.url, 'http://x');
++ const json = (status, body) => {
++ const text = JSON.stringify(body);
++ res.writeHead(status, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(text) });
++ res.end(text);
++ };
++ const vodDetail = /^\/api\/vods\/(\d+)$/.exec(url.pathname);
++ if (vodDetail) {
++ if (vodDetail[1] === '101') return json(200, { vod: publicVod, clips: [] });
++ if (vodDetail[1] === '104') return json(200, { vod: secondVod, clips: [] });
++ return json(404, { error: 'not found' });
++ }
++ const clipDetail = /^\/api\/clips\/(\d+)$/.exec(url.pathname);
++ if (clipDetail) {
++ if (clipDetail[1] === '201') return json(200, { clip: publicClip });
++ return json(404, { error: 'not found' });
++ }
++ return json(404, { error: 'not found' });
++ });
++ return new Promise((resolve) => {
++ server.listen(0, '127.0.0.1', () => resolve({
++ url: `http://127.0.0.1:${server.address().port}`,
++ close: () => new Promise((r) => server.close(r)),
++ }));
++ });
++}
++
++function vreq(base, path, { host = 'openvibe.video', method = 'GET', cookie, body, headers = {}, contentType } = {}) {
++ return new Promise((resolve, reject) => {
++ const u = new URL(base + path);
++ const h = { Host: host, ...headers };
++ if (cookie) h.cookie = cookie;
++ let data;
++ if (body !== undefined && body !== null) {
++ data = Buffer.isBuffer(body) ? body : Buffer.from(body);
++ h['content-length'] = data.length;
++ if (contentType) h['content-type'] = contentType;
++ }
++ const req = http.request({ hostname: u.hostname, port: u.port, path: u.pathname + u.search, method, headers: h }, (res) => {
++ const chunks = [];
++ res.on('data', (c) => chunks.push(c));
++ res.on('end', () => {
++ const buf = Buffer.concat(chunks);
++ resolve({ status: res.statusCode, headers: res.headers, text: buf.toString('utf8'), json() { return JSON.parse(buf.toString('utf8')); } });
++ });
++ });
++ req.on('error', reject);
++ if (data) req.write(data);
++ req.end();
++ });
++}
++
++function api(base, path, { method = 'GET', cookie, json, form, same = true } = {}) {
++ const opts = { method, cookie, headers: same ? { 'sec-fetch-site': 'same-origin' } : {} };
++ if (json !== undefined) { opts.body = JSON.stringify(json); opts.contentType = 'application/json'; }
++ if (form !== undefined) { opts.body = new URLSearchParams(form).toString(); opts.contentType = 'application/x-www-form-urlencoded'; }
++ return vreq(base, path, opts);
++}
++
++const videoEnv = (live) => ({
++ MEDIAHUB_VIDEO_URL: 'https://openvibe.video',
++ MEDIAHUB_LIVE_URL: live.url,
++ MEDIAHUB_LIVE_PUBLIC_URL: 'https://openvibe.live',
++ MEDIAHUB_CHAT_URL: live.url,
++});
++
++(async () => {
++ const live = await startLive();
++ const t = await boot({ env: { ...videoEnv(live), MEDIAHUB_EVENTS_SECRET: SECRET } });
++ const kim = t.network.addUser('kim');
++ const sam = t.network.addUser('sam');
++ const kimCookie = t.signIn(kim);
++ const samCookie = t.signIn(sam);
++
++ const envelope = (type, payload) => ({ event_id: `evt_${Math.random().toString(16).slice(2)}`, event_type: type, source: 'network', at: new Date().toISOString(), payload });
++ const deliver = (event) => {
++ const body = Buffer.from(JSON.stringify({ event }));
++ return vreq(t.base, '/internal/events', { host: 'openvibe.download', method: 'POST', body, headers: { 'content-type': 'application/json', ...signDeliveryHeaders(body, SECRET) } });
++ };
++
++ await check('sign-in on the video host uses the video callback URI', async () => {
++ const r = await vreq(t.base, '/auth/login?next=/playlists');
++ assert.strictEqual(r.status, 302);
++ const loc = new URL(r.headers.location);
++ assert.strictEqual(loc.origin + loc.pathname, `${t.network.url}/oauth/authorize`);
++ assert.strictEqual(loc.searchParams.get('redirect_uri'), 'https://openvibe.video/auth/callback');
++ });
++
++ await check('the signed-out watch page offers sign-in, not the save form', async () => {
++ const r = await vreq(t.base, '/v/101');
++ assert.strictEqual(r.status, 200);
++ assert.ok(r.text.includes('Sign in to save to a playlist'), r.text.slice(0, 400));
++ assert.ok(!r.text.includes('name="kind"'), 'no add-to-playlist form for a guest');
++ });
++
++ await check('the signed-in watch page shows the add-to-playlist form', async () => {
++ const r = await vreq(t.base, '/v/101', { cookie: kimCookie });
++ assert.strictEqual(r.status, 200);
++ assert.ok(r.text.includes('Add to playlist'), r.text.slice(0, 400));
++ assert.ok(r.text.includes('name="kind"'), 'the form carries the item kind');
++ });
++
++ await check('create, add (vod and clip), duplicate refused, reorder, remove, rename, visibility', async () => {
++ const created = await api(t.base, '/api/v1/video/playlists', { method: 'POST', cookie: kimCookie, json: { title: 'My list', visibility: 'public' } });
++ assert.strictEqual(created.status, 201, created.text);
++ const pl = created.json().playlist;
++ assert.match(pl.id, /^vpl_[0-9A-HJKMNP-TV-Z]{26}$/);
++ assert.strictEqual(pl.visibility, 'public');
++
++ const addVod = await api(t.base, `/api/v1/video/playlists/${pl.id}/items`, { method: 'POST', cookie: kimCookie, json: { kind: 'vod', id: 101 } });
++ assert.strictEqual(addVod.status, 201, addVod.text);
++ assert.strictEqual(addVod.json().item.kind, 'vod');
++ assert.strictEqual(addVod.json().item.id, 101);
++
++ const addClip = await api(t.base, `/api/v1/video/playlists/${pl.id}/items`, { method: 'POST', cookie: kimCookie, json: { kind: 'clip', id: 201 } });
++ assert.strictEqual(addClip.status, 201, addClip.text);
++ assert.strictEqual(addClip.json().item.kind, 'clip');
++ assert.strictEqual(addClip.json().item.id, 201);
++
++ const dup = await api(t.base, `/api/v1/video/playlists/${pl.id}/items`, { method: 'POST', cookie: kimCookie, json: { kind: 'vod', id: 101 } });
++ assert.strictEqual(dup.status, 409, dup.text);
++ assert.strictEqual(dup.json().code, 'playlist.item_duplicate');
++
++ const reorder = await api(t.base, `/api/v1/video/playlists/${pl.id}/items`, { method: 'PATCH', cookie: kimCookie, json: { items: [{ kind: 'clip', id: 201 }, { kind: 'vod', id: 101 }] } });
++ assert.strictEqual(reorder.status, 200, reorder.text);
++ assert.deepStrictEqual(reorder.json().items.map((x) => [x.kind, x.id]), [['clip', 201], ['vod', 101]]);
++
++ const remove = await api(t.base, `/api/v1/video/playlists/${pl.id}/items/vod/101`, { method: 'DELETE', cookie: kimCookie });
++ assert.strictEqual(remove.status, 204, remove.text);
++
++ const rename = await api(t.base, `/api/v1/video/playlists/${pl.id}`, { method: 'PATCH', cookie: kimCookie, json: { title: 'Renamed' } });
++ assert.strictEqual(rename.status, 200, rename.text);
++ assert.strictEqual(rename.json().playlist.title, 'Renamed');
++
++ const vis = await api(t.base, `/api/v1/video/playlists/${pl.id}`, { method: 'PATCH', cookie: kimCookie, json: { visibility: 'unlisted' } });
++ assert.strictEqual(vis.status, 200, vis.text);
++ assert.strictEqual(vis.json().playlist.visibility, 'unlisted');
++ });
++
++ await check('a private playlist is a 404 for someone else and signed-out; unlisted is reachable but noindex', async () => {
++ const priv = await api(t.base, '/api/v1/video/playlists', { method: 'POST', cookie: kimCookie, json: { title: 'Private list', visibility: 'private' } });
++ const pid = priv.json().playlist.id;
++ await api(t.base, `/api/v1/video/playlists/${pid}/items`, { method: 'POST', cookie: kimCookie, json: { kind: 'vod', id: 101 } });
++
++ assert.strictEqual((await vreq(t.base, `/playlists/${pid}`, { cookie: samCookie })).status, 404, 'someone else gets a 404');
++ assert.strictEqual((await vreq(t.base, `/playlists/${pid}`)).status, 404, 'a guest gets a 404');
++
++ const unlisted = await api(t.base, '/api/v1/video/playlists', { method: 'POST', cookie: kimCookie, json: { title: 'Unlisted list', visibility: 'unlisted' } });
++ const uid = unlisted.json().playlist.id;
++ const u = await vreq(t.base, `/playlists/${uid}`);
++ assert.strictEqual(u.status, 200);
++ assert.ok(u.text.includes('<meta name="robots" content="noindex">'), 'unlisted is noindex');
++ assert.ok(u.text.includes('Unlisted list'), 'the title renders');
++ });
++
++ await check('a signed-in playlist write is refused without the same-origin signal', async () => {
++ const r = await api(t.base, '/api/v1/video/playlists', { method: 'POST', cookie: kimCookie, json: { title: 'csrf' }, same: false });
++ assert.strictEqual(r.status, 403, r.text);
++ assert.strictEqual(r.json().code, 'request.cross_site');
++ });
++
++ await check('?list= shows Up next and the next item link', async () => {
++ const created = await api(t.base, '/api/v1/video/playlists', { method: 'POST', cookie: kimCookie, json: { title: 'Queue', visibility: 'public' } });
++ const pl = created.json().playlist;
++ await api(t.base, `/api/v1/video/playlists/${pl.id}/items`, { method: 'POST', cookie: kimCookie, json: { kind: 'vod', id: 101 } });
++ await api(t.base, `/api/v1/video/playlists/${pl.id}/items`, { method: 'POST', cookie: kimCookie, json: { kind: 'clip', id: 201 } });
++
++ const r = await vreq(t.base, `/v/101?list=${pl.id}`);
++ assert.strictEqual(r.status, 200);
++ assert.ok(r.text.includes('Up next in'), r.text.slice(0, 500));
++ assert.ok(r.text.includes(`/c/201?list=${pl.id}`), 'the next item is linked');
++ assert.ok(r.text.includes(`data-ovv-next="/c/201?list=${pl.id}"`), 'the player carries the next url');
++
++ const noList = await vreq(t.base, '/v/101');
++ assert.ok(!noList.text.includes('Up next in'), 'no up-next panel without ?list=');
++ });
++
++ await check('signed-in pages are private, no-store; signed-out pages are public', async () => {
++ const signedIn = await vreq(t.base, '/v/101', { cookie: kimCookie });
++ assert.strictEqual(signedIn.headers['cache-control'], 'private, no-store');
++ const signedOut = await vreq(t.base, '/v/101');
++ assert.match(signedOut.headers['cache-control'], /public, max-age=300/);
++ const lists = await vreq(t.base, '/playlists', { cookie: kimCookie });
++ assert.strictEqual(lists.headers['cache-control'], 'private, no-store');
++ });
++
++ await check('account export includes the person\'s playlists and items', async () => {
++ const r = await deliver(envelope('network.account.export_requested', { export_id: EXPORT, subject: kim.subject }));
++ assert.strictEqual(r.status, 200, r.text);
++ assert.strictEqual(r.json().outcome, 'exported');
++ assert.strictEqual(t.network.accountParts.length, 1, 'Network was given the part');
++ const part = t.network.accountParts[0].body;
++ const plFile = part.files.find((f) => f.name === 'playlists.json');
++ const itemFile = part.files.find((f) => f.name === 'playlist_items.json');
++ assert.ok(plFile, 'a playlists.json part');
++ assert.ok(plFile.content.some((p) => p.title === 'Renamed' || p.title === 'Private list' || p.title === 'Unlisted list' || p.title === 'Queue'));
++ assert.ok(itemFile, 'a playlist_items.json part');
++ assert.ok(itemFile.content.length >= 1);
++ });
++
++ await check('erasure removes the playlists', async () => {
++ const r = await deliver(envelope('network.account.deleted', { deletion_id: DELETION, subject: kim.subject }));
++ assert.strictEqual(r.status, 200, r.text);
++ assert.strictEqual(r.json().outcome, 'erased');
++ const list = await api(t.base, '/api/v1/video/playlists', { method: 'GET', cookie: kimCookie });
++ assert.strictEqual(list.json().playlists.length, 0, 'kim has no playlists left');
++ });
++
++ await t.close();
++
++ // Limits, lowered through the environment.
++ const t2 = await boot({ env: { ...videoEnv(live), MEDIAHUB_VIDEO_PLAYLISTS_MAX: '2', MEDIAHUB_VIDEO_PLAYLIST_ITEMS_MAX: '3' } });
++ const kim2 = t2.network.addUser('kim');
++ const c2 = t2.signIn(kim2);
++
++ await check('playlist and item limits are enforced', async () => {
++ for (let i = 0; i < 2; i++) {
++ const r = await api(t2.base, '/api/v1/video/playlists', { method: 'POST', cookie: c2, json: { title: 'p' + i } });
++ assert.strictEqual(r.status, 201, r.text);
++ }
++ const third = await api(t2.base, '/api/v1/video/playlists', { method: 'POST', cookie: c2, json: { title: 'p3' } });
++ assert.strictEqual(third.status, 422, third.text);
++ assert.strictEqual(third.json().code, 'playlist.limit');
++
++ const pl = (await api(t2.base, '/api/v1/video/playlists', { method: 'GET', cookie: c2 })).json().playlists[0];
++ for (let i = 0; i < 3; i++) {
++ const r = await api(t2.base, `/api/v1/video/playlists/${pl.id}/items`, { method: 'POST', cookie: c2, json: { kind: 'vod', id: 101 + i } });
++ assert.strictEqual(r.status, 201, r.text);
++ }
++ const fourth = await api(t2.base, `/api/v1/video/playlists/${pl.id}/items`, { method: 'POST', cookie: c2, json: { kind: 'vod', id: 999 } });
++ assert.strictEqual(fourth.status, 422, fourth.text);
++ assert.strictEqual(fourth.json().code, 'playlist.items_limit');
++ });
++
++ await t2.close();
++ await live.close();
++ done();
++})().catch((e) => { console.error(e); process.exit(1); });