openvibers/openvibe.media Commits
Integration fixes: response shapes, list filters, ownership checks, legacy basename + screenshot serving, multipart clip upload
Changes
@@@ -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 APIkey or the owning user's JWT.@@@ -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')";; esacTCODE=$(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); dosleep 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" ] && breakdonecheck "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"@@@ -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 = {// appshashApiKey, 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,// pastesgetPasteBySlug, likePaste, unlikePaste, hasUserLikedPaste, incrementPasteCopies,countUserPastesToday, getLastPasteTime,@@@ -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,@@@ -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 {@@@ -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 {@@@ -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' });}@@@ -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',@@@ -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' });
@@ -71,7 +71,7 @@ Two credential types on `/api/v1/:app/...`:
@@ -71,7 +71,7 @@ Two credential types on `/api/v1/:app/...`:
On every boot the service upserts apps from env (keys are hashed):
On every boot the service upserts apps from env (keys are hashed):
```bash
```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):
# fallback short form (no webhooks/origins):
MEDIA_APP_KEYS="live:key1,games:key2"
MEDIA_APP_KEYS="live:key1,games:key2"
```
```
@@ -82,7 +82,7 @@ MEDIA_APP_KEYS="live:key1,games:key2"
@@ -82,7 +82,7 @@ MEDIA_APP_KEYS="live:key1,games:key2"
| method | path | notes |
| 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/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/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 |
| POST | `/vods/:id/ingest/rtp/stop` | finalizes the recording |
@@ -90,7 +90,7 @@ MEDIA_APP_KEYS="live:key1,games:key2"
@@ -90,7 +90,7 @@ MEDIA_APP_KEYS="live:key1,games:key2"
| POST | `/vods/:id/complete` | finalize chunked upload (user JWT ok) |
| POST | `/vods/:id/complete` | finalize chunked upload (user JWT ok) |
| POST | `/vods/:id/finalize` | close recording; remux, probe, thumbnail, webhook |
| 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/: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? }` |
| PUT | `/vods/:id` | `{ title?, description?, visibility? }` |
| DELETE | `/vods/:id` | deletes local + B2 + R2 objects + row |
| DELETE | `/vods/:id` | deletes local + B2 + R2 objects + row |
@@ -108,9 +108,9 @@ lossless `.master.mkv` recovery archive. A `.seekable` sidecar is remuxed every
@@ -108,9 +108,9 @@ lossless `.master.mkv` recovery archive. A `.seekable` sidecar is remuxed every
| method | path | notes |
| 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/: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? }` |
| PUT | `/clips/:id` | `{ title?, visibility? }` |
| DELETE | `/clips/:id` | local + offloaded objects + row |
| DELETE | `/clips/:id` | local + offloaded objects + row |
@@ -120,6 +120,7 @@ lossless `.master.mkv` recovery archive. A `.seekable` sidecar is remuxed every
@@ -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 }` |
| 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?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) |
| GET | `/pastes/:slug` | full paste (private: owner/app only) |
| PUT | `/pastes/:slug` | update (owner/app) |
| PUT | `/pastes/:slug` | update (owner/app) |
| DELETE | `/pastes/:slug` | delete + screenshot (local & legacy B2 object) |
| 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.
@@ -160,13 +161,14 @@ the app's webhook_secret>`. 3 attempts with backoff, 10 s timeout.
| path | behavior |
| 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 /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` | server-rendered paste HTML page (**indexable** — Media is the canonical home for pastes) |
| `GET /p/:slug/raw` | `text/plain` |
| `GET /p/:slug/raw` | `text/plain` |
| `GET /p/:slug/screenshot` | paste screenshot image |
| `GET /p/:slug/screenshot` | paste screenshot image |
| `GET /t/:id` | thumbnail (id = filename), `noindex` |
| `GET /t/:id` | thumbnail (id = filename), `noindex` |
| `GET /f/:key` | file with stored Content-Type + Range, `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
Private items respond 403/404 unless the request bears the owning app's API
key or the owning user's JWT.
key or the owning user's JWT.
@@ -70,8 +70,8 @@ check "complete → vod ready" "ready" "$ST"
@@ -70,8 +70,8 @@ check "complete → vod ready" "ready" "$ST"
echo "-- vod: meta shows duration + thumbnail"
echo "-- vod: meta shows duration + thumbnail"
R=$(curl -s "$BASE/api/v1/live/vods/$VOD_ID" -H "$AUTH")
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')"
[ "$DUR" = "5" ] && ok "duration probed = 5s" || bad "duration (got '$DUR')"
case "$THUMB" in /t/*) ok "thumbnail_url = $THUMB";; *) bad "thumbnail_url (got '$THUMB')";; esac
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")
TCODE=$(curl -s -o "$WORK/thumb.jpg" -w '%{http_code}' "$BASE$THUMB")
@@ -85,12 +85,12 @@ CLIP_ID=$(jsonget "$R" id)
@@ -85,12 +85,12 @@ CLIP_ID=$(jsonget "$R" id)
CLIP_ST=""
CLIP_ST=""
for i in $(seq 1 60); do
for i in $(seq 1 60); do
sleep 0.5
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" = "ready" ] && break
[ "$CLIP_ST" = "failed" ] && break
[ "$CLIP_ST" = "failed" ] && break
done
done
check "clip processed" "ready" "$CLIP_ST"
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)"
echo " (clip duration: ${CDUR}s)"
CCODE=$(curl -s -o /dev/null -w '%{http_code}' "$BASE/c/$CLIP_ID")
CCODE=$(curl -s -o /dev/null -w '%{http_code}' "$BASE/c/$CLIP_ID")
check "GET /c/$CLIP_ID plays" "200" "$CCODE"
check "GET /c/$CLIP_ID plays" "200" "$CCODE"
@@ -27,10 +27,28 @@ function getDb() {
@@ -27,10 +27,28 @@ function getDb() {
database.pragma('journal_mode = WAL');
database.pragma('journal_mode = WAL');
const schema = fs.readFileSync(path.join(__dirname, 'schema.sql'), 'utf8');
const schema = fs.readFileSync(path.join(__dirname, 'schema.sql'), 'utf8');
database.exec(schema);
database.exec(schema);
+ migrateColumns();
seedSettings();
seedSettings();
return database;
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() {
function seedSettings() {
const defaults = [
const defaults = [
// Clip system
// Clip system
@@ -118,16 +136,30 @@ function appAllowedOrigins(app) {
@@ -118,16 +136,30 @@ function appAllowedOrigins(app) {
// ── VOD helpers ──────────────────────────────────────────────
// ── 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(
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,
file_path || null, master_file_path || null, file_size || 0, duration_seconds || 0, thumbnail_url || null,
JSON.stringify(meta || {})]
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) {
function getVodById(id, appId = null) {
const clause = appId ? ' AND app_id = ?' : '';
const clause = appId ? ' AND app_id = ?' : '';
const params = appId ? [id, appId] : [id];
const params = appId ? [id, appId] : [id];
@@ -135,21 +167,37 @@ function getVodById(id, appId = null) {
@@ -135,21 +167,37 @@ function getVodById(id, appId = null) {
FROM vods WHERE id = ?${clause}`, params);
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 conds = ['app_id = ?', 'COALESCE(clips_only, 0) = 0'];
const params = [appId];
const params = [appId];
if (!includeRecording) conds.push('COALESCE(is_recording, 0) = 0');
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 (user_id != null) { conds.push('user_id = ?'); params.push(user_id); }
if (stream_id != null) { conds.push('stream_id = ?'); params.push(stream_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);
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;
return get(`SELECT COUNT(*) AS count FROM vods WHERE ${conds.join(' AND ')}`, params)?.count || 0;
}
}
@@ -220,12 +268,12 @@ function vodStatus(vod) {
@@ -220,12 +268,12 @@ function vodStatus(vod) {
// ── Clip helpers ─────────────────────────────────────────────
// ── 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;
const pub = (is_public === 0 || is_public === false) ? 0 : 1;
return run(
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,
file_path || '', thumbnail_url || null, start_time || 0, end_time || 0, duration_seconds || 0,
pub, pub ? 'public' : 'unlisted', auto_generated ? 1 : 0, status || 'ready']
pub, pub ? 'public' : 'unlisted', auto_generated ? 1 : 0, status || 'ready']
);
);
@@ -237,22 +285,29 @@ function getClipById(id, appId = null) {
@@ -237,22 +285,29 @@ function getClipById(id, appId = null) {
return get(`SELECT * FROM clips WHERE id = ?${clause}`, params);
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 conds = ['app_id = ?'];
const params = [appId];
const params = [appId];
+ if (!include_private) conds.push('is_public = 1');
if (vod_id != null) { conds.push('vod_id = ?'); params.push(vod_id); }
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 (stream_id != null) { conds.push('stream_id = ?'); params.push(stream_id); }
if (user_id != null) { conds.push('user_id = ?'); params.push(user_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);
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;
return get(`SELECT COUNT(*) AS count FROM clips WHERE ${conds.join(' AND ')}`, params)?.count || 0;
}
}
@@ -440,10 +495,10 @@ module.exports = {
@@ -440,10 +495,10 @@ module.exports = {
// apps
// apps
hashApiKey, getApp, listApps, upsertApp, appAllowedOrigins,
hashApiKey, getApp, listApps, upsertApp, appAllowedOrigins,
// vods
// vods
- createVod, getVodById, listVods, countVods, setVodVisibility, vodStatus,
+ createVod, getVodById, getVodByFileBasename, listVods, countVods, setVodVisibility, vodStatus,
updateVodHealth, repairVodDuration, getVodsNeedingHealthScan, getQuarantinedVodsForCleanup,
updateVodHealth, repairVodDuration, getVodsNeedingHealthScan, getQuarantinedVodsForCleanup,
// clips
// clips
- createClip, getClipById, listClips, countClips, setClipVisibility, findDuplicateClip,
+ createClip, getClipById, getClipByFileBasename, listClips, countClips, setClipVisibility, findDuplicateClip,
// pastes
// pastes
getPasteBySlug, likePaste, unlikePaste, hasUserLikedPaste, incrementPasteCopies,
getPasteBySlug, likePaste, unlikePaste, hasUserLikedPaste, incrementPasteCopies,
countUserPastesToday, getLastPasteTime,
countUserPastesToday, getLastPasteTime,
@@ -24,6 +24,7 @@ CREATE TABLE IF NOT EXISTS vods (
@@ -24,6 +24,7 @@ CREATE TABLE IF NOT EXISTS vods (
app_id TEXT NOT NULL DEFAULT 'live',
app_id TEXT NOT NULL DEFAULT 'live',
stream_id INTEGER,
stream_id INTEGER,
stream_key TEXT,
stream_key TEXT,
+ managed_stream_id INTEGER, -- owning app's stream "slot" (list filter)
user_id INTEGER,
user_id INTEGER,
title TEXT,
title TEXT,
description TEXT DEFAULT '',
description TEXT DEFAULT '',
@@ -65,6 +66,7 @@ CREATE TABLE IF NOT EXISTS clips (
@@ -65,6 +66,7 @@ CREATE TABLE IF NOT EXISTS clips (
vod_id INTEGER,
vod_id INTEGER,
stream_id INTEGER,
stream_id INTEGER,
user_id INTEGER,
user_id INTEGER,
+ channel_user_id INTEGER, -- owner of the clipped channel/stream (list filter)
title TEXT DEFAULT 'Untitled Clip',
title TEXT DEFAULT 'Untitled Clip',
description TEXT DEFAULT '',
description TEXT DEFAULT '',
file_path TEXT,
file_path TEXT,
@@ -257,6 +257,25 @@ router.get('/', tenantAuth({ allowUser: true }), (req, res) => {
@@ -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 ────────────────────────────────
// ── Get single paste by slug ────────────────────────────────
router.get('/:slug', tenantAuth({ allowUser: true }), (req, res) => {
router.get('/:slug', tenantAuth({ allowUser: true }), (req, res) => {
try {
try {
@@ -153,12 +153,22 @@ async function serveMediaRecord(kind, record, req, res) {
@@ -153,12 +153,22 @@ async function serveMediaRecord(kind, record, req, res) {
}
}
// ── VOD playback ─────────────────────────────────────────────
// ── 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) => {
router.get('/v/:id', optionalIdentity, async (req, res) => {
try {
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) {
} catch (err) {
console.error('[Public] /v error:', err.message);
console.error('[Public] /v error:', err.message);
if (!res.headersSent) res.status(500).json({ error: 'Failed to serve media' });
if (!res.headersSent) res.status(500).json({ error: 'Failed to serve media' });
@@ -183,6 +193,26 @@ router.get('/t/:id', (req, res) => {
@@ -183,6 +193,26 @@ router.get('/t/:id', (req, res) => {
require('../thumbnails/thumbnail-service').serveThumbnail(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 ────────────────────────────────────────────────────
// ── Files ────────────────────────────────────────────────────
router.get('/f/:key', (req, res) => {
router.get('/f/:key', (req, res) => {
try {
try {
@@ -14,7 +14,10 @@
@@ -14,7 +14,10 @@
'use strict';
'use strict';
const express = require('express');
const express = require('express');
+const path = require('path');
const fs = require('fs');
const fs = require('fs');
+const multer = require('multer');
+const config = require('../config');
const db = require('../db/database');
const db = require('../db/database');
const tools = require('./media-tools');
const tools = require('./media-tools');
const cutter = require('./clip-cutter');
const cutter = require('./clip-cutter');
@@ -24,6 +27,32 @@ const { sendWebhook } = require('../webhooks');
@@ -24,6 +27,32 @@ const { sendWebhook } = require('../webhooks');
const router = express.Router({ mergeParams: true });
const router = express.Router({ mergeParams: true });
router.use(tenantCors);
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) {
function clipPublic(clip) {
if (!clip) return null;
if (!clip) return null;
return {
return {
@@ -31,6 +60,7 @@ function clipPublic(clip) {
@@ -31,6 +60,7 @@ function clipPublic(clip) {
app_id: clip.app_id,
app_id: clip.app_id,
vod_id: clip.vod_id,
vod_id: clip.vod_id,
stream_id: clip.stream_id,
stream_id: clip.stream_id,
+ channel_user_id: clip.channel_user_id || null,
user_id: clip.user_id,
user_id: clip.user_id,
title: clip.title,
title: clip.title,
description: clip.description,
description: clip.description,
@@ -38,6 +68,9 @@ function clipPublic(clip) {
@@ -38,6 +68,9 @@ function clipPublic(clip) {
start_time: clip.start_time,
start_time: clip.start_time,
end_time: clip.end_time,
end_time: clip.end_time,
duration: clip.duration_seconds || 0,
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}`,
playback_url: `/c/${clip.id}`,
thumbnail_url: clip.thumbnail_url || null,
thumbnail_url: clip.thumbnail_url || null,
visibility: clip.visibility || 'public',
visibility: clip.visibility || 'public',
@@ -62,10 +95,16 @@ function _getClipScoped(req, res) {
@@ -62,10 +95,16 @@ function _getClipScoped(req, res) {
return clip;
return clip;
}
}
+const VALID_VIS = new Set(['public', 'unlisted', 'private']);
+
// ── Create clip ──────────────────────────────────────────────
// ── 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 {
try {
const body = req.body || {};
const body = req.body || {};
+ if (req.file) return _createUploadedClip(req, res);
const vodId = parseInt(body.vod_id, 10);
const vodId = parseInt(body.vod_id, 10);
if (!Number.isFinite(vodId)) return res.status(400).json({ error: 'vod_id is required' });
if (!Number.isFinite(vodId)) return res.status(400).json({ error: 'vod_id is required' });
const vod = db.getVodById(vodId, req.appId);
const vod = db.getVodById(vodId, req.appId);
@@ -123,20 +162,23 @@ router.post('/', tenantAuth({ allowUser: true }), async (req, res) => {
@@ -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 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({
const result = db.createClip({
app_id: req.appId,
app_id: req.appId,
vod_id: vodId,
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,
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'),
title: sanitizeClipTitle(body.title, vod.title ? `Clip: ${vod.title}`.slice(0, 200) : 'Untitled Clip'),
file_path: '',
file_path: '',
start_time: startTime,
start_time: startTime,
end_time: endTime,
end_time: endTime,
duration_seconds: duration,
duration_seconds: duration,
- is_public: 1,
+ is_public: visibility === 'public' ? 1 : 0,
status: 'processing',
status: 'processing',
});
});
const clipId = result.lastInsertRowid;
const clipId = result.lastInsertRowid;
+ if (visibility !== 'public') db.setClipVisibility(clipId, visibility);
const appId = req.appId;
const appId = req.appId;
// Cut in the background; webhook on completion.
// Cut in the background; webhook on completion.
@@ -160,24 +202,90 @@ router.post('/', tenantAuth({ allowUser: true }), async (req, res) => {
@@ -160,24 +202,90 @@ router.post('/', tenantAuth({ allowUser: true }), async (req, res) => {
res.status(202).json({ id: clipId, status: 'processing' });
res.status(202).json({ id: clipId, status: 'processing' });
} catch (err) {
} catch (err) {
console.error('[Clips] Create error:', err.message);
console.error('[Clips] Create error:', err.message);
+ if (req.file) tools.cleanupTempFile(req.file.path);
res.status(500).json({ error: 'Failed to create clip' });
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 ─────────────────────────────────────────────────────
// ── 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) => {
router.get('/', tenantAuth({ allowUser: true }), (req, res) => {
try {
try {
const limit = Math.min(Math.max(parseInt(req.query.limit || '50', 10), 1), 500);
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 offset = Math.max(parseInt(req.query.offset || '0', 10), 0);
+ const channelUserId = req.query.channel_user_id ?? req.query.source_streamer_id;
const filters = {
const filters = {
limit, offset,
limit, offset,
vod_id: req.query.vod_id != null ? parseInt(req.query.vod_id, 10) : null,
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,
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,
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 clips = db.listClips(req.appId, filters);
const total = db.countClips(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) {
} catch (err) {
console.error('[Clips] List error:', err.message);
console.error('[Clips] List error:', err.message);
res.status(500).json({ error: 'Failed to list clips' });
res.status(500).json({ error: 'Failed to list clips' });
@@ -189,7 +297,8 @@ router.get('/:id', tenantAuth({ allowUser: true }), (req, res) => {
@@ -189,7 +297,8 @@ router.get('/:id', tenantAuth({ allowUser: true }), (req, res) => {
try {
try {
const clip = _getClipScoped(req, res);
const clip = _getClipScoped(req, res);
if (!clip) return;
if (!clip) return;
- res.json({ clip: clipPublic(clip) });
+ // Bare object, mirroring GET /vods/:id per CONTRACTS.md.
+ res.json(clipPublic(clip));
} catch (err) {
} catch (err) {
res.status(500).json({ error: 'Failed to get clip' });
res.status(500).json({ error: 'Failed to get clip' });
}
}
@@ -32,12 +32,17 @@ function vodPublic(vod) {
@@ -32,12 +32,17 @@ function vodPublic(vod) {
id: vod.id,
id: vod.id,
app_id: vod.app_id,
app_id: vod.app_id,
stream_id: vod.stream_id,
stream_id: vod.stream_id,
+ managed_stream_id: vod.managed_stream_id || null,
user_id: vod.user_id,
user_id: vod.user_id,
title: vod.title,
title: vod.title,
description: vod.description,
description: vod.description,
status: db.vodStatus(vod),
status: db.vodStatus(vod),
duration: vod.duration_seconds || 0,
duration: vod.duration_seconds || 0,
+ duration_seconds: vod.duration_seconds || 0,
file_size: vod.file_size || 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}`,
playback_url: `/v/${vod.id}`,
thumbnail_url: vod.thumbnail_url || null,
thumbnail_url: vod.thumbnail_url || null,
storage_provider: vod.storage_provider || 'local',
storage_provider: vod.storage_provider || 'local',
@@ -73,14 +73,27 @@ function _getVodScoped(req, res) {
@@ -73,14 +73,27 @@ function _getVodScoped(req, res) {
return vod;
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 ───────────────────────────────────────────────
// ── Create VOD ───────────────────────────────────────────────
router.post('/', tenantAuth(), (req, res) => {
router.post('/', tenantAuth(), (req, res) => {
try {
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({
const result = db.createVod({
app_id: req.appId,
app_id: req.appId,
stream_id: stream_id != null ? parseInt(stream_id, 10) || null : null,
stream_id: stream_id != null ? parseInt(stream_id, 10) || null : null,
stream_key: stream_key || 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,
user_id: user_id != null ? user_id : null,
title: (title || 'Recording').toString().slice(0, 300),
title: (title || 'Recording').toString().slice(0, 300),
meta,
meta,
@@ -143,6 +156,7 @@ router.post('/:id/chunks', tenantAuth({ allowUser: true }), chunkUpload.single('
@@ -143,6 +156,7 @@ router.post('/:id/chunks', tenantAuth({ allowUser: true }), chunkUpload.single('
try {
try {
const vod = _getVodScoped(req, res);
const vod = _getVodScoped(req, res);
if (!vod) { if (req.file) tools.cleanupTempFile(req.file.path); return; }
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' });
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);
const segmentId = Math.max(1, parseInt(req.body?.segmentId || req.query.segmentId || '1', 10) || 1);
@@ -220,6 +234,7 @@ async function _finalizeHandler(req, res) {
@@ -220,6 +234,7 @@ async function _finalizeHandler(req, res) {
try {
try {
const vod = _getVodScoped(req, res);
const vod = _getVodScoped(req, res);
if (!vod) return;
if (!vod) return;
+ if (!_userOwnsVod(req, res, vod)) return;
// Live ffmpeg recording → stop gracefully; its exit handler finalizes.
// Live ffmpeg recording → stop gracefully; its exit handler finalizes.
if (recorder.isRecording(vod.id)) {
if (recorder.isRecording(vod.id)) {
@@ -245,15 +260,26 @@ router.post('/:id/complete', tenantAuth({ allowUser: true }), _finalizeHandler);
@@ -245,15 +260,26 @@ router.post('/:id/complete', tenantAuth({ allowUser: true }), _finalizeHandler);
router.post('/:id/finalize', tenantAuth(), _finalizeHandler);
router.post('/:id/finalize', tenantAuth(), _finalizeHandler);
// ── List ─────────────────────────────────────────────────────
// ── 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) => {
router.get('/', tenantAuth({ allowUser: true }), (req, res) => {
try {
try {
const limit = Math.min(Math.max(parseInt(req.query.limit || '50', 10), 1), 500);
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 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) {
} catch (err) {
console.error('[VOD] List error:', err.message);
console.error('[VOD] List error:', err.message);
res.status(500).json({ error: 'Failed to list VODs' });
res.status(500).json({ error: 'Failed to list VODs' });
@@ -277,7 +303,8 @@ router.get('/:id', tenantAuth({ allowUser: true }), async (req, res) => {
@@ -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) {
} catch (err) {
console.error('[VOD] Get error:', err.message);
console.error('[VOD] Get error:', err.message);
res.status(500).json({ error: 'Failed to get VOD' });
res.status(500).json({ error: 'Failed to get VOD' });