feat(backend): add SIMPLE_STREAMING WebRTC control-path streaming

This commit is contained in:
2026-03-05 13:30:00 +00:00
parent c458857f0a
commit 19baf76169
14 changed files with 448 additions and 189 deletions

View File

@@ -4,6 +4,7 @@ import { z } from 'zod';
import { db } from '../db/client';
import { deviceCommands, deviceLinks, devices } from '../db/schema';
import { simpleStreamingEnabled } from '../media/config';
import { requireAuth } from '../middleware/auth';
import { requireDeviceAuth } from '../middleware/device-auth';
import { dispatchCommandById } from '../realtime/gateway';
@@ -47,6 +48,13 @@ router.post('/', requireAuth, async (req, res) => {
return;
}
if (simpleStreamingEnabled && parsed.data.commandType === 'start_stream') {
res.status(409).json({
message: 'start_stream commands are disabled while SIMPLE_STREAMING is enabled; use /streams/request instead',
});
return;
}
if (parsed.data.sourceDeviceId === parsed.data.targetDeviceId) {
res.status(400).json({ message: 'sourceDeviceId and targetDeviceId must differ' });
return;

View File

@@ -5,14 +5,20 @@ import { Router } from 'express';
import { z } from 'zod';
import { db } from '../db/client';
import { mediaMode } from '../media/config';
import { mediaMode, simpleStreamingEnabled, streamRecordingEnabled } from '../media/config';
import { deviceCommands, deviceLinks, devices, streamSessions } from '../db/schema';
import { mediaProvider } from '../media/service';
import { createLiveMediaSession, mediaProvider } from '../media/service';
import { sfuService } from '../media/sfu/service';
import { requireDeviceAuth } from '../middleware/device-auth';
import { dispatchCommandById, sendRealtimeToDevice } from '../realtime/gateway';
import { writeAuditLog } from '../services/audit';
import { enqueuePushNotification } from '../services/push';
import {
createStreamEndedPayload,
createStreamRequestedPayload,
createStreamStartedPayload,
toSimpleStreamSessionResponse,
} from '../streaming/simple';
import { createRecordingForStream } from './recordings';
const router = Router();
@@ -158,6 +164,45 @@ router.post('/request', requireDeviceAuth, async (req, res) => {
return;
}
if (simpleStreamingEnabled) {
const requestPayload = createStreamRequestedPayload(session);
const deliveredToCamera = sendRealtimeToDevice(cameraDevice.id, 'stream:requested', requestPayload);
console.info('[stream.request]', {
streamSessionId: session.id,
requesterDeviceId: sourceDevice.id,
cameraDeviceId: cameraDevice.id,
mode: 'simple',
});
sendRealtimeToDevice(sourceDevice.id, 'stream:requested', requestPayload);
if (!deliveredToCamera) {
await enqueuePushNotification({
ownerUserId: cameraDevice.userId,
recipientDeviceId: cameraDevice.id,
type: 'stream_requested',
payload: requestPayload,
});
}
res.status(201).json({
message: 'Stream request sent',
streamSession: toSimpleStreamSessionResponse(session),
});
await writeAuditLog({
ownerUserId: sourceDevice.userId,
actorDeviceId: sourceDevice.id,
action: 'stream.requested',
targetType: 'stream_session',
targetId: session.id,
metadata: { cameraDeviceId: cameraDevice.id, reason: session.reason, transport: 'webrtc' },
ipAddress: req.ip,
});
return;
}
const [command] = await db
.insert(deviceCommands)
.values({
@@ -182,6 +227,13 @@ router.post('/request', requireDeviceAuth, async (req, res) => {
}
await dispatchCommandById(command.id);
console.info('[stream.request]', {
streamSessionId: session.id,
requesterDeviceId: sourceDevice.id,
cameraDeviceId: cameraDevice.id,
mode: 'legacy',
commandId: command.id,
});
const refreshedCommand = await db.query.deviceCommands.findFirst({ where: eq(deviceCommands.id, command.id) });
@@ -259,7 +311,7 @@ router.post('/:streamSessionId/accept', requireDeviceAuth, async (req, res) => {
const now = new Date();
const streamKey = parsed.data.streamKey ?? `stream_${session.id}_${randomUUID()}`;
const mediaSession = await mediaProvider.createSession({
const mediaSession = await createLiveMediaSession({
streamSessionId: session.id,
ownerUserId: session.ownerUserId,
cameraDeviceId: session.cameraDeviceId,
@@ -270,11 +322,11 @@ router.post('/:streamSessionId/accept', requireDeviceAuth, async (req, res) => {
.update(streamSessions)
.set({
status: 'streaming',
streamKey,
mediaProvider: mediaSession.provider,
mediaSessionId: mediaSession.mediaSessionId,
publishEndpoint: mediaSession.publishUrl,
subscribeEndpoint: mediaSession.subscribeUrl,
streamKey: mediaSession ? streamKey : null,
mediaProvider: mediaSession?.provider ?? 'simple',
mediaSessionId: mediaSession?.mediaSessionId ?? null,
publishEndpoint: mediaSession?.publishUrl ?? null,
subscribeEndpoint: mediaSession?.subscribeUrl ?? null,
metadata: parsed.data.metadata ?? session.metadata,
startedAt: now,
updatedAt: now,
@@ -303,29 +355,25 @@ router.post('/:streamSessionId/accept', requireDeviceAuth, async (req, res) => {
}
}
const deliveredToRequester = sendRealtimeToDevice(session.requesterDeviceId, 'stream:started', {
const startedPayload = createStreamStartedPayload(updated);
console.info('[stream.accept]', {
streamSessionId: updated.id,
requesterDeviceId: updated.requesterDeviceId,
cameraDeviceId: updated.cameraDeviceId,
status: updated.status,
startedAt: updated.startedAt,
mediaProvider: updated.mediaProvider,
mediaSessionId: updated.mediaSessionId,
subscribeEndpoint: updated.subscribeEndpoint,
mode: mediaSession ? 'legacy' : 'simple',
});
const deliveredToRequester = sendRealtimeToDevice(session.requesterDeviceId, 'stream:started', startedPayload);
if (!deliveredToRequester) {
await enqueuePushNotification({
ownerUserId: session.ownerUserId,
recipientDeviceId: session.requesterDeviceId,
type: 'stream_started',
payload: {
streamSessionId: updated.id,
cameraDeviceId: updated.cameraDeviceId,
},
payload: startedPayload,
});
}
res.json({ message: 'Stream accepted', streamSession: updated });
res.json({ message: 'Stream accepted', streamSession: toSimpleStreamSessionResponse(updated) });
await writeAuditLog({
ownerUserId: session.ownerUserId,
@@ -333,7 +381,9 @@ router.post('/:streamSessionId/accept', requireDeviceAuth, async (req, res) => {
action: 'stream.accepted',
targetType: 'stream_session',
targetId: session.id,
metadata: { mediaSessionId: updated.mediaSessionId, mediaProvider: updated.mediaProvider },
metadata: mediaSession
? { mediaSessionId: updated.mediaSessionId, mediaProvider: updated.mediaProvider }
: { transport: 'webrtc' },
ipAddress: req.ip,
});
});
@@ -349,6 +399,11 @@ router.get('/:streamSessionId/publish-credentials', requireDeviceAuth, async (re
const deviceAuth = ensureDeviceAuth(req, res);
if (!deviceAuth) return;
if (simpleStreamingEnabled) {
res.status(409).json({ message: 'SIMPLE_STREAMING does not use publish credentials' });
return;
}
const session = await getOwnedStreamSession(parsedParams.data.streamSessionId, deviceAuth.userId);
if (!session) {
@@ -396,6 +451,11 @@ router.get('/:streamSessionId/subscribe-credentials', requireDeviceAuth, async (
const deviceAuth = ensureDeviceAuth(req, res);
if (!deviceAuth) return;
if (simpleStreamingEnabled) {
res.status(409).json({ message: 'SIMPLE_STREAMING does not use subscribe credentials' });
return;
}
const session = await getOwnedStreamSession(parsedParams.data.streamSessionId, deviceAuth.userId);
if (!session) {
@@ -605,17 +665,31 @@ router.post('/:streamSessionId/end', requireDeviceAuth, async (req, res) => {
}
const now = new Date();
const nextStatus = simpleStreamingEnabled ? 'ended' : parsed.data.reason;
const nextMetadata =
simpleStreamingEnabled && parsed.data.reason !== 'completed'
? {
...(session.metadata ?? {}),
endReason: parsed.data.reason,
}
: session.metadata;
const [updated] = await db
.update(streamSessions)
.set({
status: parsed.data.reason,
status: nextStatus,
endedAt: now,
metadata: nextMetadata,
updatedAt: now,
})
.where(eq(streamSessions.id, session.id))
.returning();
if (!updated) {
res.status(500).json({ message: 'Failed to update stream session' });
return;
}
if (sfuService) {
try {
await sfuService.endSession(session.id);
@@ -624,29 +698,42 @@ router.post('/:streamSessionId/end', requireDeviceAuth, async (req, res) => {
}
}
await createRecordingForStream(session.id);
if (streamRecordingEnabled) {
await createRecordingForStream(session.id);
}
const deliveredToRequester = sendRealtimeToDevice(session.requesterDeviceId, 'stream:ended', {
const endedPayload = simpleStreamingEnabled
? createStreamEndedPayload({
streamSessionId: session.id,
cameraDeviceId: session.cameraDeviceId,
requesterDeviceId: session.requesterDeviceId,
endedAt: now,
reason: parsed.data.reason,
})
: {
streamSessionId: session.id,
status: parsed.data.reason,
endedAt: now,
};
console.info('[stream.end]', {
streamSessionId: session.id,
status: parsed.data.reason,
endedAt: now,
requesterDeviceId: session.requesterDeviceId,
cameraDeviceId: session.cameraDeviceId,
reason: parsed.data.reason,
status: simpleStreamingEnabled ? 'ended' : parsed.data.reason,
});
const deliveredToCamera = sendRealtimeToDevice(session.cameraDeviceId, 'stream:ended', {
streamSessionId: session.id,
status: parsed.data.reason,
endedAt: now,
});
const deliveredToRequester = sendRealtimeToDevice(session.requesterDeviceId, 'stream:ended', endedPayload);
const deliveredToCamera = sendRealtimeToDevice(session.cameraDeviceId, 'stream:ended', endedPayload);
if (!deliveredToRequester) {
await enqueuePushNotification({
ownerUserId: session.ownerUserId,
recipientDeviceId: session.requesterDeviceId,
type: 'stream_ended',
payload: {
streamSessionId: session.id,
status: parsed.data.reason,
},
payload: endedPayload,
});
}
@@ -655,14 +742,11 @@ router.post('/:streamSessionId/end', requireDeviceAuth, async (req, res) => {
ownerUserId: session.ownerUserId,
recipientDeviceId: session.cameraDeviceId,
type: 'stream_ended',
payload: {
streamSessionId: session.id,
status: parsed.data.reason,
},
payload: endedPayload,
});
}
res.json({ message: 'Stream ended', streamSession: updated });
res.json({ message: 'Stream ended', streamSession: toSimpleStreamSessionResponse(updated) });
});
router.get('/:streamSessionId/playback-token', requireDeviceAuth, async (req, res) => {
@@ -676,6 +760,11 @@ router.get('/:streamSessionId/playback-token', requireDeviceAuth, async (req, re
const deviceAuth = ensureDeviceAuth(req, res);
if (!deviceAuth) return;
if (simpleStreamingEnabled) {
res.status(409).json({ message: 'SIMPLE_STREAMING does not issue playback tokens' });
return;
}
const session = await getOwnedStreamSession(parsedParams.data.streamSessionId, deviceAuth.userId);
if (!session) {