From 4e4bed0df396e2985bcc5160264f2a075e4768f9 Mon Sep 17 00:00:00 2001 From: isaacclad Date: Sat, 4 Jul 2026 03:18:28 +0000 Subject: [PATCH] Record meetings with LiveKit Egress instead of browser capture. Server-side room composite egress replaces fragile MediaRecorder DOM capture when enabled, with webhook completion and browser fallback when egress is unavailable. Co-authored-by: Cursor --- .env.example | 8 + .../Api/LiveKitWebhookController.php | 57 +++++ .../Meet/MeetingRoomController.php | 9 +- .../Meet/Media/LiveKitEgressService.php | 174 +++++++++++++ app/Services/Meet/RecordingService.php | 141 +++++++++- config/meet.php | 14 + resources/js/meet-room.js | 36 ++- resources/views/meet/room/show.blade.php | 2 + routes/api.php | 2 + .../MeetLiveKitEgressRecordingTest.php | 241 ++++++++++++++++++ 10 files changed, 674 insertions(+), 10 deletions(-) create mode 100644 app/Http/Controllers/Api/LiveKitWebhookController.php create mode 100644 app/Services/Meet/Media/LiveKitEgressService.php create mode 100644 tests/Feature/MeetLiveKitEgressRecordingTest.php diff --git a/.env.example b/.env.example index 219692a..e3979d1 100644 --- a/.env.example +++ b/.env.example @@ -40,12 +40,20 @@ SERVICE_EVENTS_INBOUND_SECRET= # --- LiveKit (WebRTC SFU) --- LIVEKIT_URL=wss://your-livekit-server.example.com +LIVEKIT_API_URL= LIVEKIT_API_KEY= LIVEKIT_API_SECRET= # --- Recordings & AI --- MEET_RECORDINGS_DISK=local MEET_RECORDING_MAX_MB=512 +# Server-side recording via LiveKit Egress (recommended for production) +MEET_EGRESS_ENABLED=false +MEET_EGRESS_S3_BUCKET= +MEET_EGRESS_S3_REGION= +MEET_EGRESS_S3_ACCESS_KEY= +MEET_EGRESS_S3_SECRET= +MEET_EGRESS_S3_ENDPOINT= MEET_AI_DRIVER=openai MEET_AI_API_KEY= MEET_AI_MODEL=gpt-4o-mini diff --git a/app/Http/Controllers/Api/LiveKitWebhookController.php b/app/Http/Controllers/Api/LiveKitWebhookController.php new file mode 100644 index 0000000..c1e3052 --- /dev/null +++ b/app/Http/Controllers/Api/LiveKitWebhookController.php @@ -0,0 +1,57 @@ +receive( + $request->getContent(), + $request->header('Authorization'), + ); + } catch (\Throwable $e) { + Log::warning('LiveKit webhook rejected.', ['error' => $e->getMessage()]); + + return response()->json(['error' => 'invalid webhook'], 401); + } + + if ($event->getEvent() !== 'egress_ended' || ! $event->hasEgressInfo()) { + return response()->json(['status' => 'ignored']); + } + + $info = $event->getEgressInfo(); + $egressId = $info->getEgressId(); + + if ($egressId === '') { + return response()->json(['status' => 'ignored']); + } + + $recording = Recording::query() + ->whereIn('status', ['recording', 'processing']) + ->where('metadata->egress_id', $egressId) + ->first(); + + if (! $recording) { + return response()->json(['status' => 'not_found']); + } + + $recordings->completeFromEgress($recording, $info); + + return response()->json(['status' => 'accepted']); + } +} diff --git a/app/Http/Controllers/Meet/MeetingRoomController.php b/app/Http/Controllers/Meet/MeetingRoomController.php index a3a9e55..e9fbeb3 100644 --- a/app/Http/Controllers/Meet/MeetingRoomController.php +++ b/app/Http/Controllers/Meet/MeetingRoomController.php @@ -9,6 +9,7 @@ use App\Models\Room; use App\Models\Session; use App\Services\Meet\BreakoutService; use App\Services\Meet\ChatService; +use App\Services\Meet\Media\LiveKitEgressService; use App\Services\Meet\Media\MediaProviderInterface; use App\Services\Meet\ParticipantPresenter; use App\Services\Meet\RecordingService; @@ -94,6 +95,7 @@ class MeetingRoomController extends Controller 'mediaConfigured' => $this->media->isConfigured(), 'messages' => $this->chat->recentMessages($session), 'activeRecording' => $session->recordings()->where('status', 'recording')->first(), + 'serverEgressAvailable' => app(LiveKitEgressService::class)->isAvailable(), 'watermark' => $room->organization?->securitySetting('watermark_enabled', false), 'isWebinar' => $room->isWebinar() || $room->isConference(), 'isConference' => $room->isConference(), @@ -514,7 +516,11 @@ class MeetingRoomController extends Controller $recording = $this->recordings->start($session, $host); - return response()->json(['recording_uuid' => $recording->uuid, 'status' => $recording->status]); + return response()->json([ + 'recording_uuid' => $recording->uuid, + 'status' => $recording->status, + 'capture_mode' => $this->recordings->captureMode($recording), + ]); } public function stopRecording(Request $request, Session $session): JsonResponse @@ -542,6 +548,7 @@ class MeetingRoomController extends Controller return response()->json([ 'status' => 'processing', 'recording_uuid' => $recording->uuid, + 'capture_mode' => $this->recordings->captureMode($recording), ]); } diff --git a/app/Services/Meet/Media/LiveKitEgressService.php b/app/Services/Meet/Media/LiveKitEgressService.php new file mode 100644 index 0000000..35ef016 --- /dev/null +++ b/app/Services/Meet/Media/LiveKitEgressService.php @@ -0,0 +1,174 @@ +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(), + ]); + } + } +} diff --git a/app/Services/Meet/RecordingService.php b/app/Services/Meet/RecordingService.php index 7f74c07..6dfb73e 100644 --- a/app/Services/Meet/RecordingService.php +++ b/app/Services/Meet/RecordingService.php @@ -5,12 +5,20 @@ namespace App\Services\Meet; use App\Models\Recording; use App\Models\Session; use App\Models\User; +use App\Services\Meet\Media\LiveKitEgressService; use Illuminate\Http\UploadedFile; use Illuminate\Support\Facades\Storage; +use Livekit\EgressInfo; +use Livekit\EgressStatus; +use Livekit\FileInfo; use RuntimeException; class RecordingService { + public function __construct( + protected LiveKitEgressService $egress, + ) {} + public function start(Session $session, User $host, string $layout = 'gallery'): Recording { $active = $session->recordings()->where('status', 'recording')->first(); @@ -27,6 +35,15 @@ class RecordingService 'started_at' => now(), ]); + $egressMeta = $this->egress->tryStart($session, $recording); + if ($egressMeta !== null) { + $recording->update(['metadata' => $egressMeta]); + $recording = $recording->fresh(); + } else { + $recording->update(['metadata' => ['capture_mode' => 'browser']]); + $recording = $recording->fresh(); + } + AuditLogger::record( $session->owner_ref, 'recording.started', @@ -39,12 +56,26 @@ class RecordingService return $recording; } + public function captureMode(Recording $recording): string + { + return (string) ($recording->metadata['capture_mode'] ?? 'browser'); + } + + public function usesServerEgress(Recording $recording): bool + { + return $this->captureMode($recording) === 'egress'; + } + public function stop(Recording $recording, string $actorRef): Recording { if ($recording->status !== 'recording') { return $recording; } + if ($this->usesServerEgress($recording)) { + $this->egress->tryStop($recording->metadata['egress_id'] ?? null); + } + $recording->update([ 'status' => 'processing', 'ended_at' => now(), @@ -65,6 +96,73 @@ class RecordingService return $recording->fresh(); } + public function completeFromEgress(Recording $recording, EgressInfo $info): Recording + { + if (! in_array($recording->status, ['recording', 'processing'], true)) { + return $recording; + } + + $status = $info->getStatus(); + if (in_array($status, [EgressStatus::EGRESS_FAILED, EgressStatus::EGRESS_ABORTED, EgressStatus::EGRESS_LIMIT_REACHED], true)) { + return $this->fail( + $recording, + filled($info->getError()) ? $info->getError() : 'LiveKit recording failed.', + ); + } + + if ($status !== EgressStatus::EGRESS_COMPLETE) { + return $recording; + } + + $file = $this->firstEgressFile($info); + if ($file === null) { + return $this->fail($recording, 'LiveKit recording finished without a file.'); + } + + $storagePath = (string) ($recording->metadata['egress_filepath'] ?? $file->getFilename()); + $size = (int) $file->getSize(); + if ($size <= 0) { + return $this->fail($recording, 'LiveKit recording file is empty.'); + } + + $durationSeconds = $this->durationSecondsFromFile($file, $recording); + + $recording->update([ + 'status' => 'ready', + 'storage_path' => $storagePath, + 'file_size' => $size, + 'ended_at' => $recording->ended_at ?? now(), + 'duration_seconds' => $durationSeconds, + 'metadata' => array_merge($recording->metadata ?? [], [ + 'egress_location' => $file->getLocation(), + ]), + ]); + + $recording = $recording->fresh(); + $session = $recording->session; + + AuditLogger::record( + $recording->owner_ref, + 'recording.ready', + $session->room->organization_id, + null, + Recording::class, + $recording->id, + ); + + app(MeetNotificationService::class)->recordingReady($recording); + + app(\App\Services\Integrations\WebhookDispatcher::class)->recordingReady($recording); + + app(MeetBillingService::class)->chargeRecordingStorageInitial($recording); + + if ($session->room->setting('auto_ai_summary', false) || config('meet.ai.auto_summarize', true)) { + app(TranscriptService::class)->ensureForSession($session, $recording); + } + + return $recording->fresh(); + } + /** Store a browser-captured recording on Ladill storage (not LiveKit). */ public function storeUpload(Recording $recording, UploadedFile $file): Recording { @@ -160,10 +258,11 @@ class RecordingService $staleAt = $recording->ended_at ?? $recording->updated_at; if ($staleAt && $staleAt->lt(now()->subMinutes($timeoutMinutes))) { - return $this->fail( - $recording, - 'Recording upload did not complete. Keep the host browser open briefly after ending the meeting so the file can upload.', - ); + $reason = $this->usesServerEgress($recording) + ? 'LiveKit did not finish processing the recording. Check your egress service and storage configuration.' + : 'Recording upload did not complete. Keep the host browser open briefly after ending the meeting so the file can upload.'; + + return $this->fail($recording, $reason); } return $recording; @@ -185,10 +284,11 @@ class RecordingService }) ->orderBy('id') ->each(function (Recording $recording) use (&$failed) { - $this->fail( - $recording, - 'Recording upload did not complete. Keep the host browser open briefly after ending the meeting so the file can upload.', - ); + $reason = $this->usesServerEgress($recording) + ? 'LiveKit did not finish processing the recording. Check your egress service and storage configuration.' + : 'Recording upload did not complete. Keep the host browser open briefly after ending the meeting so the file can upload.'; + + $this->fail($recording, $reason); $failed++; }); @@ -244,4 +344,29 @@ class RecordingService $recording->delete(); } + + protected function firstEgressFile(EgressInfo $info): ?FileInfo + { + foreach ($info->getFileResults() as $file) { + if ($file instanceof FileInfo) { + return $file; + } + } + + return null; + } + + protected function durationSecondsFromFile(FileInfo $file, Recording $recording): ?int + { + $duration = (int) $file->getDuration(); + if ($duration <= 0) { + return $recording->duration_seconds; + } + + if ($duration > 86_400) { + return (int) round($duration / 1_000_000_000); + } + + return $duration; + } } diff --git a/config/meet.php b/config/meet.php index ead2a01..944609e 100644 --- a/config/meet.php +++ b/config/meet.php @@ -114,6 +114,19 @@ 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' => [ + 'enabled' => (bool) env('MEET_EGRESS_ENABLED', false), + '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')), + 'session_token' => env('MEET_EGRESS_S3_SESSION_TOKEN'), + 'region' => env('MEET_EGRESS_S3_REGION', env('AWS_DEFAULT_REGION', 'us-east-1')), + 'bucket' => env('MEET_EGRESS_S3_BUCKET', env('AWS_BUCKET')), + 'endpoint' => env('MEET_EGRESS_S3_ENDPOINT', env('AWS_ENDPOINT')), + 'assume_role_arn' => env('MEET_EGRESS_S3_ASSUME_ROLE_ARN'), + 'assume_role_external_id' => env('MEET_EGRESS_S3_ASSUME_ROLE_EXTERNAL_ID'), + ], + ], ], 'ai' => [ @@ -129,6 +142,7 @@ return [ 'provider' => env('MEET_MEDIA_PROVIDER', 'livekit'), 'livekit' => [ 'url' => env('LIVEKIT_URL', ''), + 'api_url' => env('LIVEKIT_API_URL', ''), 'api_key' => env('LIVEKIT_API_KEY', ''), 'api_secret' => env('LIVEKIT_API_SECRET', ''), ], diff --git a/resources/js/meet-room.js b/resources/js/meet-room.js index 99dadc2..9e3e920 100644 --- a/resources/js/meet-room.js +++ b/resources/js/meet-room.js @@ -67,6 +67,8 @@ function readMeetRoomBootState() { videoOffOnJoin: el.dataset.videoOffOnJoin === '1' || audioOnly, isRecording: el.dataset.recordingActive === '1', activeRecordingUuid: el.dataset.activeRecordingUuid || null, + recordingCaptureMode: el.dataset.captureMode || null, + serverEgress: el.dataset.serverEgress === '1', isLocked: el.dataset.locked === '1', watermark: el.dataset.watermark === '1', liveCaptions: el.dataset.liveCaptions === '1', @@ -139,6 +141,8 @@ function meetRoom() { inBreakout: roomBoot.inBreakout ?? false, assignedBreakoutRoomId: roomBoot.assignedBreakoutRoomId ?? null, activeRecordingUuid: roomBoot.activeRecordingUuid ?? null, + recordingCaptureMode: roomBoot.recordingCaptureMode ?? null, + serverEgress: roomBoot.serverEgress ?? false, sessionParticipants: roomBoot.sessionParticipants ?? [], roomHost: roomBoot.roomHost ?? null, captionText: '', @@ -250,6 +254,8 @@ function meetRoom() { this.videoOffOnJoin = el.dataset.videoOffOnJoin === '1' || this.audioOnly; this.isRecording = el.dataset.recordingActive === '1'; this.activeRecordingUuid = el.dataset.activeRecordingUuid || null; + this.recordingCaptureMode = el.dataset.captureMode || null; + this.serverEgress = el.dataset.serverEgress === '1'; this.isLocked = el.dataset.locked === '1'; this.watermark = el.dataset.watermark === '1'; this.liveCaptions = el.dataset.liveCaptions === '1'; @@ -1725,11 +1731,19 @@ function meetRoom() { } }, + usesServerEgressCapture() { + return this.recordingCaptureMode === 'egress'; + }, + async maybeStartAutoRecording() { if (!this.isHost || !this.isRecording || recordingMediaRecorder) { return; } + if (this.usesServerEgressCapture()) { + return; + } + const el = document.getElementById('meet-config'); const existingUuid = this.activeRecordingUuid || el?.dataset.activeRecordingUuid || ''; @@ -1772,10 +1786,18 @@ function meetRoom() { const data = await res.json(); this.activeRecordingUuid = data.recording_uuid; + this.recordingCaptureMode = data.capture_mode || 'browser'; + + if (this.usesServerEgressCapture()) { + this.isRecording = true; + return; + } + if (! await this.tryStartClientRecordingWithRetry()) { await this.abortServerRecording('Could not capture meeting video in this browser.'); this.isRecording = false; this.activeRecordingUuid = null; + this.recordingCaptureMode = null; window.alert(`Recording could not start because this browser could not capture the ${this.sessionNoun()} video. Try Chrome on desktop, or disable stage-only layouts temporarily.`); return; } @@ -1876,8 +1898,9 @@ function meetRoom() { }, async stopServerAndUploadRecording() { + const isServerEgress = this.usesServerEgressCapture(); const hadClientRecorder = !!recordingMediaRecorder; - const blob = await this.stopClientRecording(); + const blob = isServerEgress ? null : await this.stopClientRecording(); this.isRecording = false; let recordingUuid = this.activeRecordingUuid; @@ -1885,8 +1908,18 @@ function meetRoom() { if (res.ok) { const data = await res.json().catch(() => ({})); recordingUuid = data.recording_uuid || recordingUuid; + if (data.capture_mode) { + this.recordingCaptureMode = data.capture_mode; + } } else if (res.status !== 404 || !recordingUuid) { this.activeRecordingUuid = null; + this.recordingCaptureMode = null; + return; + } + + if (isServerEgress) { + this.activeRecordingUuid = null; + this.recordingCaptureMode = null; return; } @@ -1908,6 +1941,7 @@ function meetRoom() { } this.activeRecordingUuid = null; + this.recordingCaptureMode = null; }, async abortServerRecording(reason) { diff --git a/resources/views/meet/room/show.blade.php b/resources/views/meet/room/show.blade.php index 8bee178..1d5e153 100644 --- a/resources/views/meet/room/show.blade.php +++ b/resources/views/meet/room/show.blade.php @@ -107,6 +107,8 @@ data-locked="{{ $session->is_locked ? '1' : '0' }}" data-recording-active="{{ $activeRecording ? '1' : '0' }}" data-active-recording-uuid="{{ $activeRecording?->uuid ?? '' }}" + data-capture-mode="{{ $activeRecording?->metadata['capture_mode'] ?? '' }}" + data-server-egress="{{ ($serverEgressAvailable ?? false) ? '1' : '0' }}" data-is-webinar="{{ ($isWebinar ?? false) ? '1' : '0' }}" data-is-conference="{{ ($isConference ?? false) ? '1' : '0' }}" data-session-noun="{{ $sessionNoun }}" diff --git a/routes/api.php b/routes/api.php index dbc1902..0dbb1f8 100644 --- a/routes/api.php +++ b/routes/api.php @@ -7,6 +7,8 @@ use Illuminate\Support\Facades\Route; Route::get('/health', fn () => response()->json(['status' => 'ok', 'app' => 'meet'])); +Route::post('/livekit/webhook', \App\Http\Controllers\Api\LiveKitWebhookController::class)->name('api.livekit.webhook'); + Route::post('/service-events', ServiceEventController::class)->name('api.service-events'); Route::middleware(['auth.service:meet'])->prefix('service/v1')->group(function () { diff --git a/tests/Feature/MeetLiveKitEgressRecordingTest.php b/tests/Feature/MeetLiveKitEgressRecordingTest.php new file mode 100644 index 0000000..88dac56 --- /dev/null +++ b/tests/Feature/MeetLiveKitEgressRecordingTest.php @@ -0,0 +1,241 @@ +withoutMiddleware(EnsurePlatformSession::class); + + $this->user = User::create([ + 'public_id' => 'test-user-egress', + 'name' => 'Test User', + 'email' => 'egress@example.com', + ]); + + $this->organization = Organization::create([ + 'owner_ref' => $this->user->public_id, + 'name' => 'Test Org', + 'slug' => 'test-org-egress', + 'timezone' => 'UTC', + 'settings' => ['onboarded' => true], + ]); + + Member::create([ + 'owner_ref' => $this->user->public_id, + 'organization_id' => $this->organization->id, + 'user_ref' => $this->user->public_id, + 'role' => 'owner', + ]); + + $base = rtrim((string) config('billing.api_url'), '/'); + Http::fake([ + $base.'/can-afford*' => Http::response(['affordable' => true]), + $base.'/debit' => Http::response(['ok' => true]), + ]); + } + + public function test_start_recording_returns_egress_capture_mode_when_available(): void + { + config([ + 'meet.recordings.egress.enabled' => true, + 'meet.media.livekit.url' => 'wss://livekit.example.com', + 'meet.media.livekit.api_key' => 'key', + 'meet.media.livekit.api_secret' => 'secret', + ]); + + $egressMock = Mockery::mock(LiveKitEgressService::class); + $egressMock->shouldReceive('isAvailable')->andReturn(true); + $egressMock->shouldReceive('tryStart')->once()->andReturn([ + 'egress_id' => 'EG_test123', + 'egress_filepath' => 'recordings/session/recording.mp4', + 'capture_mode' => 'egress', + 'audio_only' => false, + ]); + $egressMock->shouldReceive('tryStop')->never(); + $this->app->instance(LiveKitEgressService::class, $egressMock); + + [$session, $participant] = $this->liveSession(); + + $this->withSession(["meet.participant.{$session->uuid}" => $participant->uuid]) + ->actingAs($this->user) + ->postJson("/room/{$session->uuid}/recording/start") + ->assertOk() + ->assertJson([ + 'status' => 'recording', + 'capture_mode' => 'egress', + ]); + + $recording = Recording::first(); + $this->assertSame('egress', $recording->metadata['capture_mode']); + $this->assertSame('EG_test123', $recording->metadata['egress_id']); + } + + public function test_stop_recording_stops_egress_and_returns_processing(): void + { + config(['meet.recordings.egress.enabled' => true]); + + $egressMock = Mockery::mock(LiveKitEgressService::class); + $egressMock->shouldReceive('tryStop')->once()->with('EG_stop123'); + $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' => 'egress', + 'egress_id' => 'EG_stop123', + 'egress_filepath' => 'recordings/'.$session->uuid.'/test.mp4', + ], + ]); + + $this->withSession(["meet.participant.{$session->uuid}" => $participant->uuid]) + ->actingAs($this->user) + ->postJson("/room/{$session->uuid}/recording/stop") + ->assertOk() + ->assertJson([ + 'status' => 'processing', + 'capture_mode' => 'egress', + ]); + + $recording->refresh(); + $this->assertSame('processing', $recording->status); + } + + public function test_complete_from_egress_marks_recording_ready(): void + { + [$session] = $this->liveSession(); + + $storagePath = 'recordings/'.$session->uuid.'/ready.mp4'; + $recording = Recording::create([ + 'owner_ref' => $this->user->public_id, + 'session_id' => $session->id, + 'status' => 'processing', + 'layout' => 'gallery', + 'started_by_ref' => $this->user->public_id, + 'started_at' => now()->subMinutes(2), + 'ended_at' => now(), + 'metadata' => [ + 'capture_mode' => 'egress', + 'egress_id' => 'EG_complete', + 'egress_filepath' => $storagePath, + ], + ]); + + $file = new FileInfo([ + 'filename' => $storagePath, + 'size' => 2048, + 'duration' => 120, + 'location' => 's3://bucket/'.$storagePath, + ]); + + $info = new EgressInfo([ + 'egress_id' => 'EG_complete', + 'status' => EgressStatus::EGRESS_COMPLETE, + ]); + $info->setFileResults([$file]); + + $recording = app(RecordingService::class)->completeFromEgress($recording, $info); + + $this->assertSame('ready', $recording->status); + $this->assertSame($storagePath, $recording->storage_path); + $this->assertSame(2048, $recording->file_size); + } + + public function test_complete_from_egress_marks_failed_on_error_status(): void + { + [$session] = $this->liveSession(); + + $recording = Recording::create([ + 'owner_ref' => $this->user->public_id, + 'session_id' => $session->id, + 'status' => 'processing', + 'layout' => 'gallery', + 'started_by_ref' => $this->user->public_id, + 'started_at' => now()->subMinute(), + 'metadata' => [ + 'capture_mode' => 'egress', + 'egress_id' => 'EG_failed', + ], + ]); + + $info = new EgressInfo([ + 'egress_id' => 'EG_failed', + 'status' => EgressStatus::EGRESS_FAILED, + 'error' => 'encoder crashed', + ]); + + $recording = app(RecordingService::class)->completeFromEgress($recording, $info); + + $this->assertSame('failed', $recording->status); + $this->assertSame('encoder crashed', $recording->failure_reason); + } + + /** + * @return array{0: Session, 1: Participant} + */ + protected function liveSession(): array + { + $room = Room::create([ + 'organization_id' => $this->organization->id, + 'owner_ref' => $this->user->public_id, + 'host_user_ref' => $this->user->public_id, + 'title' => 'Egress meeting', + 'type' => 'instant', + 'status' => 'live', + 'timezone' => 'UTC', + ]); + + $session = Session::create([ + 'room_id' => $room->id, + 'owner_ref' => $this->user->public_id, + 'media_room_name' => 'room-'.$room->uuid, + 'status' => 'live', + 'started_at' => now()->subMinutes(5), + ]); + + $participant = Participant::create([ + 'owner_ref' => $this->user->public_id, + 'session_id' => $session->id, + 'user_ref' => $this->user->public_id, + 'role' => 'host', + 'display_name' => 'Test User', + 'status' => 'joined', + 'joined_at' => now(), + ]); + + return [$session, $participant]; + } +}