feat(streams): add phase-1 single-server SFU session and transport APIs
This commit is contained in:
@@ -17,6 +17,7 @@ router.get('/ready', async (_req, res) => {
|
||||
try {
|
||||
await db.execute('select 1');
|
||||
await minioClient.bucketExists(minioBucket);
|
||||
const sfuSessions = sfuService ? await sfuService.listSessions() : [];
|
||||
|
||||
res.json({
|
||||
status: 'ready',
|
||||
@@ -26,6 +27,7 @@ router.get('/ready', async (_req, res) => {
|
||||
mediaMode: mediaConfig.mode,
|
||||
mediaProvider: mediaProvider.name,
|
||||
sfuService: sfuService ? sfuService.mode : 'disabled',
|
||||
sfuActiveSessions: sfuSessions.filter((session) => session.state !== 'ended').length,
|
||||
},
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
@@ -5,8 +5,10 @@ import { Router } from 'express';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { db } from '../db/client';
|
||||
import { mediaMode } from '../media/config';
|
||||
import { deviceCommands, deviceLinks, devices, streamSessions } from '../db/schema';
|
||||
import { 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';
|
||||
@@ -34,6 +36,10 @@ const streamParamSchema = z.object({
|
||||
streamSessionId: z.string().uuid(),
|
||||
});
|
||||
|
||||
const sfuTransportRequestSchema = z.object({
|
||||
role: z.enum(['camera', 'viewer']).optional(),
|
||||
});
|
||||
|
||||
const listSchema = z.object({
|
||||
status: z.string().optional(),
|
||||
limit: z.coerce.number().int().min(1).max(100).default(25),
|
||||
@@ -68,6 +74,20 @@ router.get('/me/list', requireDeviceAuth, async (req, res) => {
|
||||
res.json({ count: filtered.length, streamSessions: filtered });
|
||||
});
|
||||
|
||||
const ensureDeviceAuth = (req: Parameters<typeof requireDeviceAuth>[0], res: Parameters<typeof requireDeviceAuth>[1]) => {
|
||||
const deviceAuth = req.deviceAuth;
|
||||
if (!deviceAuth) {
|
||||
res.status(401).json({ message: 'Unauthorized' });
|
||||
return null;
|
||||
}
|
||||
return deviceAuth;
|
||||
};
|
||||
|
||||
const getOwnedStreamSession = async (streamSessionId: string, ownerUserId: string) =>
|
||||
await db.query.streamSessions.findFirst({
|
||||
where: and(eq(streamSessions.id, streamSessionId), eq(streamSessions.ownerUserId, ownerUserId)),
|
||||
});
|
||||
|
||||
router.post('/request', requireDeviceAuth, async (req, res) => {
|
||||
const parsed = requestStreamSchema.safeParse(req.body ?? {});
|
||||
|
||||
@@ -76,12 +96,8 @@ router.post('/request', requireDeviceAuth, async (req, res) => {
|
||||
return;
|
||||
}
|
||||
|
||||
const deviceAuth = req.deviceAuth;
|
||||
|
||||
if (!deviceAuth) {
|
||||
res.status(401).json({ message: 'Unauthorized' });
|
||||
return;
|
||||
}
|
||||
const deviceAuth = ensureDeviceAuth(req, res);
|
||||
if (!deviceAuth) return;
|
||||
|
||||
const [sourceDevice, cameraDevice] = await Promise.all([
|
||||
db.query.devices.findFirst({
|
||||
@@ -220,12 +236,8 @@ router.post('/:streamSessionId/accept', requireDeviceAuth, async (req, res) => {
|
||||
return;
|
||||
}
|
||||
|
||||
const deviceAuth = req.deviceAuth;
|
||||
|
||||
if (!deviceAuth) {
|
||||
res.status(401).json({ message: 'Unauthorized' });
|
||||
return;
|
||||
}
|
||||
const deviceAuth = ensureDeviceAuth(req, res);
|
||||
if (!deviceAuth) return;
|
||||
|
||||
const session = await db.query.streamSessions.findFirst({
|
||||
where: and(
|
||||
@@ -275,6 +287,22 @@ router.post('/:streamSessionId/accept', requireDeviceAuth, async (req, res) => {
|
||||
return;
|
||||
}
|
||||
|
||||
if (sfuService) {
|
||||
try {
|
||||
await sfuService.startSession({
|
||||
streamSessionId: updated.id,
|
||||
ownerUserId: updated.ownerUserId,
|
||||
cameraDeviceId: updated.cameraDeviceId,
|
||||
requesterDeviceId: updated.requesterDeviceId,
|
||||
});
|
||||
await sfuService.setSessionState(updated.id, 'live');
|
||||
} catch (error) {
|
||||
console.error('Failed starting SFU session', error);
|
||||
res.status(500).json({ message: 'Failed to initialize SFU session' });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const deliveredToRequester = sendRealtimeToDevice(session.requesterDeviceId, 'stream:started', {
|
||||
streamSessionId: updated.id,
|
||||
cameraDeviceId: updated.cameraDeviceId,
|
||||
@@ -318,16 +346,10 @@ router.get('/:streamSessionId/publish-credentials', requireDeviceAuth, async (re
|
||||
return;
|
||||
}
|
||||
|
||||
const deviceAuth = req.deviceAuth;
|
||||
const deviceAuth = ensureDeviceAuth(req, res);
|
||||
if (!deviceAuth) return;
|
||||
|
||||
if (!deviceAuth) {
|
||||
res.status(401).json({ message: 'Unauthorized' });
|
||||
return;
|
||||
}
|
||||
|
||||
const session = await db.query.streamSessions.findFirst({
|
||||
where: and(eq(streamSessions.id, parsedParams.data.streamSessionId), eq(streamSessions.ownerUserId, deviceAuth.userId)),
|
||||
});
|
||||
const session = await getOwnedStreamSession(parsedParams.data.streamSessionId, deviceAuth.userId);
|
||||
|
||||
if (!session) {
|
||||
res.status(404).json({ message: 'Stream session not found' });
|
||||
@@ -371,16 +393,10 @@ router.get('/:streamSessionId/subscribe-credentials', requireDeviceAuth, async (
|
||||
return;
|
||||
}
|
||||
|
||||
const deviceAuth = req.deviceAuth;
|
||||
const deviceAuth = ensureDeviceAuth(req, res);
|
||||
if (!deviceAuth) return;
|
||||
|
||||
if (!deviceAuth) {
|
||||
res.status(401).json({ message: 'Unauthorized' });
|
||||
return;
|
||||
}
|
||||
|
||||
const session = await db.query.streamSessions.findFirst({
|
||||
where: and(eq(streamSessions.id, parsedParams.data.streamSessionId), eq(streamSessions.ownerUserId, deviceAuth.userId)),
|
||||
});
|
||||
const session = await getOwnedStreamSession(parsedParams.data.streamSessionId, deviceAuth.userId);
|
||||
|
||||
if (!session) {
|
||||
res.status(404).json({ message: 'Stream session not found' });
|
||||
@@ -419,6 +435,143 @@ router.get('/:streamSessionId/subscribe-credentials', requireDeviceAuth, async (
|
||||
});
|
||||
});
|
||||
|
||||
router.get('/:streamSessionId/sfu/session', requireDeviceAuth, async (req, res) => {
|
||||
const parsedParams = streamParamSchema.safeParse(req.params);
|
||||
|
||||
if (!parsedParams.success) {
|
||||
res.status(400).json({ message: 'Invalid streamSessionId', errors: parsedParams.error.flatten() });
|
||||
return;
|
||||
}
|
||||
|
||||
const deviceAuth = ensureDeviceAuth(req, res);
|
||||
if (!deviceAuth) return;
|
||||
|
||||
if (!sfuService) {
|
||||
res.status(409).json({ message: `SFU service disabled (MEDIA_MODE=${mediaMode})` });
|
||||
return;
|
||||
}
|
||||
|
||||
const session = await getOwnedStreamSession(parsedParams.data.streamSessionId, deviceAuth.userId);
|
||||
if (!session) {
|
||||
res.status(404).json({ message: 'Stream session not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
const isParticipant = session.requesterDeviceId === deviceAuth.deviceId || session.cameraDeviceId === deviceAuth.deviceId;
|
||||
if (!isParticipant) {
|
||||
res.status(403).json({ message: 'Device cannot access SFU session details for this stream' });
|
||||
return;
|
||||
}
|
||||
|
||||
const sfuSession = await sfuService.getSession(session.id);
|
||||
res.json({
|
||||
streamSessionId: session.id,
|
||||
mediaMode,
|
||||
sfuSession,
|
||||
});
|
||||
});
|
||||
|
||||
router.post('/:streamSessionId/sfu/publish-transport', requireDeviceAuth, async (req, res) => {
|
||||
const parsedParams = streamParamSchema.safeParse(req.params);
|
||||
if (!parsedParams.success) {
|
||||
res.status(400).json({ message: 'Invalid streamSessionId', errors: parsedParams.error.flatten() });
|
||||
return;
|
||||
}
|
||||
|
||||
const parsedBody = sfuTransportRequestSchema.safeParse(req.body ?? {});
|
||||
if (!parsedBody.success) {
|
||||
res.status(400).json({ message: 'Invalid request body', errors: parsedBody.error.flatten() });
|
||||
return;
|
||||
}
|
||||
|
||||
const deviceAuth = ensureDeviceAuth(req, res);
|
||||
if (!deviceAuth) return;
|
||||
|
||||
if (!sfuService) {
|
||||
res.status(409).json({ message: `SFU service disabled (MEDIA_MODE=${mediaMode})` });
|
||||
return;
|
||||
}
|
||||
|
||||
const session = await getOwnedStreamSession(parsedParams.data.streamSessionId, deviceAuth.userId);
|
||||
if (!session) {
|
||||
res.status(404).json({ message: 'Stream session not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (session.status !== 'streaming') {
|
||||
res.status(409).json({ message: 'Stream must be active before creating publish transport' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (session.cameraDeviceId !== deviceAuth.deviceId) {
|
||||
res.status(403).json({ message: 'Only camera device can create publish transport' });
|
||||
return;
|
||||
}
|
||||
|
||||
const transport = await sfuService.createPublishTransport({
|
||||
streamSessionId: session.id,
|
||||
cameraDeviceId: deviceAuth.deviceId,
|
||||
});
|
||||
await sfuService.setSessionState(session.id, 'live');
|
||||
|
||||
res.json({
|
||||
streamSessionId: session.id,
|
||||
mediaMode,
|
||||
transport,
|
||||
});
|
||||
});
|
||||
|
||||
router.post('/:streamSessionId/sfu/subscribe-transport', requireDeviceAuth, async (req, res) => {
|
||||
const parsedParams = streamParamSchema.safeParse(req.params);
|
||||
if (!parsedParams.success) {
|
||||
res.status(400).json({ message: 'Invalid streamSessionId', errors: parsedParams.error.flatten() });
|
||||
return;
|
||||
}
|
||||
|
||||
const parsedBody = sfuTransportRequestSchema.safeParse(req.body ?? {});
|
||||
if (!parsedBody.success) {
|
||||
res.status(400).json({ message: 'Invalid request body', errors: parsedBody.error.flatten() });
|
||||
return;
|
||||
}
|
||||
|
||||
const deviceAuth = ensureDeviceAuth(req, res);
|
||||
if (!deviceAuth) return;
|
||||
|
||||
if (!sfuService) {
|
||||
res.status(409).json({ message: `SFU service disabled (MEDIA_MODE=${mediaMode})` });
|
||||
return;
|
||||
}
|
||||
|
||||
const session = await getOwnedStreamSession(parsedParams.data.streamSessionId, deviceAuth.userId);
|
||||
if (!session) {
|
||||
res.status(404).json({ message: 'Stream session not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (session.status !== 'streaming') {
|
||||
res.status(409).json({ message: 'Stream must be active before creating subscribe transport' });
|
||||
return;
|
||||
}
|
||||
|
||||
const isParticipant = session.requesterDeviceId === deviceAuth.deviceId || session.cameraDeviceId === deviceAuth.deviceId;
|
||||
if (!isParticipant) {
|
||||
res.status(403).json({ message: 'Device cannot create subscribe transport for this stream' });
|
||||
return;
|
||||
}
|
||||
|
||||
const transport = await sfuService.createSubscribeTransport({
|
||||
streamSessionId: session.id,
|
||||
viewerDeviceId: deviceAuth.deviceId,
|
||||
});
|
||||
await sfuService.setSessionState(session.id, 'live');
|
||||
|
||||
res.json({
|
||||
streamSessionId: session.id,
|
||||
mediaMode,
|
||||
transport,
|
||||
});
|
||||
});
|
||||
|
||||
router.post('/:streamSessionId/end', requireDeviceAuth, async (req, res) => {
|
||||
const parsedParams = streamParamSchema.safeParse(req.params);
|
||||
|
||||
@@ -434,16 +587,10 @@ router.post('/:streamSessionId/end', requireDeviceAuth, async (req, res) => {
|
||||
return;
|
||||
}
|
||||
|
||||
const deviceAuth = req.deviceAuth;
|
||||
const deviceAuth = ensureDeviceAuth(req, res);
|
||||
if (!deviceAuth) return;
|
||||
|
||||
if (!deviceAuth) {
|
||||
res.status(401).json({ message: 'Unauthorized' });
|
||||
return;
|
||||
}
|
||||
|
||||
const session = await db.query.streamSessions.findFirst({
|
||||
where: and(eq(streamSessions.id, parsedParams.data.streamSessionId), eq(streamSessions.ownerUserId, deviceAuth.userId)),
|
||||
});
|
||||
const session = await getOwnedStreamSession(parsedParams.data.streamSessionId, deviceAuth.userId);
|
||||
|
||||
if (!session) {
|
||||
res.status(404).json({ message: 'Stream session not found' });
|
||||
@@ -469,6 +616,14 @@ router.post('/:streamSessionId/end', requireDeviceAuth, async (req, res) => {
|
||||
.where(eq(streamSessions.id, session.id))
|
||||
.returning();
|
||||
|
||||
if (sfuService) {
|
||||
try {
|
||||
await sfuService.endSession(session.id);
|
||||
} catch (error) {
|
||||
console.error('Failed ending SFU session', error);
|
||||
}
|
||||
}
|
||||
|
||||
await createRecordingForStream(session.id);
|
||||
|
||||
const deliveredToRequester = sendRealtimeToDevice(session.requesterDeviceId, 'stream:ended', {
|
||||
@@ -518,16 +673,10 @@ router.get('/:streamSessionId/playback-token', requireDeviceAuth, async (req, re
|
||||
return;
|
||||
}
|
||||
|
||||
const deviceAuth = req.deviceAuth;
|
||||
const deviceAuth = ensureDeviceAuth(req, res);
|
||||
if (!deviceAuth) return;
|
||||
|
||||
if (!deviceAuth) {
|
||||
res.status(401).json({ message: 'Unauthorized' });
|
||||
return;
|
||||
}
|
||||
|
||||
const session = await db.query.streamSessions.findFirst({
|
||||
where: and(eq(streamSessions.id, parsedParams.data.streamSessionId), eq(streamSessions.ownerUserId, deviceAuth.userId)),
|
||||
});
|
||||
const session = await getOwnedStreamSession(parsedParams.data.streamSessionId, deviceAuth.userId);
|
||||
|
||||
if (!session) {
|
||||
res.status(404).json({ message: 'Stream session not found' });
|
||||
|
||||
Reference in New Issue
Block a user