recordings()->where('status', 'recording')->first(); if ($active) { return $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(), ]); AuditLogger::record( $session->owner_ref, 'recording.started', $session->room->organization_id, $host->ownerRef(), Recording::class, $recording->id, ); return $recording; } public function stop(Recording $recording, string $actorRef): Recording { if ($recording->status !== 'recording') { return $recording; } $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(); } /** 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))) { return $this->fail( $recording, 'Recording upload did not complete. Keep the host browser open briefly after ending the meeting so the file can upload.', ); } 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) { $this->fail( $recording, 'Recording upload did not complete. Keep the host browser open briefly after ending the meeting so the file can upload.', ); $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(); } }