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>
391 lines
13 KiB
PHP
391 lines
13 KiB
PHP
<?php
|
|
|
|
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();
|
|
if ($active) {
|
|
return $this->ensureServerEgress($session, $active);
|
|
}
|
|
|
|
$recording = Recording::create([
|
|
'owner_ref' => $session->owner_ref,
|
|
'session_id' => $session->id,
|
|
'status' => 'recording',
|
|
'layout' => $layout,
|
|
'started_by_ref' => $host->ownerRef(),
|
|
'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',
|
|
$session->room->organization_id,
|
|
$host->ownerRef(),
|
|
Recording::class,
|
|
$recording->id,
|
|
);
|
|
|
|
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 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') {
|
|
return $recording;
|
|
}
|
|
|
|
if ($this->usesServerEgress($recording)) {
|
|
$this->egress->tryStop($recording->metadata['egress_id'] ?? null);
|
|
}
|
|
|
|
$recording->update([
|
|
'status' => 'processing',
|
|
'ended_at' => now(),
|
|
'duration_seconds' => $recording->started_at
|
|
? (int) $recording->started_at->diffInSeconds(now())
|
|
: null,
|
|
]);
|
|
|
|
AuditLogger::record(
|
|
$recording->owner_ref,
|
|
'recording.stopped',
|
|
$recording->session->room->organization_id,
|
|
$actorRef,
|
|
Recording::class,
|
|
$recording->id,
|
|
);
|
|
|
|
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
|
|
{
|
|
if ($recording->status !== 'processing') {
|
|
return $recording;
|
|
}
|
|
|
|
$session = $recording->session;
|
|
$extension = strtolower($file->getClientOriginalExtension() ?: $file->extension() ?: 'webm');
|
|
if (! in_array($extension, ['webm', 'mp4', 'mkv'], true)) {
|
|
$extension = 'webm';
|
|
}
|
|
|
|
$filename = 'recordings/'.$session->uuid.'/'.$recording->uuid.'.'.$extension;
|
|
$disk = Storage::disk(config('meet.recordings.disk', 'local'));
|
|
$disk->putFileAs(
|
|
'recordings/'.$session->uuid,
|
|
$file,
|
|
$recording->uuid.'.'.$extension,
|
|
);
|
|
|
|
$size = $disk->size($filename) ?: $file->getSize();
|
|
|
|
if ($size <= 0) {
|
|
throw new RuntimeException('Recording upload was empty.');
|
|
}
|
|
|
|
$recording->update([
|
|
'status' => 'ready',
|
|
'storage_path' => $filename,
|
|
'file_size' => $size,
|
|
'thumbnail_path' => null,
|
|
]);
|
|
|
|
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->fresh());
|
|
|
|
if ($session->room->setting('auto_ai_summary', false) || config('meet.ai.auto_summarize', true)) {
|
|
app(TranscriptService::class)->ensureForSession($session, $recording);
|
|
}
|
|
|
|
return $recording->fresh();
|
|
}
|
|
|
|
public function fail(Recording $recording, string $reason, ?string $actorRef = null): Recording
|
|
{
|
|
if (! in_array($recording->status, ['recording', 'processing'], true)) {
|
|
return $recording;
|
|
}
|
|
|
|
$recording->update([
|
|
'status' => 'failed',
|
|
'failure_reason' => $reason,
|
|
'ended_at' => $recording->ended_at ?? now(),
|
|
'duration_seconds' => $recording->duration_seconds
|
|
?? ($recording->started_at ? (int) $recording->started_at->diffInSeconds(now()) : null),
|
|
]);
|
|
|
|
if ($actorRef) {
|
|
AuditLogger::record(
|
|
$recording->owner_ref,
|
|
'recording.failed',
|
|
$recording->session->room->organization_id,
|
|
$actorRef,
|
|
Recording::class,
|
|
$recording->id,
|
|
);
|
|
}
|
|
|
|
return $recording->fresh();
|
|
}
|
|
|
|
/** Fail recordings left in processing long after the meeting ended. */
|
|
public function resolveProcessingState(Recording $recording): Recording
|
|
{
|
|
if ($recording->status !== 'processing') {
|
|
return $this->ensureStorageValid($recording);
|
|
}
|
|
|
|
$timeoutMinutes = max(5, (int) config('meet.recordings.processing_timeout_minutes', 15));
|
|
$staleAt = $recording->ended_at ?? $recording->updated_at;
|
|
|
|
if ($staleAt && $staleAt->lt(now()->subMinutes($timeoutMinutes))) {
|
|
$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;
|
|
}
|
|
|
|
public function failStaleProcessingRecordings(?int $timeoutMinutes = null): int
|
|
{
|
|
$timeoutMinutes = max(5, $timeoutMinutes ?? (int) config('meet.recordings.processing_timeout_minutes', 15));
|
|
$cutoff = now()->subMinutes($timeoutMinutes);
|
|
$failed = 0;
|
|
|
|
Recording::query()
|
|
->where('status', 'processing')
|
|
->where(function ($query) use ($cutoff) {
|
|
$query->where('ended_at', '<', $cutoff)
|
|
->orWhere(function ($query) use ($cutoff) {
|
|
$query->whereNull('ended_at')->where('updated_at', '<', $cutoff);
|
|
});
|
|
})
|
|
->orderBy('id')
|
|
->each(function (Recording $recording) use (&$failed) {
|
|
$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++;
|
|
});
|
|
|
|
return $failed;
|
|
}
|
|
|
|
/** Mark ready recordings with missing/empty files as failed. */
|
|
public function ensureStorageValid(Recording $recording): Recording
|
|
{
|
|
if ($recording->status !== 'ready' || ! $recording->storage_path) {
|
|
return $recording;
|
|
}
|
|
|
|
$disk = Storage::disk(config('meet.recordings.disk', 'local'));
|
|
$size = $disk->exists($recording->storage_path) ? (int) $disk->size($recording->storage_path) : 0;
|
|
|
|
if ($size > 0) {
|
|
if ((int) ($recording->file_size ?? 0) !== $size) {
|
|
$recording->update(['file_size' => $size]);
|
|
}
|
|
|
|
return $recording->fresh();
|
|
}
|
|
|
|
if ($recording->storage_path) {
|
|
$disk->delete($recording->storage_path);
|
|
}
|
|
|
|
$recording->update([
|
|
'status' => 'failed',
|
|
'storage_path' => null,
|
|
'file_size' => null,
|
|
'failure_reason' => 'Recording file is missing or empty. Please record the meeting again.',
|
|
]);
|
|
|
|
return $recording->fresh();
|
|
}
|
|
|
|
public function delete(Recording $recording, string $actorRef): void
|
|
{
|
|
if ($recording->storage_path) {
|
|
Storage::disk(config('meet.recordings.disk', 'local'))->delete($recording->storage_path);
|
|
}
|
|
|
|
AuditLogger::record(
|
|
$recording->owner_ref,
|
|
'recording.deleted',
|
|
$recording->session->room->organization_id,
|
|
$actorRef,
|
|
Recording::class,
|
|
$recording->id,
|
|
);
|
|
|
|
$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;
|
|
}
|
|
}
|