openvibers/openvibe.mediahub Commits
MediaHub: OpenVibe.Video playlists, with sign-in on openvibe.video (#31)
Changes
@@@ -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 appno longer has "coming" pages or host-based brand handling.@@@ -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 Hostheader. **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)@@@ -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"],nnew file mode 100644@@@ -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);@@@ -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; }@@@ -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')));@@@ -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 = [];@@@ -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) ──────@@@ -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 = () => Datconst { 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) {@@@ -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 onennew file mode 100644@@@ -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 };@@@ -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));}@@@ -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 iconst 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 iscripts: ['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) {nnew file mode 100644@@@ -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 };nnew file mode 100644@@@ -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); });
@@ -13,6 +13,10 @@ What changed in OpenVibe.MediaHub, newest first. Each site also publishes its pa
@@ -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,
- 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
oldest first, the first 1,000 messages) and read-only comments with the OpenVibe.Community thread link, plus the
`MEDIAHUB_CHAT_URL` setting.
`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
- 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.
no longer has "coming" pages or host-based brand handling.
@@ -3,7 +3,7 @@
@@ -3,7 +3,7 @@
> Your files, shared on your terms.
> Your files, shared on your terms.
**Status:** alpha. One process serves **openvibe.download** (a private drive, live in v1) and
**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.
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.
**Port:** 4990 · **Service id:** `media-hub` · **Env prefix:** `MEDIAHUB` · **License:** AGPL-3.0.
@@ -31,6 +31,8 @@ creates none):
@@ -31,6 +31,8 @@ creates none):
- `mh_uploads` — an in-flight chunked upload session (Media's multipart id, the parts expected, the expiry).
- `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
- `account_data_events` — the once-per-event record of applied account export and deletion deliveries
(openvibe-sdk/account-data, ADR-033).
(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
## Does not own
@@ -110,33 +112,43 @@ MediaHub owns the drive: `mh_files`, `mh_folders`, `mh_shares`, `mh_reports`, `m
@@ -110,33 +112,43 @@ MediaHub owns the drive: `mh_files`, `mh_folders`, `mh_shares`, `mh_reports`, `m
## OpenVibe.Video (openvibe.video)
## 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 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),
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_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`
`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
(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
## Pages
@@ -159,6 +171,17 @@ Not built yet: playlists and uploads.
@@ -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 /uploads/:id`, `PUT /uploads/:id/parts/:n`, `POST /uploads/:id/complete`, `DELETE /uploads/:id` | resumable uploads |
| `GET /usage` | your usage against the limits |
| `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`.
Session-cookie writes must be same-origin. Errors are RFC 9457 problem+json with a stable `code`.
## Account export and deletion (ADR-033)
## Account export and deletion (ADR-033)
@@ -3,7 +3,7 @@
@@ -3,7 +3,7 @@
"stage": "alpha",
"stage": "alpha",
"code": true,
"code": true,
"wave": "W20",
"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",
"runtime": "node 22, express 4, PostgreSQL 18 (openvibe-sdk/db, async) + Valkey 9",
"port": "4990",
"port": "4990",
"domain": "openvibe.download and openvibe.video (openvibe.pics is parked: it redirects to openvibe.network)",
"domain": "openvibe.download and openvibe.video (openvibe.pics is parked: it redirects to openvibe.network)",
@@ -12,7 +12,7 @@
@@ -12,7 +12,7 @@
"openvibe-sdk v0.44.0",
"openvibe-sdk v0.44.0",
"openvibe-shared v4.0.2"
"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": [
"works": [
"Sign-in with OpenVibe.Network (PKCE S256, httpOnly session cookies); pages server-rendered, complete without JavaScript",
"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",
"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 @@
@@ -21,13 +21,14 @@
"Reports: 3 distinct people or one report of illegal content suspend a share; staff restore or remove (Media object deleted)",
"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",
"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 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",
"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",
"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"
"Per-caller and per-address limits; readiness (database required); metrics on loopback"
],
],
"notYet": [
"notYet": [
"Uploads on openvibe.pics (images, albums, embeds): parked until scanning (hash lists, malware) and abuse intake shared with Media holds exist",
"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",
"Public (signed-out) share links: deliberately not offered while there is no scanning",
"Previews and thumbnails of files"
"Previews and thumbnails of files"
],
],
new file mode 100644
new file mode 100644
@@ -0,0 +1,32 @@
@@ -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);
@@ -125,3 +125,50 @@ a.ovv-channel-name:hover { text-decoration: underline; }
@@ -125,3 +125,50 @@ a.ovv-channel-name:hover { text-decoration: underline; }
.ovv-player-wrap { border-radius: 10px; margin: 0 -4px; }
.ovv-player-wrap { border-radius: 10px; margin: 0 -4px; }
.ovv-btn { white-space: normal; }
.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; }
@@ -29,6 +29,14 @@
@@ -29,6 +29,14 @@
const t = startSeconds();
const t = startSeconds();
if (t != null) seek(video, t);
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) {
document.querySelectorAll('[data-ovv-seek]').forEach(function (el) {
el.addEventListener('click', function () {
el.addEventListener('click', function () {
seek(video, Number(el.getAttribute('data-ovv-seek')));
seek(video, Number(el.getAttribute('data-ovv-seek')));
@@ -29,6 +29,9 @@ const TABLES = [
@@ -29,6 +29,9 @@ const TABLES = [
{ table: 'mh_usage', subject: 'owner', value: AS_REQUESTER, file: 'usage.json' },
{ table: 'mh_usage', subject: 'owner', value: AS_REQUESTER, file: 'usage.json' },
{ table: 'mh_files', subject: 'owner', value: AS_REQUESTER, file: 'files.json' },
{ table: 'mh_files', subject: 'owner', value: AS_REQUESTER, file: 'files.json' },
{ table: 'mh_folders', subject: 'owner', value: AS_REQUESTER, file: 'folders.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
// 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.
// the account but is not part of the export.
{ table: 'mh_uploads', subject: 'owner', value: AS_REQUESTER, file: null },
{ table: 'mh_uploads', subject: 'owner', value: AS_REQUESTER, file: null },
@@ -42,7 +45,16 @@ function createAccount({ db, config, media, log = console }) {
@@ -42,7 +45,16 @@ function createAccount({ db, config, media, log = console }) {
db,
db,
service: 'media-hub',
service: 'media-hub',
tables: TABLES,
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 () => {
extraErase: async () => {
const ids = pendingObjects;
const ids = pendingObjects;
pendingObjects = [];
pendingObjects = [];
@@ -39,6 +39,7 @@ const { createIdentity } = require('./network/identity');
@@ -39,6 +39,7 @@ const { createIdentity } = require('./network/identity');
const { createService } = require('./drive/service');
const { createService } = require('./drive/service');
const { createUploads } = require('./drive/upload');
const { createUploads } = require('./drive/upload');
const { createAccount, createSender } = require('./account');
const { createAccount, createSender } = require('./account');
+const { createPlaylists } = require('./video/playlists');
const PUBLIC_DIR = path.join(__dirname, '..', 'public');
const PUBLIC_DIR = path.join(__dirname, '..', 'public');
const VERSION = require('../package.json').version;
const VERSION = require('../package.json').version;
@@ -62,7 +63,8 @@ async function createApp(opts = {}) {
@@ -62,7 +63,8 @@ async function createApp(opts = {}) {
const drive = createService({ s, media, identity, config, log });
const drive = createService({ s, media, identity, config, log });
const uploads = createUploads({ s, media, config, service: drive, log });
const uploads = createUploads({ s, media, config, service: drive, log });
const account = createAccount({ db: s.db, config, media, 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();
const app = express();
app.disable('x-powered-by');
app.disable('x-powered-by');
@@ -103,8 +105,19 @@ async function createApp(opts = {}) {
@@ -103,8 +105,19 @@ async function createApp(opts = {}) {
}));
}));
app.use(cookieParser());
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
// ── 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 videoHost = (() => { try { return new URL(config.video.url).hostname; } catch { return null; } })();
const videoRoutes = createVideoRoutes(ctx);
const videoRoutes = createVideoRoutes(ctx);
app.use((req, res, next) => {
app.use((req, res, next) => {
@@ -123,12 +136,6 @@ async function createApp(opts = {}) {
@@ -123,12 +136,6 @@ async function createApp(opts = {}) {
// through a proxy, so only loopback can reach it even though nginx proxies this path.
// 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 }) }));
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' })); }
{ 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) ──────
// ── Static assets (content-hashed ?v= → immutable) ──────
@@ -37,11 +37,11 @@ function sanitizeNext(next) {
@@ -37,11 +37,11 @@ function sanitizeNext(next) {
return next;
return next;
}
}
-function authorizeUrl(config, { state, challenge, silent = false }) {
+function authorizeUrl(config, { state, challenge, silent = false, redirectUri = null }) {
const q = new URLSearchParams({
const q = new URLSearchParams({
response_type: 'code',
response_type: 'code',
client_id: config.oauth.clientId,
client_id: config.oauth.clientId,
- redirect_uri: config.oauth.redirectUri,
+ redirect_uri: redirectUri || config.oauth.redirectUri,
scope: config.oauth.scope,
scope: config.oauth.scope,
state,
state,
code_challenge: challenge,
code_challenge: challenge,
@@ -54,6 +54,15 @@ function authorizeUrl(config, { state, challenge, silent = false }) {
@@ -54,6 +54,15 @@ function authorizeUrl(config, { state, challenge, silent = false }) {
function createSso({ config, keys, fetchImpl = globalThis.fetch, now = () => Date.now(), log = console }) {
function createSso({ config, keys, fetchImpl = globalThis.fetch, now = () => Date.now(), log = console }) {
const cookieBase = () => ({ sameSite: 'lax', secure: config.cookies.secure, httpOnly: true, path: '/' });
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) {
function setSession(res, data) {
res.cookie(ACCESS_COOKIE, data.access_token, { ...cookieBase(), maxAge: 24 * 60 * 60 * 1000 });
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 });
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
@@ -175,7 +184,7 @@ function createSso({ config, keys, fetchImpl = globalThis.fetch, now = () => Dat
const { verifier, challenge } = pkcePair();
const { verifier, challenge } = pkcePair();
res.cookie(FLOW_COOKIE, JSON.stringify({ state, verifier, next: sanitizeNext(req.query.next), silent }), flowOpts());
res.cookie(FLOW_COOKIE, JSON.stringify({ state, verifier, next: sanitizeNext(req.query.next), silent }), flowOpts());
res.set('Cache-Control', cache.htmlHeaders({ private: true }));
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) => {
r.get('/callback', async (req, res) => {
@@ -195,7 +204,7 @@ function createSso({ config, keys, fetchImpl = globalThis.fetch, now = () => Dat
@@ -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.');
if (!code) return res.status(400).type('text/plain').send('The Network sent no authorization code.');
try {
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);
setSession(res, data);
return res.redirect(next);
return res.redirect(next);
} catch (err) {
} catch (err) {
@@ -63,6 +63,9 @@ function load(env = process.env) {
@@ -63,6 +63,9 @@ function load(env = process.env) {
liveUrl: trim(env.MEDIAHUB_LIVE_URL || 'https://openvibe.live'),
liveUrl: trim(env.MEDIAHUB_LIVE_URL || 'https://openvibe.live'),
livePublicUrl: trim(env.MEDIAHUB_LIVE_PUBLIC_URL || 'https://openvibe.live'),
livePublicUrl: trim(env.MEDIAHUB_LIVE_PUBLIC_URL || 'https://openvibe.live'),
chatUrl: trim(env.MEDIAHUB_CHAT_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
// What one person may keep and do here. Media's own quotas are per tenant (this whole service is one
new file mode 100644
new file mode 100644
@@ -0,0 +1,123 @@
@@ -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 };
@@ -2,9 +2,12 @@
@@ -2,9 +2,12 @@
/**
/**
* The OpenVibe.Video page shell. Same composition as the drive's layout (openvibe-shared/shell,
* 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 crypto = require('crypto');
const fs = require('fs');
const fs = require('fs');
@@ -29,9 +32,11 @@ function assetVersion(rel) {
@@ -29,9 +32,11 @@ function assetVersion(rel) {
}
}
const asset = (rel) => `/${rel}?v=${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) {
function renderPage(o) {
const title = o.title || SITE_NAME;
const title = o.title || SITE_NAME;
+ const viewer = o.viewer || { kind: 'anonymous' };
+ const signedIn = viewer.kind === 'user';
const pathNow = o.path || '/';
const pathNow = o.path || '/';
const canonical = `${String(o.baseUrl).replace(/\/+$/, '')}${pathNow.split('?')[0]}`;
const canonical = `${String(o.baseUrl).replace(/\/+$/, '')}${pathNow.split('?')[0]}`;
const crumbs = (o.crumbs || []).length
const crumbs = (o.crumbs || []).length
@@ -43,8 +48,12 @@ ${appIcon.headTags({ site: 'media-hub' })}
@@ -43,8 +48,12 @@ ${appIcon.headTags({ site: 'media-hub' })}
<link rel="stylesheet" href="${asset('css/video.css')}">
<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">
<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')}`;
${(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>
const body = `<a class="skip" href="#main">Skip to content</a>
<div id="navbar-mount"></div>
<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">
<main id="main" class="page ovv-page">
${render(crumbs)}
${render(crumbs)}
${render(o.body)}
${render(o.body)}
@@ -60,16 +69,24 @@ ${render(o.body)}
@@ -60,16 +69,24 @@ ${render(o.body)}
robots: o.noindex ? 'noindex' : (o.index ? 'index, follow' : 'noindex, nofollow'),
robots: o.noindex ? 'noindex' : (o.index ? 'index, follow' : 'noindex, nofollow'),
home: '/',
home: '/',
navLinks: [],
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 },
footer: { service: 'media-hub', variant: 'compact', brandName: SITE_NAME },
head,
head,
body,
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) {
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.set('Vary', 'Host');
res.status(status).type('html').send(renderPage(o));
res.status(status).type('html').send(renderPage(o));
}
}
@@ -22,10 +22,13 @@ const cache = require('openvibe-shared/cache-policy');
@@ -22,10 +22,13 @@ const cache = require('openvibe-shared/cache-policy');
const seo = require('openvibe-shared/seo');
const seo = require('openvibe-shared/seo');
const ovServe = require('openvibe-shared/serve');
const ovServe = require('openvibe-shared/serve');
const { asyncRouter } = require('../http/router');
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 { send, assetVersion, SITE_NAME } = require('./layout');
const { createLiveClient } = require('./live-client');
const { createLiveClient } = require('./live-client');
const { createChatClient } = require('./chat-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 PUBLIC_DIR = path.join(__dirname, '..', '..', 'public');
const HOME_LIMIT = 10;
const HOME_LIMIT = 10;
@@ -287,7 +290,7 @@ function clipCard(c) {
@@ -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>`);
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) {
function createVideoRoutes(ctx) {
- const { config, log } = ctx;
+ const { config, log, playlists } = ctx;
const live = createLiveClient({ config, fetchImpl: ctx.fetchImpl || globalThis.fetch, log });
const live = createLiveClient({ config, fetchImpl: ctx.fetchImpl || globalThis.fetch, log });
const chat = createChatClient({ config, fetchImpl: ctx.fetchImpl || globalThis.fetch, log });
const chat = createChatClient({ config, fetchImpl: ctx.fetchImpl || globalThis.fetch, log });
const r = asyncRouter();
const r = asyncRouter();
@@ -295,7 +298,14 @@ function createVideoRoutes(ctx) {
@@ -295,7 +298,14 @@ function createVideoRoutes(ctx) {
const livePublic = String(config.video.livePublicUrl).replace(/\/+$/, '');
const livePublic = String(config.video.livePublicUrl).replace(/\/+$/, '');
const videoHostname = (() => { try { return new URL(config.video.url).hostname; } catch { return 'openvibe.video'; } })();
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, {
const notFound = (req, res) => page(req, res, {
title: 'Not found',
title: 'Not found',
cache: cache.htmlHeaders({ private: true }),
cache: cache.htmlHeaders({ private: true }),
@@ -304,6 +314,23 @@ function createVideoRoutes(ctx) {
@@ -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 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 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
// The shell's navbar/footer scripts come from the site's own /shared copy, and the one
// product stylesheet/script live in public/.
// product stylesheet/script live in public/.
r.use('/shared', ovServe.handler());
r.use('/shared', ovServe.handler());
@@ -316,6 +343,100 @@ function createVideoRoutes(ctx) {
@@ -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 ────────────────────────────────────────────────
// ── Home ────────────────────────────────────────────────
r.get('/', async (req, res) => {
r.get('/', async (req, res) => {
const [vods, clips] = await Promise.all([
const [vods, clips] = await Promise.all([
@@ -405,6 +526,10 @@ function createVideoRoutes(ctx) {
@@ -405,6 +526,10 @@ function createVideoRoutes(ctx) {
if (vod.stream_category) metaParts.push(vod.stream_category);
if (vod.stream_category) metaParts.push(vod.stream_category);
if (vod.view_count != null) metaParts.push(`${Number(vod.view_count) || 0} views`);
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, {
return page(req, res, {
index: !unlisted,
index: !unlisted,
noindex: unlisted,
noindex: unlisted,
@@ -413,13 +538,13 @@ function createVideoRoutes(ctx) {
@@ -413,13 +538,13 @@ function createVideoRoutes(ctx) {
scripts: ['js/video.js'],
scripts: ['js/video.js'],
body: html`<div class="ovv-watch">
body: html`<div class="ovv-watch">
<div class="ovv-main">
<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>
<h1 class="ovv-title">${vod.title || 'Stream recording'}</h1>
<div class="ovv-byline">
<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>` : ''}
${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>`) : ''}
<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>
${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>
</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.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>` : ''}
${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
@@ -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>` : ''}
${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>
</div>
<aside class="ovv-side">
<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>` : ''}
${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>` : ''}
${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>` : ''}
${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
@@ -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 madeBy = clip.display_name || clip.username || 'an OpenVibe member';
const attribution = clip.auto_generated ? `AI clip from ${sourceName}'s stream` : `Made by ${madeBy}`;
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, {
return page(req, res, {
index: !unlisted,
index: !unlisted,
noindex: unlisted,
noindex: unlisted,
@@ -465,13 +595,14 @@ ${clips.length ? html`<section class="ovv-panel" aria-labelledby="h-clips"><h2 i
@@ -465,13 +595,14 @@ ${clips.length ? html`<section class="ovv-panel" aria-labelledby="h-clips"><h2 i
scripts: ['js/video.js'],
scripts: ['js/video.js'],
body: html`<div class="ovv-watch ovv-watch--clip">
body: html`<div class="ovv-watch ovv-watch--clip">
<div class="ovv-main">
<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>
<h1 class="ovv-title">${clip.title || 'Clip'}</h1>
<div class="ovv-byline">
<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>
<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>
${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>
</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>` : ''}
${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>
<p class="ovv-manage"><a href="${livePublic}/clip/${id}">Manage on OpenVibe.Live</a></p>
</div>
</div>
@@ -515,6 +646,132 @@ ${clips.length ? html`<section class="ovv-section" aria-labelledby="h-clips"><h2
@@ -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 ────────
// ── 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. */
/** Up to SITEMAP_PAGES pages of one feed, following Live's cursor; a page that fails ends the walk. */
async function feedItems(type) {
async function feedItems(type) {
new file mode 100644
new file mode 100644
@@ -0,0 +1,191 @@
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,271 @@
@@ -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); });