diff --git a/app/Services/Meet/Media/LiveKitEgressService.php b/app/Services/Meet/Media/LiveKitEgressService.php index 94d6141..07d871f 100644 --- a/app/Services/Meet/Media/LiveKitEgressService.php +++ b/app/Services/Meet/Media/LiveKitEgressService.php @@ -26,14 +26,7 @@ class LiveKitEgressService 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)); + return app(LiveKitProvider::class)->apiHost(); } public function startRoomRecording(Session $session, Recording $recording, bool $audioOnly = false): EgressInfo @@ -54,6 +47,33 @@ class LiveKitEgressService ); } + protected function startRoomRecordingWithRetry(Session $session, Recording $recording, bool $audioOnly): EgressInfo + { + $attempts = max(1, (int) config('meet.recordings.egress.start_attempts', 5)); + $lastError = null; + + for ($attempt = 1; $attempt <= $attempts; $attempt++) { + try { + return $this->startRoomRecording($session, $recording, $audioOnly); + } catch (\Throwable $e) { + $lastError = $e; + if (! $this->isMissingRoomError($e) || $attempt === $attempts) { + throw $e; + } + + app(LiveKitProvider::class)->createRoom($session->media_room_name); + usleep(500_000); + } + } + + throw $lastError ?? new RuntimeException('LiveKit egress could not start.'); + } + + protected function isMissingRoomError(\Throwable $e): bool + { + return str_contains(strtolower($e->getMessage()), 'room does not exist'); + } + public function stopEgress(string $egressId): EgressInfo { return $this->client()->stopEgress($egressId); @@ -146,7 +166,7 @@ class LiveKitEgressService try { $audioOnly = $session->room->isAudioOnly(); - $info = $this->startRoomRecording($session, $recording, $audioOnly); + $info = $this->startRoomRecordingWithRetry($session, $recording, $audioOnly); $egressId = $info->getEgressId(); if ($egressId === '') { diff --git a/app/Services/Meet/Media/LiveKitProvider.php b/app/Services/Meet/Media/LiveKitProvider.php index f40b315..dae3e97 100644 --- a/app/Services/Meet/Media/LiveKitProvider.php +++ b/app/Services/Meet/Media/LiveKitProvider.php @@ -4,18 +4,58 @@ namespace App\Services\Meet\Media; use Agence104\LiveKit\AccessToken; use Agence104\LiveKit\AccessTokenOptions; +use Agence104\LiveKit\RoomCreateOptions; +use Agence104\LiveKit\RoomServiceClient; use Agence104\LiveKit\VideoGrant; +use Illuminate\Support\Facades\Log; class LiveKitProvider implements MediaProviderInterface { public function createRoom(string $roomName, array $options = []): void { - // LiveKit creates rooms lazily on first join. + if (! $this->isConfigured()) { + return; + } + + try { + $client = new RoomServiceClient( + $this->apiHost(), + (string) config('meet.media.livekit.api_key'), + (string) config('meet.media.livekit.api_secret'), + ); + + $client->createRoom(new RoomCreateOptions([ + 'name' => $roomName, + 'empty_timeout' => (int) ($options['empty_timeout'] ?? 300), + ])); + } catch (\Throwable $e) { + Log::warning('LiveKit createRoom failed.', [ + 'room' => $roomName, + 'error' => $e->getMessage(), + ]); + } } public function deleteRoom(string $roomName): void { - // + if (! $this->isConfigured() || $roomName === '') { + return; + } + + try { + $client = new RoomServiceClient( + $this->apiHost(), + (string) config('meet.media.livekit.api_key'), + (string) config('meet.media.livekit.api_secret'), + ); + + $client->deleteRoom($roomName); + } catch (\Throwable $e) { + Log::warning('LiveKit deleteRoom failed.', [ + 'room' => $roomName, + 'error' => $e->getMessage(), + ]); + } } public function generateToken( @@ -60,4 +100,16 @@ class LiveKitProvider implements MediaProviderInterface { return (string) config('meet.media.livekit.url'); } + + 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)); + } } diff --git a/app/Services/Meet/RecordingService.php b/app/Services/Meet/RecordingService.php index 6dfb73e..ffcec84 100644 --- a/app/Services/Meet/RecordingService.php +++ b/app/Services/Meet/RecordingService.php @@ -23,7 +23,7 @@ class RecordingService { $active = $session->recordings()->where('status', 'recording')->first(); if ($active) { - return $active; + return $this->ensureServerEgress($session, $active); } $recording = Recording::create([ @@ -66,6 +66,24 @@ class RecordingService return $this->captureMode($recording) === 'egress'; } + public function ensureServerEgress(Session $session, Recording $recording): Recording + { + if ($this->usesServerEgress($recording) || ! $this->egress->isAvailable()) { + return $recording; + } + + $egressMeta = $this->egress->tryStart($session, $recording); + if ($egressMeta === null) { + return $recording; + } + + $recording->update([ + 'metadata' => array_merge($recording->metadata ?? [], $egressMeta), + ]); + + return $recording->fresh(); + } + public function stop(Recording $recording, string $actorRef): Recording { if ($recording->status !== 'recording') { diff --git a/config/meet.php b/config/meet.php index 97f50b9..5b78dc4 100644 --- a/config/meet.php +++ b/config/meet.php @@ -114,9 +114,10 @@ return [ 'disk' => env('MEET_RECORDINGS_DISK', 'local'), 'max_mb' => (int) env('MEET_RECORDING_MAX_MB', 512), 'processing_timeout_minutes' => (int) env('MEET_RECORDING_PROCESSING_TIMEOUT_MINUTES', 15), - 'egress' => [ + 'egress' => [ 'enabled' => (bool) env('MEET_EGRESS_ENABLED', false), 'webhook_url' => env('MEET_EGRESS_WEBHOOK_URL', ''), + 'start_attempts' => (int) env('MEET_EGRESS_START_ATTEMPTS', 5), 's3' => [ 'access_key' => env('MEET_EGRESS_S3_ACCESS_KEY', env('AWS_ACCESS_KEY_ID')), 'secret' => env('MEET_EGRESS_S3_SECRET', env('AWS_SECRET_ACCESS_KEY')), diff --git a/resources/js/meet-room.js b/resources/js/meet-room.js index 9e3e920..413b069 100644 --- a/resources/js/meet-room.js +++ b/resources/js/meet-room.js @@ -1749,6 +1749,12 @@ function meetRoom() { if (existingUuid) { this.activeRecordingUuid = existingUuid; + if (this.serverEgress && !this.usesServerEgressCapture()) { + await this.resumeServerEgress(); + if (this.usesServerEgressCapture()) { + return; + } + } } else { await this.startServerRecording(); return; @@ -1780,6 +1786,19 @@ function meetRoom() { } }, + async resumeServerEgress() { + const res = await fetch(this.config.recordingStartUrl, { method: 'POST', headers: this.headers() }); + if (!res.ok) { + return; + } + + const data = await res.json(); + this.activeRecordingUuid = data.recording_uuid || this.activeRecordingUuid; + if (data.capture_mode) { + this.recordingCaptureMode = data.capture_mode; + } + }, + async startServerRecording() { const res = await fetch(this.config.recordingStartUrl, { method: 'POST', headers: this.headers() }); if (!res.ok) return; diff --git a/tests/Feature/MeetLiveKitEgressRecordingTest.php b/tests/Feature/MeetLiveKitEgressRecordingTest.php index 487cf7f..92efdcc 100644 --- a/tests/Feature/MeetLiveKitEgressRecordingTest.php +++ b/tests/Feature/MeetLiveKitEgressRecordingTest.php @@ -225,6 +225,45 @@ class MeetLiveKitEgressRecordingTest extends TestCase $this->assertTrue($config['force_path_style']); } + public function test_start_recording_retries_egress_for_active_browser_capture(): void + { + config(['meet.recordings.egress.enabled' => true]); + + $egressMock = Mockery::mock(LiveKitEgressService::class); + $egressMock->shouldReceive('isAvailable')->andReturn(true); + $egressMock->shouldReceive('tryStart')->once()->andReturn([ + 'egress_id' => 'EG_retry123', + 'egress_filepath' => 'recordings/session/recording.mp4', + 'capture_mode' => 'egress', + 'audio_only' => false, + ]); + $this->app->instance(LiveKitEgressService::class, $egressMock); + + [$session, $participant] = $this->liveSession(); + + $recording = Recording::create([ + 'owner_ref' => $this->user->public_id, + 'session_id' => $session->id, + 'status' => 'recording', + 'layout' => 'gallery', + 'started_by_ref' => $this->user->public_id, + 'started_at' => now()->subMinute(), + 'metadata' => ['capture_mode' => 'browser'], + ]); + + $this->withSession(["meet.participant.{$session->uuid}" => $participant->uuid]) + ->actingAs($this->user) + ->postJson("/room/{$session->uuid}/recording/start") + ->assertOk() + ->assertJson([ + 'recording_uuid' => $recording->uuid, + 'capture_mode' => 'egress', + ]); + + $recording->refresh(); + $this->assertSame('egress', $recording->metadata['capture_mode']); + } + /** * @return array{0: Session, 1: Participant} */