Add recording support (#290)

* WIP add recording support

* Add region env var

* Add recording indicator

* Indicator and support for stopping recording

* remove logs

* rename server functions
This commit is contained in:
lukasIO 2024-08-13 11:02:28 +02:00 committed by GitHub
parent 84c6151e4b
commit ff15c2ee31
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 231 additions and 7 deletions

View File

@ -8,10 +8,21 @@ LIVEKIT_API_SECRET=secret
# URL pointing to the LiveKit server.
LIVEKIT_URL=wss://my-livekit-project.livekit.cloud
## PUBLIC
# Recording
# S3_KEY_ID=
# S3_KEY_SECRET=
# S3_ENDPOINT=
# S3_BUCKET=
# S3_REGION=
# PUBLIC
NEXT_PUBLIC_LK_TOKEN_ENDPOINT=/api/token
#NEXT_PUBLIC_LK_RECORD_ENDPOINT=/api/record
# Uncomment settings menu when using a LiveKit Cloud, it'll enable Krisp noise filters.
# NEXT_PUBLIC_SHOW_SETTINGS_MENU=true
# Optional, to pipe logs to datadog
# NEXT_PUBLIC_DATADOG_CLIENT_TOKEN=client-token
# NEXT_PUBLIC_DATADOG_SITE=datadog-site

View File

@ -0,0 +1,30 @@
import { useIsRecording } from '@livekit/components-react';
import * as React from 'react';
export function RecordingIndicator() {
const isRecording = useIsRecording();
const [wasRecording, setWasRecording] = React.useState(false);
React.useEffect(() => {
if (isRecording !== wasRecording) {
setWasRecording(isRecording);
if (isRecording) {
window.alert('This meeting is being recorded');
}
}
}, [isRecording]);
return (
<div
style={{
position: 'absolute',
top: '0',
left: '0',
width: '100%',
height: '100%',
boxShadow: isRecording ? 'red 0px 0px 0px 3px inset' : 'none',
pointerEvents: 'none',
}}
></div>
);
}

View File

@ -7,6 +7,7 @@ import {
MediaDeviceMenu,
TrackToggle,
useRoomContext,
useIsRecording,
} from '@livekit/components-react';
import styles from '../styles/SettingsMenu.module.css';
@ -21,16 +22,18 @@ export interface SettingsMenuProps extends React.HTMLAttributes<HTMLDivElement>
export function SettingsMenu(props: SettingsMenuProps) {
const layoutContext = useMaybeLayoutContext();
const room = useRoomContext();
const recordingEndpoint = process.env.NEXT_PUBLIC_LK_RECORD_ENDPOINT;
const settings = React.useMemo(() => {
return {
media: { camera: true, microphone: true, label: 'Media Devices', speaker: true },
effects: { label: 'Effects' },
recording: recordingEndpoint ? { label: 'Recording' } : undefined,
};
}, []);
const tabs = React.useMemo(
() => Object.keys(settings) as Array<keyof typeof settings>,
() => Object.keys(settings).filter((t) => t !== undefined) as Array<keyof typeof settings>,
[settings],
);
const { microphoneTrack } = useLocalParticipant();
@ -62,6 +65,42 @@ export function SettingsMenu(props: SettingsMenuProps) {
}
}, [isNoiseFilterEnabled, microphoneTrack]);
const isRecording = useIsRecording();
const [initialRecStatus, setInitialRecStatus] = React.useState(isRecording);
const [processingRecRequest, setProcessingRecRequest] = React.useState(false);
React.useEffect(() => {
if (initialRecStatus !== isRecording) {
setProcessingRecRequest(false);
}
}, [isRecording, initialRecStatus]);
const toggleRoomRecording = async () => {
if (!recordingEndpoint) {
throw TypeError('No recording endpoint specified');
}
if (room.isE2EEEnabled) {
throw Error('Recording of encrypted meetings is currently not supported');
}
setProcessingRecRequest(true);
setInitialRecStatus(isRecording);
let response: Response;
if (isRecording) {
response = await fetch(recordingEndpoint + `/stop?roomName=${room.name}`);
} else {
response = await fetch(recordingEndpoint + `/start?roomName=${room.name}`);
}
if (response.ok) {
} else {
console.error(
'Error handling recording request, check server logs:',
response.status,
response.statusText,
);
setProcessingRecRequest(false);
}
};
return (
<div className="settings-menu" style={{ width: '100%' }} {...props}>
<div className={styles.tabs}>
@ -134,6 +173,21 @@ export function SettingsMenu(props: SettingsMenuProps) {
</section>
</>
)}
{activeTab === 'recording' && (
<>
<h3>Record Meeting</h3>
<section>
<p>
{isRecording
? 'Meeting is currently being recorded'
: 'No active recordings for this meeting'}
</p>
<button disabled={processingRecRequest} onClick={() => toggleRoomRecording()}>
{isRecording ? 'Stop' : 'Start'} Recording
</button>
</section>
</>
)}
</div>
<button
className={`lk-button ${styles.settingsCloseButton}`}

View File

@ -41,3 +41,8 @@ export function randomString(length: number): string {
}
return result;
}
export const sleep = (time: number) =>
new Promise((resolve) => {
setTimeout(resolve, time);
});

View File

@ -12,7 +12,7 @@
"@datadog/browser-logs": "^5.10.0",
"@livekit/components-react": "2.4.3",
"@livekit/components-styles": "1.0.12",
"@livekit/krisp-noise-filter": "^0.2.0",
"@livekit/krisp-noise-filter": "^0.2.5",
"livekit-client": "2.4.2",
"livekit-server-sdk": "2.6.0",
"next": "14.2.5",

76
pages/api/record/start.ts Normal file
View File

@ -0,0 +1,76 @@
import { EgressClient, EncodedFileOutput, S3Upload } from 'livekit-server-sdk';
import { NextApiRequest, NextApiResponse } from 'next';
export default async function startRecording(req: NextApiRequest, res: NextApiResponse) {
try {
const { roomName } = req.query;
/**
* CAUTION:
* for simplicity this implementation does not authenticate users and therefore allows anyone with knowledge of a roomName
* to start/stop recordings for that room.
* DO NOT USE THIS FOR PRODUCTION PURPOSES AS IS
*/
if (typeof roomName !== 'string') {
res.statusMessage = 'Missing roomName parameter';
res.status(403).end();
return;
}
const {
LIVEKIT_API_KEY,
LIVEKIT_API_SECRET,
LIVEKIT_URL,
S3_KEY_ID,
S3_KEY_SECRET,
S3_BUCKET,
S3_ENDPOINT,
S3_REGION,
} = process.env;
const hostURL = new URL(LIVEKIT_URL!);
hostURL.protocol = 'https:';
const egressClient = new EgressClient(hostURL.origin, LIVEKIT_API_KEY, LIVEKIT_API_SECRET);
const existingEgresses = await egressClient.listEgress({ roomName });
if (existingEgresses.length > 0 && existingEgresses.some((e) => e.status < 2)) {
(res.statusMessage = 'Meeting is already being recorded'), res.status(409).end();
return;
}
const fileOutput = new EncodedFileOutput({
filepath: `${new Date(Date.now()).toISOString()}-${roomName}.mp4`,
output: {
case: 's3',
value: new S3Upload({
endpoint: S3_ENDPOINT,
accessKey: S3_KEY_ID,
secret: S3_KEY_SECRET,
region: S3_REGION,
bucket: S3_BUCKET,
}),
},
});
await egressClient.startRoomCompositeEgress(
roomName,
{
file: fileOutput,
},
{
layout: 'speaker',
},
);
res.status(200).end();
} catch (e) {
if (e instanceof Error) {
res.statusMessage = e.name;
console.error(e);
res.status(500).end();
return;
}
}
}

46
pages/api/record/stop.ts Normal file
View File

@ -0,0 +1,46 @@
import { EgressClient } from 'livekit-server-sdk';
import { NextApiRequest, NextApiResponse } from 'next';
export default async function stopRecording(req: NextApiRequest, res: NextApiResponse) {
try {
const { roomName } = req.query;
/**
* CAUTION:
* for simplicity this implementation does not authenticate users and therefore allows anyone with knowledge of a roomName
* to start/stop recordings for that room.
* DO NOT USE THIS FOR PRODUCTION PURPOSES AS IS
*/
if (typeof roomName !== 'string') {
res.statusMessage = 'Missing roomName parameter';
res.status(403).end();
return;
}
const { LIVEKIT_API_KEY, LIVEKIT_API_SECRET, LIVEKIT_URL } = process.env;
const hostURL = new URL(LIVEKIT_URL!);
hostURL.protocol = 'https:';
const egressClient = new EgressClient(hostURL.origin, LIVEKIT_API_KEY, LIVEKIT_API_SECRET);
const activeEgresses = (await egressClient.listEgress({ roomName })).filter(
(info) => info.status < 2,
);
if (activeEgresses.length === 0) {
res.statusMessage = 'No active recording found';
res.status(404).end();
return;
}
await Promise.all(activeEgresses.map((info) => egressClient.stopEgress(info.egressId)));
res.status(200).end();
} catch (e) {
if (e instanceof Error) {
res.statusMessage = e.name;
console.error(e);
res.status(500).end();
return;
}
}
}

View File

@ -25,6 +25,7 @@ import * as React from 'react';
import { DebugMode } from '../../lib/Debug';
import { decodePassphrase, useServerUrl } from '../../lib/client-utils';
import { SettingsMenu } from '../../lib/SettingsMenu';
import { RecordingIndicator } from '../../lib/RecordingIndicator';
const Home: NextPage = () => {
const router = useRouter();
@ -189,6 +190,7 @@ const ActiveRoom = ({ roomName, userChoices, onLeave }: ActiveRoomProps) => {
}
/>
<DebugMode />
<RecordingIndicator />
</LiveKitRoom>
)}
</>

8
pnpm-lock.yaml generated
View File

@ -18,7 +18,7 @@ importers:
specifier: 1.0.12
version: 1.0.12
'@livekit/krisp-noise-filter':
specifier: ^0.2.0
specifier: ^0.2.5
version: 0.2.5(livekit-client@2.4.2)
livekit-client:
specifier: 2.4.2
@ -2281,7 +2281,7 @@ snapshots:
eslint: 9.8.0
eslint-import-resolver-node: 0.3.9
eslint-import-resolver-typescript: 3.6.1(@typescript-eslint/parser@6.10.0(eslint@9.8.0)(typescript@5.5.4))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.0(eslint@9.8.0))(eslint@9.8.0)
eslint-plugin-import: 2.29.0(@typescript-eslint/parser@6.10.0(eslint@9.8.0)(typescript@5.5.4))(eslint-import-resolver-typescript@3.6.1)(eslint@9.8.0)
eslint-plugin-import: 2.29.0(@typescript-eslint/parser@6.10.0(eslint@9.8.0)(typescript@5.5.4))(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@6.10.0(eslint@9.8.0)(typescript@5.5.4))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.0(eslint@9.8.0))(eslint@9.8.0))(eslint@9.8.0)
eslint-plugin-jsx-a11y: 6.8.0(eslint@9.8.0)
eslint-plugin-react: 7.33.2(eslint@9.8.0)
eslint-plugin-react-hooks: 4.6.0(eslint@9.8.0)
@ -2305,7 +2305,7 @@ snapshots:
enhanced-resolve: 5.17.1
eslint: 9.8.0
eslint-module-utils: 2.8.0(@typescript-eslint/parser@6.10.0(eslint@9.8.0)(typescript@5.5.4))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@6.10.0(eslint@9.8.0)(typescript@5.5.4))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.0(eslint@9.8.0))(eslint@9.8.0))(eslint@9.8.0)
eslint-plugin-import: 2.29.0(@typescript-eslint/parser@6.10.0(eslint@9.8.0)(typescript@5.5.4))(eslint-import-resolver-typescript@3.6.1)(eslint@9.8.0)
eslint-plugin-import: 2.29.0(@typescript-eslint/parser@6.10.0(eslint@9.8.0)(typescript@5.5.4))(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@6.10.0(eslint@9.8.0)(typescript@5.5.4))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.0(eslint@9.8.0))(eslint@9.8.0))(eslint@9.8.0)
fast-glob: 3.3.2
get-tsconfig: 4.7.2
is-core-module: 2.13.1
@ -2327,7 +2327,7 @@ snapshots:
transitivePeerDependencies:
- supports-color
eslint-plugin-import@2.29.0(@typescript-eslint/parser@6.10.0(eslint@9.8.0)(typescript@5.5.4))(eslint-import-resolver-typescript@3.6.1)(eslint@9.8.0):
eslint-plugin-import@2.29.0(@typescript-eslint/parser@6.10.0(eslint@9.8.0)(typescript@5.5.4))(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@6.10.0(eslint@9.8.0)(typescript@5.5.4))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.0(eslint@9.8.0))(eslint@9.8.0))(eslint@9.8.0):
dependencies:
array-includes: 3.1.7
array.prototype.findlastindex: 1.2.3