openvibers/openvibe.mediahub Commits
The watch address, everywhere #316 left a numbered one (#317)
Changes
@@@ -13,11 +13,13 @@ module.exports = {policy: {auth: ['none', 'Network OIDC'],rateLimit: 'The public watch pages are not rate-limited by the app; the playlist API is limited to 120 reads a minute and 3,000 an hour per caller',-- // What an id in a watch page's address is, and that it never changes meaning (AGT-4). The ids are OpenVibe.Media's:-- // its vods and clips tables number them with a PostgreSQL identity column (migrations/0001_initial.sql).++ // What an id in a watch page's address is, and that it never changes meaning (AGT-4). A page is addressed by++ // its watch id, minted once by OpenVibe.Media for a recording or a clip (and copied onto an upload), never by++ // the number behind it; the numbered addresses (/v/{id}, /c/{id}, /u/{id}) answer 301 to the watch page.stableIds: [-- { kind: 'recording', url: 'https://openvibe.video/v/{id}', id: 'OpenVibe.Media\'s recording id, a positive integer from an identity column: never reused or renumbered, even after the recording is deleted', gone: 'a deleted, private or missing recording answers 404, never another recording' },-- { kind: 'clip', url: 'https://openvibe.video/c/{id}', id: 'OpenVibe.Media\'s clip id, the same kind of integer: never reused or renumbered', gone: 'a deleted, private or missing clip answers 404, never another clip' },++ { kind: 'recording', url: 'https://openvibe.video/watch?v={id}', id: 'OpenVibe.Media\'s watch id for the recording, 11 characters of URL-safe base64, never reused and never derived from the number behind it', gone: 'a deleted, private or missing recording answers 404, never another recording' },++ { kind: 'clip', url: 'https://openvibe.video/watch?v={id}', id: 'OpenVibe.Media\'s watch id for the clip, the same kind of id as a recording\'s, never reused', gone: 'a deleted, private or missing clip answers 404, never another clip' },++ { kind: 'upload', url: 'https://openvibe.video/watch?v={id}', id: 'OpenVibe.Video\'s watch id for an upload or a browser recording, 11 characters of URL-safe base64, never reused', gone: 'a deleted, private or missing upload answers 404, never another video' },],},};@@@ -51,7 +51,7 @@ function createEventsOutbox({ s, config, log = console, fetchImpl = null }) {return await out.emit({event_type: 'mediahub.video.published', version: 1,actor: { type: 'user', id: owner }, subject: { type: 'video', id: row.id }, visibility: 'public',-- payload: { upload: row.id, owner: { type: 'user', id: owner }, title: String(row.title || 'Video').slice(0, 120), url: `${videoUrl}/u/${row.id}`, published_at: publishedAt },++ payload: { upload: row.id, owner: { type: 'user', id: owner }, title: String(row.title || 'Video').slice(0, 120), url: row.watch_id ? `${videoUrl}/watch?v=${row.watch_id}` : `${videoUrl}/u/${row.id}`, published_at: publishedAt },});},start: () => out.start(),@@@ -164,9 +164,9 @@ function describeRef(db, config) {return { title: row.title || 'An album', link: `${base}/a/${row.slug}`, roles: ['viewer'], audiences: AUDIENCES, transferable: true };}if (ref.kind === 'video') {-- const row = await db.maybe('SELECT id, title FROM video_uploads WHERE id = $1', [ref.id]);++ const row = await db.maybe('SELECT id, watch_id, title FROM video_uploads WHERE id = $1', [ref.id]);if (!row) return {};-- return { title: row.title || 'A video', link: `${base}/u/${row.id}`, roles: ['viewer'], audiences: AUDIENCES, transferable: true };++ return { title: row.title || 'A video', link: `${base}${row.watch_id ? `/watch?v=${row.watch_id}` : `/u/${row.id}`}`, roles: ['viewer'], audiences: AUDIENCES, transferable: true };}return {};};@@@ -569,7 +569,7 @@ ${meta([channel, o.extra || null, o.views ? viewsLabel(o.views) : null, o.date ?}function feedCard(item, { views = false } = {}) {-- const href = item.kind === 'clip' ? `/c/${item.id}` : `/v/${item.id}`;++ const href = watchHref(item.kind, item.id, item.watch_id);return card({href,kind: item.kind,@@@ -56,8 +56,8 @@ function picsClient(t) {await check('an upload\'s first time public and ready writes mediahub.video.published once', async () => {const up = t0.ctx.videoUploads;-- const ins = (id, visibility) => s.db.exec(`INSERT INTO video_uploads (id, owner, owner_username, title, description, visibility, media_vod_id, content_type, size_bytes, part_size, parts_expected, parts_received, status, state, created_at, updated_at, ready_at)-- VALUES ($1, $2, 'kim', 'A walk on the coast', '', $3, 1, 'video/mp4', 10, 10, 1, 1, 'ready', 'active', $4, $4, $4)`, [id, `user:${kim.subject}`, visibility, s.iso()]);++ const ins = (id, visibility) => s.db.exec(`INSERT INTO video_uploads (id, watch_id, owner, owner_username, title, description, visibility, media_vod_id, content_type, size_bytes, part_size, parts_expected, parts_received, status, state, created_at, updated_at, ready_at)++ VALUES ($1, 'watchAAAAAA', $2, 'kim', 'A walk on the coast', '', $3, 1, 'video/mp4', 10, 10, 1, 1, 'ready', 'active', $4, $4, $4)`, [id, `user:${kim.subject}`, visibility, s.iso()]);const A = 'vup_01J0000000000000000000000A';await ins(A, 'private');let row = await up.update(await up.byId(A), { title: 'A walk on the coast' });@@@ -66,7 +66,7 @@ function picsClient(t) {let ev = await rows('mediahub.video.published');assert.strictEqual(ev.length, 1);assert.ok(row.published_at);-- assert.deepStrictEqual([ev[0].visibility, ev[0].payload.url, ev[0].payload.upload], ['public', `https://openvibe.video/u/${A}`, A]);++ assert.deepStrictEqual([ev[0].visibility, ev[0].payload.url, ev[0].payload.upload], ['public', 'https://openvibe.video/watch?v=watchAAAAAA', A]);assert.ok(validate('mediahub.video.published@1', ev[0].payload).valid);row = await up.update(row, { visibility: 'private' });await up.update(row, { visibility: 'public' });@@@ -76,7 +76,11 @@ function vreq(base, path, { method = 'GET', cookie, host = 'openvibe.video', accassert.strictEqual(d.sitemap, 'https://openvibe.video/sitemap.xml');assert.deepStrictEqual(d.policy.auth, ['none', 'Network OIDC']);// AGT-4: the id rule of the watch pages, as the pages behave (a private or missing id is a 404 below).-- assert.deepStrictEqual(d.policy.stableIds.map((x) => [x.kind, x.url]), [['recording', 'https://openvibe.video/v/{id}'], ['clip', 'https://openvibe.video/c/{id}']]);++ assert.deepStrictEqual(d.policy.stableIds.map((x) => [x.kind, x.url]), [++ ['recording', 'https://openvibe.video/watch?v={id}'],++ ['clip', 'https://openvibe.video/watch?v={id}'],++ ['upload', 'https://openvibe.video/watch?v={id}'],++ ], 'one watch address for all three kinds of video');assert.ok(d.policy.stableIds.every((x) => /never reused/.test(x.id) && /404/.test(x.gone)));const drive = await vreq(t.base, '/.well-known/agent.json', { host: 'openvibe.download' });assert.strictEqual(drive.status, 200, 'the drive answers with its own manifest');@@@ -142,6 +142,17 @@ function request(base, path, host) {assert.ok(!r.text.includes('/v/101<'), 'no numbered recording page');});++ await check('cards built from the feed, and the sharing sheet, name the watch address', async () => {++ const home = await getVideo('/');++ assert.strictEqual(home.status, 200);++ assert.ok(home.text.includes(`href="/watch?v=${VOD_WATCH}"`), 'a recording card links to its watch page');++ assert.ok(home.text.includes(`href="/watch?v=${CLIP_WATCH}"`), 'a clip card too');++ // The sharing sheet's describe(): the upload's title and its watch link.++ const { describeRef } = require('../server/sharing');++ const described = await describeRef(db, { baseUrl: 'https://openvibe.video' })({ service: 'mediahub', kind: 'video', id: ONE });++ assert.strictEqual(described.link, `https://openvibe.video/watch?v=${UPLOAD_ONE}`);++ });++await check('the Markdown twin of a watch page is that page, canonically at /watch?v=<id>', async () => {const r = await getVideo(`/watch?v=${UPLOAD_ONE}&format=markdown`);assert.strictEqual(r.status, 200, r.text.slice(0, 120));
@@ -13,11 +13,13 @@ module.exports = {
@@ -13,11 +13,13 @@ module.exports = {
policy: {
policy: {
auth: ['none', 'Network OIDC'],
auth: ['none', 'Network OIDC'],
rateLimit: 'The public watch pages are not rate-limited by the app; the playlist API is limited to 120 reads a minute and 3,000 an hour per caller',
rateLimit: 'The public watch pages are not rate-limited by the app; the playlist API is limited to 120 reads a minute and 3,000 an hour per caller',
- // What an id in a watch page's address is, and that it never changes meaning (AGT-4). The ids are OpenVibe.Media's:
- // its vods and clips tables number them with a PostgreSQL identity column (migrations/0001_initial.sql).
+ // What an id in a watch page's address is, and that it never changes meaning (AGT-4). A page is addressed by
+ // its watch id, minted once by OpenVibe.Media for a recording or a clip (and copied onto an upload), never by
+ // the number behind it; the numbered addresses (/v/{id}, /c/{id}, /u/{id}) answer 301 to the watch page.
stableIds: [
stableIds: [
- { kind: 'recording', url: 'https://openvibe.video/v/{id}', id: 'OpenVibe.Media\'s recording id, a positive integer from an identity column: never reused or renumbered, even after the recording is deleted', gone: 'a deleted, private or missing recording answers 404, never another recording' },
- { kind: 'clip', url: 'https://openvibe.video/c/{id}', id: 'OpenVibe.Media\'s clip id, the same kind of integer: never reused or renumbered', gone: 'a deleted, private or missing clip answers 404, never another clip' },
+ { kind: 'recording', url: 'https://openvibe.video/watch?v={id}', id: 'OpenVibe.Media\'s watch id for the recording, 11 characters of URL-safe base64, never reused and never derived from the number behind it', gone: 'a deleted, private or missing recording answers 404, never another recording' },
+ { kind: 'clip', url: 'https://openvibe.video/watch?v={id}', id: 'OpenVibe.Media\'s watch id for the clip, the same kind of id as a recording\'s, never reused', gone: 'a deleted, private or missing clip answers 404, never another clip' },
+ { kind: 'upload', url: 'https://openvibe.video/watch?v={id}', id: 'OpenVibe.Video\'s watch id for an upload or a browser recording, 11 characters of URL-safe base64, never reused', gone: 'a deleted, private or missing upload answers 404, never another video' },
],
],
},
},
};
};
@@ -51,7 +51,7 @@ function createEventsOutbox({ s, config, log = console, fetchImpl = null }) {
@@ -51,7 +51,7 @@ function createEventsOutbox({ s, config, log = console, fetchImpl = null }) {
return await out.emit({
return await out.emit({
event_type: 'mediahub.video.published', version: 1,
event_type: 'mediahub.video.published', version: 1,
actor: { type: 'user', id: owner }, subject: { type: 'video', id: row.id }, visibility: 'public',
actor: { type: 'user', id: owner }, subject: { type: 'video', id: row.id }, visibility: 'public',
- payload: { upload: row.id, owner: { type: 'user', id: owner }, title: String(row.title || 'Video').slice(0, 120), url: `${videoUrl}/u/${row.id}`, published_at: publishedAt },
+ payload: { upload: row.id, owner: { type: 'user', id: owner }, title: String(row.title || 'Video').slice(0, 120), url: row.watch_id ? `${videoUrl}/watch?v=${row.watch_id}` : `${videoUrl}/u/${row.id}`, published_at: publishedAt },
});
});
},
},
start: () => out.start(),
start: () => out.start(),
@@ -164,9 +164,9 @@ function describeRef(db, config) {
@@ -164,9 +164,9 @@ function describeRef(db, config) {
return { title: row.title || 'An album', link: `${base}/a/${row.slug}`, roles: ['viewer'], audiences: AUDIENCES, transferable: true };
return { title: row.title || 'An album', link: `${base}/a/${row.slug}`, roles: ['viewer'], audiences: AUDIENCES, transferable: true };
}
}
if (ref.kind === 'video') {
if (ref.kind === 'video') {
- const row = await db.maybe('SELECT id, title FROM video_uploads WHERE id = $1', [ref.id]);
+ const row = await db.maybe('SELECT id, watch_id, title FROM video_uploads WHERE id = $1', [ref.id]);
if (!row) return {};
if (!row) return {};
- return { title: row.title || 'A video', link: `${base}/u/${row.id}`, roles: ['viewer'], audiences: AUDIENCES, transferable: true };
+ return { title: row.title || 'A video', link: `${base}${row.watch_id ? `/watch?v=${row.watch_id}` : `/u/${row.id}`}`, roles: ['viewer'], audiences: AUDIENCES, transferable: true };
}
}
return {};
return {};
};
};
@@ -569,7 +569,7 @@ ${meta([channel, o.extra || null, o.views ? viewsLabel(o.views) : null, o.date ?
@@ -569,7 +569,7 @@ ${meta([channel, o.extra || null, o.views ? viewsLabel(o.views) : null, o.date ?
}
}
function feedCard(item, { views = false } = {}) {
function feedCard(item, { views = false } = {}) {
- const href = item.kind === 'clip' ? `/c/${item.id}` : `/v/${item.id}`;
+ const href = watchHref(item.kind, item.id, item.watch_id);
return card({
return card({
href,
href,
kind: item.kind,
kind: item.kind,
@@ -56,8 +56,8 @@ function picsClient(t) {
@@ -56,8 +56,8 @@ function picsClient(t) {
await check('an upload\'s first time public and ready writes mediahub.video.published once', async () => {
await check('an upload\'s first time public and ready writes mediahub.video.published once', async () => {
const up = t0.ctx.videoUploads;
const up = t0.ctx.videoUploads;
- const ins = (id, visibility) => s.db.exec(`INSERT INTO video_uploads (id, owner, owner_username, title, description, visibility, media_vod_id, content_type, size_bytes, part_size, parts_expected, parts_received, status, state, created_at, updated_at, ready_at)
- VALUES ($1, $2, 'kim', 'A walk on the coast', '', $3, 1, 'video/mp4', 10, 10, 1, 1, 'ready', 'active', $4, $4, $4)`, [id, `user:${kim.subject}`, visibility, s.iso()]);
+ const ins = (id, visibility) => s.db.exec(`INSERT INTO video_uploads (id, watch_id, owner, owner_username, title, description, visibility, media_vod_id, content_type, size_bytes, part_size, parts_expected, parts_received, status, state, created_at, updated_at, ready_at)
+ VALUES ($1, 'watchAAAAAA', $2, 'kim', 'A walk on the coast', '', $3, 1, 'video/mp4', 10, 10, 1, 1, 'ready', 'active', $4, $4, $4)`, [id, `user:${kim.subject}`, visibility, s.iso()]);
const A = 'vup_01J0000000000000000000000A';
const A = 'vup_01J0000000000000000000000A';
await ins(A, 'private');
await ins(A, 'private');
let row = await up.update(await up.byId(A), { title: 'A walk on the coast' });
let row = await up.update(await up.byId(A), { title: 'A walk on the coast' });
@@ -66,7 +66,7 @@ function picsClient(t) {
@@ -66,7 +66,7 @@ function picsClient(t) {
let ev = await rows('mediahub.video.published');
let ev = await rows('mediahub.video.published');
assert.strictEqual(ev.length, 1);
assert.strictEqual(ev.length, 1);
assert.ok(row.published_at);
assert.ok(row.published_at);
- assert.deepStrictEqual([ev[0].visibility, ev[0].payload.url, ev[0].payload.upload], ['public', `https://openvibe.video/u/${A}`, A]);
+ assert.deepStrictEqual([ev[0].visibility, ev[0].payload.url, ev[0].payload.upload], ['public', 'https://openvibe.video/watch?v=watchAAAAAA', A]);
assert.ok(validate('mediahub.video.published@1', ev[0].payload).valid);
assert.ok(validate('mediahub.video.published@1', ev[0].payload).valid);
row = await up.update(row, { visibility: 'private' });
row = await up.update(row, { visibility: 'private' });
await up.update(row, { visibility: 'public' });
await up.update(row, { visibility: 'public' });
@@ -76,7 +76,11 @@ function vreq(base, path, { method = 'GET', cookie, host = 'openvibe.video', acc
@@ -76,7 +76,11 @@ function vreq(base, path, { method = 'GET', cookie, host = 'openvibe.video', acc
assert.strictEqual(d.sitemap, 'https://openvibe.video/sitemap.xml');
assert.strictEqual(d.sitemap, 'https://openvibe.video/sitemap.xml');
assert.deepStrictEqual(d.policy.auth, ['none', 'Network OIDC']);
assert.deepStrictEqual(d.policy.auth, ['none', 'Network OIDC']);
// AGT-4: the id rule of the watch pages, as the pages behave (a private or missing id is a 404 below).
// AGT-4: the id rule of the watch pages, as the pages behave (a private or missing id is a 404 below).
- assert.deepStrictEqual(d.policy.stableIds.map((x) => [x.kind, x.url]), [['recording', 'https://openvibe.video/v/{id}'], ['clip', 'https://openvibe.video/c/{id}']]);
+ assert.deepStrictEqual(d.policy.stableIds.map((x) => [x.kind, x.url]), [
+ ['recording', 'https://openvibe.video/watch?v={id}'],
+ ['clip', 'https://openvibe.video/watch?v={id}'],
+ ['upload', 'https://openvibe.video/watch?v={id}'],
+ ], 'one watch address for all three kinds of video');
assert.ok(d.policy.stableIds.every((x) => /never reused/.test(x.id) && /404/.test(x.gone)));
assert.ok(d.policy.stableIds.every((x) => /never reused/.test(x.id) && /404/.test(x.gone)));
const drive = await vreq(t.base, '/.well-known/agent.json', { host: 'openvibe.download' });
const drive = await vreq(t.base, '/.well-known/agent.json', { host: 'openvibe.download' });
assert.strictEqual(drive.status, 200, 'the drive answers with its own manifest');
assert.strictEqual(drive.status, 200, 'the drive answers with its own manifest');
@@ -142,6 +142,17 @@ function request(base, path, host) {
@@ -142,6 +142,17 @@ function request(base, path, host) {
assert.ok(!r.text.includes('/v/101<'), 'no numbered recording page');
assert.ok(!r.text.includes('/v/101<'), 'no numbered recording page');
});
});
+ await check('cards built from the feed, and the sharing sheet, name the watch address', async () => {
+ const home = await getVideo('/');
+ assert.strictEqual(home.status, 200);
+ assert.ok(home.text.includes(`href="/watch?v=${VOD_WATCH}"`), 'a recording card links to its watch page');
+ assert.ok(home.text.includes(`href="/watch?v=${CLIP_WATCH}"`), 'a clip card too');
+ // The sharing sheet's describe(): the upload's title and its watch link.
+ const { describeRef } = require('../server/sharing');
+ const described = await describeRef(db, { baseUrl: 'https://openvibe.video' })({ service: 'mediahub', kind: 'video', id: ONE });
+ assert.strictEqual(described.link, `https://openvibe.video/watch?v=${UPLOAD_ONE}`);
+ });
+
await check('the Markdown twin of a watch page is that page, canonically at /watch?v=<id>', async () => {
await check('the Markdown twin of a watch page is that page, canonically at /watch?v=<id>', async () => {
const r = await getVideo(`/watch?v=${UPLOAD_ONE}&format=markdown`);
const r = await getVideo(`/watch?v=${UPLOAD_ONE}&format=markdown`);
assert.strictEqual(r.status, 200, r.text.slice(0, 120));
assert.strictEqual(r.status, 200, r.text.slice(0, 120));