isConfigured(); } public function apiHost(): string { $override = (string) config('meet.media.livekit.api_url', ''); if ($override !== '') { return rtrim($override, '/'); } $url = (string) config('meet.media.livekit.url', ''); return (string) preg_replace('#^wss:#', 'https:', (string) preg_replace('#^ws:#', 'http:', $url)); } public function startRoomRecording(Session $session, Recording $recording, bool $audioOnly = false): EgressInfo { $client = $this->client(); $layout = $this->egressLayout($recording->layout); $output = $this->buildFileOutput($session, $recording); $preset = EncodingOptionsPreset::H264_720P_30; return $client->startRoomCompositeEgress( $session->media_room_name, $layout, $output, $preset, $audioOnly, ); } public function stopEgress(string $egressId): EgressInfo { return $this->client()->stopEgress($egressId); } protected function client(): EgressServiceClient { return new EgressServiceClient( $this->apiHost(), (string) config('meet.media.livekit.api_key'), (string) config('meet.media.livekit.api_secret'), ); } protected function buildFileOutput(Session $session, Recording $recording): EncodedFileOutput { $filepath = sprintf( 'recordings/%s/%s.mp4', $session->uuid, $recording->uuid, ); $output = new EncodedFileOutput([ 'file_type' => EncodedFileType::MP4, 'filepath' => $filepath, ]); $s3 = $this->s3UploadConfig(); if ($s3 !== null) { $output->setS3(new S3Upload($s3)); } return $output; } /** * @return array|null */ protected function s3UploadConfig(): ?array { $egress = config('meet.recordings.egress.s3', []); $bucket = (string) ($egress['bucket'] ?? ''); if ($bucket === '') { return null; } $config = [ 'bucket' => $bucket, 'region' => (string) ($egress['region'] ?? 'us-east-1'), ]; foreach (['access_key', 'secret', 'session_token', 'endpoint', 'assume_role_arn', 'assume_role_external_id'] as $key) { $value = (string) ($egress[$key] ?? ''); if ($value !== '') { $config[$key] = $value; } } return $config; } protected function egressLayout(?string $layout): string { return match ($layout) { 'speaker', 'screen' => 'speaker', default => 'grid', }; } public function tryStart(Session $session, Recording $recording): ?array { if (! $this->isAvailable()) { return null; } try { $audioOnly = $session->room->isAudioOnly(); $info = $this->startRoomRecording($session, $recording, $audioOnly); $egressId = $info->getEgressId(); if ($egressId === '') { throw new RuntimeException('LiveKit egress did not return an egress id.'); } $filepath = sprintf('recordings/%s/%s.mp4', $session->uuid, $recording->uuid); return [ 'egress_id' => $egressId, 'egress_filepath' => $filepath, 'capture_mode' => 'egress', 'audio_only' => $audioOnly, ]; } catch (\Throwable $e) { Log::warning('LiveKit egress start failed; falling back to browser capture.', [ 'session_uuid' => $session->uuid, 'recording_uuid' => $recording->uuid, 'error' => $e->getMessage(), ]); return null; } } public function tryStop(?string $egressId): void { if (! filled($egressId)) { return; } try { $this->stopEgress($egressId); } catch (\Throwable $e) { Log::warning('LiveKit egress stop failed.', [ 'egress_id' => $egressId, 'error' => $e->getMessage(), ]); } } }