openvibers/openvibe.media Commits
Content labels on VODs and clips (L1) (#242)
Changes
nnew file mode 100644@@@ -0,0 +1,13 @@++-- phase: expand++-- Content labels (L1, labels and choices, 2026-10-31; Contracts 0.257.0 common.content-labels@1).++--++-- A creator labels a VOD or a clip; MediaHub writes it through PUT /api/v1/:app/vods/:id and /clips/:id, and every++-- read (the watch page, Live's cards, the app's own manager) carries the shape. The AI may suggest a label and it is++-- stored in `suggested`; a suggestion changes nothing until the creator confirms it. A viewer's Network label prefs++-- decide what to do with a label; the label itself never hides anything.++--++-- Expand only: an older release never reads these columns, and the defaults keep every existing row label-free.++ALTER TABLE vods ADD COLUMN IF NOT EXISTS content_labels jsonb NOT NULL DEFAULT '{"labels": [], "suggested": []}'::jsonb;++ALTER TABLE clips ADD COLUMN IF NOT EXISTS content_labels jsonb NOT NULL DEFAULT '{"labels": [], "suggested": []}'::jsonb;++CREATE INDEX IF NOT EXISTS vods_content_labels ON vods USING gin (content_labels);++CREATE INDEX IF NOT EXISTS clips_content_labels ON clips USING gin (content_labels);nnew file mode 100644@@@ -0,0 +1,41 @@++'use strict';++/**++ * Content labels (L1, labels and choices, 2026-10-31; Contracts 0.257.0 common.content-labels@1).++ *++ * The one label list and the shape a VOD or clip stores and returns: `{ labels, suggested }`. A creator sets a++ * label; the AI may suggest one, and a suggestion never applies itself. A label is not a ban: Media never hides a++ * row because of it, and the viewer's Network prefs decide what they see.++ */++const LABELS = Object.freeze(['nudity', 'language', 'graphic', '18+', 'flashing', 'spoilers']);++++const isLabel = (x) => typeof x === 'string' && LABELS.includes(x);++++/** A clean label list from a creator's request, or null when it is not a list of known labels. */++function parseLabels(value) {++ if (!Array.isArray(value)) return null;++ const out = [];++ for (const label of value) {++ if (!isLabel(label)) return null;++ if (!out.includes(label)) out.push(label);++ }++ return out;++}++++/** The contract's shape from a jsonb column, a JSON string, or nothing: always two arrays of known labels. */++function labelsOf(row) {++ let raw = row && typeof row === 'object' && 'content_labels' in row ? row.content_labels : row;++ if (typeof raw === 'string') { try { raw = JSON.parse(raw); } catch { raw = null; } }++ const pick = (v) => [...new Set((Array.isArray(v) ? v : []).filter(isLabel))];++ return { labels: pick(raw && raw.labels), suggested: pick(raw && raw.suggested) };++}++++/** The shape to store. */++const shape = (labels, suggested = []) => ({ labels: [...new Set((labels || []).filter(isLabel))], suggested: [...new Set((suggested || []).filter(isLabel))] });++++/** Apply a creator's labels and drop the ones they just confirmed from the suggestions. */++function confirmed(current, labels) {++ const now = labelsOf(current);++ return { labels: [...labels], suggested: now.suggested.filter((l) => !labels.includes(l)) };++}++++module.exports = { LABELS, isLabel, parseLabels, labelsOf, shape, confirmed };@@@ -24,6 +24,7 @@ const tools = require('./media-tools');const { tenantAuth, tenantCors } = require('../auth');const cache = require('openvibe-shared/cache-policy');const objects = require('../objects/model');++const labels = require('../labels');const router = express.Router({ mergeParams: true });router.use(tenantCors);@@@ -93,6 +94,8 @@ async function clipPublic(clip, { readiness = false, hls } = {}) {is_public: (clip.visibility || (clip.is_public ? 'public' : 'private')) === 'public',storage_provider: clip.storage_provider || 'local',auto_generated: !!clip.auto_generated,++ // L1 content labels: `{ labels, suggested }`, the shape common.content-labels@1. A suggestion is not applied.++ content_labels: labels.labelsOf(clip),ai_overview: clip.ai_overview || null,ai_analyzed_at: clip.ai_analyzed_at || null,view_count: clip.view_count || 0,@@@ -584,6 +587,9 @@ router.put('/:id', tenantAuth({ verb: 'write' }), async (req, res) => {const clip = await _getClipForWrite(req, res);if (!clip) return;const { title, description, visibility, auto_generated: autoGen } = req.body || {};++ // L1: the creator sets the labels (a confirmed suggestion leaves `suggested`); labels never hide the clip.++ const nextLabels = req.body && req.body.labels !== undefined ? labels.parseLabels(req.body.labels) : undefined;++ if (nextLabels === null) return res.status(400).json({ error: 'labels must be a list of known labels' });if (title !== undefined) {const t = sanitizeClipTitle(title, '');if (!t) return res.status(400).json({ error: 'Title must be 1-200 characters' });@@@ -600,6 +606,9 @@ router.put('/:id', tenantAuth({ verb: 'write' }), async (req, res) => {if (req.authType !== 'app') return res.status(403).json({ error: 'auto_generated is set by the app only' });await objects.withObject('clip', clip.id, async () => await db.run('UPDATE clips SET auto_generated = ? WHERE id = ?', [flag(autoGen), clip.id]));}++ if (nextLabels !== undefined) {++ await objects.withObject('clip', clip.id, async () => await db.run('UPDATE clips SET content_labels = ? WHERE id = ?', [JSON.stringify(labels.confirmed(clip.content_labels, nextLabels)), clip.id]));++ }if (visibility !== undefined) {await db.setClipVisibility(clip.id, visibility);// Made public (or already public): its watch page and the sitemap are indexable now.@@@ -19,6 +19,7 @@ const { spawn } = require('child_process');const db = require('../db/database');const config = require('../config');const tools = require('./media-tools');++const labels = require('../labels');const { announce } = require('../announce');const objects = () => require('../objects/model');@@@ -80,6 +81,8 @@ async function vodPublic(vod, { readiness = false, hls } = {}) {ai_analyzed_at: vod.ai_analyzed_at || null,view_count: vod.view_count || 0,created_at: vod.created_at,++ // L1 content labels: `{ labels, suggested }`, the shape common.content-labels@1. A suggestion is not applied.++ content_labels: labels.labelsOf(vod),meta: (() => { try { return JSON.parse(vod.meta_json || '{}'); } catch { return {}; } })(),};// The HLS master playlist of the VOD's object, when it has one (objects/hls.js): the same field and access rules as@@@ -27,6 +27,7 @@ const { finalizeVod, vodPublic } = require('./finalize');const { tenantAuth, tenantCors } = require('../auth');const cache = require('openvibe-shared/cache-policy');const objects = require('../objects/model');++const labels = require('../labels');const router = express.Router({ mergeParams: true });router.use(tenantCors);@@@ -369,10 +370,14 @@ router.put('/:id', tenantAuth({ verb: 'write' }), async (req, res) => {const vod = await _getVodForWrite(req, res);if (!vod) return;const { title, description, visibility } = req.body || {};++ // L1: the creator sets the labels (a suggestion they confirm leaves `suggested`); labels never hide the VOD.++ const nextLabels = req.body && req.body.labels !== undefined ? labels.parseLabels(req.body.labels) : undefined;++ if (nextLabels === null) return res.status(400).json({ error: 'labels must be a list of known labels' });const updates = [];const params = [];if (title !== undefined) { updates.push('title = ?'); params.push(String(title).slice(0, 300)); }if (description !== undefined) { updates.push('description = ?'); params.push(String(description)); }++ if (nextLabels !== undefined) { updates.push('content_labels = ?'); params.push(JSON.stringify(labels.confirmed(vod.content_labels, nextLabels))); }// Title, description and visibility, and the object, in one transaction.await objects.withObject('vod', vod.id, async () => {if (updates.length) {nnew file mode 100644@@@ -0,0 +1,89 @@++'use strict';++// Content labels on VODs and clips (L1, 2026-10-31; Contracts 0.257.0 common.content-labels@1): the creator sets++// `labels`, the AI's `suggested` stays a suggestion until confirmed, every read carries the shape, and a label never++// hides a row from Media (the viewer's Network prefs decide).++// node test/content-labels.test.js++const assert = require('assert');++const fs = require('fs');++const os = require('os');++const path = require('path');++const http = require('http');++++(async () => {++ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-media-labels-'));++ const dir = (n) => { const d = path.join(tmp, n); fs.mkdirSync(d, { recursive: true }); return d; };++ process.env.VOD_PATH = dir('vods');++ process.env.CLIPS_PATH = dir('clips');++ process.env.FILES_PATH = dir('files');++ process.env.OBJECTS_PATH = dir('objects');++ process.env.THUMBNAILS_PATH = dir('thumbnails');++ process.env.PASTES_PATH = dir('pastes');++ process.env.MEDIA_PUBLIC_URL = 'https://media.test';++ delete process.env.EVENTS_URL;++ console.log = () => {};++ console.warn = () => {};++++ const db = require('../server/db/database');++ await db.upsertApp({ app_id: 'live', api_key: 'live-key' });++ const raw = db.getDb();++ const file = path.join(process.env.VOD_PATH, 'vod-1.webm');++ fs.writeFileSync(file, Buffer.alloc(64, 1));++ await raw.prepare(`INSERT INTO vods (id, app_id, user_id, title, file_path, is_public, visibility, duration_seconds)++ OVERRIDING SYSTEM VALUE VALUES (1, 'live', 5, 'A VOD', ?, 1, 'public', 60)`).run(file);++ await raw.prepare(`INSERT INTO clips (id, app_id, vod_id, user_id, title, file_path, is_public, visibility, status)++ OVERRIDING SYSTEM VALUE VALUES (10, 'live', 1, 5, 'A clip', 'x.webm', 1, 'public', 'ready')`).run();++++ const express = require('express');++ const app = express();++ app.use(express.json());++ app.use('/api/v1/:app/vods', require('../server/vod/routes'));++ app.use('/api/v1/:app/clips', require('../server/vod/clips-routes'));++ const server = http.createServer(app);++++ try {++ await new Promise((r) => server.listen(0, '127.0.0.1', r));++ const base = `http://127.0.0.1:${server.address().port}/api/v1/live`;++ const call = async (method, p, body) => {++ const headers = { authorization: 'Bearer live-key' };++ if (body !== undefined) headers['content-type'] = 'application/json';++ const res = await fetch(base + p, { method, headers, body: body === undefined ? undefined : JSON.stringify(body) });++ return { status: res.status, json: await res.json().catch(() => null) };++ };++++ // Every read carries the shape, even for a row that never had a label.++ let r = await call('GET', '/vods/1');++ assert.deepStrictEqual(r.json.content_labels, { labels: [], suggested: [] }, 'a VOD is label-free by default');++ r = await call('GET', '/clips/10');++ assert.deepStrictEqual(r.json.content_labels, { labels: [], suggested: [] }, 'a clip is label-free by default');++++ // The creator sets labels; the AI's suggestion is stored but never applied.++ r = await call('PUT', '/vods/1', { labels: ['18+', 'language'] });++ assert.strictEqual(r.status, 200);++ assert.deepStrictEqual(r.json.vod.content_labels, { labels: ['18+', 'language'], suggested: [] }, 'the creator labels a VOD');++ await raw.prepare(`UPDATE vods SET content_labels = '{"labels":["18+"],"suggested":["nudity","language"]}' WHERE id = 1`).run();++ r = await call('GET', '/vods/1');++ assert.deepStrictEqual(r.json.content_labels, { labels: ['18+'], suggested: ['nudity', 'language'] }, 'a suggestion is not a label');++ // Confirming nudity applies it and clears that suggestion; the other stays.++ r = await call('PUT', '/vods/1', { labels: ['18+', 'nudity'] });++ assert.deepStrictEqual(r.json.vod.content_labels, { labels: ['18+', 'nudity'], suggested: ['language'] }, 'confirming clears only the confirmed suggestion');++++ // Clips take the same shape; unknown labels are refused and change nothing.++ r = await call('PUT', '/clips/10', { labels: ['spoilers', 'flashing'] });++ assert.strictEqual(r.status, 200);++ assert.deepStrictEqual(r.json.clip.content_labels, { labels: ['spoilers', 'flashing'], suggested: [] });++ assert.strictEqual((await call('PUT', '/clips/10', { labels: ['gore'] })).status, 400, 'an unknown label is refused');++ assert.strictEqual((await call('PUT', '/vods/1', { labels: 'nudity' })).status, 400, 'a string is not a label list');++ assert.deepStrictEqual((await call('GET', '/clips/10')).json.content_labels.labels, ['spoilers', 'flashing'], 'a refused write changed nothing');++++ // A label is not a filter: the VOD labelled 18+ is still in the list, with its shape.++ r = await call('GET', '/vods');++ const vod = r.json.vods.find((v) => v.id === 1);++ assert.ok(vod, 'the labelled VOD is still listed');++ assert.deepStrictEqual(vod.content_labels.labels, ['18+', 'nudity'], 'the list carries the labels');++ r = await call('GET', '/clips');++ assert.deepStrictEqual(r.json.clips.find((c) => c.id === 10).content_labels.labels, ['spoilers', 'flashing'], 'the clip list carries the labels');++ } finally {++ await new Promise((r) => server.close(r));++ }++ console.log('content labels: VODs and clips carry the shape; suggestions stay suggestions');++})().catch((err) => { console.error(err); process.exit(1); });
new file mode 100644
new file mode 100644
@@ -0,0 +1,13 @@
@@ -0,0 +1,13 @@
+-- phase: expand
+-- Content labels (L1, labels and choices, 2026-10-31; Contracts 0.257.0 common.content-labels@1).
+--
+-- A creator labels a VOD or a clip; MediaHub writes it through PUT /api/v1/:app/vods/:id and /clips/:id, and every
+-- read (the watch page, Live's cards, the app's own manager) carries the shape. The AI may suggest a label and it is
+-- stored in `suggested`; a suggestion changes nothing until the creator confirms it. A viewer's Network label prefs
+-- decide what to do with a label; the label itself never hides anything.
+--
+-- Expand only: an older release never reads these columns, and the defaults keep every existing row label-free.
+ALTER TABLE vods ADD COLUMN IF NOT EXISTS content_labels jsonb NOT NULL DEFAULT '{"labels": [], "suggested": []}'::jsonb;
+ALTER TABLE clips ADD COLUMN IF NOT EXISTS content_labels jsonb NOT NULL DEFAULT '{"labels": [], "suggested": []}'::jsonb;
+CREATE INDEX IF NOT EXISTS vods_content_labels ON vods USING gin (content_labels);
+CREATE INDEX IF NOT EXISTS clips_content_labels ON clips USING gin (content_labels);
new file mode 100644
new file mode 100644
@@ -0,0 +1,41 @@
@@ -0,0 +1,41 @@
+'use strict';
+/**
+ * Content labels (L1, labels and choices, 2026-10-31; Contracts 0.257.0 common.content-labels@1).
+ *
+ * The one label list and the shape a VOD or clip stores and returns: `{ labels, suggested }`. A creator sets a
+ * label; the AI may suggest one, and a suggestion never applies itself. A label is not a ban: Media never hides a
+ * row because of it, and the viewer's Network prefs decide what they see.
+ */
+const LABELS = Object.freeze(['nudity', 'language', 'graphic', '18+', 'flashing', 'spoilers']);
+
+const isLabel = (x) => typeof x === 'string' && LABELS.includes(x);
+
+/** A clean label list from a creator's request, or null when it is not a list of known labels. */
+function parseLabels(value) {
+ if (!Array.isArray(value)) return null;
+ const out = [];
+ for (const label of value) {
+ if (!isLabel(label)) return null;
+ if (!out.includes(label)) out.push(label);
+ }
+ return out;
+}
+
+/** The contract's shape from a jsonb column, a JSON string, or nothing: always two arrays of known labels. */
+function labelsOf(row) {
+ let raw = row && typeof row === 'object' && 'content_labels' in row ? row.content_labels : row;
+ if (typeof raw === 'string') { try { raw = JSON.parse(raw); } catch { raw = null; } }
+ const pick = (v) => [...new Set((Array.isArray(v) ? v : []).filter(isLabel))];
+ return { labels: pick(raw && raw.labels), suggested: pick(raw && raw.suggested) };
+}
+
+/** The shape to store. */
+const shape = (labels, suggested = []) => ({ labels: [...new Set((labels || []).filter(isLabel))], suggested: [...new Set((suggested || []).filter(isLabel))] });
+
+/** Apply a creator's labels and drop the ones they just confirmed from the suggestions. */
+function confirmed(current, labels) {
+ const now = labelsOf(current);
+ return { labels: [...labels], suggested: now.suggested.filter((l) => !labels.includes(l)) };
+}
+
+module.exports = { LABELS, isLabel, parseLabels, labelsOf, shape, confirmed };
@@ -24,6 +24,7 @@ const tools = require('./media-tools');
@@ -24,6 +24,7 @@ const tools = require('./media-tools');
const { tenantAuth, tenantCors } = require('../auth');
const { tenantAuth, tenantCors } = require('../auth');
const cache = require('openvibe-shared/cache-policy');
const cache = require('openvibe-shared/cache-policy');
const objects = require('../objects/model');
const objects = require('../objects/model');
+const labels = require('../labels');
const router = express.Router({ mergeParams: true });
const router = express.Router({ mergeParams: true });
router.use(tenantCors);
router.use(tenantCors);
@@ -93,6 +94,8 @@ async function clipPublic(clip, { readiness = false, hls } = {}) {
@@ -93,6 +94,8 @@ async function clipPublic(clip, { readiness = false, hls } = {}) {
is_public: (clip.visibility || (clip.is_public ? 'public' : 'private')) === 'public',
is_public: (clip.visibility || (clip.is_public ? 'public' : 'private')) === 'public',
storage_provider: clip.storage_provider || 'local',
storage_provider: clip.storage_provider || 'local',
auto_generated: !!clip.auto_generated,
auto_generated: !!clip.auto_generated,
+ // L1 content labels: `{ labels, suggested }`, the shape common.content-labels@1. A suggestion is not applied.
+ content_labels: labels.labelsOf(clip),
ai_overview: clip.ai_overview || null,
ai_overview: clip.ai_overview || null,
ai_analyzed_at: clip.ai_analyzed_at || null,
ai_analyzed_at: clip.ai_analyzed_at || null,
view_count: clip.view_count || 0,
view_count: clip.view_count || 0,
@@ -584,6 +587,9 @@ router.put('/:id', tenantAuth({ verb: 'write' }), async (req, res) => {
@@ -584,6 +587,9 @@ router.put('/:id', tenantAuth({ verb: 'write' }), async (req, res) => {
const clip = await _getClipForWrite(req, res);
const clip = await _getClipForWrite(req, res);
if (!clip) return;
if (!clip) return;
const { title, description, visibility, auto_generated: autoGen } = req.body || {};
const { title, description, visibility, auto_generated: autoGen } = req.body || {};
+ // L1: the creator sets the labels (a confirmed suggestion leaves `suggested`); labels never hide the clip.
+ const nextLabels = req.body && req.body.labels !== undefined ? labels.parseLabels(req.body.labels) : undefined;
+ if (nextLabels === null) return res.status(400).json({ error: 'labels must be a list of known labels' });
if (title !== undefined) {
if (title !== undefined) {
const t = sanitizeClipTitle(title, '');
const t = sanitizeClipTitle(title, '');
if (!t) return res.status(400).json({ error: 'Title must be 1-200 characters' });
if (!t) return res.status(400).json({ error: 'Title must be 1-200 characters' });
@@ -600,6 +606,9 @@ router.put('/:id', tenantAuth({ verb: 'write' }), async (req, res) => {
@@ -600,6 +606,9 @@ router.put('/:id', tenantAuth({ verb: 'write' }), async (req, res) => {
if (req.authType !== 'app') return res.status(403).json({ error: 'auto_generated is set by the app only' });
if (req.authType !== 'app') return res.status(403).json({ error: 'auto_generated is set by the app only' });
await objects.withObject('clip', clip.id, async () => await db.run('UPDATE clips SET auto_generated = ? WHERE id = ?', [flag(autoGen), clip.id]));
await objects.withObject('clip', clip.id, async () => await db.run('UPDATE clips SET auto_generated = ? WHERE id = ?', [flag(autoGen), clip.id]));
}
}
+ if (nextLabels !== undefined) {
+ await objects.withObject('clip', clip.id, async () => await db.run('UPDATE clips SET content_labels = ? WHERE id = ?', [JSON.stringify(labels.confirmed(clip.content_labels, nextLabels)), clip.id]));
+ }
if (visibility !== undefined) {
if (visibility !== undefined) {
await db.setClipVisibility(clip.id, visibility);
await db.setClipVisibility(clip.id, visibility);
// Made public (or already public): its watch page and the sitemap are indexable now.
// Made public (or already public): its watch page and the sitemap are indexable now.
@@ -19,6 +19,7 @@ const { spawn } = require('child_process');
@@ -19,6 +19,7 @@ const { spawn } = require('child_process');
const db = require('../db/database');
const db = require('../db/database');
const config = require('../config');
const config = require('../config');
const tools = require('./media-tools');
const tools = require('./media-tools');
+const labels = require('../labels');
const { announce } = require('../announce');
const { announce } = require('../announce');
const objects = () => require('../objects/model');
const objects = () => require('../objects/model');
@@ -80,6 +81,8 @@ async function vodPublic(vod, { readiness = false, hls } = {}) {
@@ -80,6 +81,8 @@ async function vodPublic(vod, { readiness = false, hls } = {}) {
ai_analyzed_at: vod.ai_analyzed_at || null,
ai_analyzed_at: vod.ai_analyzed_at || null,
view_count: vod.view_count || 0,
view_count: vod.view_count || 0,
created_at: vod.created_at,
created_at: vod.created_at,
+ // L1 content labels: `{ labels, suggested }`, the shape common.content-labels@1. A suggestion is not applied.
+ content_labels: labels.labelsOf(vod),
meta: (() => { try { return JSON.parse(vod.meta_json || '{}'); } catch { return {}; } })(),
meta: (() => { try { return JSON.parse(vod.meta_json || '{}'); } catch { return {}; } })(),
};
};
// The HLS master playlist of the VOD's object, when it has one (objects/hls.js): the same field and access rules as
// The HLS master playlist of the VOD's object, when it has one (objects/hls.js): the same field and access rules as
@@ -27,6 +27,7 @@ const { finalizeVod, vodPublic } = require('./finalize');
@@ -27,6 +27,7 @@ const { finalizeVod, vodPublic } = require('./finalize');
const { tenantAuth, tenantCors } = require('../auth');
const { tenantAuth, tenantCors } = require('../auth');
const cache = require('openvibe-shared/cache-policy');
const cache = require('openvibe-shared/cache-policy');
const objects = require('../objects/model');
const objects = require('../objects/model');
+const labels = require('../labels');
const router = express.Router({ mergeParams: true });
const router = express.Router({ mergeParams: true });
router.use(tenantCors);
router.use(tenantCors);
@@ -369,10 +370,14 @@ router.put('/:id', tenantAuth({ verb: 'write' }), async (req, res) => {
@@ -369,10 +370,14 @@ router.put('/:id', tenantAuth({ verb: 'write' }), async (req, res) => {
const vod = await _getVodForWrite(req, res);
const vod = await _getVodForWrite(req, res);
if (!vod) return;
if (!vod) return;
const { title, description, visibility } = req.body || {};
const { title, description, visibility } = req.body || {};
+ // L1: the creator sets the labels (a suggestion they confirm leaves `suggested`); labels never hide the VOD.
+ const nextLabels = req.body && req.body.labels !== undefined ? labels.parseLabels(req.body.labels) : undefined;
+ if (nextLabels === null) return res.status(400).json({ error: 'labels must be a list of known labels' });
const updates = [];
const updates = [];
const params = [];
const params = [];
if (title !== undefined) { updates.push('title = ?'); params.push(String(title).slice(0, 300)); }
if (title !== undefined) { updates.push('title = ?'); params.push(String(title).slice(0, 300)); }
if (description !== undefined) { updates.push('description = ?'); params.push(String(description)); }
if (description !== undefined) { updates.push('description = ?'); params.push(String(description)); }
+ if (nextLabels !== undefined) { updates.push('content_labels = ?'); params.push(JSON.stringify(labels.confirmed(vod.content_labels, nextLabels))); }
// Title, description and visibility, and the object, in one transaction.
// Title, description and visibility, and the object, in one transaction.
await objects.withObject('vod', vod.id, async () => {
await objects.withObject('vod', vod.id, async () => {
if (updates.length) {
if (updates.length) {
new file mode 100644
new file mode 100644
@@ -0,0 +1,89 @@
@@ -0,0 +1,89 @@
+'use strict';
+// Content labels on VODs and clips (L1, 2026-10-31; Contracts 0.257.0 common.content-labels@1): the creator sets
+// `labels`, the AI's `suggested` stays a suggestion until confirmed, every read carries the shape, and a label never
+// hides a row from Media (the viewer's Network prefs decide).
+// node test/content-labels.test.js
+const assert = require('assert');
+const fs = require('fs');
+const os = require('os');
+const path = require('path');
+const http = require('http');
+
+(async () => {
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-media-labels-'));
+ const dir = (n) => { const d = path.join(tmp, n); fs.mkdirSync(d, { recursive: true }); return d; };
+ process.env.VOD_PATH = dir('vods');
+ process.env.CLIPS_PATH = dir('clips');
+ process.env.FILES_PATH = dir('files');
+ process.env.OBJECTS_PATH = dir('objects');
+ process.env.THUMBNAILS_PATH = dir('thumbnails');
+ process.env.PASTES_PATH = dir('pastes');
+ process.env.MEDIA_PUBLIC_URL = 'https://media.test';
+ delete process.env.EVENTS_URL;
+ console.log = () => {};
+ console.warn = () => {};
+
+ const db = require('../server/db/database');
+ await db.upsertApp({ app_id: 'live', api_key: 'live-key' });
+ const raw = db.getDb();
+ const file = path.join(process.env.VOD_PATH, 'vod-1.webm');
+ fs.writeFileSync(file, Buffer.alloc(64, 1));
+ await raw.prepare(`INSERT INTO vods (id, app_id, user_id, title, file_path, is_public, visibility, duration_seconds)
+ OVERRIDING SYSTEM VALUE VALUES (1, 'live', 5, 'A VOD', ?, 1, 'public', 60)`).run(file);
+ await raw.prepare(`INSERT INTO clips (id, app_id, vod_id, user_id, title, file_path, is_public, visibility, status)
+ OVERRIDING SYSTEM VALUE VALUES (10, 'live', 1, 5, 'A clip', 'x.webm', 1, 'public', 'ready')`).run();
+
+ const express = require('express');
+ const app = express();
+ app.use(express.json());
+ app.use('/api/v1/:app/vods', require('../server/vod/routes'));
+ app.use('/api/v1/:app/clips', require('../server/vod/clips-routes'));
+ const server = http.createServer(app);
+
+ try {
+ await new Promise((r) => server.listen(0, '127.0.0.1', r));
+ const base = `http://127.0.0.1:${server.address().port}/api/v1/live`;
+ const call = async (method, p, body) => {
+ const headers = { authorization: 'Bearer live-key' };
+ if (body !== undefined) headers['content-type'] = 'application/json';
+ const res = await fetch(base + p, { method, headers, body: body === undefined ? undefined : JSON.stringify(body) });
+ return { status: res.status, json: await res.json().catch(() => null) };
+ };
+
+ // Every read carries the shape, even for a row that never had a label.
+ let r = await call('GET', '/vods/1');
+ assert.deepStrictEqual(r.json.content_labels, { labels: [], suggested: [] }, 'a VOD is label-free by default');
+ r = await call('GET', '/clips/10');
+ assert.deepStrictEqual(r.json.content_labels, { labels: [], suggested: [] }, 'a clip is label-free by default');
+
+ // The creator sets labels; the AI's suggestion is stored but never applied.
+ r = await call('PUT', '/vods/1', { labels: ['18+', 'language'] });
+ assert.strictEqual(r.status, 200);
+ assert.deepStrictEqual(r.json.vod.content_labels, { labels: ['18+', 'language'], suggested: [] }, 'the creator labels a VOD');
+ await raw.prepare(`UPDATE vods SET content_labels = '{"labels":["18+"],"suggested":["nudity","language"]}' WHERE id = 1`).run();
+ r = await call('GET', '/vods/1');
+ assert.deepStrictEqual(r.json.content_labels, { labels: ['18+'], suggested: ['nudity', 'language'] }, 'a suggestion is not a label');
+ // Confirming nudity applies it and clears that suggestion; the other stays.
+ r = await call('PUT', '/vods/1', { labels: ['18+', 'nudity'] });
+ assert.deepStrictEqual(r.json.vod.content_labels, { labels: ['18+', 'nudity'], suggested: ['language'] }, 'confirming clears only the confirmed suggestion');
+
+ // Clips take the same shape; unknown labels are refused and change nothing.
+ r = await call('PUT', '/clips/10', { labels: ['spoilers', 'flashing'] });
+ assert.strictEqual(r.status, 200);
+ assert.deepStrictEqual(r.json.clip.content_labels, { labels: ['spoilers', 'flashing'], suggested: [] });
+ assert.strictEqual((await call('PUT', '/clips/10', { labels: ['gore'] })).status, 400, 'an unknown label is refused');
+ assert.strictEqual((await call('PUT', '/vods/1', { labels: 'nudity' })).status, 400, 'a string is not a label list');
+ assert.deepStrictEqual((await call('GET', '/clips/10')).json.content_labels.labels, ['spoilers', 'flashing'], 'a refused write changed nothing');
+
+ // A label is not a filter: the VOD labelled 18+ is still in the list, with its shape.
+ r = await call('GET', '/vods');
+ const vod = r.json.vods.find((v) => v.id === 1);
+ assert.ok(vod, 'the labelled VOD is still listed');
+ assert.deepStrictEqual(vod.content_labels.labels, ['18+', 'nudity'], 'the list carries the labels');
+ r = await call('GET', '/clips');
+ assert.deepStrictEqual(r.json.clips.find((c) => c.id === 10).content_labels.labels, ['spoilers', 'flashing'], 'the clip list carries the labels');
+ } finally {
+ await new Promise((r) => server.close(r));
+ }
+ console.log('content labels: VODs and clips carry the shape; suggestions stay suggestions');
+})().catch((err) => { console.error(err); process.exit(1); });