Record meetings with LiveKit Egress instead of browser capture.
Deploy Ladill Meet / deploy (push) Successful in 52s

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 <cursoragent@cursor.com>
This commit is contained in:
isaacclad
2026-07-04 03:18:28 +00:00
co-authored by Cursor
parent c2cb9cf33a
commit 4e4bed0df3
10 changed files with 674 additions and 10 deletions
+8
View File
@@ -40,12 +40,20 @@ SERVICE_EVENTS_INBOUND_SECRET=
# --- LiveKit (WebRTC SFU) --- # --- LiveKit (WebRTC SFU) ---
LIVEKIT_URL=wss://your-livekit-server.example.com LIVEKIT_URL=wss://your-livekit-server.example.com
LIVEKIT_API_URL=
LIVEKIT_API_KEY= LIVEKIT_API_KEY=
LIVEKIT_API_SECRET= LIVEKIT_API_SECRET=
# --- Recordings & AI --- # --- Recordings & AI ---
MEET_RECORDINGS_DISK=local MEET_RECORDINGS_DISK=local
MEET_RECORDING_MAX_MB=512 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_DRIVER=openai
MEET_AI_API_KEY= MEET_AI_API_KEY=
MEET_AI_MODEL=gpt-4o-mini MEET_AI_MODEL=gpt-4o-mini
@@ -0,0 +1,57 @@
<?php
namespace App\Http\Controllers\Api;
use Agence104\LiveKit\WebhookReceiver;
use App\Http\Controllers\Controller;
use App\Models\Recording;
use App\Services\Meet\RecordingService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
class LiveKitWebhookController extends Controller
{
public function __invoke(Request $request, RecordingService $recordings): JsonResponse
{
try {
$receiver = new WebhookReceiver(
(string) config('meet.media.livekit.api_key'),
(string) config('meet.media.livekit.api_secret'),
);
$event = $receiver->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']);
}
}
@@ -9,6 +9,7 @@ use App\Models\Room;
use App\Models\Session; use App\Models\Session;
use App\Services\Meet\BreakoutService; use App\Services\Meet\BreakoutService;
use App\Services\Meet\ChatService; use App\Services\Meet\ChatService;
use App\Services\Meet\Media\LiveKitEgressService;
use App\Services\Meet\Media\MediaProviderInterface; use App\Services\Meet\Media\MediaProviderInterface;
use App\Services\Meet\ParticipantPresenter; use App\Services\Meet\ParticipantPresenter;
use App\Services\Meet\RecordingService; use App\Services\Meet\RecordingService;
@@ -94,6 +95,7 @@ class MeetingRoomController extends Controller
'mediaConfigured' => $this->media->isConfigured(), 'mediaConfigured' => $this->media->isConfigured(),
'messages' => $this->chat->recentMessages($session), 'messages' => $this->chat->recentMessages($session),
'activeRecording' => $session->recordings()->where('status', 'recording')->first(), 'activeRecording' => $session->recordings()->where('status', 'recording')->first(),
'serverEgressAvailable' => app(LiveKitEgressService::class)->isAvailable(),
'watermark' => $room->organization?->securitySetting('watermark_enabled', false), 'watermark' => $room->organization?->securitySetting('watermark_enabled', false),
'isWebinar' => $room->isWebinar() || $room->isConference(), 'isWebinar' => $room->isWebinar() || $room->isConference(),
'isConference' => $room->isConference(), 'isConference' => $room->isConference(),
@@ -514,7 +516,11 @@ class MeetingRoomController extends Controller
$recording = $this->recordings->start($session, $host); $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 public function stopRecording(Request $request, Session $session): JsonResponse
@@ -542,6 +548,7 @@ class MeetingRoomController extends Controller
return response()->json([ return response()->json([
'status' => 'processing', 'status' => 'processing',
'recording_uuid' => $recording->uuid, 'recording_uuid' => $recording->uuid,
'capture_mode' => $this->recordings->captureMode($recording),
]); ]);
} }
@@ -0,0 +1,174 @@
<?php
namespace App\Services\Meet\Media;
use Agence104\LiveKit\EgressServiceClient;
use App\Models\Recording;
use App\Models\Session;
use Illuminate\Support\Facades\Log;
use Livekit\EncodedFileOutput;
use Livekit\EncodedFileType;
use Livekit\EncodingOptionsPreset;
use Livekit\EgressInfo;
use Livekit\S3Upload;
use RuntimeException;
class LiveKitEgressService
{
public function isAvailable(): bool
{
if (! (bool) config('meet.recordings.egress.enabled', false)) {
return false;
}
return app(LiveKitProvider::class)->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<string, string>|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(),
]);
}
}
}
+133 -8
View File
@@ -5,12 +5,20 @@ namespace App\Services\Meet;
use App\Models\Recording; use App\Models\Recording;
use App\Models\Session; use App\Models\Session;
use App\Models\User; use App\Models\User;
use App\Services\Meet\Media\LiveKitEgressService;
use Illuminate\Http\UploadedFile; use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\Storage;
use Livekit\EgressInfo;
use Livekit\EgressStatus;
use Livekit\FileInfo;
use RuntimeException; use RuntimeException;
class RecordingService class RecordingService
{ {
public function __construct(
protected LiveKitEgressService $egress,
) {}
public function start(Session $session, User $host, string $layout = 'gallery'): Recording public function start(Session $session, User $host, string $layout = 'gallery'): Recording
{ {
$active = $session->recordings()->where('status', 'recording')->first(); $active = $session->recordings()->where('status', 'recording')->first();
@@ -27,6 +35,15 @@ class RecordingService
'started_at' => now(), '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( AuditLogger::record(
$session->owner_ref, $session->owner_ref,
'recording.started', 'recording.started',
@@ -39,12 +56,26 @@ class RecordingService
return $recording; 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 public function stop(Recording $recording, string $actorRef): Recording
{ {
if ($recording->status !== 'recording') { if ($recording->status !== 'recording') {
return $recording; return $recording;
} }
if ($this->usesServerEgress($recording)) {
$this->egress->tryStop($recording->metadata['egress_id'] ?? null);
}
$recording->update([ $recording->update([
'status' => 'processing', 'status' => 'processing',
'ended_at' => now(), 'ended_at' => now(),
@@ -65,6 +96,73 @@ class RecordingService
return $recording->fresh(); 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). */ /** Store a browser-captured recording on Ladill storage (not LiveKit). */
public function storeUpload(Recording $recording, UploadedFile $file): Recording public function storeUpload(Recording $recording, UploadedFile $file): Recording
{ {
@@ -160,10 +258,11 @@ class RecordingService
$staleAt = $recording->ended_at ?? $recording->updated_at; $staleAt = $recording->ended_at ?? $recording->updated_at;
if ($staleAt && $staleAt->lt(now()->subMinutes($timeoutMinutes))) { if ($staleAt && $staleAt->lt(now()->subMinutes($timeoutMinutes))) {
return $this->fail( $reason = $this->usesServerEgress($recording)
$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.', : '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; return $recording;
@@ -185,10 +284,11 @@ class RecordingService
}) })
->orderBy('id') ->orderBy('id')
->each(function (Recording $recording) use (&$failed) { ->each(function (Recording $recording) use (&$failed) {
$this->fail( $reason = $this->usesServerEgress($recording)
$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.', : '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++; $failed++;
}); });
@@ -244,4 +344,29 @@ class RecordingService
$recording->delete(); $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;
}
} }
+14
View File
@@ -114,6 +114,19 @@ return [
'disk' => env('MEET_RECORDINGS_DISK', 'local'), 'disk' => env('MEET_RECORDINGS_DISK', 'local'),
'max_mb' => (int) env('MEET_RECORDING_MAX_MB', 512), 'max_mb' => (int) env('MEET_RECORDING_MAX_MB', 512),
'processing_timeout_minutes' => (int) env('MEET_RECORDING_PROCESSING_TIMEOUT_MINUTES', 15), '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' => [ 'ai' => [
@@ -129,6 +142,7 @@ return [
'provider' => env('MEET_MEDIA_PROVIDER', 'livekit'), 'provider' => env('MEET_MEDIA_PROVIDER', 'livekit'),
'livekit' => [ 'livekit' => [
'url' => env('LIVEKIT_URL', ''), 'url' => env('LIVEKIT_URL', ''),
'api_url' => env('LIVEKIT_API_URL', ''),
'api_key' => env('LIVEKIT_API_KEY', ''), 'api_key' => env('LIVEKIT_API_KEY', ''),
'api_secret' => env('LIVEKIT_API_SECRET', ''), 'api_secret' => env('LIVEKIT_API_SECRET', ''),
], ],
+35 -1
View File
@@ -67,6 +67,8 @@ function readMeetRoomBootState() {
videoOffOnJoin: el.dataset.videoOffOnJoin === '1' || audioOnly, videoOffOnJoin: el.dataset.videoOffOnJoin === '1' || audioOnly,
isRecording: el.dataset.recordingActive === '1', isRecording: el.dataset.recordingActive === '1',
activeRecordingUuid: el.dataset.activeRecordingUuid || null, activeRecordingUuid: el.dataset.activeRecordingUuid || null,
recordingCaptureMode: el.dataset.captureMode || null,
serverEgress: el.dataset.serverEgress === '1',
isLocked: el.dataset.locked === '1', isLocked: el.dataset.locked === '1',
watermark: el.dataset.watermark === '1', watermark: el.dataset.watermark === '1',
liveCaptions: el.dataset.liveCaptions === '1', liveCaptions: el.dataset.liveCaptions === '1',
@@ -139,6 +141,8 @@ function meetRoom() {
inBreakout: roomBoot.inBreakout ?? false, inBreakout: roomBoot.inBreakout ?? false,
assignedBreakoutRoomId: roomBoot.assignedBreakoutRoomId ?? null, assignedBreakoutRoomId: roomBoot.assignedBreakoutRoomId ?? null,
activeRecordingUuid: roomBoot.activeRecordingUuid ?? null, activeRecordingUuid: roomBoot.activeRecordingUuid ?? null,
recordingCaptureMode: roomBoot.recordingCaptureMode ?? null,
serverEgress: roomBoot.serverEgress ?? false,
sessionParticipants: roomBoot.sessionParticipants ?? [], sessionParticipants: roomBoot.sessionParticipants ?? [],
roomHost: roomBoot.roomHost ?? null, roomHost: roomBoot.roomHost ?? null,
captionText: '', captionText: '',
@@ -250,6 +254,8 @@ function meetRoom() {
this.videoOffOnJoin = el.dataset.videoOffOnJoin === '1' || this.audioOnly; this.videoOffOnJoin = el.dataset.videoOffOnJoin === '1' || this.audioOnly;
this.isRecording = el.dataset.recordingActive === '1'; this.isRecording = el.dataset.recordingActive === '1';
this.activeRecordingUuid = el.dataset.activeRecordingUuid || null; 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.isLocked = el.dataset.locked === '1';
this.watermark = el.dataset.watermark === '1'; this.watermark = el.dataset.watermark === '1';
this.liveCaptions = el.dataset.liveCaptions === '1'; this.liveCaptions = el.dataset.liveCaptions === '1';
@@ -1725,11 +1731,19 @@ function meetRoom() {
} }
}, },
usesServerEgressCapture() {
return this.recordingCaptureMode === 'egress';
},
async maybeStartAutoRecording() { async maybeStartAutoRecording() {
if (!this.isHost || !this.isRecording || recordingMediaRecorder) { if (!this.isHost || !this.isRecording || recordingMediaRecorder) {
return; return;
} }
if (this.usesServerEgressCapture()) {
return;
}
const el = document.getElementById('meet-config'); const el = document.getElementById('meet-config');
const existingUuid = this.activeRecordingUuid || el?.dataset.activeRecordingUuid || ''; const existingUuid = this.activeRecordingUuid || el?.dataset.activeRecordingUuid || '';
@@ -1772,10 +1786,18 @@ function meetRoom() {
const data = await res.json(); const data = await res.json();
this.activeRecordingUuid = data.recording_uuid; this.activeRecordingUuid = data.recording_uuid;
this.recordingCaptureMode = data.capture_mode || 'browser';
if (this.usesServerEgressCapture()) {
this.isRecording = true;
return;
}
if (! await this.tryStartClientRecordingWithRetry()) { if (! await this.tryStartClientRecordingWithRetry()) {
await this.abortServerRecording('Could not capture meeting video in this browser.'); await this.abortServerRecording('Could not capture meeting video in this browser.');
this.isRecording = false; this.isRecording = false;
this.activeRecordingUuid = null; 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.`); 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; return;
} }
@@ -1876,8 +1898,9 @@ function meetRoom() {
}, },
async stopServerAndUploadRecording() { async stopServerAndUploadRecording() {
const isServerEgress = this.usesServerEgressCapture();
const hadClientRecorder = !!recordingMediaRecorder; const hadClientRecorder = !!recordingMediaRecorder;
const blob = await this.stopClientRecording(); const blob = isServerEgress ? null : await this.stopClientRecording();
this.isRecording = false; this.isRecording = false;
let recordingUuid = this.activeRecordingUuid; let recordingUuid = this.activeRecordingUuid;
@@ -1885,8 +1908,18 @@ function meetRoom() {
if (res.ok) { if (res.ok) {
const data = await res.json().catch(() => ({})); const data = await res.json().catch(() => ({}));
recordingUuid = data.recording_uuid || recordingUuid; recordingUuid = data.recording_uuid || recordingUuid;
if (data.capture_mode) {
this.recordingCaptureMode = data.capture_mode;
}
} else if (res.status !== 404 || !recordingUuid) { } else if (res.status !== 404 || !recordingUuid) {
this.activeRecordingUuid = null; this.activeRecordingUuid = null;
this.recordingCaptureMode = null;
return;
}
if (isServerEgress) {
this.activeRecordingUuid = null;
this.recordingCaptureMode = null;
return; return;
} }
@@ -1908,6 +1941,7 @@ function meetRoom() {
} }
this.activeRecordingUuid = null; this.activeRecordingUuid = null;
this.recordingCaptureMode = null;
}, },
async abortServerRecording(reason) { async abortServerRecording(reason) {
+2
View File
@@ -107,6 +107,8 @@
data-locked="{{ $session->is_locked ? '1' : '0' }}" data-locked="{{ $session->is_locked ? '1' : '0' }}"
data-recording-active="{{ $activeRecording ? '1' : '0' }}" data-recording-active="{{ $activeRecording ? '1' : '0' }}"
data-active-recording-uuid="{{ $activeRecording?->uuid ?? '' }}" 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-webinar="{{ ($isWebinar ?? false) ? '1' : '0' }}"
data-is-conference="{{ ($isConference ?? false) ? '1' : '0' }}" data-is-conference="{{ ($isConference ?? false) ? '1' : '0' }}"
data-session-noun="{{ $sessionNoun }}" data-session-noun="{{ $sessionNoun }}"
+2
View File
@@ -7,6 +7,8 @@ use Illuminate\Support\Facades\Route;
Route::get('/health', fn () => response()->json(['status' => 'ok', 'app' => 'meet'])); 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::post('/service-events', ServiceEventController::class)->name('api.service-events');
Route::middleware(['auth.service:meet'])->prefix('service/v1')->group(function () { Route::middleware(['auth.service:meet'])->prefix('service/v1')->group(function () {
@@ -0,0 +1,241 @@
<?php
namespace Tests\Feature;
use App\Http\Middleware\EnsurePlatformSession;
use App\Models\Member;
use App\Models\Organization;
use App\Models\Participant;
use App\Models\Recording;
use App\Models\Room;
use App\Models\Session;
use App\Models\User;
use App\Services\Meet\Media\LiveKitEgressService;
use App\Services\Meet\RecordingService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Http;
use Livekit\EgressInfo;
use Livekit\EgressStatus;
use Livekit\FileInfo;
use Mockery;
use Tests\TestCase;
class MeetLiveKitEgressRecordingTest extends TestCase
{
use RefreshDatabase;
protected User $user;
protected Organization $organization;
protected function setUp(): void
{
parent::setUp();
$this->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];
}
}