openvibers/openvibe.mediahub Commits

Publish licensed audio to OpenVibe Radio (#335)

OP OpenVibers committed 5 days ago · 8a940741aa · 7 files, +269 −0

* Publish licensed audio files to OpenVibe Radio

* Show published Radio track on its audio file

* Radio publish: the title starts as the file's name without its extension

Changes

ddiff --git a/migrations/0026_radio_tracks.sql b/migrations/0026_radio_tracks.sql
nnew file mode 100644
iindex 0000000..19f084f
---- /dev/null
++++ b/migrations/0026_radio_tracks.sql
@@@ -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;
ddiff --git a/public/css/radio.css b/public/css/radio.css
nnew file mode 100644
iindex 0000000..8fd6815
---- /dev/null
++++ b/public/css/radio.css
@@@ -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; } }
ddiff --git a/server/account.js b/server/account.js
iindex 0200935..74f0a9f 100644
---- a/server/account.js
++++ b/server/account.js
@@@ -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
ddiff --git a/server/app.js b/server/app.js
iindex 3fdd900..ce92aa9 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -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 }));
ddiff --git a/server/http/pages.js b/server/http/pages.js
iindex 85eb1de..2065c76 100644
---- a/server/http/pages.js
++++ b/server/http/pages.js
@@@ -448,6 +448,8 @@ ${files.length ? html`<ul class="dr-files" data-items>${files.map((row) => ui.fi
const 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">
ddiff --git a/server/radio/routes.js b/server/radio/routes.js
nnew file mode 100644
iindex 0000000..eaca13d
---- /dev/null
++++ b/server/radio/routes.js
@@@ -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) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[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 };
ddiff --git a/test/radio.test.js b/test/radio.test.js
nnew file mode 100644
iindex 0000000..df99fb4
---- /dev/null
++++ b/test/radio.test.js
@@@ -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); });