Fix stuck recording processing and add invoice service API test.
Deploy Ladill Meet / deploy (push) Successful in 52s

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 <cursoragent@cursor.com>
This commit is contained in:
isaacclad
2026-07-03 22:54:26 +00:00
co-authored by Cursor
parent 94a7906188
commit 4af53f9374
11 changed files with 395 additions and 18 deletions
@@ -0,0 +1,22 @@
<?php
namespace App\Console\Commands;
use App\Services\Meet\RecordingService;
use Illuminate\Console\Command;
class FailStaleMeetRecordingsCommand extends Command
{
protected $signature = 'meet:fail-stale-recordings';
protected $description = 'Mark Meet recordings stuck in processing as failed';
public function handle(RecordingService $recordings): int
{
$failed = $recordings->failStaleProcessingRecordings();
$this->info("Marked {$failed} stale recording(s) as failed.");
return self::SUCCESS;
}
}
@@ -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;
}
}
@@ -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.');
+74
View File
@@ -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
{