openvibers/openvibe.mediahub Commits

Video: the playlist API for people's tokens and apps (video.playlist.read/manage), public reads, playlists on channel pages (#33)

OP OpenVibers committed 12 Oct 2026 · 0fefc66268 · 11 files, +513 −84

Plan T10, "`video.playlist.*` for apps". Until now openvibe.video's playlist API answered only a browser session. It now uses MediaHub's existing principal (`server/http/principal.js`), as the drive does.

- **People:** a person's own Network token as a Bearer works like the session. Session-cookie writes must still be same-origin.
- **Apps, agents and services** need one capability:
  - `video.playlist.read` lists the playlists the caller owns, and reads any public or unlisted playlist by id.
  - `video.playlist.manage` creates, edits, reorders, adds to, removes from and deletes the caller's own playlists.
  - An app owns a playlist under its requester (`app:app_…`, `agent:agt_…`, `service:<slug>`), and the playlist records its project (migration `0004_video_playlist_apps.sql`, expand, which adds `project_id` and `owner_username`).
  - An app can never touch a person's playlist; it gets 404.
  - Its public page says "Curated by an app".
- **Anyone:** `GET /api/v1/video/playlists/:id` returns a public or unlisted playlist. A private one answers 404 to everyone but its owner. `GET /api/v1/video/users/:username/playlists` returns a person's public playlists, newest first, paged by an opaque cursor.
- **Channel pages** (`/@user`) list that person's public playlists, the newest twelve. The username resolves through Network's `identity.subject.resolve`, remembered for ten minutes, so a crawled page is not a Network call per visit.
- **Owner name:** a person's public playlist page links `@username`. Playlists made before this change have no stored name; they get the owner's current name the next time the owner opens their playlists, and the page says "an OpenVibe member" until then.
- **Limits:** creates and writes have per-caller budgets (`media-hub.playlist.create`, `media-hub.playlist.write`).
- **Docs:** a README "Playlist API" section with the route table, who may call each route, and a curl example. App tokens use the media hub's audience, `openvibe.download`.

Both capability manifests already exist in openvibe-contracts as planned, and `capabilities.check` accepts them now. Contracts 0.152.0 marks them active.

The API was built by a Command Code (DeepSeek) job and reviewed. During review I added the owner-name refresh and the channel-page list, each with a test.

Tests: the new `video-playlist-api.test.js` covers Bearer CRUD, the cross-origin session refusal, an app without the capability, manage versus read, anonymous reads, the private 404, the public per-user list with paging, "Curated by an app" and the name refresh. `video-playlists.test.js` adds the channel-page list. The full suite passes (26 of 26).

Changes

ddiff --git a/README.md b/README.md
iindex b4ce61e..12e124f 100644
---- a/README.md
++++ b/README.md
@@@ -67,9 +67,11 @@ creates none):
The service manifest (openvibe-contracts, `manifests/services/media-hub.json`) lists ten capabilities it owns —
`video.vod.read`, `video.playlist.read`, `video.playlist.manage`, `pics.image.read`, `pics.image.upload`,
--`pics.album.read`, `pics.album.manage`, `download.file.read`, `download.file.upload`, `download.share.create` — but
--no route guards one yet: `CAPABILITIES` in `server/http/principal.js` is empty, and every `/api/v1` route requires a
--signed-in person, never an app, agent or service token.
++`pics.album.read`, `pics.album.manage`, `download.file.read`, `download.file.upload`, `download.share.create`.
++The two playlist capabilities are registered in `server/http/principal.js` and guarded on the video host's API
++(see **Playlist API**): `video.playlist.read` lets an app, agent or service list and read the playlists it owns and
++any public or unlisted playlist by id, and `video.playlist.manage` lets it create, edit, reorder and delete its own.
++Every other `/api/v1` route still requires a signed-in person, never an app, agent or service token.
On other services it calls:
@@@ -150,13 +152,14 @@ address). The video host is chosen by `Host` header in `server/app.js`, so the d
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
++Playlists: 100 per owner and 500 items per playlist, from `MEDIAHUB_VIDEO_PLAYLISTS_MAX` and
`MEDIAHUB_VIDEO_PLAYLIST_ITEMS_MAX`. A playlist holds Live ids only (`vod` or `clip` plus the numeric id); the
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.
++watch page opened with `?list=` shows "Up next" and advances when the video ends. A playlist may also be owned by
++an app, agent or service through the API below; its public page attributes it "Curated by an app".
--Not built yet: uploads, and public playlists of a channel.
++A channel page (`/@user`) lists that person's public playlists (newest twelve). Not built yet: uploads.
## Pages
@@@ -179,18 +182,63 @@ Not built yet: uploads, and public playlists of a channel.
| `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:
++On the video host (openvibe.video), the playlist API accepts a person Bearer token, an app/agent/service token
++with the right capability, or (for public reads) no token — see **Playlist API** below. The drive's `/api/v1`
++routes are unchanged and remain a signed-in person only.
--| 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 |
++## Playlist API
++
++The video host's playlist API is `/api/v1/video/*`. The caller is the existing principal
++(`server/http/principal.js`): a person's own Network token as `Authorization: Bearer` works exactly like the
++session, an app/agent/service token needs the capability the route names, and public reads are open to anyone.
++
++| Route | Who | What |
++|---|---|---|
++| `GET /api/v1/video/playlists` | person, or app with `video.playlist.read` | the caller's own playlists, newest first |
++| `POST /api/v1/video/playlists` | person, or app with `video.playlist.manage` | create one from `{ title, description?, visibility? }` |
++| `GET /api/v1/video/playlists/:id` | owner (private too); anyone for public/unlisted; app with `video.playlist.read` for others' public/unlisted | one playlist and its items |
++| `PATCH /api/v1/video/playlists/:id` | owner, or app with `video.playlist.manage` | rename / describe / change visibility |
++| `DELETE /api/v1/video/playlists/:id` | owner, or app with `video.playlist.manage` | delete it (its items go with it) |
++| `POST /api/v1/video/playlists/:id/items` | owner, or app with `video.playlist.manage` | add one `{ kind: 'vod'|'clip', id }` |
++| `PATCH /api/v1/video/playlists/:id/items` | owner, or app with `video.playlist.manage` | reorder: the full ordered list `{ items: [{ kind, id }, …] }` |
++| `DELETE /api/v1/video/playlists/:id/items/:kind/:itemId` | owner, or app with `video.playlist.manage` | remove one |
++| `GET /api/v1/video/users/:username/playlists` | anyone | that person's public playlists, newest first, `limit` (default 20, max 50) and an opaque `cursor` |
++
++An app/agent/service owns a playlist under its requester (`app:app_…`, `agent:agt_…`, `service:<slug>`) and the
++playlist records its `project_id`. Its Network token is minted for the media hub's audience, `openvibe.download`, as
++for the drive (one service, one audience). A person's session-cookie writes must be same-origin; a person Bearer token needs
++no capability. A private playlist answers `404 playlist.not_found` to anyone but its owner (never 403). Errors are
++RFC 9457 problem+json with a stable code (`token.required`, `capability.denied`, `playlist.not_found`, and the
++playlist validation codes).
++
++Create with an app token:
++
++```bash
++curl -sS https://openvibe.video/api/v1/video/playlists \
++ -H 'Authorization: Bearer <app token>' \
++ -H 'Content-Type: application/json' \
++ -d '{"title":"Clipped moments","visibility":"unlisted"}'
++```
++
++A public read (no token) returns the playlist and its items:
++
++```bash
++curl -sS https://openvibe.video/api/v1/video/playlists/<id>
++```
++
++```json
++{
++ "playlist": {
++ "id": "vpl_…", "title": "…", "description": "…", "visibility": "unlisted",
++ "owner": { "kind": "user", "username": "kim" },
++ "item_count": 2, "created_at": "…", "updated_at": "…"
++ },
++ "items": [{ "kind": "vod", "id": 101, "position": 0, "added_at": "…" }]
++}
++```
--Session-cookie writes must be same-origin. Errors are RFC 9457 problem+json with a stable `code`.
++`owner` is `{ kind: 'user', username }` for a person, or `{ kind: 'app' | 'agent' | 'service' }` otherwise. Items
++are Live ids only; the watch pages resolve their display fields lazily through the cached Live client.
## Account export and deletion (ADR-033)
ddiff --git a/STATUS.json b/STATUS.json
iindex b459dab..5833af4 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -21,7 +21,7 @@
"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",
++ "OpenVibe.Video playlists: a signed-in person's named lists of Live recordings and clips (/playlists, /playlists/:id), with a browser-session and app API (video.playlist.read / video.playlist.manage), 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"
@@@ -32,7 +32,7 @@
"Public (signed-out) share links: deliberately not offered while there is no scanning",
"Previews and thumbnails of files"
],
-- "capabilitiesRegistered": false,
++ "capabilitiesRegistered": true,
"serviceManifestRegistered": true,
"deployed": true,
"placeholderInSites": false,
ddiff --git a/migrations/0004_video_playlist_apps.sql b/migrations/0004_video_playlist_apps.sql
nnew file mode 100644
iindex 0000000..bc6b061
---- /dev/null
++++ b/migrations/0004_video_playlist_apps.sql
@@@ -0,0 +1,9 @@
++-- phase: expand
++-- OpenVibe.Video playlists for apps, agents and services (plan T10): a playlist an app, agent or service
++-- token makes is a normal playlist, owned by the token's requester (app:app_…, agent:agt_… or service:<slug>)
++-- and recorded with the token's project. owner_username is the person's username when a person owns it, so
++-- a public page can say who curated it. Nothing here is ever a credential, a key or a token.
++
++ALTER TABLE video_playlists
++ ADD COLUMN project_id text COLLATE "C", -- prj_… when an app/agent/service token made it; NULL for a person
++ ADD COLUMN owner_username text; -- the person's username, when a person owns it; NULL otherwise
ddiff --git a/server/http/caller-limits.js b/server/http/caller-limits.js
iindex 3d968d4..60eeda5 100644
---- a/server/http/caller-limits.js
++++ b/server/http/caller-limits.js
@@@ -45,6 +45,8 @@ const BUDGETS = {
'media-hub.upload.complete': { minute: 30, hour: 300 },
'media-hub.folder.create': { minute: 20, hour: 120 },
'media-hub.share.create': { minute: 20, hour: 200 },
++ 'media-hub.playlist.create': { minute: 30, hour: 300 },
++ 'media-hub.playlist.write': { minute: 120, hour: 1_200 },
};
function createCallerLimits({ config, now = () => Date.now(), registry = null, log = console, enabled = true, valkey = null }) {
ddiff --git a/server/http/principal.js b/server/http/principal.js
iindex 1375229..442cffc 100644
---- a/server/http/principal.js
++++ b/server/http/principal.js
@@@ -27,7 +27,7 @@ const PROJECT_RE = /^prj_[0-9A-HJKMNP-TV-Z]{26}$/;
// The product fills this in: one entry per capability its routes name, in openvibe-contracts
// manifests/capabilities/<media-hub>.*. requireCapability('<name>') refuses a name that is not listed here, so a
// route can never be guarded by a capability the service does not declare.
--const CAPABILITIES = [];
++const CAPABILITIES = ['video.playlist.read', 'video.playlist.manage'];
function decodePayload(token) {
const parts = String(token || '').split('.');
@@@ -99,14 +99,20 @@ function createPrincipal({ config, keys }) {
return next();
}
-- /** One route's guard: a person needs nothing more (writes by cookie must be same-origin); an app the capability. */
-- function require_(cap) {
++ /**
++ * One route's guard: a person needs nothing more (writes by cookie must be same-origin); an app the capability.
++ * The drive guards with its own defaults; the video host passes the video origin and name so its session-cookie
++ * writes are judged against openvibe.video.
++ */
++ function require_(cap, opts = {}) {
if (!CAPABILITIES.includes(cap)) throw new Error(`requireCapability(${cap}): not one of OpenVibe.MediaHub's capabilities`);
++ const origin = opts.origin || config.baseUrl;
++ const site = opts.site || 'openvibe.download';
return function guard(req, res, next) {
const p = req.principal;
-- if (p.kind === 'anonymous') return http.sendProblem(res, 401, 'token.required', { detail: 'Sign in at openvibe.download, or send a Network token (a person\'s, or an app\'s with this capability for openvibe.download).', ctx: req.ov });
++ if (p.kind === 'anonymous') return http.sendProblem(res, 401, 'token.required', { detail: `Sign in at ${site}, or send a Network token (a person's, or an app's with this capability for ${site}).`, ctx: req.ov });
if (p.kind === 'user') {
-- if (p.viaSession && req.method !== 'GET' && !sameOrigin(req, config.baseUrl)) return http.sendProblem(res, 403, 'request.cross_site', { detail: 'A signed-in request that changes something must come from openvibe.download itself.', ctx: req.ov });
++ if (p.viaSession && req.method !== 'GET' && !sameOrigin(req, origin)) return http.sendProblem(res, 403, 'request.cross_site', { detail: `A signed-in request that changes something must come from ${site} itself.`, ctx: req.ov });
return next();
}
const decision = capabilities.check(p.claims, cap);
ddiff --git a/server/video/api.js b/server/video/api.js
iindex 029ea6f..ad437ad 100644
---- a/server/video/api.js
++++ b/server/video/api.js
@@@ -1,51 +1,49 @@
'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.
++ * /api/v1/video — the playlist API on openvibe.video (step C, plan T10). It is mounted only on the video host,
++ * inside createVideoRoutes, so the drive's /api/v1 stays exactly as it is. The caller is the existing
++ * principal (server/http/principal.js): a person's own Network token works like the session, an app, agent or
++ * service token needs the capability the route names, and a signed-out browser may read public playlists.
*
-- * 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
++ * GET /api/v1/video/playlists the caller's own playlists (person, or app with read)
++ * POST /api/v1/video/playlists make one: { title, description?, visibility? } (person, or app with manage)
++ * GET /api/v1/video/playlists/:id one playlist: the owner sees private too; public/unlisted open to all, private answers 404
++ * PATCH /api/v1/video/playlists/:id rename / describe / change visibility (owner; app with manage)
++ * DELETE /api/v1/video/playlists/:id delete it (its items go with it) (owner; app with manage)
++ * POST /api/v1/video/playlists/:id/items add one: { kind: 'vod'|'clip', id } (owner; app with manage)
++ * PATCH /api/v1/video/playlists/:id/items reorder: the full ordered list { items: [{ kind, id }, …] } (owner; app with manage)
++ * DELETE /api/v1/video/playlists/:id/items/:kind/:itemId remove one (owner; app with manage)
++ * GET /api/v1/video/users/:username/playlists a person's public playlists, newest first, paged by cursor
*
++ * An app owns a playlist under its requester (app:app_…, agent:agt_…, service:<slug>) and records its project.
* A refusal is an RFC 9457 problem+json with a stable code, in the repo's own style.
*/
const express = require('express');
++const rateLimit = require('express-rate-limit');
const contracts = require('openvibe-contracts');
const { asyncRouter } = require('../http/router');
--const { sameOrigin } = require('../http/principal');
++const { USERNAME_RE } = require('./live-client');
function createVideoApiRoutes(ctx) {
-- const { config, playlists } = ctx;
++ const { config, playlists, principal, limits, identity } = ctx;
const r = asyncRouter();
const problem = (req, res, status, code, detail) => contracts.http.sendProblem(res, status, code, { detail, ctx: req.ov });
++ const videoSite = 'openvibe.video';
r.use((req, res, next) => { res.set('Cache-Control', 'no-store'); next(); });
++ r.use(rateLimit({ windowMs: 60_000, limit: Number(process.env.MEDIAHUB_API_RATE_LIMIT_PER_MIN) || 240, standardHeaders: true, legacyHeaders: false, handler: (req, res) => contracts.http.sendProblem(res, 429, 'rate_limited', { detail: 'too many requests from this address; retry shortly', ctx: req.ov }) }));
++ r.use(principal.middleware);
r.use(express.json({ limit: '64kb' }));
-- const ownerOf = (req) => (req.viewer && req.viewer.kind === 'user' && req.viewer.subject ? `user:${req.viewer.subject}` : null);
++ const read = principal.requireCapability('video.playlist.read', { origin: config.video.url, site: videoSite });
++ const manage = principal.requireCapability('video.playlist.manage', { origin: config.video.url, site: videoSite });
-- /** 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();
-- }
++ /** The owner string this principal acts as, or null for a signed-out caller. */
++ const requesterOf = (req) => {
++ const p = req.principal;
++ return (p && (p.kind === 'user' || p.kind === 'app') && p.requester) ? p.requester : null;
++ };
function refused(code) {
if (code === 'playlist.not_found' || code === 'playlist.item_not_found') return 404;
@@@ -63,60 +61,98 @@ function createVideoApiRoutes(ctx) {
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);
++ const wireOwner = (row) => {
++ const owner = String(row.owner || '');
++ if (owner.startsWith('user:')) return { kind: 'user', username: row.owner_username || null };
++ const kind = owner.split(':')[0];
++ return { kind: kind === 'agent' || kind === 'service' ? kind : 'app' };
++ };
++
++ r.get('/playlists', read, limits.reads('media-hub.playlist.read'), async (req, res) => {
++ const rows = await playlists.list(requesterOf(req));
++ if (req.principal.kind === 'user' && req.principal.username) await playlists.refreshOwnerUsername(requesterOf(req), req.principal.username);
return res.json({ playlists: rows.map(wirePlaylist) });
});
-- r.post('/', person, async (req, res) => {
++ r.post('/playlists', manage, limits.budget('media-hub.playlist.create'), async (req, res) => {
const b = req.body || {};
-- const out = await playlists.create(req.playlistOwner, { title: b.title, description: b.description, visibility: b.visibility });
++ const p = req.principal;
++ const out = await playlists.create(requesterOf(req), {
++ title: b.title, description: b.description, visibility: b.visibility,
++ ownerUsername: p.kind === 'user' ? p.username : null,
++ projectId: p.kind === 'app' ? p.project : null,
++ });
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 || ''));
++ r.get('/playlists/:id', limits.reads('media-hub.playlist.read'), async (req, res) => {
++ const row = await playlists.get(String(req.params.id || ''));
if (!row) return problem(req, res, 404, 'playlist.not_found', 'No such playlist.');
++ const owner = requesterOf(req);
++ const isOwner = owner != null && owner === row.owner;
++ const isPublic = row.visibility === 'public' || row.visibility === 'unlisted';
++
++ if (isOwner) {
++ const items = await playlists.items(row.id);
++ return res.json({ playlist: { ...wirePlaylist(row, items.length), owner: wireOwner(row) }, items: items.map(wireItem) });
++ }
++ // A private playlist answers 404 to everyone but its owner: never reveal that it exists.
++ if (!isPublic) return problem(req, res, 404, 'playlist.not_found', 'No such playlist.');
++ if (req.principal.kind === 'app') {
++ const decision = contracts.capabilities.check(req.principal.claims, 'video.playlist.read');
++ if (!decision.allowed) return problem(req, res, 403, decision.code || 'capability.denied', decision.reason || 'video.playlist.read not granted');
++ }
++
const items = await playlists.items(row.id);
-- return res.json({ playlist: wirePlaylist(row, items.length), items: items.map(wireItem) });
++ return res.json({ playlist: { ...wirePlaylist(row, items.length), owner: wireOwner(row) }, items: items.map(wireItem) });
});
-- r.patch('/:id', person, async (req, res) => {
-- const out = await playlists.update(req.playlistOwner, String(req.params.id || ''), req.body || {});
++ r.patch('/playlists/:id', manage, limits.budget('media-hub.playlist.write'), async (req, res) => {
++ const out = await playlists.update(requesterOf(req), 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 || ''));
++ r.delete('/playlists/:id', manage, limits.budget('media-hub.playlist.write'), async (req, res) => {
++ const out = await playlists.remove(requesterOf(req), 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) => {
++ r.post('/playlists/:id/items', manage, limits.budget('media-hub.playlist.write'), async (req, res) => {
const b = req.body || {};
-- const out = await playlists.addItem(req.playlistOwner, String(req.params.id || ''), { kind: b.kind, id: b.id });
++ const out = await playlists.addItem(requesterOf(req), 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) => {
++ r.patch('/playlists/:id/items', manage, limits.budget('media-hub.playlist.write'), 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);
++ const out = await playlists.reorder(requesterOf(req), 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);
++ r.delete('/playlists/:id/items/:kind/:itemId', manage, limits.budget('media-hub.playlist.write'), async (req, res) => {
++ const out = await playlists.removeItem(requesterOf(req), 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();
});
++ r.get('/users/:username/playlists', limits.reads('media-hub.playlist.read'), async (req, res) => {
++ const username = String(req.params.username || '');
++ if (!USERNAME_RE.test(username)) return problem(req, res, 404, 'user.not_found', 'No such OpenVibe user.');
++ const subject = await identity.resolveUsername(username);
++ if (!subject) return problem(req, res, 404, 'user.not_found', 'No such OpenVibe user.');
++ const limit = Math.min(50, Math.max(1, Number.parseInt(req.query.limit, 10) || 20));
++ const cursor = req.query.cursor ? String(req.query.cursor) : null;
++ const out = await playlists.publicByOwner(`user:${subject}`, { limit, cursor });
++ return res.json({ playlists: out.playlists.map(wirePlaylist), next_cursor: out.next });
++ });
++
return r;
}
ddiff --git a/server/video/pages.js b/server/video/pages.js
iindex bebe049..4d4e4c2 100644
---- a/server/video/pages.js
++++ b/server/video/pages.js
@@@ -299,6 +299,21 @@ const grid = (cards, empty = 'Nothing here yet.') => (cards && cards.length ? ht
function createVideoRoutes(ctx) {
const { config, log, playlists } = ctx;
++
++ // A channel page lists that person's public playlists, which are stored by subject: the username is resolved
++ // through Network (identity.subject.resolve) and remembered for ten minutes (an unknown name for one), at most
++ // 1,000, so a crawled channel page is not a Network call per visit.
++ const subjects = new Map(); // username (lowercase) → { at, ttl, subject }
++ async function subjectOf(username) {
++ const key = username.toLowerCase();
++ const hit = subjects.get(key);
++ if (hit && Date.now() - hit.at < hit.ttl) return hit.subject;
++ const subject = ctx.identity && ctx.identity.resolveUsername ? await ctx.identity.resolveUsername(username) : null;
++ subjects.delete(key);
++ subjects.set(key, { at: Date.now(), ttl: subject ? 600_000 : 60_000, subject });
++ if (subjects.size > 1000) subjects.delete(subjects.keys().next().value);
++ return subject;
++ }
const live = createLiveClient({ config, fetchImpl: ctx.fetchImpl || globalThis.fetch, log });
const chat = createChatClient({ config, fetchImpl: ctx.fetchImpl || globalThis.fetch, log });
const r = asyncRouter();
@@@ -352,7 +367,7 @@ function createVideoRoutes(ctx) {
}));
// ── Playlist API (openvibe.video only; the drive's /api/v1 is untouched) ──
-- r.use('/api/v1/video/playlists', createVideoApiRoutes(ctx));
++ r.use('/api/v1/video', 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' }));
@@@ -417,6 +432,13 @@ function createVideoRoutes(ctx) {
</li>`;
}
++ /** A public playlist on a channel page: its title and size, no owner controls. */
++ function publicPlaylistRow(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">${n} item${n === 1 ? '' : 's'} · updated ${dateLabel(p.updated_at)}</span></div></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);
@@@ -645,6 +667,14 @@ ${clip.ai_overview ? html`<section class="ovv-panel"><p>${clip.ai_overview}</p><
: [];
} catch { clips = []; }
++ let lists = [];
++ if (pageNum === 1) {
++ try {
++ const subject = await subjectOf(username);
++ if (subject) lists = (await playlists.publicByOwner(`user:${subject}`, { limit: 12 })).playlists;
++ } catch (err) { log.warn('[OpenVibe.Video] channel playlists:', (err && err.message) || ''); }
++ }
++
const prevLink = pageNum > 1 ? `?page=${pageNum - 1}` : null;
const nextLink = hasMore ? `?page=${pageNum + 1}` : null;
@@@ -654,13 +684,15 @@ ${clip.ai_overview ? html`<section class="ovv-panel"><p>${clip.ai_overview}</p><
description: `Recordings and clips from @${username} on OpenVibe.Video.`,
body: html`<header class="ovv-channel-head"><span class="ovv-avatar ovv-avatar--big" aria-hidden="true">${username.slice(0, 1).toUpperCase()}</span><div><h1>@${username}</h1><p class="ovv-meta">Recordings and clips from this channel's streams on OpenVibe.Live.</p></div><a class="ovv-btn" href="${livePublic}/@${username}"><i class="fa-solid fa-tower-broadcast" aria-hidden="true"></i> Channel on OpenVibe.Live</a></header>
<section class="ovv-section" aria-labelledby="h-vods"><h2 id="h-vods">Recordings</h2>${grid(vods.map(vodCard), 'No recordings yet.')}${prevLink || nextLink ? html`<nav class="ovv-pagination" aria-label="Pages">${prevLink ? html`<a href="${prevLink}">← Newer</a>` : ''}${nextLink ? html`<a href="${nextLink}">Older →</a>` : ''}</nav>` : ''}</section>
--${clips.length ? html`<section class="ovv-section" aria-labelledby="h-clips"><h2 id="h-clips">Clips</h2>${grid(clips.map(clipCard), 'No clips yet.')}</section>` : ''}`,
++${clips.length ? html`<section class="ovv-section" aria-labelledby="h-clips"><h2 id="h-clips">Clips</h2>${grid(clips.map(clipCard), 'No clips yet.')}</section>` : ''}
++${lists.length ? html`<section class="ovv-section" aria-labelledby="h-lists"><h2 id="h-lists">Playlists</h2><ul class="ovv-playlist-list">${lists.map(publicPlaylistRow)}</ul></section>` : ''}`,
});
});
// ── 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));
++ if (req.viewer && req.viewer.username) await playlists.refreshOwnerUsername(ownerOf(req), req.viewer.username);
return page(req, res, {
title: 'Your playlists',
crumbs: [{ label: 'Home', href: '/' }, { label: 'Playlists' }],
@@@ -674,7 +706,7 @@ ${rows.length ? html`<ul class="ovv-playlist-list">${rows.map(playlistRow)}</ul>
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' });
++ const out = await playlists.create(ownerOf(req), { title: (req.body || {}).title, visibility: (req.body || {}).visibility || 'private', ownerUsername: req.viewer && req.viewer.username });
if (!out.ok) return playlistError(req, res, out);
return res.redirect(303, `/playlists/${out.playlist.id}`);
});
@@@ -715,7 +747,7 @@ ${rows.length ? html`<ul class="ovv-playlist-list">${rows.map(playlistRow)}</ul>
const owner = ownerOf(req);
let target = String(b.playlist || '');
if (title) {
-- const created = await playlists.create(owner, { title });
++ const created = await playlists.create(owner, { title, ownerUsername: req.viewer && req.viewer.username });
if (!created.ok) return playlistError(req, res, created);
target = created.playlist.id;
}
@@@ -731,6 +763,12 @@ ${rows.length ? html`<ul class="ovv-playlist-list">${rows.map(playlistRow)}</ul>
const isOwner = signedIn(req) && ownerOf(req) === pl.owner;
if (pl.visibility === 'private' && !isOwner) return notFound(req, res);
++ const ownerIsPerson = String(pl.owner || '').startsWith('user:');
++ const ownerUsername = ownerIsPerson && pl.owner_username && live.isUsername(pl.owner_username) ? pl.owner_username : null;
++ const ownerBy = ownerIsPerson
++ ? (ownerUsername ? html`<a class="ovv-channel-name" href="/@${ownerUsername}">@${ownerUsername}</a>` : 'an OpenVibe member')
++ : 'an app';
++
const items = await playlists.items(pl.id);
const rows = [];
for (const it of items) {
@@@ -768,6 +806,7 @@ ${isOwner ? itemControls(pl.id, it) : ''}
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>
++<p class="ovv-meta">Curated by ${ownerBy}</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>` : ''}`,
ddiff --git a/server/video/playlists.js b/server/video/playlists.js
iindex fb4b384..4de3b90 100644
---- a/server/video/playlists.js
++++ b/server/video/playlists.js
@@@ -6,11 +6,12 @@
* 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 POST enforce the same numbers: 100 playlists per owner, 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.
++ * A playlist belongs to a person (owner user:usr_…) or, through the API, to an app, agent or service
++ * (owner app:app_… / agent:agt_… / service:<slug>). 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;
@@@ -60,9 +61,49 @@ function createPlaylists({ s, config, log = console }) {
WHERE p.owner = $1
ORDER BY p.updated_at DESC, p.id DESC`, [owner]);
++ /**
++ * A person's public playlists, newest first, paged by an opaque keyset cursor over (updated_at, id). Unlisted
++ * and private playlists never appear here. `limit` is clamped to 1-50 by the caller and again here.
++ */
++ async function publicByOwner(owner, { limit, cursor = null } = {}) {
++ const n = Math.min(50, Math.max(1, Number(limit) || 20));
++ let after = null;
++ if (cursor) {
++ try {
++ const parsed = JSON.parse(Buffer.from(String(cursor), 'base64url').toString('utf8'));
++ if (parsed && typeof parsed.updated_at === 'string' && typeof parsed.id === 'string' && PLAYLIST_ID_RE.test(parsed.id)) after = parsed;
++ } catch { after = null; }
++ }
++ const params = [owner];
++ let where = "p.owner = $1 AND p.visibility = 'public'";
++ if (after) {
++ params.push(after.updated_at, after.id);
++ where += ` AND (p.updated_at, p.id) < ($${params.length - 1}, $${params.length})`;
++ }
++ const rows = await 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 ${where}
++ ORDER BY p.updated_at DESC, p.id DESC
++ LIMIT ${n + 1}`, params);
++ const hasMore = rows.length > n;
++ const page = hasMore ? rows.slice(0, n) : rows;
++ const last = page.length ? page[page.length - 1] : null;
++ const next = hasMore && last ? Buffer.from(JSON.stringify({ updated_at: last.updated_at, id: last.id })).toString('base64url') : null;
++ return { playlists: page, next };
++ }
++
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' }) {
++ /** A person's public name on their playlists follows their account: playlists made before the column existed, or
++ * before a rename, get the current username when the person next opens their playlists. */
++ async function refreshOwnerUsername(owner, username) {
++ if (!String(owner || '').startsWith('user:') || !username) return;
++ await s.db.query('UPDATE video_playlists SET owner_username = $2 WHERE owner = $1 AND owner_username IS DISTINCT FROM $2',
++ [owner, String(username).slice(0, 64)]);
++ }
++
++ async function create(owner, { title, description = '', visibility = 'private', ownerUsername = null, projectId = null }) {
const t = cleanTitle(title);
const terr = titleError(t);
if (terr) return { ok: false, code: 'playlist.invalid_title', detail: terr };
@@@ -74,11 +115,16 @@ function createPlaylists({ s, config, log = console }) {
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 };
++ const row = {
++ id: s.newId('vpl'), owner, title: t, description: d, visibility,
++ owner_username: ownerUsername ? String(ownerUsername).slice(0, 64) : null,
++ project_id: projectId ? String(projectId) : null,
++ 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]);
++ `INSERT INTO video_playlists (id, owner, title, description, visibility, owner_username, project_id, created_at, updated_at)
++ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`,
++ [row.id, row.owner, row.title, row.description, row.visibility, row.owner_username, row.project_id, row.created_at, row.updated_at]);
return { ok: true, playlist: row };
}
@@@ -184,7 +230,7 @@ function createPlaylists({ s, config, log = console }) {
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,
++ list, publicByOwner, refreshOwnerUsername, get, own, create, update, remove, items, getItem, addItem, removeItem, reorder,
};
}
ddiff --git a/test/caller-limits.test.js b/test/caller-limits.test.js
iindex 1357555..ca36eca 100644
---- a/test/caller-limits.test.js
++++ b/test/caller-limits.test.js
@@@ -22,7 +22,8 @@ const READ = 'media-hub.api.read';
await check('BUDGETS names the product\'s own expensive routes, and nothing else', () => {
assert.deepStrictEqual(Object.keys(BUDGETS).sort(), [
-- 'media-hub.folder.create', 'media-hub.share.create',
++ 'media-hub.folder.create', 'media-hub.playlist.create', 'media-hub.playlist.write',
++ 'media-hub.share.create',
'media-hub.upload.complete', 'media-hub.upload.part', 'media-hub.upload.start',
]);
for (const [name, own] of Object.entries(BUDGETS)) {
ddiff --git a/test/video-playlist-api.test.js b/test/video-playlist-api.test.js
nnew file mode 100644
iindex 0000000..b01ecc3
---- /dev/null
++++ b/test/video-playlist-api.test.js
@@@ -0,0 +1,220 @@
++'use strict';
++/**
++ * OpenVibe.Video playlists as a real API (plan T10): Bearer person tokens, app/agent/service capability
++ * tokens (video.playlist.read / video.playlist.manage), anonymous public reads and the per-user public list.
++ * No Live is started: the API returns item ids (the watch pages resolve the cards lazily), and the one page
++ * checked here is an empty playlist.
++ */
++const assert = require('assert');
++const http = require('http');
++const { ids } = require('openvibe-contracts');
++const { boot, check, done } = require('./helpers/boot');
++
++function vreq(base, path, { host = 'openvibe.video', method = 'GET', cookie, bearer, json, headers = {}, same = true } = {}) {
++ return new Promise((resolve, reject) => {
++ const u = new URL(base + path);
++ const h = { Host: host, ...headers };
++ if (cookie) h.cookie = cookie;
++ if (bearer) h.authorization = `Bearer ${bearer}`;
++ if (same) h['sec-fetch-site'] = 'same-origin';
++ let data;
++ if (json !== undefined) {
++ data = Buffer.from(JSON.stringify(json));
++ h['content-length'] = data.length;
++ h['content-type'] = 'application/json';
++ }
++ 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(t, path, { method = 'GET', cookie, bearer, json, same = true } = {}) {
++ return vreq(t.base, path, { method, cookie, bearer, json, same });
++}
++
++/** Seed a developer app in the mock Network and mint an app token with the given capabilities. */
++function mintApp(t, caps) {
++ const appId = ids.newId('app');
++ const projectId = ids.newId('project');
++ t.network.state.projects.set(projectId, { id: projectId, name: 'project', environment_policy: 'production', allowance: caps, created_at: new Date().toISOString(), archived_at: null });
++ t.network.state.apps.set(appId, { id: appId, project_id: projectId, environment: 'production', name: 'app', redirect_uris: [], created_at: new Date().toISOString(), revoked_at: null });
++ return t.network.mintApp(appId, 'openvibe.download', caps);
++}
++
++(async () => {
++ const t = await boot({ env: { MEDIAHUB_VIDEO_URL: 'https://openvibe.video' } });
++ const kim = t.network.addUser('kim');
++ const sam = t.network.addUser('sam');
++ const kimBearer = t.network.userToken(kim);
++ const kimCookie = t.signIn(kim);
++
++ await check('a person Bearer token does playlist CRUD', async () => {
++ const created = await api(t, '/api/v1/video/playlists', { method: 'POST', bearer: kimBearer, json: { title: 'Bearer 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}$/);
++
++ const list = await api(t, '/api/v1/video/playlists', { method: 'GET', bearer: kimBearer });
++ assert.strictEqual(list.status, 200, list.text);
++ assert.ok(list.json().playlists.some((x) => x.id === pl.id));
++
++ const renamed = await api(t, `/api/v1/video/playlists/${pl.id}`, { method: 'PATCH', bearer: kimBearer, json: { title: 'Renamed by token' } });
++ assert.strictEqual(renamed.status, 200, renamed.text);
++ assert.strictEqual(renamed.json().playlist.title, 'Renamed by token');
++
++ const gone = await api(t, `/api/v1/video/playlists/${pl.id}`, { method: 'DELETE', bearer: kimBearer });
++ assert.strictEqual(gone.status, 204, gone.text);
++ });
++
++ await check('a session write from another origin is refused', async () => {
++ const r = await api(t, '/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('an app token without the capability is refused', async () => {
++ const token = mintApp(t, []);
++ const r = await api(t, '/api/v1/video/playlists', { method: 'POST', bearer: token, json: { title: 'nope' } });
++ assert.strictEqual(r.status, 403, r.text);
++ assert.strictEqual(r.json().code, 'capability.denied');
++ });
++
++ await check('an app with video.playlist.manage manages its own, and a person\'s is 404', async () => {
++ const token = mintApp(t, ['video.playlist.manage']);
++
++ const created = await api(t, '/api/v1/video/playlists', { method: 'POST', bearer: token, json: { title: 'App list', visibility: 'public' } });
++ assert.strictEqual(created.status, 201, created.text);
++ const pl = created.json().playlist;
++
++ const renamed = await api(t, `/api/v1/video/playlists/${pl.id}`, { method: 'PATCH', bearer: token, json: { title: 'App list edited' } });
++ assert.strictEqual(renamed.status, 200, renamed.text);
++ assert.strictEqual(renamed.json().playlist.title, 'App list edited');
++
++ // A person's playlist belongs to the person; an app is not its owner, so it answers 404 (not 403).
++ const persons = await api(t, '/api/v1/video/playlists', { method: 'POST', bearer: kimBearer, json: { title: 'Kim\'s own', visibility: 'private' } });
++ const pid = persons.json().playlist.id;
++ const touch = await api(t, `/api/v1/video/playlists/${pid}`, { method: 'PATCH', bearer: token, json: { title: 'steal' } });
++ assert.strictEqual(touch.status, 404, touch.text);
++ assert.strictEqual(touch.json().code, 'playlist.not_found');
++
++ const gone = await api(t, `/api/v1/video/playlists/${pl.id}`, { method: 'DELETE', bearer: token });
++ assert.strictEqual(gone.status, 204, gone.text);
++ });
++
++ await check('an app with only video.playlist.read cannot write', async () => {
++ const token = mintApp(t, ['video.playlist.read']);
++ const write = await api(t, '/api/v1/video/playlists', { method: 'POST', bearer: token, json: { title: 'readonly' } });
++ assert.strictEqual(write.status, 403, write.text);
++ assert.strictEqual(write.json().code, 'capability.denied');
++
++ const list = await api(t, '/api/v1/video/playlists', { method: 'GET', bearer: token });
++ assert.strictEqual(list.status, 200, list.text);
++ });
++
++ await check('anonymous reads public and unlisted by id, private answers 404', async () => {
++ const pub = await api(t, '/api/v1/video/playlists', { method: 'POST', bearer: kimBearer, json: { title: 'Public for the world', visibility: 'public' } });
++ const pubId = pub.json().playlist.id;
++ await api(t, `/api/v1/video/playlists/${pubId}/items`, { method: 'POST', bearer: kimBearer, json: { kind: 'vod', id: 101 } });
++
++ const unlisted = await api(t, '/api/v1/video/playlists', { method: 'POST', bearer: kimBearer, json: { title: 'Unlisted by link', visibility: 'unlisted' } });
++ const unlistedId = unlisted.json().playlist.id;
++
++ const priv = await api(t, '/api/v1/video/playlists', { method: 'POST', bearer: kimBearer, json: { title: 'Private', visibility: 'private' } });
++ const privId = priv.json().playlist.id;
++
++ const publicRead = await api(t, `/api/v1/video/playlists/${pubId}`);
++ assert.strictEqual(publicRead.status, 200, publicRead.text);
++ const publicBody = publicRead.json();
++ assert.strictEqual(publicBody.playlist.visibility, 'public');
++ assert.strictEqual(publicBody.playlist.owner.kind, 'user');
++ assert.strictEqual(publicBody.playlist.owner.username, 'kim');
++ assert.deepStrictEqual(publicBody.items.map((x) => [x.kind, x.id, x.position]), [['vod', 101, 0]]);
++
++ const unlistedRead = await api(t, `/api/v1/video/playlists/${unlistedId}`);
++ assert.strictEqual(unlistedRead.status, 200, unlistedRead.text);
++
++ const privateRead = await api(t, `/api/v1/video/playlists/${privId}`);
++ assert.strictEqual(privateRead.status, 404, privateRead.text);
++ assert.strictEqual(privateRead.json().code, 'playlist.not_found');
++
++ // An app without video.playlist.read is refused a public read, but a private one still answers 404 (never 403).
++ const noCap = mintApp(t, []);
++ const appPublic = await api(t, `/api/v1/video/playlists/${pubId}`, { bearer: noCap });
++ assert.strictEqual(appPublic.status, 403, appPublic.text);
++ assert.strictEqual(appPublic.json().code, 'capability.denied');
++ const appPrivate = await api(t, `/api/v1/video/playlists/${privId}`, { bearer: noCap });
++ assert.strictEqual(appPrivate.status, 404, appPrivate.text);
++ assert.strictEqual(appPrivate.json().code, 'playlist.not_found');
++ });
++
++ await check('the per-user public list is paginated and never lists unlisted/private', async () => {
++ const pager = t.network.addUser('pager');
++ const pagerBearer = t.network.userToken(pager);
++ for (const title of ['public-a', 'public-b', 'public-c']) {
++ const r = await api(t, '/api/v1/video/playlists', { method: 'POST', bearer: pagerBearer, json: { title, visibility: 'public' } });
++ assert.strictEqual(r.status, 201, r.text);
++ }
++ await api(t, '/api/v1/video/playlists', { method: 'POST', bearer: pagerBearer, json: { title: 'hidden-unlisted', visibility: 'unlisted' } });
++ await api(t, '/api/v1/video/playlists', { method: 'POST', bearer: pagerBearer, json: { title: 'hidden-private', visibility: 'private' } });
++
++ const page1 = await api(t, '/api/v1/video/users/pager/playlists?limit=2');
++ assert.strictEqual(page1.status, 200, page1.text);
++ const body1 = page1.json();
++ assert.strictEqual(body1.playlists.length, 2);
++ assert.ok(body1.next_cursor, 'a second page exists');
++ assert.deepStrictEqual(body1.playlists.map((x) => x.title), ['public-c', 'public-b']);
++ assert.ok(!body1.playlists.some((x) => x.title.startsWith('hidden-')), 'unlisted/private never listed');
++
++ const page2 = await api(t, `/api/v1/video/users/pager/playlists?limit=2&cursor=${encodeURIComponent(body1.next_cursor)}`);
++ assert.strictEqual(page2.status, 200, page2.text);
++ const body2 = page2.json();
++ assert.deepStrictEqual(body2.playlists.map((x) => x.title), ['public-a']);
++ assert.strictEqual(body2.next_cursor, null);
++
++ const unknown = await api(t, '/api/v1/video/users/sam/playlists');
++ assert.strictEqual(unknown.status, 200, unknown.text);
++ assert.strictEqual(unknown.json().playlists.length, 0);
++ });
++
++ await check('an app-owned public playlist page says Curated by an app', async () => {
++ const token = mintApp(t, ['video.playlist.manage']);
++ const created = await api(t, '/api/v1/video/playlists', { method: 'POST', bearer: token, json: { title: 'App public list', visibility: 'public' } });
++ const id = created.json().playlist.id;
++
++ const page = await vreq(t.base, `/playlists/${id}`);
++ assert.strictEqual(page.status, 200, page.text);
++ assert.ok(page.text.includes('Curated by an app'), page.text.slice(0, 600));
++ assert.ok(!page.text.includes('href="/@'), 'no username link for an app owner');
++
++ // A person-owned public playlist attributes the person with a username link.
++ const persons = await api(t, '/api/v1/video/playlists', { method: 'POST', bearer: kimBearer, json: { title: 'Kim public', visibility: 'public' } });
++ const personId = persons.json().playlist.id;
++ const personPage = await vreq(t.base, `/playlists/${personId}`);
++ assert.ok(personPage.text.includes('@kim'), personPage.text.slice(0, 600));
++ assert.ok(personPage.text.includes('href="/@kim"'), 'a person owner links to their channel');
++ });
++
++ await check('a playlist with no stored username gets the owner\'s when they open their playlists', async () => {
++ const made = await api(t, '/api/v1/video/playlists', { method: 'POST', bearer: kimBearer, json: { title: 'Older list', visibility: 'public' } });
++ const id = made.json().playlist.id;
++ await t.ctx.s.db.query('UPDATE video_playlists SET owner_username = NULL WHERE id = $1', [id]); // as made before the column
++ let pg = await vreq(t.base, `/playlists/${id}`);
++ assert.ok(pg.text.includes('Curated by an OpenVibe member'), pg.text.slice(0, 600));
++ const mine = await vreq(t.base, '/playlists', { cookie: kimCookie });
++ assert.strictEqual(mine.status, 200);
++ pg = await vreq(t.base, `/playlists/${id}`);
++ assert.ok(pg.text.includes('href="/@kim"'), 'the owner\'s visit stored their username');
++ });
++
++ await t.close();
++ done();
++})().catch((e) => { console.error(e); process.exit(1); });
ddiff --git a/test/video-playlists.test.js b/test/video-playlists.test.js
iindex d44e49d..79cfbc7 100644
---- a/test/video-playlists.test.js
++++ b/test/video-playlists.test.js
@@@ -48,6 +48,8 @@ function startLive() {
if (clipDetail[1] === '201') return json(200, { clip: publicClip });
return json(404, { error: 'not found' });
}
++ if (url.pathname === '/api/vods') return json(200, { vods: [publicVod], hasMore: false });
++ if (url.pathname === '/api/clips') return json(200, { clips: [] });
return json(404, { error: 'not found' });
});
return new Promise((resolve) => {
@@@ -186,6 +188,26 @@ const videoEnv = (live) => ({
assert.ok(u.text.includes('Unlisted list'), 'the title renders');
});
++ await check('a channel page lists that person\'s public playlists only', async () => {
++ const mk = async (title, visibility) => {
++ const r = await api(t.base, '/playlists', { method: 'POST', cookie: kimCookie, form: { title, visibility } });
++ assert.strictEqual(r.status, 303, r.text);
++ return r.headers.location.split('/').pop();
++ };
++ const pub = await mk('Kim channel public', 'public');
++ await mk('Kim channel unlisted', 'unlisted');
++ await mk('Kim channel private', 'private');
++ const page = await vreq(t.base, '/@kim');
++ assert.strictEqual(page.status, 200, page.text.slice(0, 300));
++ assert.ok(page.text.includes('id="h-lists"'), 'a Playlists section');
++ assert.ok(page.text.includes(`href="/playlists/${pub}"`) && page.text.includes('Kim channel public'), 'the public playlist');
++ assert.ok(!page.text.includes('Kim channel unlisted') && !page.text.includes('Kim channel private'), 'never unlisted or private');
++ assert.ok(!page.text.includes('ovv-pl-edit'), 'no owner controls on a channel page');
++ const other = await vreq(t.base, '/@sam');
++ assert.strictEqual(other.status, 200);
++ assert.ok(!other.text.includes('id="h-lists"'), 'no section without public playlists');
++ });
++
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);