openvibers/openvibe.media Commits

Integration fixes: response shapes, list filters, ownership checks, legacy basename + screenshot serving, multipart clip upload

OP OpenVibers committed 17 Aug 2026 · 842b30fe89 · 9 files, +300 −51


Changes

ddiff --git a/README.md b/README.md
iindex 1bd380e..969128f 100644
---- a/README.md
++++ b/README.md
@@@ -71,7 +71,7 @@ Two credential types on `/api/v1/:app/...`:
On every boot the service upserts apps from env (keys are hashed):
```bash
--MEDIA_APPS_SEED='[{"app_id":"live","name":"OpenVibe.Live","api_key":"…","webhook_url":"http://127.0.0.1:3000/api/media-webhook","webhook_secret":"…","allowed_origins":["https://openvibe.live"],"quota_bytes":0}]'
++MEDIA_APPS_SEED='[{"app_id":"live","name":"OpenVibe.Live","api_key":"…","webhook_url":"http://127.0.0.1:3000/internal/media-webhook","webhook_secret":"…","allowed_origins":["https://openvibe.live"],"quota_bytes":0}]'
# fallback short form (no webhooks/origins):
MEDIA_APP_KEYS="live:key1,games:key2"
```
@@@ -82,7 +82,7 @@ MEDIA_APP_KEYS="live:key1,games:key2"
| method | path | notes |
|---|---|---|
--| POST | `/vods` | `{ title?, stream_id?, stream_key?, user_id?, meta?, visibility?, clips_only? }` → `{ id }` |
++| POST | `/vods` | `{ title?, stream_id?, stream_key?, managed_stream_id?, user_id?, meta?, visibility?, clips_only? }` → `{ id }` |
| POST | `/vods/:id/ingest/rtmp` | `{ rtmp_url }` → **202**; ffmpeg pulls the URL, lossless stream-copy → fragmented `.mp4` |
| POST | `/vods/:id/ingest/rtp/start` | `{ video: {payloadType, codec, clockRate, ssrc?, parameters?}, audio?: {…} }` → `{ videoPort, audioPort }` from UDP 12000-12199; point PlainRtpTransports at `127.0.0.1` (RTCP = port+1) |
| POST | `/vods/:id/ingest/rtp/stop` | finalizes the recording |
@@@ -90,7 +90,7 @@ MEDIA_APP_KEYS="live:key1,games:key2"
| POST | `/vods/:id/complete` | finalize chunked upload (user JWT ok) |
| POST | `/vods/:id/finalize` | close recording; remux, probe, thumbnail, webhook |
| GET | `/vods/:id` | `{ id, title, status, duration, playback_url, thumbnail_url, storage_provider, … }` |
--| GET | `/vods?limit&offset&user_id&stream_id` | list |
++| GET | `/vods?limit&offset&user_id&stream_id&managed_stream_id&include_private&order` | list; `include_private` app-key only; `order` = newest\|oldest\|views |
| PUT | `/vods/:id` | `{ title?, description?, visibility? }` |
| DELETE | `/vods/:id` | deletes local + B2 + R2 objects + row |
@@@ -108,9 +108,9 @@ lossless `.master.mkv` recovery archive. A `.seekable` sidecar is remuxed every
| method | path | notes |
|---|---|---|
--| POST | `/clips` | `{ vod_id, start_s, end_s, title?, user_id? }` → **202** `{ id, status: 'processing' }`; cut runs in background (from the local file or a presigned B2/R2 URL); duplicate windows are deduplicated; live recordings are clamped to flushed footage |
++| POST | `/clips` | `{ vod_id, start_s, end_s, title?, user_id?, visibility? }` → **202** `{ id, status: 'processing' }`; cut runs in background (from the local file or a presigned B2/R2 URL); duplicate windows are deduplicated; live recordings are clamped to flushed footage. Multipart `video` = direct upload of an already-cut blob → **201** ready |
| GET | `/clips/:id` | status: `processing | ready | failed` |
--| GET | `/clips?limit&offset&vod_id&stream_id&user_id` | list |
++| GET | `/clips?limit&offset&vod_id&stream_id&user_id&channel_user_id&hide_self&include_private&order` | list; `channel_user_id` = clipped-channel owner; `include_private` app-key only |
| PUT | `/clips/:id` | `{ title?, visibility? }` |
| DELETE | `/clips/:id` | local + offloaded objects + row |
@@@ -120,6 +120,7 @@ lossless `.master.mkv` recovery archive. A `.seekable` sidecar is remuxed every
|---|---|---|
| POST | `/pastes` | `{ title?, content?, language?, user_id?, visibility?, burn_after_read?, is_nsfw? }` or multipart with `screenshot` image (EXIF-stripped via sharp) → `{ id, slug, url }` |
| GET | `/pastes?limit&offset&type&search&user_id` | public list |
++| GET | `/pastes/config` | paste limits (`maxSizeKb`, `cooldownSeconds`, `maxPerUserPerDay`, `todayCount`, …) |
| GET | `/pastes/:slug` | full paste (private: owner/app only) |
| PUT | `/pastes/:slug` | update (owner/app) |
| DELETE | `/pastes/:slug` | delete + screenshot (local & legacy B2 object) |
@@@ -160,13 +161,14 @@ the app's webhook_secret>`. 3 attempts with backoff, 10 s timeout.
| path | behavior |
|---|---|
--| `GET /v/:id` | VOD playback — local stream with Range support, live-DVR `.seekable` sidecar while recording, or **302** to a presigned B2/R2 URL. `X-Robots-Tag: noindex` |
++| `GET /v/:id` | VOD playback — local stream with Range support, live-DVR `.seekable` sidecar while recording, or **302** to a presigned B2/R2 URL. `X-Robots-Tag: noindex`. Also accepts a legacy **file basename** (old `/api/vods/file/<name>` URLs; clip basenames resolve too) |
| `GET /c/:id` | clip playback, same logic, `noindex` |
| `GET /p/:slug` | server-rendered paste HTML page (**indexable** — Media is the canonical home for pastes) |
| `GET /p/:slug/raw` | `text/plain` |
| `GET /p/:slug/screenshot` | paste screenshot image |
| `GET /t/:id` | thumbnail (id = filename), `noindex` |
| `GET /f/:key` | file with stored Content-Type + Range, `noindex` |
++| `GET /f/screenshots/:name` | paste screenshot by filename — serves straight from `PASTES_PATH/screenshots` (migrated legacy files have no files-table rows), `noindex` |
Private items respond 403/404 unless the request bears the owning app's API
key or the owning user's JWT.
ddiff --git a/scripts/smoke-test.sh b/scripts/smoke-test.sh
iindex 323b00a..1066822 100755
---- a/scripts/smoke-test.sh
++++ b/scripts/smoke-test.sh
@@@ -70,8 +70,8 @@ check "complete → vod ready" "ready" "$ST"
echo "-- vod: meta shows duration + thumbnail"
R=$(curl -s "$BASE/api/v1/live/vods/$VOD_ID" -H "$AUTH")
--DUR=$(jsonget "$R" vod.duration)
--THUMB=$(jsonget "$R" vod.thumbnail_url)
++DUR=$(jsonget "$R" duration)
++THUMB=$(jsonget "$R" thumbnail_url)
[ "$DUR" = "5" ] && ok "duration probed = 5s" || bad "duration (got '$DUR')"
case "$THUMB" in /t/*) ok "thumbnail_url = $THUMB";; *) bad "thumbnail_url (got '$THUMB')";; esac
TCODE=$(curl -s -o "$WORK/thumb.jpg" -w '%{http_code}' "$BASE$THUMB")
@@@ -85,12 +85,12 @@ CLIP_ID=$(jsonget "$R" id)
CLIP_ST=""
for i in $(seq 1 60); do
sleep 0.5
-- CLIP_ST=$(jsonget "$(curl -s "$BASE/api/v1/live/clips/$CLIP_ID" -H "$AUTH")" clip.status)
++ CLIP_ST=$(jsonget "$(curl -s "$BASE/api/v1/live/clips/$CLIP_ID" -H "$AUTH")" status)
[ "$CLIP_ST" = "ready" ] && break
[ "$CLIP_ST" = "failed" ] && break
done
check "clip processed" "ready" "$CLIP_ST"
--CDUR=$(jsonget "$(curl -s "$BASE/api/v1/live/clips/$CLIP_ID" -H "$AUTH")" clip.duration)
++CDUR=$(jsonget "$(curl -s "$BASE/api/v1/live/clips/$CLIP_ID" -H "$AUTH")" duration)
echo " (clip duration: ${CDUR}s)"
CCODE=$(curl -s -o /dev/null -w '%{http_code}' "$BASE/c/$CLIP_ID")
check "GET /c/$CLIP_ID plays" "200" "$CCODE"
ddiff --git a/server/db/database.js b/server/db/database.js
iindex 36ef81f..5331b20 100644
---- a/server/db/database.js
++++ b/server/db/database.js
@@@ -27,10 +27,28 @@ function getDb() {
database.pragma('journal_mode = WAL');
const schema = fs.readFileSync(path.join(__dirname, 'schema.sql'), 'utf8');
database.exec(schema);
++ migrateColumns();
seedSettings();
return database;
}
++// Additive column migrations for DBs created before the column existed
++// (CREATE TABLE IF NOT EXISTS won't add columns to an existing table).
++function migrateColumns() {
++ const wanted = {
++ vods: [['managed_stream_id', 'INTEGER']],
++ clips: [['channel_user_id', 'INTEGER']],
++ };
++ for (const [table, cols] of Object.entries(wanted)) {
++ const existing = database.prepare(`PRAGMA table_info(${table})`).all().map(c => c.name);
++ for (const [name, type] of cols) {
++ if (existing.includes(name)) continue;
++ database.exec(`ALTER TABLE ${table} ADD COLUMN ${name} ${type}`);
++ console.log(`[DB] Added ${table}.${name}`);
++ }
++ }
++}
++
function seedSettings() {
const defaults = [
// Clip system
@@@ -118,16 +136,30 @@ function appAllowedOrigins(app) {
// ── VOD helpers ──────────────────────────────────────────────
--function createVod({ app_id, stream_id, stream_key, user_id, title, description, file_path, file_size, duration_seconds, thumbnail_url, master_file_path, meta }) {
++function createVod({ app_id, stream_id, stream_key, managed_stream_id, user_id, title, description, file_path, file_size, duration_seconds, thumbnail_url, master_file_path, meta }) {
return run(
-- `INSERT INTO vods (app_id, stream_id, stream_key, user_id, title, description, file_path, master_file_path, file_size, duration_seconds, thumbnail_url, meta_json, is_public)
-- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1)`,
-- [app_id, stream_id || null, stream_key || null, user_id || null, title || 'Recording', description || '',
++ `INSERT INTO vods (app_id, stream_id, stream_key, managed_stream_id, user_id, title, description, file_path, master_file_path, file_size, duration_seconds, thumbnail_url, meta_json, is_public)
++ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1)`,
++ [app_id, stream_id || null, stream_key || null, managed_stream_id || null, user_id || null, title || 'Recording', description || '',
file_path || null, master_file_path || null, file_size || 0, duration_seconds || 0, thumbnail_url || null,
JSON.stringify(meta || {})]
);
}
++/** Legacy lookup: resolve a row by its file's basename (old /api/vods/file/<name> URLs). */
++function _byFileBasename(table, basename, appId) {
++ const name = path.basename(String(basename || ''));
++ if (!name) return null;
++ const clause = appId ? ' AND app_id = ?' : '';
++ const params = appId ? [`%${name}`, appId] : [`%${name}`];
++ // LIKE narrows the scan; exact basename match is confirmed in JS (LIKE
++ // wildcards inside the filename can't produce false positives that way).
++ const rows = all(`SELECT * FROM ${table} WHERE file_path LIKE ?${clause}`, params);
++ return rows.find(r => r.file_path && path.basename(r.file_path) === name) || null;
++}
++function getVodByFileBasename(basename, appId = null) { return _byFileBasename('vods', basename, appId); }
++function getClipByFileBasename(basename, appId = null) { return _byFileBasename('clips', basename, appId); }
++
function getVodById(id, appId = null) {
const clause = appId ? ' AND app_id = ?' : '';
const params = appId ? [id, appId] : [id];
@@@ -135,21 +167,37 @@ function getVodById(id, appId = null) {
FROM vods WHERE id = ?${clause}`, params);
}
--function listVods(appId, { limit = 50, offset = 0, user_id = null, stream_id = null, includeRecording = true } = {}) {
++// Sort orders shared by the vod/clip lists (inherited query shapes). The
++// predecessor's 'peak_viewers' needed a streams join that lives in the owning
++// app now — view_count is the closest popularity proxy here.
++const LIST_ORDERS = {
++ newest: 'created_at DESC',
++ oldest: 'created_at ASC',
++ views: 'view_count DESC, created_at DESC',
++ peak_viewers: 'view_count DESC, created_at DESC',
++};
++function _listOrder(order) { return LIST_ORDERS[order] || LIST_ORDERS.newest; }
++
++function _vodConds(appId, { user_id = null, stream_id = null, managed_stream_id = null, include_private = false, includeRecording = true } = {}) {
const conds = ['app_id = ?', 'COALESCE(clips_only, 0) = 0'];
const params = [appId];
if (!includeRecording) conds.push('COALESCE(is_recording, 0) = 0');
++ if (!include_private) conds.push('is_public = 1');
if (user_id != null) { conds.push('user_id = ?'); params.push(user_id); }
if (stream_id != null) { conds.push('stream_id = ?'); params.push(stream_id); }
++ if (managed_stream_id != null) { conds.push('managed_stream_id = ?'); params.push(managed_stream_id); }
++ return { conds, params };
++}
++
++function listVods(appId, filters = {}) {
++ const { limit = 50, offset = 0, order = 'newest' } = filters;
++ const { conds, params } = _vodConds(appId, filters);
params.push(limit, offset);
-- return all(`SELECT * FROM vods WHERE ${conds.join(' AND ')} ORDER BY created_at DESC LIMIT ? OFFSET ?`, params);
++ return all(`SELECT * FROM vods WHERE ${conds.join(' AND ')} ORDER BY ${_listOrder(order)} LIMIT ? OFFSET ?`, params);
}
--function countVods(appId, { user_id = null, stream_id = null } = {}) {
-- const conds = ['app_id = ?', 'COALESCE(clips_only, 0) = 0'];
-- const params = [appId];
-- if (user_id != null) { conds.push('user_id = ?'); params.push(user_id); }
-- if (stream_id != null) { conds.push('stream_id = ?'); params.push(stream_id); }
++function countVods(appId, filters = {}) {
++ const { conds, params } = _vodConds(appId, filters);
return get(`SELECT COUNT(*) AS count FROM vods WHERE ${conds.join(' AND ')}`, params)?.count || 0;
}
@@@ -220,12 +268,12 @@ function vodStatus(vod) {
// ── Clip helpers ─────────────────────────────────────────────
--function createClip({ app_id, vod_id, stream_id, user_id, title, description, file_path, thumbnail_url, start_time, end_time, duration_seconds, is_public, auto_generated, status }) {
++function createClip({ app_id, vod_id, stream_id, user_id, channel_user_id, title, description, file_path, thumbnail_url, start_time, end_time, duration_seconds, is_public, auto_generated, status }) {
const pub = (is_public === 0 || is_public === false) ? 0 : 1;
return run(
-- `INSERT INTO clips (app_id, vod_id, stream_id, user_id, title, description, file_path, thumbnail_url, start_time, end_time, duration_seconds, is_public, visibility, auto_generated, status)
-- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
-- [app_id, vod_id || null, stream_id || null, user_id || null, title || 'Untitled Clip', description || '',
++ `INSERT INTO clips (app_id, vod_id, stream_id, user_id, channel_user_id, title, description, file_path, thumbnail_url, start_time, end_time, duration_seconds, is_public, visibility, auto_generated, status)
++ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
++ [app_id, vod_id || null, stream_id || null, user_id || null, channel_user_id || null, title || 'Untitled Clip', description || '',
file_path || '', thumbnail_url || null, start_time || 0, end_time || 0, duration_seconds || 0,
pub, pub ? 'public' : 'unlisted', auto_generated ? 1 : 0, status || 'ready']
);
@@@ -237,22 +285,29 @@ function getClipById(id, appId = null) {
return get(`SELECT * FROM clips WHERE id = ?${clause}`, params);
}
--function listClips(appId, { limit = 50, offset = 0, vod_id = null, stream_id = null, user_id = null } = {}) {
++function _clipConds(appId, { vod_id = null, stream_id = null, user_id = null, channel_user_id = null, include_private = false, hide_self = false } = {}) {
const conds = ['app_id = ?'];
const params = [appId];
++ if (!include_private) conds.push('is_public = 1');
if (vod_id != null) { conds.push('vod_id = ?'); params.push(vod_id); }
if (stream_id != null) { conds.push('stream_id = ?'); params.push(stream_id); }
if (user_id != null) { conds.push('user_id = ?'); params.push(user_id); }
++ // channel_user_id = owner of the clipped channel ("clips taken OF this streamer")
++ if (channel_user_id != null) { conds.push('channel_user_id = ?'); params.push(channel_user_id); }
++ // hide_self hides self-clips (creator == channel owner) — clips-taken tab default
++ if (hide_self) conds.push('(channel_user_id IS NULL OR channel_user_id != user_id)');
++ return { conds, params };
++}
++
++function listClips(appId, filters = {}) {
++ const { limit = 50, offset = 0, order = 'newest' } = filters;
++ const { conds, params } = _clipConds(appId, filters);
params.push(limit, offset);
-- return all(`SELECT * FROM clips WHERE ${conds.join(' AND ')} ORDER BY created_at DESC LIMIT ? OFFSET ?`, params);
++ return all(`SELECT * FROM clips WHERE ${conds.join(' AND ')} ORDER BY ${_listOrder(order)} LIMIT ? OFFSET ?`, params);
}
--function countClips(appId, { vod_id = null, stream_id = null, user_id = null } = {}) {
-- const conds = ['app_id = ?'];
-- const params = [appId];
-- if (vod_id != null) { conds.push('vod_id = ?'); params.push(vod_id); }
-- if (stream_id != null) { conds.push('stream_id = ?'); params.push(stream_id); }
-- if (user_id != null) { conds.push('user_id = ?'); params.push(user_id); }
++function countClips(appId, filters = {}) {
++ const { conds, params } = _clipConds(appId, filters);
return get(`SELECT COUNT(*) AS count FROM clips WHERE ${conds.join(' AND ')}`, params)?.count || 0;
}
@@@ -440,10 +495,10 @@ module.exports = {
// apps
hashApiKey, getApp, listApps, upsertApp, appAllowedOrigins,
// vods
-- createVod, getVodById, listVods, countVods, setVodVisibility, vodStatus,
++ createVod, getVodById, getVodByFileBasename, listVods, countVods, setVodVisibility, vodStatus,
updateVodHealth, repairVodDuration, getVodsNeedingHealthScan, getQuarantinedVodsForCleanup,
// clips
-- createClip, getClipById, listClips, countClips, setClipVisibility, findDuplicateClip,
++ createClip, getClipById, getClipByFileBasename, listClips, countClips, setClipVisibility, findDuplicateClip,
// pastes
getPasteBySlug, likePaste, unlikePaste, hasUserLikedPaste, incrementPasteCopies,
countUserPastesToday, getLastPasteTime,
ddiff --git a/server/db/schema.sql b/server/db/schema.sql
iindex 9793ce8..7df3e21 100644
---- a/server/db/schema.sql
++++ b/server/db/schema.sql
@@@ -24,6 +24,7 @@ CREATE TABLE IF NOT EXISTS vods (
app_id TEXT NOT NULL DEFAULT 'live',
stream_id INTEGER,
stream_key TEXT,
++ managed_stream_id INTEGER, -- owning app's stream "slot" (list filter)
user_id INTEGER,
title TEXT,
description TEXT DEFAULT '',
@@@ -65,6 +66,7 @@ CREATE TABLE IF NOT EXISTS clips (
vod_id INTEGER,
stream_id INTEGER,
user_id INTEGER,
++ channel_user_id INTEGER, -- owner of the clipped channel/stream (list filter)
title TEXT DEFAULT 'Untitled Clip',
description TEXT DEFAULT '',
file_path TEXT,
ddiff --git a/server/pastes/routes.js b/server/pastes/routes.js
iindex 25cccd0..a61933c 100644
---- a/server/pastes/routes.js
++++ b/server/pastes/routes.js
@@@ -257,6 +257,25 @@ router.get('/', tenantAuth({ allowUser: true }), (req, res) => {
}
});
++// ── Paste config / limits (inherited SPA reads this before posting) ──
++// Registered before /:slug so the literal path wins.
++router.get('/config', tenantAuth({ allowUser: true }), (req, res) => {
++ try {
++ const cfg = getPasteConfig();
++ const actor = _actor(req);
++ res.json({
++ maxSizeKb: cfg.maxSizeKb,
++ screenshotMaxSizeMb: cfg.screenshotMaxSizeMb,
++ cooldownSeconds: cfg.cooldownSeconds,
++ maxPerUserPerDay: cfg.maxPerUserPerDay,
++ todayCount: db.countUserPastesToday(req.appId, actor.userId, actor.ip),
++ });
++ } catch (err) {
++ console.error('[Pastes] Config error:', err.message);
++ res.status(500).json({ error: 'Failed to load paste config' });
++ }
++});
++
// ── Get single paste by slug ────────────────────────────────
router.get('/:slug', tenantAuth({ allowUser: true }), (req, res) => {
try {
ddiff --git a/server/public/routes.js b/server/public/routes.js
iindex 35f88ec..e574bb3 100644
---- a/server/public/routes.js
++++ b/server/public/routes.js
@@@ -153,12 +153,22 @@ async function serveMediaRecord(kind, record, req, res) {
}
// ── VOD playback ─────────────────────────────────────────────
++// Accepts a numeric id OR a legacy file basename (old /api/vods/file/<name>
++// URLs in the wild redirect here with the basename; migrated rows keep their
++// original file_path basenames). The old route served CLIP files too, so a
++// basename that matches a clip serves that clip.
router.get('/v/:id', optionalIdentity, async (req, res) => {
try {
-- if (!/^\d+$/.test(req.params.id)) return res.status(404).json({ error: 'Not found' });
-- const vod = db.getVodById(parseInt(req.params.id, 10));
-- if (!vod || vod.clips_only) return res.status(404).json({ error: 'Not found' });
-- await serveMediaRecord('vod', vod, req, res);
++ if (/^\d+$/.test(req.params.id)) {
++ const vod = db.getVodById(parseInt(req.params.id, 10));
++ if (!vod || vod.clips_only) return res.status(404).json({ error: 'Not found' });
++ return await serveMediaRecord('vod', vod, req, res);
++ }
++ const vod = db.getVodByFileBasename(req.params.id);
++ if (vod && !vod.clips_only) return await serveMediaRecord('vod', vod, req, res);
++ const clip = db.getClipByFileBasename(req.params.id);
++ if (clip) return await serveMediaRecord('clip', clip, req, res);
++ res.status(404).json({ error: 'Not found' });
} catch (err) {
console.error('[Public] /v error:', err.message);
if (!res.headersSent) res.status(500).json({ error: 'Failed to serve media' });
@@@ -183,6 +193,26 @@ router.get('/t/:id', (req, res) => {
require('../thumbnails/thumbnail-service').serveThumbnail(req, res);
});
++// ── Paste screenshots by filename ────────────────────────────
++// Legacy /data/pastes/screenshots/<name> URLs (old avatars, hero moments,
++// pre-cutover pastes) map here. Migrated screenshot files have arbitrary
++// basenames on disk and NO files-table rows, so this serves straight from
++// PASTES_PATH/screenshots for exactly that namespace.
++router.get('/f/screenshots/:name', (req, res) => {
++ try {
++ const name = path.basename(String(req.params.name || ''));
++ const dir = path.join(require('../config').pastes.path, 'screenshots');
++ const filePath = path.join(dir, name);
++ if (!name || !filePath.startsWith(dir) || !fs.existsSync(filePath) || !fs.statSync(filePath).isFile()) {
++ return res.status(404).json({ error: 'Not found' });
++ }
++ streamFileWithRange(req, res, filePath, { 'Cache-Control': 'public, max-age=86400', 'X-Robots-Tag': 'noindex' });
++ } catch (err) {
++ console.error('[Public] /f/screenshots error:', err.message);
++ if (!res.headersSent) res.status(500).json({ error: 'Failed to serve file' });
++ }
++});
++
// ── Files ────────────────────────────────────────────────────
router.get('/f/:key', (req, res) => {
try {
ddiff --git a/server/vod/clips-routes.js b/server/vod/clips-routes.js
iindex 123ef00..b7a95c7 100644
---- a/server/vod/clips-routes.js
++++ b/server/vod/clips-routes.js
@@@ -14,7 +14,10 @@
'use strict';
const express = require('express');
++const path = require('path');
const fs = require('fs');
++const multer = require('multer');
++const config = require('../config');
const db = require('../db/database');
const tools = require('./media-tools');
const cutter = require('./clip-cutter');
@@@ -24,6 +27,32 @@ const { sendWebhook } = require('../webhooks');
const router = express.Router({ mergeParams: true });
router.use(tenantCors);
++// ── Direct clip upload (browser MediaRecorder blob → multipart `video`) ──
++// Blobs can arrive with codec-qualified types, empty types, or
++// application/octet-stream depending on the browser/platform.
++const CLIP_MIME_TO_EXT = { 'video/webm': '.webm', 'video/mp4': '.mp4', 'video/x-matroska': '.mkv', 'video/ogg': '.ogg' };
++function baseMediaType(mime) { return (mime || '').split(';')[0].trim().toLowerCase(); }
++const clipUploadStorage = multer.diskStorage({
++ destination: (_req, _file, cb) => {
++ const dir = path.resolve(config.vod.clipsPath);
++ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
++ cb(null, dir);
++ },
++ filename: (req, file, cb) => {
++ const ext = CLIP_MIME_TO_EXT[baseMediaType(file.mimetype)] || '.webm';
++ cb(null, `clip-${req.params.app}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}${ext}`);
++ },
++});
++const clipUpload = multer({
++ storage: clipUploadStorage,
++ limits: { fileSize: config.vod.maxSizeMb * 1024 * 1024 },
++ fileFilter: (_req, file, cb) => {
++ const base = baseMediaType(file.mimetype);
++ if (CLIP_MIME_TO_EXT[base] || base === 'application/octet-stream' || base === '' || base.startsWith('video/')) cb(null, true);
++ else cb(new Error('Only video files are allowed for clips'));
++ },
++});
++
function clipPublic(clip) {
if (!clip) return null;
return {
@@@ -31,6 +60,7 @@ function clipPublic(clip) {
app_id: clip.app_id,
vod_id: clip.vod_id,
stream_id: clip.stream_id,
++ channel_user_id: clip.channel_user_id || null,
user_id: clip.user_id,
title: clip.title,
description: clip.description,
@@@ -38,6 +68,9 @@ function clipPublic(clip) {
start_time: clip.start_time,
end_time: clip.end_time,
duration: clip.duration_seconds || 0,
++ duration_seconds: clip.duration_seconds || 0,
++ // Basename only — the inherited SPA derives its playback URL from this.
++ file_path: clip.file_path ? path.basename(clip.file_path) : null,
playback_url: `/c/${clip.id}`,
thumbnail_url: clip.thumbnail_url || null,
visibility: clip.visibility || 'public',
@@@ -62,10 +95,16 @@ function _getClipScoped(req, res) {
return clip;
}
++const VALID_VIS = new Set(['public', 'unlisted', 'private']);
++
// ── Create clip ──────────────────────────────────────────────
--router.post('/', tenantAuth({ allowUser: true }), async (req, res) => {
++// JSON body: cut a window out of a VOD (202, background cut + webhook).
++// Multipart `video`: direct upload of an already-cut blob (201, ready) —
++// inherited browser-MediaRecorder clip path.
++router.post('/', tenantAuth({ allowUser: true }), clipUpload.single('video'), async (req, res) => {
try {
const body = req.body || {};
++ if (req.file) return _createUploadedClip(req, res);
const vodId = parseInt(body.vod_id, 10);
if (!Number.isFinite(vodId)) return res.status(400).json({ error: 'vod_id is required' });
const vod = db.getVodById(vodId, req.appId);
@@@ -123,20 +162,23 @@ router.post('/', tenantAuth({ allowUser: true }), async (req, res) => {
}
const userId = body.user_id != null ? body.user_id : (req.userId ?? null);
++ const visibility = VALID_VIS.has(body.visibility) ? body.visibility : 'public';
const result = db.createClip({
app_id: req.appId,
vod_id: vodId,
-- stream_id: vod.stream_id || null,
++ stream_id: vod.stream_id || (body.stream_id != null ? parseInt(body.stream_id, 10) || null : null),
user_id: userId,
++ channel_user_id: body.channel_user_id != null ? body.channel_user_id : (vod.user_id ?? null),
title: sanitizeClipTitle(body.title, vod.title ? `Clip: ${vod.title}`.slice(0, 200) : 'Untitled Clip'),
file_path: '',
start_time: startTime,
end_time: endTime,
duration_seconds: duration,
-- is_public: 1,
++ is_public: visibility === 'public' ? 1 : 0,
status: 'processing',
});
const clipId = result.lastInsertRowid;
++ if (visibility !== 'public') db.setClipVisibility(clipId, visibility);
const appId = req.appId;
// Cut in the background; webhook on completion.
@@@ -160,24 +202,90 @@ router.post('/', tenantAuth({ allowUser: true }), async (req, res) => {
res.status(202).json({ id: clipId, status: 'processing' });
} catch (err) {
console.error('[Clips] Create error:', err.message);
++ if (req.file) tools.cleanupTempFile(req.file.path);
res.status(500).json({ error: 'Failed to create clip' });
}
});
++/**
++ * Direct clip upload: the blob is already cut client-side — remux it for
++ * seeking (rebases cluster timestamps hours from zero on long streams),
++ * probe the real duration, store as ready. No webhook (nothing async).
++ */
++async function _createUploadedClip(req, res) {
++ const body = req.body || {};
++ const clipPath = req.file.path;
++ try {
++ await tools.remuxForSeeking(clipPath);
++
++ let duration = 0;
++ try { duration = (await tools.probeVodInfo(clipPath)).duration || 0; } catch { /* */ }
++ if (!duration || tools.getFileSizeSafe(clipPath) === 0) {
++ tools.cleanupTempFile(clipPath);
++ return res.status(422).json({ error: 'Clip upload was corrupt or empty. Please try again.' });
++ }
++ const maxClipDuration = db.getSetting('max_clip_duration') || 60;
++ if (duration > maxClipDuration + 5) {
++ tools.cleanupTempFile(clipPath);
++ return res.status(400).json({ error: `Clips are limited to ${maxClipDuration} seconds` });
++ }
++
++ const startTime = Number.parseFloat(body.start_s ?? body.start_time);
++ const endTime = Number.parseFloat(body.end_s ?? body.end_time);
++ const visibility = VALID_VIS.has(body.visibility) ? body.visibility : 'public';
++ const vodId = body.vod_id != null ? parseInt(body.vod_id, 10) || null : null;
++ const result = db.createClip({
++ app_id: req.appId,
++ vod_id: vodId,
++ stream_id: body.stream_id != null ? parseInt(body.stream_id, 10) || null : null,
++ user_id: body.user_id != null ? body.user_id : (req.userId ?? null),
++ channel_user_id: body.channel_user_id != null ? body.channel_user_id : null,
++ title: sanitizeClipTitle(body.title, 'Untitled Clip'),
++ file_path: clipPath,
++ start_time: Number.isFinite(startTime) ? startTime : 0,
++ end_time: Number.isFinite(endTime) ? endTime : duration,
++ duration_seconds: duration,
++ is_public: visibility === 'public' ? 1 : 0,
++ status: 'ready',
++ });
++ const clipId = result.lastInsertRowid;
++ if (visibility !== 'public') db.setClipVisibility(clipId, visibility);
++
++ require('../thumbnails/thumbnail-service').generateClipThumbnail(clipId, clipPath)
++ .catch(err => console.warn(`[Clips] Thumbnail failed for clip ${clipId}:`, err.message));
++
++ console.log(`[Clips] Direct upload: clip ${clipId} (${req.appId}, ${duration.toFixed ? duration.toFixed(1) : duration}s)`);
++ res.status(201).json({ ...clipPublic(db.getClipById(clipId, req.appId)) });
++ } catch (err) {
++ console.error('[Clips] Upload error:', err.message);
++ tools.cleanupTempFile(clipPath);
++ res.status(500).json({ error: 'Failed to save uploaded clip' });
++ }
++}
++
// ── List ─────────────────────────────────────────────────────
++// Filters follow the inherited query shapes: vod_id, stream_id, user_id
++// (creator), channel_user_id / source_streamer_id (owner of the clipped
++// channel), hide_self, include_private, order, limit/offset.
router.get('/', tenantAuth({ allowUser: true }), (req, res) => {
try {
const limit = Math.min(Math.max(parseInt(req.query.limit || '50', 10), 1), 500);
const offset = Math.max(parseInt(req.query.offset || '0', 10), 0);
++ const channelUserId = req.query.channel_user_id ?? req.query.source_streamer_id;
const filters = {
limit, offset,
vod_id: req.query.vod_id != null ? parseInt(req.query.vod_id, 10) : null,
stream_id: req.query.stream_id != null ? parseInt(req.query.stream_id, 10) : null,
user_id: req.query.user_id != null ? req.query.user_id : null,
++ channel_user_id: channelUserId != null ? channelUserId : null,
++ hide_self: ['1', 'true'].includes(String(req.query.hide_self || '')),
++ // Only the owning app may list private/unlisted rows (see vods list).
++ include_private: req.authType === 'app' && ['1', 'true'].includes(String(req.query.include_private || '')),
++ order: req.query.order || req.query.sort, // `sort` = inherited alias
};
const clips = db.listClips(req.appId, filters);
const total = db.countClips(req.appId, filters);
-- res.json({ clips: clips.map(clipPublic), total, limit, offset });
++ res.json({ clips: clips.map(clipPublic), total, limit, offset, hasMore: offset + clips.length < total });
} catch (err) {
console.error('[Clips] List error:', err.message);
res.status(500).json({ error: 'Failed to list clips' });
@@@ -189,7 +297,8 @@ router.get('/:id', tenantAuth({ allowUser: true }), (req, res) => {
try {
const clip = _getClipScoped(req, res);
if (!clip) return;
-- res.json({ clip: clipPublic(clip) });
++ // Bare object, mirroring GET /vods/:id per CONTRACTS.md.
++ res.json(clipPublic(clip));
} catch (err) {
res.status(500).json({ error: 'Failed to get clip' });
}
ddiff --git a/server/vod/finalize.js b/server/vod/finalize.js
iindex 7654c9b..1685b4c 100644
---- a/server/vod/finalize.js
++++ b/server/vod/finalize.js
@@@ -32,12 +32,17 @@ function vodPublic(vod) {
id: vod.id,
app_id: vod.app_id,
stream_id: vod.stream_id,
++ managed_stream_id: vod.managed_stream_id || null,
user_id: vod.user_id,
title: vod.title,
description: vod.description,
status: db.vodStatus(vod),
duration: vod.duration_seconds || 0,
++ duration_seconds: vod.duration_seconds || 0,
file_size: vod.file_size || 0,
++ // Basename only (server paths stay private) — the inherited SPA derives
++ // its /file/<name> playback URL from this.
++ file_path: vod.file_path ? path.basename(vod.file_path) : null,
playback_url: `/v/${vod.id}`,
thumbnail_url: vod.thumbnail_url || null,
storage_provider: vod.storage_provider || 'local',
ddiff --git a/server/vod/routes.js b/server/vod/routes.js
iindex c621100..5e76f44 100644
---- a/server/vod/routes.js
++++ b/server/vod/routes.js
@@@ -73,14 +73,27 @@ function _getVodScoped(req, res) {
return vod;
}
++/**
++ * Ownership gate for user-JWT callers on write endpoints (chunks/complete):
++ * the vod's user_id must be THEIR network id. App-key callers are trusted —
++ * they proxy server-side and own their app-local user-id space (Live does).
++ */
++function _userOwnsVod(req, res, vod) {
++ if (req.authType !== 'user') return true;
++ if (vod.user_id != null && String(vod.user_id) === String(req.userId)) return true;
++ res.status(403).json({ error: 'Not your VOD' });
++ return false;
++}
++
// ── Create VOD ───────────────────────────────────────────────
router.post('/', tenantAuth(), (req, res) => {
try {
-- const { title, stream_id, stream_key, user_id, meta, visibility, clips_only } = req.body || {};
++ const { title, stream_id, stream_key, managed_stream_id, user_id, meta, visibility, clips_only } = req.body || {};
const result = db.createVod({
app_id: req.appId,
stream_id: stream_id != null ? parseInt(stream_id, 10) || null : null,
stream_key: stream_key || null,
++ managed_stream_id: managed_stream_id != null ? parseInt(managed_stream_id, 10) || null : null,
user_id: user_id != null ? user_id : null,
title: (title || 'Recording').toString().slice(0, 300),
meta,
@@@ -143,6 +156,7 @@ router.post('/:id/chunks', tenantAuth({ allowUser: true }), chunkUpload.single('
try {
const vod = _getVodScoped(req, res);
if (!vod) { if (req.file) tools.cleanupTempFile(req.file.path); return; }
++ if (!_userOwnsVod(req, res, vod)) { if (req.file) tools.cleanupTempFile(req.file.path); return; }
if (!req.file) return res.status(400).json({ error: 'No chunk data' });
const segmentId = Math.max(1, parseInt(req.body?.segmentId || req.query.segmentId || '1', 10) || 1);
@@@ -220,6 +234,7 @@ async function _finalizeHandler(req, res) {
try {
const vod = _getVodScoped(req, res);
if (!vod) return;
++ if (!_userOwnsVod(req, res, vod)) return;
// Live ffmpeg recording → stop gracefully; its exit handler finalizes.
if (recorder.isRecording(vod.id)) {
@@@ -245,15 +260,26 @@ router.post('/:id/complete', tenantAuth({ allowUser: true }), _finalizeHandler);
router.post('/:id/finalize', tenantAuth(), _finalizeHandler);
// ── List ─────────────────────────────────────────────────────
++// Filters follow the inherited query shapes: user_id, stream_id,
++// managed_stream_id, include_private (private+unlisted too), order
++// (newest|oldest|views|peak_viewers), limit/offset.
router.get('/', tenantAuth({ allowUser: true }), (req, res) => {
try {
const limit = Math.min(Math.max(parseInt(req.query.limit || '50', 10), 1), 500);
const offset = Math.max(parseInt(req.query.offset || '0', 10), 0);
-- const user_id = req.query.user_id != null ? req.query.user_id : null;
-- const stream_id = req.query.stream_id != null ? parseInt(req.query.stream_id, 10) : null;
-- const vods = db.listVods(req.appId, { limit, offset, user_id, stream_id });
-- const total = db.countVods(req.appId, { user_id, stream_id });
-- res.json({ vods: vods.map(vodPublic), total, limit, offset });
++ const filters = {
++ limit, offset,
++ user_id: req.query.user_id != null ? req.query.user_id : null,
++ stream_id: req.query.stream_id != null ? parseInt(req.query.stream_id, 10) : null,
++ managed_stream_id: req.query.managed_stream_id != null ? parseInt(req.query.managed_stream_id, 10) : null,
++ // Only the owning app may list private/unlisted rows — it enforces its
++ // own ownership checks (app-local user ids are opaque to Media).
++ include_private: req.authType === 'app' && ['1', 'true'].includes(String(req.query.include_private || '')),
++ order: req.query.order || req.query.sort, // `sort` = inherited alias
++ };
++ const vods = db.listVods(req.appId, filters);
++ const total = db.countVods(req.appId, filters);
++ res.json({ vods: vods.map(vodPublic), total, limit, offset, hasMore: offset + vods.length < total });
} catch (err) {
console.error('[VOD] List error:', err.message);
res.status(500).json({ error: 'Failed to list VODs' });
@@@ -277,7 +303,8 @@ router.get('/:id', tenantAuth({ allowUser: true }), async (req, res) => {
}
}
-- res.json({ vod: vodPublic(vod) });
++ // Bare object per CONTRACTS.md: { id, title, status, duration, … }
++ res.json(vodPublic(vod));
} catch (err) {
console.error('[VOD] Get error:', err.message);
res.status(500).json({ error: 'Failed to get VOD' });