openvibers/openvibe.media Commits

Content labels on VODs and clips (L1) (#242)

OP OpenVibers committed 11 days ago · aeb52f93fe · 6 files, +160 −0

* Content labels on VODs and clips (L1)

Migration 0016 adds content_labels jsonb ({ labels, suggested }, the shape of common.content-labels@1) to vods
and clips, with a GIN index. vodPublic and clipPublic carry it on every read (the detail, the lists, MediaHub and
Live's cards), so the shared chips and the viewer's Network prefs have what they need.

The creator sets labels with PUT /vods/:id and PUT /clips/:id (a confirmed suggestion leaves suggested); an
unknown label is a 400 and the write changes nothing. Media never filters a row by its labels: the label is a
choice, not a ban. test/content-labels.test.js covers the default shape, the suggestion rule and the lists.

* Merge remote-tracking branch 'origin/main' into refresh-242

Changes

ddiff --git a/migrations/0016_content_labels.sql b/migrations/0016_content_labels.sql
nnew file mode 100644
iindex 0000000..c0b3451
---- /dev/null
++++ b/migrations/0016_content_labels.sql
@@@ -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);
ddiff --git a/server/labels.js b/server/labels.js
nnew file mode 100644
iindex 0000000..cdf0ed9
---- /dev/null
++++ b/server/labels.js
@@@ -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 };
ddiff --git a/server/vod/clips-routes.js b/server/vod/clips-routes.js
iindex 1e0b22e..653fe3f 100644
---- a/server/vod/clips-routes.js
++++ b/server/vod/clips-routes.js
@@@ -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.
ddiff --git a/server/vod/finalize.js b/server/vod/finalize.js
iindex 745de50..7f41b3d 100644
---- a/server/vod/finalize.js
++++ b/server/vod/finalize.js
@@@ -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
ddiff --git a/server/vod/routes.js b/server/vod/routes.js
iindex 71ee89e..da07356 100644
---- a/server/vod/routes.js
++++ b/server/vod/routes.js
@@@ -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) {
ddiff --git a/test/content-labels.test.js b/test/content-labels.test.js
nnew file mode 100644
iindex 0000000..05fec40
---- /dev/null
++++ b/test/content-labels.test.js
@@@ -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); });