openvibers/openvibe.mediahub Commits
Publish licensed audio to OpenVibe Radio (#335)
Changes
nnew file mode 100644@@@ -0,0 +1,18 @@++-- phase: expand++-- OpenVibe Radio: published audio is an owned MediaHub file with an explicit reuse grant.++CREATE TABLE IF NOT EXISTS mh_radio_tracks (++ id text COLLATE "C" PRIMARY KEY,++ file_id text COLLATE "C" NOT NULL UNIQUE REFERENCES mh_files(id) ON DELETE CASCADE,++ owner text COLLATE "C" NOT NULL,++ title text NOT NULL,++ artist text NOT NULL,++ source_kind text COLLATE "C" NOT NULL CHECK (source_kind IN ('creator', 'creative_commons', 'public_domain', 'ai')),++ source_url text,++ licence text COLLATE "C" NOT NULL CHECK (licence IN ('creator_grant', 'CC0-1.0', 'CC-BY-4.0', 'CC-BY-SA-4.0', 'public_domain')),++ licence_url text NOT NULL,++ changes_note text,++ ai_made boolean NOT NULL DEFAULT false,++ published_at timestamptz NOT NULL DEFAULT now(),++ revoked_at timestamptz++);++CREATE INDEX IF NOT EXISTS mh_radio_tracks_public ON mh_radio_tracks (published_at DESC) WHERE revoked_at IS NULL;nnew file mode 100644@@@ -0,0 +1,13 @@++:root { color-scheme: dark light; font-family: system-ui, sans-serif; background: #0e1422; color: #edf3ff; }++body { margin: 0; }++.radio-page { box-sizing: border-box; width: min(100%, 680px); margin: 36px auto; padding: 24px; }++.radio-page h1 { font-size: clamp(26px, 4vw, 38px); line-height: 1.15; margin: 0 0 16px; }++.radio-page p { line-height: 1.55; }++.radio-page form { padding: 18px; border-radius: 16px; border: 1px solid #38465f; background: #172135; }++.radio-page label:not(.radio-check) { display: grid; gap: 7px; font-weight: 650; }++.radio-page input:not([type=checkbox]), .radio-page select { box-sizing: border-box; width: 100%; min-height: 44px; border-radius: 9px; border: 1px solid #64748b; background: #0e1422; color: inherit; padding: 8px 12px; font: inherit; }++.radio-page input[type=checkbox] { width: 20px; height: 20px; vertical-align: middle; }++.radio-page button { min-height: 44px; border: 0; border-radius: 9px; background: #79b4ff; color: #071323; padding: 0 18px; font: inherit; font-weight: 750; cursor: pointer; }++.radio-page a { color: #9dc9ff; }++@media (prefers-color-scheme: light) { :root { background: #f6f8fc; color: #172033; } .radio-page form { background: #fff; border-color: #cbd5e1; } .radio-page input:not([type=checkbox]), .radio-page select { background: #fff; color: #172033; } .radio-page a { color: #1459a3; } }++@media (max-width: 600px) { .radio-page { margin: 12px auto; padding: 16px; } }@@@ -28,6 +28,8 @@ const TABLES = [{ table: 'mh_reports', subject: 'reporter', value: AS_REQUESTER, file: 'reports.json' },{ table: 'mh_shares', subject: 'owner', value: AS_REQUESTER, file: 'shares.json' },{ table: 'mh_usage', subject: 'owner', value: AS_REQUESTER, file: 'usage.json' },++ // Radio publications refer to drive files. Export and erase them before the file rows cascade.++ { table: 'mh_radio_tracks', subject: 'owner', value: AS_REQUESTER, file: 'radio_tracks.json' },{ table: 'mh_files', subject: 'owner', value: AS_REQUESTER, file: 'files.json' },{ table: 'mh_folders', subject: 'owner', value: AS_REQUESTER, file: 'folders.json' },// A playlist's items have no owner column of their own; they are exported with extraExport below and erased@@@ -224,6 +224,8 @@ async function createApp(opts = {}) {if (picsHost && req.hostname === picsHost) return picsRoutes(req, res, next);return next();});++ const radioRoutes = require('./radio/routes').createRadioRoutes(ctx);++ app.use((req, res, next) => (req.hostname === videoHost || req.hostname === picsHost ? next() : radioRoutes(req, res, next)));// ── Machine endpoints ───────────────────────────────────app.get('/api/health', (_req, res) => res.json({ status: 'ok', service: 'openvibe-media-hub', version: VERSION }));@@@ -448,6 +448,8 @@ ${files.length ? html`<ul class="dr-files" data-items>${files.map((row) => ui.ficonst kind = ui.kindOf(row);const ext = ui.extOf(row.name);const pic = rules.isPreviewable(row.content_type);++ const radioTrack = /^audio\/(mpeg|mp4|ogg|webm|wav|flac)$/.test(row.content_type)++ ? await s.db.maybe('SELECT id FROM mh_radio_tracks WHERE file_id = $1 AND revoked_at IS NULL', [row.id]) : null;const choices = [...labels.entries()].sort((a, b) => a[1].localeCompare(b[1], 'en', { numeric: true, sensitivity: 'base' }));return page(req, res, {title: row.name,@@@ -464,6 +466,9 @@ ${pathBar(path, row.name)}<h1 class="fv-name">${row.name}</h1><p class="fv-sub"><span class="fv-kind" data-kind="${kind}">${ui.icon(ui.KINDS[kind].icon)}${ui.kindWord(row)}</span><span>${fileSize(row)}</span><span>Added ${ui.when(row.created_at)}</span></p><div class="fv-actions"><a class="dr-btn is-primary" href="/files/${row.id}/download">${ui.icon('download')}Download</a>${raw(shareButton({ url: `${config.baseUrl}/files/${row.id}`, title: row.name, text: `${row.name} — a file on ${SITE_NAME}.`, image: pic ? `${config.baseUrl}/files/${row.id}/preview?w=640` : '', cls: 'dr-btn' }))}${raw(askAgent({ text: `About my file “${row.name}” (${ui.kindWord(row)}, ${fileSize(row)}): ` }))}</div>++${/^audio\/(mpeg|mp4|ogg|webm|wav|flac)$/.test(row.content_type) ? html`<section class="fv-card"><h2>OpenVibe Radio</h2>${radioTrack++ ? html`<p>This music is published for listening parties.</p><a class="dr-btn" href="/radio/tracks/${radioTrack.id}">Open in Radio</a>`++ : html`<p>Mark this audio file as music and choose a licence before anyone can listen in a party.</p><a class="dr-btn" href="/radio/publish?file=${row.id}">Publish as music</a>`}</section>` : ''}<section class="fv-card" id="share" aria-labelledby="h-share"><h2 id="h-share">${ui.icon('link')}Share a link</h2><form class="fv-share" method="post" action="/files/${row.id}/share">nnew file mode 100644@@@ -0,0 +1,165 @@++'use strict';++const express = require('express');++const { Readable } = require('stream');++const { pipeline } = require('stream/promises');++const { sameOrigin } = require('../http/principal');++++const AUDIO = new Set(['audio/mpeg', 'audio/mp4', 'audio/ogg', 'audio/webm', 'audio/wav', 'audio/flac']);++const LICENCES = Object.freeze({++ 'CC0-1.0': 'https://creativecommons.org/publicdomain/zero/1.0/',++ 'CC-BY-4.0': 'https://creativecommons.org/licenses/by/4.0/',++ 'CC-BY-SA-4.0': 'https://creativecommons.org/licenses/by-sa/4.0/',++ public_domain: 'https://creativecommons.org/publicdomain/mark/1.0/',++});++const text = (value, max) => typeof value === 'string' ? value.replace(/\s+/g, ' ').trim().slice(0, max) : '';++const https = (value) => { try { const u = new URL(value); return u.protocol === 'https:' && !u.username && !u.password && !u.port && value.length <= 2048 ? u.href : null; } catch { return null; } };++const esc = (value) => String(value).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]);++++function validGrant(body) {++ const kind = text(body.source_kind, 30), licence = text(body.licence, 30);++ const title = text(body.title, 160), artist = text(body.artist, 120);++ const source = https(text(body.source_url, 2048));++ const changes = text(body.changes_note, 200) || null;++ if (!title || !artist || (body.rights_confirmed !== 'yes' && body.rights_confirmed !== true)) return null;++ if (kind === 'creator' || kind === 'ai') {++ if (licence !== 'creator_grant') return null;++ return { kind, licence, title, artist, source, changes, licenceUrl: 'https://openvibe.download/radio/licence', aiMade: kind === 'ai' };++ }++ if (kind === 'creative_commons' && ['CC0-1.0', 'CC-BY-4.0', 'CC-BY-SA-4.0'].includes(licence) && source && changes)++ return { kind, licence, title, artist, source, changes, licenceUrl: LICENCES[licence], aiMade: false };++ if (kind === 'public_domain' && licence === 'public_domain' && source)++ return { kind, licence, title, artist, source, changes, licenceUrl: LICENCES[licence], aiMade: false };++ return null;++}++++function createRadioRoutes({ s, drive, media, config }) {++ const r = express.Router();++ r.use(express.urlencoded({ extended: false, limit: '12kb' }));++ const owner = (req) => req.viewer && req.viewer.kind === 'user' && req.viewer.subject ? `user:${req.viewer.subject}` : null;++ const row = (id) => /^rad_[0-9A-HJKMNP-TV-Z]{26}$/.test(String(id || '')) ? s.db.maybe(++ `SELECT t.*, f.object_id, f.content_type FROM mh_radio_tracks t JOIN mh_files f ON f.id = t.file_id++ WHERE t.id = $1 AND t.revoked_at IS NULL AND f.deleted_at IS NULL`, [id]) : null;++ const wire = (t) => ({ id: t.id, title: t.title, artist: t.artist, source: t.source_kind, source_url: t.source_url,++ licence: t.licence, licence_url: t.licence_url, changes_note: t.changes_note, ai_made: t.ai_made,++ audio_url: `${config.baseUrl}/api/v1/radio/tracks/${encodeURIComponent(t.id)}/audio` });++ r.get('/api/v1/radio/tracks', async (req, res, next) => {++ try {++ const rows = await s.db.many(`SELECT t.* FROM mh_radio_tracks t JOIN mh_files f ON f.id = t.file_id++ WHERE t.revoked_at IS NULL AND f.deleted_at IS NULL ORDER BY t.published_at DESC LIMIT 100`);++ res.set('Cache-Control', 'public, max-age=30').json({ tracks: rows.map(wire) });++ } catch (e) { next(e); }++ });++ r.get('/api/v1/radio/tracks/:id', async (req, res, next) => {++ try { const t = await row(req.params.id); return t ? res.set('Cache-Control', 'no-store').json({ track: wire(t) }) : res.status(404).json({ code: 'radio.not_found' }); }++ catch (e) { next(e); }++ });++ r.get('/api/v1/radio/tracks/:id/audio', async (req, res, next) => {++ try {++ const t = await row(req.params.id);++ if (!t) return res.status(404).end();++ const got = await media.bytes(t.object_id, { range: req.get('range') });++ if (!got.ok) return res.status(got.status === 404 || got.status === 410 ? 410 : 502).end();++ const up = got.res;++ res.status(up.status === 206 ? 206 : 200).set({ 'Content-Type': t.content_type, 'Accept-Ranges': 'bytes',++ 'Cache-Control': 'no-store', 'X-Content-Type-Options': 'nosniff', 'Content-Security-Policy': "default-src 'none'; sandbox" });++ for (const h of ['content-length', 'content-range']) if (up.headers.get(h)) res.set(h, up.headers.get(h));++ if (!up.body) return res.end();++ await pipeline(Readable.fromWeb(up.body), res);++ } catch (e) { if (!res.headersSent) next(e); else res.destroy(); }++ });++ r.get('/radio/licence', (_req, res) => res.type('html').send('<h1>OpenVibe Radio creator grant</h1><p>By publishing, you grant OpenVibe permission to stream this recording to anyone in a listening party until you unpublish it. You confirm you own or control every right needed, including the composition and recording. You retain ownership.</p>'));++ r.get('/radio/publish', async (req, res, next) => {++ try {++ const who = owner(req);++ if (!who) return res.redirect(303, `/auth/login?next=${encodeURIComponent(req.originalUrl)}`);++ const file = await drive.ownFile(who, String(req.query.file || ''));++ if (!file || !AUDIO.has(file.content_type)) return res.status(404).type('text/plain').send('Choose an audio file from your files.');++ res.set('Cache-Control', 'private, no-store').type('html').send(`<!doctype html>++<meta name="viewport" content="width=device-width,initial-scale=1">++<title>Publish on OpenVibe Radio</title>++<link rel="stylesheet" href="/css/radio.css">++<main class="radio-page">++<h1>Publish on OpenVibe Radio</h1>++<p>${esc(file.name)}</p>++<p>The recording will be playable by anyone. Only publish music you have the right to share.</p>++<form method="post" action="/radio/publish">++<input type="hidden" name="file" value="${esc(file.id)}">++<p>++<label>Track title <input name="title" required maxlength="160" value="${esc(String(file.name || '').replace(/\.[a-z0-9]{2,5}$/i, '').replace(/[_-]+/g, ' ').trim())}">++</label>++</p>++<p>++<label>Artist or creator <input name="artist" required maxlength="120">++</label>++</p>++<p>++<label>Source <select name="source_kind">++<option value="creator">My own music</option>++<option value="creative_commons">Creative Commons</option>++<option value="public_domain">Public domain</option>++<option value="ai">AI-made music</option>++</select>++</label>++</p>++<p>++<label>Licence <select name="licence">++<option value="creator_grant">My grant to stream</option>++<option value="CC0-1.0">CC0 1.0</option>++<option value="CC-BY-4.0">CC BY 4.0</option>++<option value="CC-BY-SA-4.0">CC BY-SA 4.0</option>++<option value="public_domain">Public domain</option>++</select>++</label>++</p>++<p>++<label>Original source URL for CC or public domain music <input name="source_url" type="url" placeholder="https://…">++</label>++</p>++<p>++<label>Changes to the original (for CC music, write “none” if unchanged) <input name="changes_note" maxlength="200">++</label>++</p>++<p>++<label class="radio-check">++<input type="checkbox" name="rights_confirmed" value="yes" required> I confirm the recording and composition can be streamed publicly under these rights, and I can provide the required attribution. Public domain means free of known copyright restrictions worldwide.</label>++</p>++<button type="submit">Publish track</button>++</form>++<p>++<a href="/radio/licence">Read the creator grant</a> · <a href="/files/${encodeURIComponent(file.id)}">Back to file</a>++</p>++</main>`);++ } catch (e) { next(e); }++ });++ r.post('/radio/publish', async (req, res, next) => {++ try {++ const who = owner(req);++ if (!who) return res.status(401).end();++ if (!sameOrigin(req, config.baseUrl)) return res.status(403).end();++ const body = req.body || {};++ const file = await drive.ownFile(who, String(body.file || ''));++ if (!file || !AUDIO.has(file.content_type)) return res.status(404).end();++ const grant = validGrant(body);++ if (!grant) return res.status(422).type('text/plain').send('Choose a permitted licence, give its original source and changes where required, and confirm your rights.');++ const id = s.newId('rad');++ await s.db.query(`INSERT INTO mh_radio_tracks (id, file_id, owner, title, artist, source_kind, source_url, licence, licence_url, changes_note, ai_made)++ VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11)`, [id, file.id, who, grant.title, grant.artist, grant.kind, grant.source, grant.licence, grant.licenceUrl, grant.changes, grant.aiMade]);++ return res.redirect(303, `/radio/tracks/${encodeURIComponent(id)}`);++ } catch (e) { if (e.code === '23505') return res.status(409).type('text/plain').send('This file is already published.'); next(e); }++ });++ r.get('/radio/tracks/:id', async (req, res, next) => {++ try { const t = await row(req.params.id); return t ? res.type('html').send(`<!doctype html><meta name="viewport" content="width=device-width,initial-scale=1"><title>${esc(t.title)} · OpenVibe Radio</title><link rel="stylesheet" href="/css/radio.css"><main class="radio-page"><h1>${esc(t.title)}</h1><p>${esc(t.artist)}</p><audio controls src="/api/v1/radio/tracks/${encodeURIComponent(t.id)}/audio"></audio><p>Source: ${esc(t.source_kind)}${t.source_url ? ` · <a href="${esc(t.source_url)}">Original source</a>` : ''}</p><p>Licence or status: <a href="${esc(t.licence_url)}">${esc(t.licence === 'public_domain' ? 'Public domain mark' : t.licence === 'creator_grant' ? 'Creator permission to stream' : t.licence)}</a>${t.changes_note ? ` · Changes: ${esc(t.changes_note)}` : ''}${t.ai_made ? ' · AI-made music' : ''}</p><p><a href="https://openvibe.party/new?radio=${encodeURIComponent(t.id)}">Listen together on OpenVibe.Party</a></p>${owner(req) === t.owner ? `<form method="post" action="/radio/tracks/${encodeURIComponent(t.id)}/unpublish"><button type="submit">Unpublish</button></form>` : ''}</main>`) : res.status(404).end(); }++ catch (e) { next(e); }++ });++ r.post('/radio/tracks/:id/unpublish', async (req, res, next) => {++ try {++ const who = owner(req);++ if (!who || !sameOrigin(req, config.baseUrl)) return res.status(403).end();++ await s.db.exec('UPDATE mh_radio_tracks SET revoked_at = now() WHERE id = $1 AND owner = $2 AND revoked_at IS NULL', [req.params.id, who]);++ return res.redirect(303, '/files');++ } catch (e) { next(e); }++ });++ return r;++}++++module.exports = { createRadioRoutes, validGrant };nnew file mode 100644@@@ -0,0 +1,64 @@++'use strict';++const assert = require('assert');++const { boot, check, done } = require('./helpers/boot');++const { SAME, bytes, upload } = require('./helpers/drive');++++(async () => {++ const t = await boot();++ const ann = t.network.addUser('ann');++ const bob = t.network.addUser('bob');++ const file = (await upload(t, ann, bytes(300, 'a song'), { name: 'song.mp3', contentType: 'audio/mpeg' })).json().file;++ const form = (extra = {}) => ({ file: file.id, title: 'Our Song', artist: 'Ann', source_kind: 'creator', licence: 'creator_grant', rights_confirmed: 'yes', ...extra });++ let track;++++ await check('only an audio file owner can publish with a streaming grant', async () => {++ assert.strictEqual((await t.get('/radio/publish', { as: ann, form: form(), headers: SAME })).status, 303);++ const tracks = (await t.get('/api/v1/radio/tracks')).json().tracks;++ assert.strictEqual(tracks.length, 1);++ track = tracks[0];++ assert.strictEqual(track.licence, 'creator_grant');++ assert.strictEqual(track.source, 'creator');++ const filePage = await t.get(`/files/${file.id}`, { as: ann });++ assert.match(filePage.text, new RegExp(`/radio/tracks/${track.id}`));++ assert.ok(!filePage.text.includes('Publish as music'), 'a published file links to its track');++ assert.strictEqual((await t.get('/radio/publish', { as: bob, form: form(), headers: SAME })).status, 404);++ assert.strictEqual((await t.get('/radio/publish', { as: ann, form: form() })).status, 403);++ });++++ await check('licence gate rejects unsupported or unproved sources and labels AI music', async () => {++ const second = (await upload(t, ann, bytes(300, 'another'), { name: 'second.mp3', contentType: 'audio/mpeg' })).json().file;++ const post = (extra) => t.get('/radio/publish', { as: ann, form: { ...form({ file: second.id }), ...extra }, headers: SAME });++ assert.strictEqual((await post({ licence: 'all_rights_reserved' })).status, 422);++ assert.strictEqual((await post({ source_kind: 'creative_commons', licence: 'CC-BY-4.0', source_url: '' })).status, 422);++ assert.strictEqual((await post({ source_kind: 'creative_commons', licence: 'CC-BY-4.0', source_url: 'https://example.org/cc-song' })).status, 422, 'CC attribution records whether it was changed');++ assert.strictEqual((await post({ rights_confirmed: 'no' })).status, 422);++ assert.strictEqual((await post({ source_kind: 'ai' })).status, 303);++ const ai = (await t.get('/api/v1/radio/tracks')).json().tracks.find((x) => x.source === 'ai');++ assert.strictEqual(ai.ai_made, true);++ assert.strictEqual(ai.licence, 'creator_grant');++ const ccFile = (await upload(t, ann, bytes(300, 'cc'), { name: 'cc.ogg', contentType: 'audio/ogg' })).json().file;++ assert.strictEqual((await post({ file: ccFile.id, source_kind: 'creative_commons', licence: 'CC-BY-4.0', source_url: 'https://example.org/cc-song', changes_note: 'none' })).status, 303);++ const cc = (await t.get('/api/v1/radio/tracks')).json().tracks.find((x) => x.licence === 'CC-BY-4.0');++ assert.strictEqual(cc.source_url, 'https://example.org/cc-song');++ assert.strictEqual(cc.changes_note, 'none');++ const pdFile = (await upload(t, ann, bytes(300, 'pd'), { name: 'pd.mp3', contentType: 'audio/mpeg' })).json().file;++ assert.strictEqual((await post({ file: pdFile.id, source_kind: 'public_domain', licence: 'public_domain', source_url: 'https://example.org/archive' })).status, 303);++ const pd = (await t.get('/api/v1/radio/tracks')).json().tracks.find((x) => x.source === 'public_domain');++ assert.strictEqual(pd.source_url, 'https://example.org/archive');++ });++++ await check('public audio supports ranges and disappears when unpublished', async () => {++ const path = `/api/v1/radio/tracks/${track.id}/audio`;++ const stream = await t.get(path, { headers: { range: 'bytes=10-19' } });++ assert.strictEqual(stream.status, 206);++ assert.strictEqual(stream.buffer.length, 10);++ assert.match(stream.headers.get('content-type'), /audio\/mpeg/);++ assert.strictEqual((await t.get(`/radio/tracks/${track.id}/unpublish`, { as: bob, form: {}, headers: SAME })).status, 303);++ assert.strictEqual((await t.get(path)).status, 200, 'another user cannot revoke it');++ assert.strictEqual((await t.get(`/radio/tracks/${track.id}/unpublish`, { as: ann, form: {}, headers: SAME })).status, 303);++ assert.strictEqual((await t.get(path)).status, 404);++ });++++ await t.close();++ done();++})().catch((e) => { console.error(e); process.exit(1); });
new file mode 100644
new file mode 100644
@@ -0,0 +1,18 @@
@@ -0,0 +1,18 @@
+-- phase: expand
+-- OpenVibe Radio: published audio is an owned MediaHub file with an explicit reuse grant.
+CREATE TABLE IF NOT EXISTS mh_radio_tracks (
+ id text COLLATE "C" PRIMARY KEY,
+ file_id text COLLATE "C" NOT NULL UNIQUE REFERENCES mh_files(id) ON DELETE CASCADE,
+ owner text COLLATE "C" NOT NULL,
+ title text NOT NULL,
+ artist text NOT NULL,
+ source_kind text COLLATE "C" NOT NULL CHECK (source_kind IN ('creator', 'creative_commons', 'public_domain', 'ai')),
+ source_url text,
+ licence text COLLATE "C" NOT NULL CHECK (licence IN ('creator_grant', 'CC0-1.0', 'CC-BY-4.0', 'CC-BY-SA-4.0', 'public_domain')),
+ licence_url text NOT NULL,
+ changes_note text,
+ ai_made boolean NOT NULL DEFAULT false,
+ published_at timestamptz NOT NULL DEFAULT now(),
+ revoked_at timestamptz
+);
+CREATE INDEX IF NOT EXISTS mh_radio_tracks_public ON mh_radio_tracks (published_at DESC) WHERE revoked_at IS NULL;
new file mode 100644
new file mode 100644
@@ -0,0 +1,13 @@
@@ -0,0 +1,13 @@
+:root { color-scheme: dark light; font-family: system-ui, sans-serif; background: #0e1422; color: #edf3ff; }
+body { margin: 0; }
+.radio-page { box-sizing: border-box; width: min(100%, 680px); margin: 36px auto; padding: 24px; }
+.radio-page h1 { font-size: clamp(26px, 4vw, 38px); line-height: 1.15; margin: 0 0 16px; }
+.radio-page p { line-height: 1.55; }
+.radio-page form { padding: 18px; border-radius: 16px; border: 1px solid #38465f; background: #172135; }
+.radio-page label:not(.radio-check) { display: grid; gap: 7px; font-weight: 650; }
+.radio-page input:not([type=checkbox]), .radio-page select { box-sizing: border-box; width: 100%; min-height: 44px; border-radius: 9px; border: 1px solid #64748b; background: #0e1422; color: inherit; padding: 8px 12px; font: inherit; }
+.radio-page input[type=checkbox] { width: 20px; height: 20px; vertical-align: middle; }
+.radio-page button { min-height: 44px; border: 0; border-radius: 9px; background: #79b4ff; color: #071323; padding: 0 18px; font: inherit; font-weight: 750; cursor: pointer; }
+.radio-page a { color: #9dc9ff; }
+@media (prefers-color-scheme: light) { :root { background: #f6f8fc; color: #172033; } .radio-page form { background: #fff; border-color: #cbd5e1; } .radio-page input:not([type=checkbox]), .radio-page select { background: #fff; color: #172033; } .radio-page a { color: #1459a3; } }
+@media (max-width: 600px) { .radio-page { margin: 12px auto; padding: 16px; } }
@@ -28,6 +28,8 @@ const TABLES = [
@@ -28,6 +28,8 @@ const TABLES = [
{ table: 'mh_reports', subject: 'reporter', value: AS_REQUESTER, file: 'reports.json' },
{ table: 'mh_reports', subject: 'reporter', value: AS_REQUESTER, file: 'reports.json' },
{ table: 'mh_shares', subject: 'owner', value: AS_REQUESTER, file: 'shares.json' },
{ table: 'mh_shares', subject: 'owner', value: AS_REQUESTER, file: 'shares.json' },
{ table: 'mh_usage', subject: 'owner', value: AS_REQUESTER, file: 'usage.json' },
{ table: 'mh_usage', subject: 'owner', value: AS_REQUESTER, file: 'usage.json' },
+ // Radio publications refer to drive files. Export and erase them before the file rows cascade.
+ { table: 'mh_radio_tracks', subject: 'owner', value: AS_REQUESTER, file: 'radio_tracks.json' },
{ table: 'mh_files', subject: 'owner', value: AS_REQUESTER, file: 'files.json' },
{ table: 'mh_files', subject: 'owner', value: AS_REQUESTER, file: 'files.json' },
{ table: 'mh_folders', subject: 'owner', value: AS_REQUESTER, file: 'folders.json' },
{ table: 'mh_folders', subject: 'owner', value: AS_REQUESTER, file: 'folders.json' },
// A playlist's items have no owner column of their own; they are exported with extraExport below and erased
// A playlist's items have no owner column of their own; they are exported with extraExport below and erased
@@ -224,6 +224,8 @@ async function createApp(opts = {}) {
@@ -224,6 +224,8 @@ async function createApp(opts = {}) {
if (picsHost && req.hostname === picsHost) return picsRoutes(req, res, next);
if (picsHost && req.hostname === picsHost) return picsRoutes(req, res, next);
return next();
return next();
});
});
+ const radioRoutes = require('./radio/routes').createRadioRoutes(ctx);
+ app.use((req, res, next) => (req.hostname === videoHost || req.hostname === picsHost ? next() : radioRoutes(req, res, next)));
// ── Machine endpoints ───────────────────────────────────
// ── Machine endpoints ───────────────────────────────────
app.get('/api/health', (_req, res) => res.json({ status: 'ok', service: 'openvibe-media-hub', version: VERSION }));
app.get('/api/health', (_req, res) => res.json({ status: 'ok', service: 'openvibe-media-hub', version: VERSION }));
@@ -448,6 +448,8 @@ ${files.length ? html`<ul class="dr-files" data-items>${files.map((row) => ui.fi
@@ -448,6 +448,8 @@ ${files.length ? html`<ul class="dr-files" data-items>${files.map((row) => ui.fi
const kind = ui.kindOf(row);
const kind = ui.kindOf(row);
const ext = ui.extOf(row.name);
const ext = ui.extOf(row.name);
const pic = rules.isPreviewable(row.content_type);
const pic = rules.isPreviewable(row.content_type);
+ const radioTrack = /^audio\/(mpeg|mp4|ogg|webm|wav|flac)$/.test(row.content_type)
+ ? await s.db.maybe('SELECT id FROM mh_radio_tracks WHERE file_id = $1 AND revoked_at IS NULL', [row.id]) : null;
const choices = [...labels.entries()].sort((a, b) => a[1].localeCompare(b[1], 'en', { numeric: true, sensitivity: 'base' }));
const choices = [...labels.entries()].sort((a, b) => a[1].localeCompare(b[1], 'en', { numeric: true, sensitivity: 'base' }));
return page(req, res, {
return page(req, res, {
title: row.name,
title: row.name,
@@ -464,6 +466,9 @@ ${pathBar(path, row.name)}
@@ -464,6 +466,9 @@ ${pathBar(path, row.name)}
<h1 class="fv-name">${row.name}</h1>
<h1 class="fv-name">${row.name}</h1>
<p class="fv-sub"><span class="fv-kind" data-kind="${kind}">${ui.icon(ui.KINDS[kind].icon)}${ui.kindWord(row)}</span><span>${fileSize(row)}</span><span>Added ${ui.when(row.created_at)}</span></p>
<p class="fv-sub"><span class="fv-kind" data-kind="${kind}">${ui.icon(ui.KINDS[kind].icon)}${ui.kindWord(row)}</span><span>${fileSize(row)}</span><span>Added ${ui.when(row.created_at)}</span></p>
<div class="fv-actions"><a class="dr-btn is-primary" href="/files/${row.id}/download">${ui.icon('download')}Download</a>${raw(shareButton({ url: `${config.baseUrl}/files/${row.id}`, title: row.name, text: `${row.name} — a file on ${SITE_NAME}.`, image: pic ? `${config.baseUrl}/files/${row.id}/preview?w=640` : '', cls: 'dr-btn' }))}${raw(askAgent({ text: `About my file “${row.name}” (${ui.kindWord(row)}, ${fileSize(row)}): ` }))}</div>
<div class="fv-actions"><a class="dr-btn is-primary" href="/files/${row.id}/download">${ui.icon('download')}Download</a>${raw(shareButton({ url: `${config.baseUrl}/files/${row.id}`, title: row.name, text: `${row.name} — a file on ${SITE_NAME}.`, image: pic ? `${config.baseUrl}/files/${row.id}/preview?w=640` : '', cls: 'dr-btn' }))}${raw(askAgent({ text: `About my file “${row.name}” (${ui.kindWord(row)}, ${fileSize(row)}): ` }))}</div>
+${/^audio\/(mpeg|mp4|ogg|webm|wav|flac)$/.test(row.content_type) ? html`<section class="fv-card"><h2>OpenVibe Radio</h2>${radioTrack
+ ? html`<p>This music is published for listening parties.</p><a class="dr-btn" href="/radio/tracks/${radioTrack.id}">Open in Radio</a>`
+ : html`<p>Mark this audio file as music and choose a licence before anyone can listen in a party.</p><a class="dr-btn" href="/radio/publish?file=${row.id}">Publish as music</a>`}</section>` : ''}
<section class="fv-card" id="share" aria-labelledby="h-share"><h2 id="h-share">${ui.icon('link')}Share a link</h2>
<section class="fv-card" id="share" aria-labelledby="h-share"><h2 id="h-share">${ui.icon('link')}Share a link</h2>
<form class="fv-share" method="post" action="/files/${row.id}/share">
<form class="fv-share" method="post" action="/files/${row.id}/share">
new file mode 100644
new file mode 100644
@@ -0,0 +1,165 @@
@@ -0,0 +1,165 @@
+'use strict';
+const express = require('express');
+const { Readable } = require('stream');
+const { pipeline } = require('stream/promises');
+const { sameOrigin } = require('../http/principal');
+
+const AUDIO = new Set(['audio/mpeg', 'audio/mp4', 'audio/ogg', 'audio/webm', 'audio/wav', 'audio/flac']);
+const LICENCES = Object.freeze({
+ 'CC0-1.0': 'https://creativecommons.org/publicdomain/zero/1.0/',
+ 'CC-BY-4.0': 'https://creativecommons.org/licenses/by/4.0/',
+ 'CC-BY-SA-4.0': 'https://creativecommons.org/licenses/by-sa/4.0/',
+ public_domain: 'https://creativecommons.org/publicdomain/mark/1.0/',
+});
+const text = (value, max) => typeof value === 'string' ? value.replace(/\s+/g, ' ').trim().slice(0, max) : '';
+const https = (value) => { try { const u = new URL(value); return u.protocol === 'https:' && !u.username && !u.password && !u.port && value.length <= 2048 ? u.href : null; } catch { return null; } };
+const esc = (value) => String(value).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]);
+
+function validGrant(body) {
+ const kind = text(body.source_kind, 30), licence = text(body.licence, 30);
+ const title = text(body.title, 160), artist = text(body.artist, 120);
+ const source = https(text(body.source_url, 2048));
+ const changes = text(body.changes_note, 200) || null;
+ if (!title || !artist || (body.rights_confirmed !== 'yes' && body.rights_confirmed !== true)) return null;
+ if (kind === 'creator' || kind === 'ai') {
+ if (licence !== 'creator_grant') return null;
+ return { kind, licence, title, artist, source, changes, licenceUrl: 'https://openvibe.download/radio/licence', aiMade: kind === 'ai' };
+ }
+ if (kind === 'creative_commons' && ['CC0-1.0', 'CC-BY-4.0', 'CC-BY-SA-4.0'].includes(licence) && source && changes)
+ return { kind, licence, title, artist, source, changes, licenceUrl: LICENCES[licence], aiMade: false };
+ if (kind === 'public_domain' && licence === 'public_domain' && source)
+ return { kind, licence, title, artist, source, changes, licenceUrl: LICENCES[licence], aiMade: false };
+ return null;
+}
+
+function createRadioRoutes({ s, drive, media, config }) {
+ const r = express.Router();
+ r.use(express.urlencoded({ extended: false, limit: '12kb' }));
+ const owner = (req) => req.viewer && req.viewer.kind === 'user' && req.viewer.subject ? `user:${req.viewer.subject}` : null;
+ const row = (id) => /^rad_[0-9A-HJKMNP-TV-Z]{26}$/.test(String(id || '')) ? s.db.maybe(
+ `SELECT t.*, f.object_id, f.content_type FROM mh_radio_tracks t JOIN mh_files f ON f.id = t.file_id
+ WHERE t.id = $1 AND t.revoked_at IS NULL AND f.deleted_at IS NULL`, [id]) : null;
+ const wire = (t) => ({ id: t.id, title: t.title, artist: t.artist, source: t.source_kind, source_url: t.source_url,
+ licence: t.licence, licence_url: t.licence_url, changes_note: t.changes_note, ai_made: t.ai_made,
+ audio_url: `${config.baseUrl}/api/v1/radio/tracks/${encodeURIComponent(t.id)}/audio` });
+ r.get('/api/v1/radio/tracks', async (req, res, next) => {
+ try {
+ const rows = await s.db.many(`SELECT t.* FROM mh_radio_tracks t JOIN mh_files f ON f.id = t.file_id
+ WHERE t.revoked_at IS NULL AND f.deleted_at IS NULL ORDER BY t.published_at DESC LIMIT 100`);
+ res.set('Cache-Control', 'public, max-age=30').json({ tracks: rows.map(wire) });
+ } catch (e) { next(e); }
+ });
+ r.get('/api/v1/radio/tracks/:id', async (req, res, next) => {
+ try { const t = await row(req.params.id); return t ? res.set('Cache-Control', 'no-store').json({ track: wire(t) }) : res.status(404).json({ code: 'radio.not_found' }); }
+ catch (e) { next(e); }
+ });
+ r.get('/api/v1/radio/tracks/:id/audio', async (req, res, next) => {
+ try {
+ const t = await row(req.params.id);
+ if (!t) return res.status(404).end();
+ const got = await media.bytes(t.object_id, { range: req.get('range') });
+ if (!got.ok) return res.status(got.status === 404 || got.status === 410 ? 410 : 502).end();
+ const up = got.res;
+ res.status(up.status === 206 ? 206 : 200).set({ 'Content-Type': t.content_type, 'Accept-Ranges': 'bytes',
+ 'Cache-Control': 'no-store', 'X-Content-Type-Options': 'nosniff', 'Content-Security-Policy': "default-src 'none'; sandbox" });
+ for (const h of ['content-length', 'content-range']) if (up.headers.get(h)) res.set(h, up.headers.get(h));
+ if (!up.body) return res.end();
+ await pipeline(Readable.fromWeb(up.body), res);
+ } catch (e) { if (!res.headersSent) next(e); else res.destroy(); }
+ });
+ r.get('/radio/licence', (_req, res) => res.type('html').send('<h1>OpenVibe Radio creator grant</h1><p>By publishing, you grant OpenVibe permission to stream this recording to anyone in a listening party until you unpublish it. You confirm you own or control every right needed, including the composition and recording. You retain ownership.</p>'));
+ r.get('/radio/publish', async (req, res, next) => {
+ try {
+ const who = owner(req);
+ if (!who) return res.redirect(303, `/auth/login?next=${encodeURIComponent(req.originalUrl)}`);
+ const file = await drive.ownFile(who, String(req.query.file || ''));
+ if (!file || !AUDIO.has(file.content_type)) return res.status(404).type('text/plain').send('Choose an audio file from your files.');
+ res.set('Cache-Control', 'private, no-store').type('html').send(`<!doctype html>
+<meta name="viewport" content="width=device-width,initial-scale=1">
+<title>Publish on OpenVibe Radio</title>
+<link rel="stylesheet" href="/css/radio.css">
+<main class="radio-page">
+<h1>Publish on OpenVibe Radio</h1>
+<p>${esc(file.name)}</p>
+<p>The recording will be playable by anyone. Only publish music you have the right to share.</p>
+<form method="post" action="/radio/publish">
+<input type="hidden" name="file" value="${esc(file.id)}">
+<p>
+<label>Track title <input name="title" required maxlength="160" value="${esc(String(file.name || '').replace(/\.[a-z0-9]{2,5}$/i, '').replace(/[_-]+/g, ' ').trim())}">
+</label>
+</p>
+<p>
+<label>Artist or creator <input name="artist" required maxlength="120">
+</label>
+</p>
+<p>
+<label>Source <select name="source_kind">
+<option value="creator">My own music</option>
+<option value="creative_commons">Creative Commons</option>
+<option value="public_domain">Public domain</option>
+<option value="ai">AI-made music</option>
+</select>
+</label>
+</p>
+<p>
+<label>Licence <select name="licence">
+<option value="creator_grant">My grant to stream</option>
+<option value="CC0-1.0">CC0 1.0</option>
+<option value="CC-BY-4.0">CC BY 4.0</option>
+<option value="CC-BY-SA-4.0">CC BY-SA 4.0</option>
+<option value="public_domain">Public domain</option>
+</select>
+</label>
+</p>
+<p>
+<label>Original source URL for CC or public domain music <input name="source_url" type="url" placeholder="https://…">
+</label>
+</p>
+<p>
+<label>Changes to the original (for CC music, write “none” if unchanged) <input name="changes_note" maxlength="200">
+</label>
+</p>
+<p>
+<label class="radio-check">
+<input type="checkbox" name="rights_confirmed" value="yes" required> I confirm the recording and composition can be streamed publicly under these rights, and I can provide the required attribution. Public domain means free of known copyright restrictions worldwide.</label>
+</p>
+<button type="submit">Publish track</button>
+</form>
+<p>
+<a href="/radio/licence">Read the creator grant</a> · <a href="/files/${encodeURIComponent(file.id)}">Back to file</a>
+</p>
+</main>`);
+ } catch (e) { next(e); }
+ });
+ r.post('/radio/publish', async (req, res, next) => {
+ try {
+ const who = owner(req);
+ if (!who) return res.status(401).end();
+ if (!sameOrigin(req, config.baseUrl)) return res.status(403).end();
+ const body = req.body || {};
+ const file = await drive.ownFile(who, String(body.file || ''));
+ if (!file || !AUDIO.has(file.content_type)) return res.status(404).end();
+ const grant = validGrant(body);
+ if (!grant) return res.status(422).type('text/plain').send('Choose a permitted licence, give its original source and changes where required, and confirm your rights.');
+ const id = s.newId('rad');
+ await s.db.query(`INSERT INTO mh_radio_tracks (id, file_id, owner, title, artist, source_kind, source_url, licence, licence_url, changes_note, ai_made)
+ VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11)`, [id, file.id, who, grant.title, grant.artist, grant.kind, grant.source, grant.licence, grant.licenceUrl, grant.changes, grant.aiMade]);
+ return res.redirect(303, `/radio/tracks/${encodeURIComponent(id)}`);
+ } catch (e) { if (e.code === '23505') return res.status(409).type('text/plain').send('This file is already published.'); next(e); }
+ });
+ r.get('/radio/tracks/:id', async (req, res, next) => {
+ try { const t = await row(req.params.id); return t ? res.type('html').send(`<!doctype html><meta name="viewport" content="width=device-width,initial-scale=1"><title>${esc(t.title)} · OpenVibe Radio</title><link rel="stylesheet" href="/css/radio.css"><main class="radio-page"><h1>${esc(t.title)}</h1><p>${esc(t.artist)}</p><audio controls src="/api/v1/radio/tracks/${encodeURIComponent(t.id)}/audio"></audio><p>Source: ${esc(t.source_kind)}${t.source_url ? ` · <a href="${esc(t.source_url)}">Original source</a>` : ''}</p><p>Licence or status: <a href="${esc(t.licence_url)}">${esc(t.licence === 'public_domain' ? 'Public domain mark' : t.licence === 'creator_grant' ? 'Creator permission to stream' : t.licence)}</a>${t.changes_note ? ` · Changes: ${esc(t.changes_note)}` : ''}${t.ai_made ? ' · AI-made music' : ''}</p><p><a href="https://openvibe.party/new?radio=${encodeURIComponent(t.id)}">Listen together on OpenVibe.Party</a></p>${owner(req) === t.owner ? `<form method="post" action="/radio/tracks/${encodeURIComponent(t.id)}/unpublish"><button type="submit">Unpublish</button></form>` : ''}</main>`) : res.status(404).end(); }
+ catch (e) { next(e); }
+ });
+ r.post('/radio/tracks/:id/unpublish', async (req, res, next) => {
+ try {
+ const who = owner(req);
+ if (!who || !sameOrigin(req, config.baseUrl)) return res.status(403).end();
+ await s.db.exec('UPDATE mh_radio_tracks SET revoked_at = now() WHERE id = $1 AND owner = $2 AND revoked_at IS NULL', [req.params.id, who]);
+ return res.redirect(303, '/files');
+ } catch (e) { next(e); }
+ });
+ return r;
+}
+
+module.exports = { createRadioRoutes, validGrant };
new file mode 100644
new file mode 100644
@@ -0,0 +1,64 @@
@@ -0,0 +1,64 @@
+'use strict';
+const assert = require('assert');
+const { boot, check, done } = require('./helpers/boot');
+const { SAME, bytes, upload } = require('./helpers/drive');
+
+(async () => {
+ const t = await boot();
+ const ann = t.network.addUser('ann');
+ const bob = t.network.addUser('bob');
+ const file = (await upload(t, ann, bytes(300, 'a song'), { name: 'song.mp3', contentType: 'audio/mpeg' })).json().file;
+ const form = (extra = {}) => ({ file: file.id, title: 'Our Song', artist: 'Ann', source_kind: 'creator', licence: 'creator_grant', rights_confirmed: 'yes', ...extra });
+ let track;
+
+ await check('only an audio file owner can publish with a streaming grant', async () => {
+ assert.strictEqual((await t.get('/radio/publish', { as: ann, form: form(), headers: SAME })).status, 303);
+ const tracks = (await t.get('/api/v1/radio/tracks')).json().tracks;
+ assert.strictEqual(tracks.length, 1);
+ track = tracks[0];
+ assert.strictEqual(track.licence, 'creator_grant');
+ assert.strictEqual(track.source, 'creator');
+ const filePage = await t.get(`/files/${file.id}`, { as: ann });
+ assert.match(filePage.text, new RegExp(`/radio/tracks/${track.id}`));
+ assert.ok(!filePage.text.includes('Publish as music'), 'a published file links to its track');
+ assert.strictEqual((await t.get('/radio/publish', { as: bob, form: form(), headers: SAME })).status, 404);
+ assert.strictEqual((await t.get('/radio/publish', { as: ann, form: form() })).status, 403);
+ });
+
+ await check('licence gate rejects unsupported or unproved sources and labels AI music', async () => {
+ const second = (await upload(t, ann, bytes(300, 'another'), { name: 'second.mp3', contentType: 'audio/mpeg' })).json().file;
+ const post = (extra) => t.get('/radio/publish', { as: ann, form: { ...form({ file: second.id }), ...extra }, headers: SAME });
+ assert.strictEqual((await post({ licence: 'all_rights_reserved' })).status, 422);
+ assert.strictEqual((await post({ source_kind: 'creative_commons', licence: 'CC-BY-4.0', source_url: '' })).status, 422);
+ assert.strictEqual((await post({ source_kind: 'creative_commons', licence: 'CC-BY-4.0', source_url: 'https://example.org/cc-song' })).status, 422, 'CC attribution records whether it was changed');
+ assert.strictEqual((await post({ rights_confirmed: 'no' })).status, 422);
+ assert.strictEqual((await post({ source_kind: 'ai' })).status, 303);
+ const ai = (await t.get('/api/v1/radio/tracks')).json().tracks.find((x) => x.source === 'ai');
+ assert.strictEqual(ai.ai_made, true);
+ assert.strictEqual(ai.licence, 'creator_grant');
+ const ccFile = (await upload(t, ann, bytes(300, 'cc'), { name: 'cc.ogg', contentType: 'audio/ogg' })).json().file;
+ assert.strictEqual((await post({ file: ccFile.id, source_kind: 'creative_commons', licence: 'CC-BY-4.0', source_url: 'https://example.org/cc-song', changes_note: 'none' })).status, 303);
+ const cc = (await t.get('/api/v1/radio/tracks')).json().tracks.find((x) => x.licence === 'CC-BY-4.0');
+ assert.strictEqual(cc.source_url, 'https://example.org/cc-song');
+ assert.strictEqual(cc.changes_note, 'none');
+ const pdFile = (await upload(t, ann, bytes(300, 'pd'), { name: 'pd.mp3', contentType: 'audio/mpeg' })).json().file;
+ assert.strictEqual((await post({ file: pdFile.id, source_kind: 'public_domain', licence: 'public_domain', source_url: 'https://example.org/archive' })).status, 303);
+ const pd = (await t.get('/api/v1/radio/tracks')).json().tracks.find((x) => x.source === 'public_domain');
+ assert.strictEqual(pd.source_url, 'https://example.org/archive');
+ });
+
+ await check('public audio supports ranges and disappears when unpublished', async () => {
+ const path = `/api/v1/radio/tracks/${track.id}/audio`;
+ const stream = await t.get(path, { headers: { range: 'bytes=10-19' } });
+ assert.strictEqual(stream.status, 206);
+ assert.strictEqual(stream.buffer.length, 10);
+ assert.match(stream.headers.get('content-type'), /audio\/mpeg/);
+ assert.strictEqual((await t.get(`/radio/tracks/${track.id}/unpublish`, { as: bob, form: {}, headers: SAME })).status, 303);
+ assert.strictEqual((await t.get(path)).status, 200, 'another user cannot revoke it');
+ assert.strictEqual((await t.get(`/radio/tracks/${track.id}/unpublish`, { as: ann, form: {}, headers: SAME })).status, 303);
+ assert.strictEqual((await t.get(path)).status, 404);
+ });
+
+ await t.close();
+ done();
+})().catch((e) => { console.error(e); process.exit(1); });