Deploy Ladill Meet / deploy (push) Successful in 51s
Create rooms via the LiveKit API, retry egress when the room is missing, and resume server egress after the host connects when auto-record fell back to browser capture. Co-authored-by: Cursor <cursoragent@cursor.com>
211 lines
6.0 KiB
PHP
211 lines
6.0 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
|
|
{
|
|
return app(LiveKitProvider::class)->apiHost();
|
|
}
|
|
|
|
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(),
|
|
);
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
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, bool|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'] ?? 'default'),
|
|
'force_path_style' => (bool) ($egress['force_path_style'] ?? true),
|
|
];
|
|
|
|
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->startRoomRecordingWithRetry($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(),
|
|
]);
|
|
}
|
|
}
|
|
}
|