feat(media): add single-server SFU scaffolding and media mode config
This commit is contained in:
43
Backend/media/config.ts
Normal file
43
Backend/media/config.ts
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
export type MediaMode = 'legacy' | 'single_server_sfu';
|
||||||
|
|
||||||
|
const parseMediaMode = (value: string | undefined): MediaMode => {
|
||||||
|
const normalized = (value ?? 'legacy').trim().toLowerCase();
|
||||||
|
if (normalized === 'single_server_sfu') {
|
||||||
|
return 'single_server_sfu';
|
||||||
|
}
|
||||||
|
return 'legacy';
|
||||||
|
};
|
||||||
|
|
||||||
|
const parseCsv = (value: string | undefined): string[] => {
|
||||||
|
if (!value) return [];
|
||||||
|
return value
|
||||||
|
.split(',')
|
||||||
|
.map((item) => item.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
};
|
||||||
|
|
||||||
|
const parsePositiveNumber = (value: string | undefined): number | null => {
|
||||||
|
if (!value) return null;
|
||||||
|
const parsed = Number(value);
|
||||||
|
if (!Number.isFinite(parsed) || parsed <= 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return parsed;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const mediaMode: MediaMode = parseMediaMode(process.env.MEDIA_MODE);
|
||||||
|
|
||||||
|
export const mediaConfig = {
|
||||||
|
mode: mediaMode,
|
||||||
|
turn: {
|
||||||
|
urls: parseCsv(process.env.TURN_URLS),
|
||||||
|
username: process.env.TURN_USERNAME ?? '',
|
||||||
|
credential: process.env.TURN_CREDENTIAL ?? '',
|
||||||
|
},
|
||||||
|
recordingsDir: process.env.MEDIA_RECORDINGS_DIR ?? 'media-recordings',
|
||||||
|
limits: {
|
||||||
|
maxPublishers: parsePositiveNumber(process.env.MEDIA_MAX_PUBLISHERS),
|
||||||
|
maxSubscribersPerRoom: parsePositiveNumber(process.env.MEDIA_MAX_SUBSCRIBERS_PER_ROOM),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
71
Backend/media/sfu/noop.ts
Normal file
71
Backend/media/sfu/noop.ts
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
import { randomUUID } from 'crypto';
|
||||||
|
|
||||||
|
import { mediaConfig } from '../config';
|
||||||
|
import type {
|
||||||
|
SfuPublishTransportRequest,
|
||||||
|
SfuPublishTransportResult,
|
||||||
|
SfuService,
|
||||||
|
SfuSessionDescriptor,
|
||||||
|
SfuSessionStartInput,
|
||||||
|
SfuSubscribeTransportRequest,
|
||||||
|
SfuSubscribeTransportResult,
|
||||||
|
} from './types';
|
||||||
|
|
||||||
|
const toIceServers = (): Array<{ urls: string; username?: string; credential?: string }> => {
|
||||||
|
if (mediaConfig.turn.urls.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return mediaConfig.turn.urls.map((urls) => ({
|
||||||
|
urls,
|
||||||
|
...(mediaConfig.turn.username ? { username: mediaConfig.turn.username } : {}),
|
||||||
|
...(mediaConfig.turn.credential ? { credential: mediaConfig.turn.credential } : {}),
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
export class NoopSfuService implements SfuService {
|
||||||
|
mode: 'single_server_sfu' = 'single_server_sfu';
|
||||||
|
private readonly sessions = new Map<string, SfuSessionDescriptor>();
|
||||||
|
|
||||||
|
async startSession(input: SfuSessionStartInput): Promise<SfuSessionDescriptor> {
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const existing = this.sessions.get(input.streamSessionId);
|
||||||
|
if (existing) return existing;
|
||||||
|
|
||||||
|
const descriptor: SfuSessionDescriptor = {
|
||||||
|
streamSessionId: input.streamSessionId,
|
||||||
|
ownerUserId: input.ownerUserId,
|
||||||
|
cameraDeviceId: input.cameraDeviceId,
|
||||||
|
requesterDeviceId: input.requesterDeviceId,
|
||||||
|
state: 'starting',
|
||||||
|
createdAt: now,
|
||||||
|
};
|
||||||
|
this.sessions.set(input.streamSessionId, descriptor);
|
||||||
|
return descriptor;
|
||||||
|
}
|
||||||
|
|
||||||
|
async endSession(streamSessionId: string): Promise<void> {
|
||||||
|
const existing = this.sessions.get(streamSessionId);
|
||||||
|
if (!existing) return;
|
||||||
|
this.sessions.set(streamSessionId, { ...existing, state: 'ended' });
|
||||||
|
}
|
||||||
|
|
||||||
|
async getSession(streamSessionId: string): Promise<SfuSessionDescriptor | null> {
|
||||||
|
return this.sessions.get(streamSessionId) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async createPublishTransport(_input: SfuPublishTransportRequest): Promise<SfuPublishTransportResult> {
|
||||||
|
return {
|
||||||
|
transportId: `pub_${randomUUID()}`,
|
||||||
|
iceServers: toIceServers(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async createSubscribeTransport(_input: SfuSubscribeTransportRequest): Promise<SfuSubscribeTransportResult> {
|
||||||
|
return {
|
||||||
|
transportId: `sub_${randomUUID()}`,
|
||||||
|
iceServers: toIceServers(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
14
Backend/media/sfu/service.ts
Normal file
14
Backend/media/sfu/service.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { mediaMode } from '../config';
|
||||||
|
import { NoopSfuService } from './noop';
|
||||||
|
import type { SfuService } from './types';
|
||||||
|
|
||||||
|
const createSfuService = (): SfuService | null => {
|
||||||
|
if (mediaMode !== 'single_server_sfu') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new NoopSfuService();
|
||||||
|
};
|
||||||
|
|
||||||
|
export const sfuService = createSfuService();
|
||||||
|
|
||||||
47
Backend/media/sfu/types.ts
Normal file
47
Backend/media/sfu/types.ts
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
export type SfuSessionState = 'idle' | 'starting' | 'live' | 'ending' | 'ended';
|
||||||
|
|
||||||
|
export type SfuSessionDescriptor = {
|
||||||
|
streamSessionId: string;
|
||||||
|
ownerUserId: string;
|
||||||
|
cameraDeviceId: string;
|
||||||
|
requesterDeviceId: string;
|
||||||
|
state: SfuSessionState;
|
||||||
|
createdAt: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SfuSessionStartInput = {
|
||||||
|
streamSessionId: string;
|
||||||
|
ownerUserId: string;
|
||||||
|
cameraDeviceId: string;
|
||||||
|
requesterDeviceId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SfuPublishTransportRequest = {
|
||||||
|
streamSessionId: string;
|
||||||
|
cameraDeviceId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SfuPublishTransportResult = {
|
||||||
|
transportId: string;
|
||||||
|
iceServers: Array<{ urls: string; username?: string; credential?: string }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SfuSubscribeTransportRequest = {
|
||||||
|
streamSessionId: string;
|
||||||
|
viewerDeviceId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SfuSubscribeTransportResult = {
|
||||||
|
transportId: string;
|
||||||
|
iceServers: Array<{ urls: string; username?: string; credential?: string }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface SfuService {
|
||||||
|
mode: 'single_server_sfu';
|
||||||
|
startSession(input: SfuSessionStartInput): Promise<SfuSessionDescriptor>;
|
||||||
|
endSession(streamSessionId: string): Promise<void>;
|
||||||
|
getSession(streamSessionId: string): Promise<SfuSessionDescriptor | null>;
|
||||||
|
createPublishTransport(input: SfuPublishTransportRequest): Promise<SfuPublishTransportResult>;
|
||||||
|
createSubscribeTransport(input: SfuSubscribeTransportRequest): Promise<SfuSubscribeTransportResult>;
|
||||||
|
}
|
||||||
|
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
import { Router } from 'express';
|
import { Router } from 'express';
|
||||||
|
|
||||||
import { db } from '../db/client';
|
import { db } from '../db/client';
|
||||||
|
import { mediaConfig } from '../media/config';
|
||||||
import { mediaProvider } from '../media/service';
|
import { mediaProvider } from '../media/service';
|
||||||
|
import { sfuService } from '../media/sfu/service';
|
||||||
import { getAllMetrics } from '../observability/metrics';
|
import { getAllMetrics } from '../observability/metrics';
|
||||||
import { minioBucket, minioClient } from '../utils/minio';
|
import { minioBucket, minioClient } from '../utils/minio';
|
||||||
|
|
||||||
@@ -21,7 +23,9 @@ router.get('/ready', async (_req, res) => {
|
|||||||
checks: {
|
checks: {
|
||||||
database: 'ok',
|
database: 'ok',
|
||||||
minio: 'ok',
|
minio: 'ok',
|
||||||
|
mediaMode: mediaConfig.mode,
|
||||||
mediaProvider: mediaProvider.name,
|
mediaProvider: mediaProvider.name,
|
||||||
|
sfuService: sfuService ? sfuService.mode : 'disabled',
|
||||||
},
|
},
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user