// SPDX-FileCopyrightText: 2024 LiveKit, Inc. // // SPDX-License-Identifier: Apache-2.0 import type { DirectFileOutput, EncodedFileOutput, EncodingOptions, EncodingOptionsPreset, ImageOutput, SegmentedFileOutput, StreamOutput, WebhookConfig, } from '@livekit/protocol'; import { AudioMixing, EgressInfo, ListEgressRequest, ListEgressResponse, ParticipantEgressRequest, RoomCompositeEgressRequest, StopEgressRequest, TrackCompositeEgressRequest, TrackEgressRequest, UpdateLayoutRequest, UpdateStreamRequest, WebEgressRequest, } from '@livekit/protocol'; import type { ClientOptions } from './ClientOptions.js'; import { ServiceBase } from './ServiceBase.js'; import type { Rpc } from './TwirpRPC.js'; import { TwirpRpc, livekitPackage } from './TwirpRPC.js'; const svc = 'Egress'; export interface BaseOptions { /** * webhooks to call for this request, optional. */ webhooks?: WebhookConfig[]; } export interface RoomCompositeOptions extends BaseOptions { /** * egress layout. optional */ layout?: string; /** * encoding options or preset. optional */ encodingOptions?: EncodingOptionsPreset | EncodingOptions; /** * record audio only. optional */ audioOnly?: boolean; /** * record video only. optional */ videoOnly?: boolean; /** * custom template url. optional */ customBaseUrl?: string; /** * audio mixing options. optional */ audioMixing?: AudioMixing; } export interface WebOptions extends BaseOptions { /** * encoding options or preset. optional */ encodingOptions?: EncodingOptionsPreset | EncodingOptions; /** * record audio only. optional */ audioOnly?: boolean; /** * record video only. optional */ videoOnly?: boolean; /** * await START_RECORDING chrome log */ awaitStartSignal?: boolean; } export interface ParticipantEgressOptions extends BaseOptions { /** * true to capture source screenshare and screenshare_audio * false to capture camera and microphone */ screenShare?: boolean; /** * encoding options or preset. optional */ encodingOptions?: EncodingOptionsPreset | EncodingOptions; } export interface TrackCompositeOptions extends BaseOptions { /** * audio track ID */ audioTrackId?: string; /** * video track ID */ videoTrackId?: string; /** * encoding options or preset. optional */ encodingOptions?: EncodingOptionsPreset | EncodingOptions; } /** * Used to supply multiple outputs with an egress request */ export interface EncodedOutputs { file?: EncodedFileOutput | undefined; stream?: StreamOutput | undefined; segments?: SegmentedFileOutput | undefined; images?: ImageOutput | undefined; } export interface ListEgressOptions { roomName?: string; egressId?: string; active?: boolean; } /** * Client to access Egress APIs */ export class EgressClient extends ServiceBase { private readonly rpc: Rpc; /** * @param host - hostname including protocol. i.e. 'https://.livekit.cloud' * @param apiKey - API Key, can be set in env var LIVEKIT_API_KEY * @param secret - API Secret, can be set in env var LIVEKIT_API_SECRET * @param options - client options */ constructor(host: string, apiKey?: string, secret?: string, options?: ClientOptions) { super(apiKey, secret); const rpcOptions = options?.requestTimeout ? { requestTimeout: options.requestTimeout } : undefined; this.rpc = new TwirpRpc(host, livekitPackage, rpcOptions); } /** * @param roomName - room name * @param output - file or stream output * @param opts - RoomCompositeOptions */ async startRoomCompositeEgress( roomName: string, output: EncodedOutputs | EncodedFileOutput | StreamOutput | SegmentedFileOutput, opts?: RoomCompositeOptions, ): Promise; /** * @deprecated use RoomCompositeOptions instead */ async startRoomCompositeEgress( roomName: string, output: EncodedOutputs | EncodedFileOutput | StreamOutput | SegmentedFileOutput, layout?: string, options?: EncodingOptionsPreset | EncodingOptions, audioOnly?: boolean, videoOnly?: boolean, customBaseUrl?: string, audioMixing?: AudioMixing, ): Promise; async startRoomCompositeEgress( roomName: string, output: EncodedOutputs | EncodedFileOutput | StreamOutput | SegmentedFileOutput, optsOrLayout?: RoomCompositeOptions | string, options?: EncodingOptionsPreset | EncodingOptions, audioOnly?: boolean, videoOnly?: boolean, customBaseUrl?: string, audioMixing?: AudioMixing, ): Promise { let layout: string | undefined; let webhooks: WebhookConfig[] | undefined; if (optsOrLayout !== undefined) { if (typeof optsOrLayout === 'string') { layout = optsOrLayout; } else { const opts = optsOrLayout; layout = opts.layout; options = opts.encodingOptions; audioOnly = opts.audioOnly; videoOnly = opts.videoOnly; customBaseUrl = opts.customBaseUrl; audioMixing = opts.audioMixing; webhooks = opts.webhooks; } } layout ??= ''; audioOnly ??= false; videoOnly ??= false; customBaseUrl ??= ''; audioMixing ??= AudioMixing.DEFAULT_MIXING; const { output: legacyOutput, options: egressOptions, fileOutputs, streamOutputs, segmentOutputs, imageOutputs, } = this.getOutputParams(output, options); const req = new RoomCompositeEgressRequest({ roomName, layout, audioOnly, audioMixing, videoOnly, customBaseUrl, output: legacyOutput, options: egressOptions, fileOutputs, streamOutputs, segmentOutputs, imageOutputs, webhooks, }).toJson(); const data = await this.rpc.request( svc, 'StartRoomCompositeEgress', req, await this.authHeader({ roomRecord: true }), ); return EgressInfo.fromJson(data, { ignoreUnknownFields: true }); } /** * @param url - url * @param output - file or stream output * @param opts - WebOptions */ async startWebEgress( url: string, output: EncodedOutputs | EncodedFileOutput | StreamOutput | SegmentedFileOutput, opts?: WebOptions, ): Promise { const audioOnly = opts?.audioOnly || false; const videoOnly = opts?.videoOnly || false; const awaitStartSignal = opts?.awaitStartSignal || false; const webhooks = opts?.webhooks || []; const { output: legacyOutput, options, fileOutputs, streamOutputs, segmentOutputs, imageOutputs, } = this.getOutputParams(output, opts?.encodingOptions); const req = new WebEgressRequest({ url, audioOnly, videoOnly, awaitStartSignal, output: legacyOutput, options, fileOutputs, streamOutputs, segmentOutputs, imageOutputs, webhooks, }).toJson(); const data = await this.rpc.request( svc, 'StartWebEgress', req, await this.authHeader({ roomRecord: true }), ); return EgressInfo.fromJson(data, { ignoreUnknownFields: true }); } /** * Export a participant's audio and video tracks, * * @param roomName - room name * @param output - one or more outputs * @param opts - ParticipantEgressOptions */ async startParticipantEgress( roomName: string, identity: string, output: EncodedOutputs, opts?: ParticipantEgressOptions, ): Promise { const webhooks = opts?.webhooks || []; const { options, fileOutputs, streamOutputs, segmentOutputs, imageOutputs } = this.getOutputParams(output, opts?.encodingOptions); const req = new ParticipantEgressRequest({ roomName, identity, screenShare: opts?.screenShare ?? false, options, fileOutputs, streamOutputs, segmentOutputs, imageOutputs, webhooks, }).toJson(); const data = await this.rpc.request( svc, 'StartParticipantEgress', req, await this.authHeader({ roomRecord: true }), ); return EgressInfo.fromJson(data, { ignoreUnknownFields: true }); } /** * @param roomName - room name * @param output - file or stream output * @param opts - TrackCompositeOptions */ async startTrackCompositeEgress( roomName: string, output: EncodedOutputs | EncodedFileOutput | StreamOutput | SegmentedFileOutput, opts?: TrackCompositeOptions, ): Promise; /** * @deprecated use TrackCompositeOptions instead */ async startTrackCompositeEgress( roomName: string, output: EncodedOutputs | EncodedFileOutput | StreamOutput | SegmentedFileOutput, audioTrackId?: string, videoTrackId?: string, options?: EncodingOptionsPreset | EncodingOptions, ): Promise; async startTrackCompositeEgress( roomName: string, output: EncodedOutputs | EncodedFileOutput | StreamOutput | SegmentedFileOutput, optsOrAudioTrackId?: TrackCompositeOptions | string, videoTrackId?: string, options?: EncodingOptionsPreset | EncodingOptions, ): Promise { let audioTrackId: string | undefined; let webhooks: WebhookConfig[] | undefined; if (optsOrAudioTrackId !== undefined) { if (typeof optsOrAudioTrackId === 'string') { audioTrackId = optsOrAudioTrackId; } else { const opts = optsOrAudioTrackId; audioTrackId = opts.audioTrackId; videoTrackId = opts.videoTrackId; options = opts.encodingOptions; webhooks = opts.webhooks; } } audioTrackId ??= ''; videoTrackId ??= ''; const { output: legacyOutput, options: egressOptions, fileOutputs, streamOutputs, segmentOutputs, imageOutputs, } = this.getOutputParams(output, options); const req = new TrackCompositeEgressRequest({ roomName, audioTrackId, videoTrackId, output: legacyOutput, options: egressOptions, fileOutputs, streamOutputs, segmentOutputs, imageOutputs, webhooks, }).toJson(); const data = await this.rpc.request( svc, 'StartTrackCompositeEgress', req, await this.authHeader({ roomRecord: true }), ); return EgressInfo.fromJson(data, { ignoreUnknownFields: true }); } // eslint-disable-next-line @typescript-eslint/no-explicit-any private isEncodedOutputs(output: any): output is EncodedOutputs { return ( (output).file !== undefined || (output).stream !== undefined || (output).segments !== undefined || (output).images !== undefined ); } // eslint-disable-next-line @typescript-eslint/no-explicit-any private isEncodedFileOutput(output: any): output is EncodedFileOutput { return ( (output).filepath !== undefined || (output).fileType !== undefined ); } // eslint-disable-next-line @typescript-eslint/no-explicit-any private isSegmentedFileOutput(output: any): output is SegmentedFileOutput { return ( (output).filenamePrefix !== undefined || (output).playlistName !== undefined || (output).filenameSuffix !== undefined ); } // eslint-disable-next-line @typescript-eslint/no-explicit-any private isStreamOutput(output: any): output is StreamOutput { return ( (output).protocol !== undefined || (output).urls !== undefined ); } private getOutputParams( output: EncodedOutputs | EncodedFileOutput | StreamOutput | SegmentedFileOutput, opts?: EncodingOptionsPreset | EncodingOptions, ) { let file: EncodedFileOutput | undefined; let fileOutputs: Array | undefined; let stream: StreamOutput | undefined; let streamOutputs: Array | undefined; let segments: SegmentedFileOutput | undefined; let segmentOutputs: Array | undefined; let imageOutputs: Array | undefined; if (this.isEncodedOutputs(output)) { if (output.file !== undefined) { fileOutputs = [output.file]; } if (output.stream !== undefined) { streamOutputs = [output.stream]; } if (output.segments !== undefined) { segmentOutputs = [output.segments]; } if (output.images !== undefined) { imageOutputs = [output.images]; } } else if (this.isEncodedFileOutput(output)) { file = output; fileOutputs = [file]; } else if (this.isSegmentedFileOutput(output)) { segments = output; segmentOutputs = [segments]; } else if (this.isStreamOutput(output)) { stream = output; streamOutputs = [stream]; } let legacyOutput: | { value: EncodedFileOutput; case: 'file'; } | { value: StreamOutput; case: 'stream'; } | { value: SegmentedFileOutput; case: 'segments'; } | undefined; if (file) { legacyOutput = { case: 'file', value: file, }; } else if (stream) { legacyOutput = { case: 'stream', value: stream, }; } else if (segments) { legacyOutput = { case: 'segments', value: segments, }; } let egressOptions: | { value: EncodingOptionsPreset; case: 'preset'; } | { value: EncodingOptions; case: 'advanced'; } | undefined; if (opts) { if (typeof opts === 'number') { egressOptions = { case: 'preset', value: opts, }; } else { egressOptions = { case: 'advanced', value: opts, }; } } return { output: legacyOutput, options: egressOptions, fileOutputs, streamOutputs, segmentOutputs, imageOutputs, }; } /** * @param roomName - room name * @param output - file or websocket output * @param trackId - track Id */ async startTrackEgress( roomName: string, output: DirectFileOutput | string, trackId: string, webhooks?: WebhookConfig[], ): Promise { let legacyOutput: | { value: DirectFileOutput; case: 'file'; } | { value: string; case: 'websocketUrl'; } | undefined; if (typeof output === 'string') { legacyOutput = { case: 'websocketUrl', value: output, }; } else { legacyOutput = { case: 'file', value: output, }; } const req = new TrackEgressRequest({ roomName, trackId, output: legacyOutput, webhooks, }).toJson(); const data = await this.rpc.request( svc, 'StartTrackEgress', req, await this.authHeader({ roomRecord: true }), ); return EgressInfo.fromJson(data, { ignoreUnknownFields: true }); } /** * @param egressId - * @param layout - */ async updateLayout(egressId: string, layout: string): Promise { const data = await this.rpc.request( svc, 'UpdateLayout', new UpdateLayoutRequest({ egressId, layout }).toJson(), await this.authHeader({ roomRecord: true }), ); return EgressInfo.fromJson(data, { ignoreUnknownFields: true }); } /** * @param egressId - * @param addOutputUrls - * @param removeOutputUrls - */ async updateStream( egressId: string, addOutputUrls?: string[], removeOutputUrls?: string[], ): Promise { addOutputUrls ??= []; removeOutputUrls ??= []; const data = await this.rpc.request( svc, 'UpdateStream', new UpdateStreamRequest({ egressId, addOutputUrls, removeOutputUrls }).toJson(), await this.authHeader({ roomRecord: true }), ); return EgressInfo.fromJson(data, { ignoreUnknownFields: true }); } /** * @param options - options to filter listed Egresses, by default returns all * Egress instances */ async listEgress(options?: ListEgressOptions): Promise>; /** * @deprecated use `listEgress(options?: ListEgressOptions)` instead * @param roomName - list egress for one room only */ async listEgress(roomName?: string): Promise>; /** * @param roomName - list egress for one room only */ async listEgress(options?: string | ListEgressOptions): Promise> { let req: Partial = {}; if (typeof options === 'string') { req.roomName = options; } else if (options !== undefined) { req = options; } const data = await this.rpc.request( svc, 'ListEgress', new ListEgressRequest(req).toJson(), await this.authHeader({ roomRecord: true }), ); return ListEgressResponse.fromJson(data, { ignoreUnknownFields: true }).items ?? []; } /** * @param egressId - */ async stopEgress(egressId: string): Promise { const data = await this.rpc.request( svc, 'StopEgress', new StopEgressRequest({ egressId }).toJson(), await this.authHeader({ roomRecord: true }), ); return EgressInfo.fromJson(data, { ignoreUnknownFields: true }); } }