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); $transcripts = app(TranscriptService::class); if ($transcripts->shouldAutoSummarize($session)) { // Re-run after recording is ready so the transcript is linked and content refreshed. $transcripts->finalizeAndSummarize($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()); $transcripts = app(TranscriptService::class); if ($transcripts->shouldAutoSummarize($session)) { $transcripts->finalizeAndSummarize($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; } }