Files
ladill-meet/app/Services/Meet/Media/LiveKitEgressService.php
T
isaaccladandCursor ebc11691e2
Deploy Ladill Meet / deploy (push) Successful in 45s
Fix LiveKit egress preset call and attach completion webhook per job.
Pass null encoding preset to match the PHP SDK, and register the meet webhook on each egress start so LiveKit Cloud does not need a global webhook URL.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-04 03:42:28 +00:00

190 lines
5.2 KiB
PHP

<?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\EgressInfo;
use Livekit\S3Upload;
use Livekit\WebhookConfig;
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);
return $client->startRoomCompositeEgress(
$session->media_room_name,
$layout,
$output,
null,
$audioOnly,
false,
'',
\Livekit\AudioMixing::DEFAULT_MIXING,
$this->egressWebhooks(),
);
}
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',
};
}
/**
* @return list<WebhookConfig>
*/
protected function egressWebhooks(): array
{
$url = (string) config('meet.recordings.egress.webhook_url', '');
if ($url === '') {
$url = rtrim((string) config('app.url'), '/').'/api/livekit/webhook';
}
return [new WebhookConfig(['url' => $url])];
}
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(),
]);
}
}
}