From 4af53f93745f629f1b1ed70f5d6136c5b772e24d Mon Sep 17 00:00:00 2001 From: isaacclad Date: Fri, 3 Jul 2026 22:54:26 +0000 Subject: [PATCH] Fix stuck recording processing and add invoice service API test. Capture stage spotlight video for browser recordings, fail incomplete uploads, allow post-session host uploads, and expire stale processing recordings automatically. Co-authored-by: Cursor --- .../FailStaleMeetRecordingsCommand.php | 22 +++ .../Meet/MeetingRoomController.php | 70 ++++++++- .../Controllers/Meet/RecordingController.php | 4 +- app/Services/Meet/RecordingService.php | 74 +++++++++ config/meet.php | 1 + resources/js/meet-room.js | 80 ++++++++-- resources/views/meet/room/show.blade.php | 2 +- routes/console.php | 1 + routes/web.php | 1 + tests/Feature/MeetRecordingUploadTest.php | 142 ++++++++++++++++++ tests/Feature/MeetWebTest.php | 16 ++ 11 files changed, 395 insertions(+), 18 deletions(-) create mode 100644 app/Console/Commands/FailStaleMeetRecordingsCommand.php diff --git a/app/Console/Commands/FailStaleMeetRecordingsCommand.php b/app/Console/Commands/FailStaleMeetRecordingsCommand.php new file mode 100644 index 0000000..1476caa --- /dev/null +++ b/app/Console/Commands/FailStaleMeetRecordingsCommand.php @@ -0,0 +1,22 @@ +failStaleProcessingRecordings(); + + $this->info("Marked {$failed} stale recording(s) as failed."); + + return self::SUCCESS; + } +} diff --git a/app/Http/Controllers/Meet/MeetingRoomController.php b/app/Http/Controllers/Meet/MeetingRoomController.php index 4f3a22c..367f590 100644 --- a/app/Http/Controllers/Meet/MeetingRoomController.php +++ b/app/Http/Controllers/Meet/MeetingRoomController.php @@ -431,11 +431,25 @@ class MeetingRoomController extends Controller public function stopRecording(Request $request, Session $session): JsonResponse { - $participant = $this->currentParticipant($request, $session); - abort_unless($participant->isHost(), 403); + $participant = $this->recordingHost($request, $session); $recording = $session->recordings()->where('status', 'recording')->firstOrFail(); - $recording = $this->recordings->stop($recording, $participant->user_ref ?? $participant->uuid); + $actorRef = $participant->user_ref ?? $participant->uuid; + + if ($request->boolean('failed')) { + $recording = $this->recordings->fail( + $recording, + (string) $request->input('reason', 'Recording capture did not complete.'), + $actorRef, + ); + + return response()->json([ + 'status' => 'failed', + 'recording_uuid' => $recording->uuid, + ]); + } + + $recording = $this->recordings->stop($recording, $actorRef); return response()->json([ 'status' => 'processing', @@ -443,10 +457,27 @@ class MeetingRoomController extends Controller ]); } + public function failRecording(Request $request, Session $session, Recording $recording): JsonResponse + { + $participant = $this->recordingHost($request, $session); + abort_unless($recording->session_id === $session->id, 404); + abort_if(! in_array($recording->status, ['recording', 'processing'], true), 422); + + $recording = $this->recordings->fail( + $recording, + (string) $request->input('reason', 'Recording capture did not complete.'), + $participant->user_ref ?? $participant->uuid, + ); + + return response()->json([ + 'status' => $recording->status, + 'recording_uuid' => $recording->uuid, + ]); + } + public function uploadRecording(Request $request, Session $session, Recording $recording): JsonResponse { - $participant = $this->currentParticipant($request, $session); - abort_unless($participant->isHost(), 403); + $participant = $this->recordingHost($request, $session); abort_unless($recording->session_id === $session->id, 404); $maxKb = max(1, (int) config('meet.recordings.max_mb', 512)) * 1024; @@ -529,4 +560,33 @@ class MeetingRoomController extends Controller ->firstOrFail() ); } + + /** Host/co-host in the room session, or the room host after the meeting ended. */ + protected function recordingHost(Request $request, Session $session): Participant + { + $participantUuid = $request->session()->get("meet.participant.{$session->uuid}"); + if ($participantUuid) { + $participant = Participant::query() + ->where('uuid', $participantUuid) + ->where('session_id', $session->id) + ->first(); + + if ($participant?->isHost()) { + return $this->sessions->syncEventsSpeakerRole($request, $participant); + } + } + + $user = $request->user(); + abort_unless($user, 403); + + $hostParticipant = $session->participants() + ->where('user_ref', $user->ownerRef()) + ->whereIn('role', ['host', 'co_host']) + ->orderByRaw("case when role = 'host' then 0 else 1 end") + ->first(); + + abort_unless($hostParticipant, 403); + + return $hostParticipant; + } } diff --git a/app/Http/Controllers/Meet/RecordingController.php b/app/Http/Controllers/Meet/RecordingController.php index 16fe5e2..1d6fc0c 100644 --- a/app/Http/Controllers/Meet/RecordingController.php +++ b/app/Http/Controllers/Meet/RecordingController.php @@ -40,7 +40,7 @@ class RecordingController extends Controller $this->authorizeAbility($request, 'meetings.view'); $this->authorizeOwner($request, $recording); $recording->load(['session.room', 'session.aiSummaries', 'session.participants']); - $recording = $this->recordings->ensureStorageValid($recording); + $recording = $this->recordings->resolveProcessingState($recording); $canManage = app(\App\Services\Meet\MeetPermissions::class) ->can($this->member($request), 'meetings.manage'); @@ -52,7 +52,7 @@ class RecordingController extends Controller { $this->authorizeAbility($request, 'meetings.view'); $this->authorizeOwner($request, $recording); - $recording = $this->recordings->ensureStorageValid($recording); + $recording = $this->recordings->resolveProcessingState($recording); abort_unless($recording->hasPlayableFile(), 404, 'Recording file is not available.'); abort_unless(app(\App\Services\Meet\MeetBillingService::class)->storageIsAccessible($recording), 402, 'Recording storage billing is overdue. Top up your Ladill wallet to download this recording.'); diff --git a/app/Services/Meet/RecordingService.php b/app/Services/Meet/RecordingService.php index 9a42b5d..7f74c07 100644 --- a/app/Services/Meet/RecordingService.php +++ b/app/Services/Meet/RecordingService.php @@ -121,6 +121,80 @@ class RecordingService 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 { diff --git a/config/meet.php b/config/meet.php index e1d6493..ead2a01 100644 --- a/config/meet.php +++ b/config/meet.php @@ -113,6 +113,7 @@ return [ 'recordings' => [ 'disk' => env('MEET_RECORDINGS_DISK', 'local'), 'max_mb' => (int) env('MEET_RECORDING_MAX_MB', 512), + 'processing_timeout_minutes' => (int) env('MEET_RECORDING_PROCESSING_TIMEOUT_MINUTES', 15), ], 'ai' => [ diff --git a/resources/js/meet-room.js b/resources/js/meet-room.js index 94842ce..602b59a 100644 --- a/resources/js/meet-room.js +++ b/resources/js/meet-room.js @@ -1486,8 +1486,10 @@ function meetRoom() { const data = await res.json(); this.activeRecordingUuid = data.recording_uuid; if (! await this.tryStartClientRecordingWithRetry()) { + await this.abortServerRecording('Could not capture meeting video in this browser.'); this.isRecording = false; this.activeRecordingUuid = null; + window.alert(`Recording could not start because this browser could not capture the ${this.sessionNoun()} video. Try Chrome on desktop, or disable stage-only layouts temporarily.`); return; } @@ -1530,7 +1532,11 @@ function meetRoom() { const screenVideo = screenEl && !screenEl.classList.contains('hidden') ? screenEl.querySelector('video') : null; - const videoSource = screenVideo || document.getElementById('video-grid'); + + let videoSource = screenVideo; + if (!videoSource) { + videoSource = this.recordingVideoCaptureElement(); + } if (!videoSource?.captureStream) { return null; @@ -1542,6 +1548,26 @@ function meetRoom() { return tracks.length ? new MediaStream(tracks) : null; }, + recordingVideoCaptureElement() { + if (!this.usesStageLayout) { + return document.getElementById('video-grid'); + } + + const spotlight = document.getElementById('meet-stage-spotlight'); + const grid = document.getElementById('video-grid'); + const stage = document.getElementById('meet-stage'); + + if (spotlight?.querySelector('video')) { + return spotlight; + } + + if (grid?.querySelector('video')) { + return grid; + } + + return stage || spotlight || grid; + }, + collectMeetingAudioTracks() { const tracks = []; const addFromParticipant = (participant) => { @@ -1564,30 +1590,56 @@ function meetRoom() { async stopServerAndUploadRecording() { const hadClientRecorder = !!recordingMediaRecorder; - const res = await fetch(this.config.recordingStopUrl, { method: 'POST', headers: this.headers() }); const blob = await this.stopClientRecording(); this.isRecording = false; - if (!res.ok) { + let recordingUuid = this.activeRecordingUuid; + const res = await fetch(this.config.recordingStopUrl, { method: 'POST', headers: this.headers() }); + if (res.ok) { + const data = await res.json().catch(() => ({})); + recordingUuid = data.recording_uuid || recordingUuid; + } else if (res.status !== 404 || !recordingUuid) { this.activeRecordingUuid = null; return; } - const data = await res.json().catch(() => ({})); - const recordingUuid = data.recording_uuid || this.activeRecordingUuid; - if (blob && recordingUuid) { const uploaded = await this.uploadRecording(blob, recordingUuid); if (!uploaded) { - window.alert(`Recording upload failed. Open Recordings and try again, or re-record the ${this.sessionNoun()}.`); + window.alert(`Recording upload failed. Open Recordings and refresh shortly, or re-record the ${this.sessionNoun()}.`); + } + } else if (recordingUuid) { + await this.failServerRecording( + recordingUuid, + hadClientRecorder + ? `No recording was captured from this ${this.sessionNoun()}.` + : 'Recording capture stopped before any video was saved.', + ); + if (hadClientRecorder) { + window.alert(`No recording was captured from this ${this.sessionNoun()}. Start recording again while the ${this.sessionNoun()} is live.`); } - } else if (recordingUuid && hadClientRecorder) { - window.alert(`No recording was captured from this ${this.sessionNoun()}. Start recording again while the ${this.sessionNoun()} is live.`); } this.activeRecordingUuid = null; }, + async abortServerRecording(reason) { + await fetch(this.config.recordingStopUrl, { + method: 'POST', + headers: this.headers(), + body: JSON.stringify({ failed: true, reason }), + }); + }, + + async failServerRecording(recordingUuid, reason) { + const url = `/room/${this.config.sessionUuid}/recordings/${recordingUuid}/fail`; + await fetch(url, { + method: 'POST', + headers: this.headers(), + body: JSON.stringify({ reason }), + }); + }, + stopClientRecording() { return new Promise((resolve) => { const recorder = recordingMediaRecorder; @@ -1826,7 +1878,7 @@ function meetRoom() { }, submitEndMeeting() { - if (this.isRecording) { + if (this.isRecording || this.activeRecordingUuid || recordingMediaRecorder) { this.stopServerAndUploadRecording().finally(() => { document.getElementById('meet-end-form')?.requestSubmit(); }); @@ -1836,6 +1888,14 @@ function meetRoom() { this.endMeetingConfirmOpen = false; }, + async submitLeave() { + if (this.isHost && (this.isRecording || this.activeRecordingUuid || recordingMediaRecorder)) { + await this.stopServerAndUploadRecording(); + } + + document.getElementById('meet-leave-form')?.submit(); + }, + async uploadFile(event) { const file = event.target.files?.[0]; if (!file || !this.config.filesUploadUrl) return; diff --git a/resources/views/meet/room/show.blade.php b/resources/views/meet/room/show.blade.php index 6b7b150..742e0cd 100644 --- a/resources/views/meet/room/show.blade.php +++ b/resources/views/meet/room/show.blade.php @@ -427,7 +427,7 @@
-
+ @csrf