feat(backend): add SIMPLE_STREAMING WebRTC control-path streaming
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user