Store meet recordings on Ladill servers via browser upload.
Deploy Ladill Meet / deploy (push) Successful in 53s

Capture the meeting in the host browser, upload to Ladill storage on stop, and remove the LiveKit egress placeholder job.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
isaacclad
2026-07-01 17:40:44 +00:00
co-authored by Cursor
parent 5137672ef5
commit b765b5dd97
10 changed files with 322 additions and 47 deletions
+1
View File
@@ -44,6 +44,7 @@ LIVEKIT_API_SECRET=
# --- Recordings & AI ---
MEET_RECORDINGS_DISK=local
MEET_RECORDING_MAX_MB=512
MEET_AI_DRIVER=openai
MEET_AI_API_KEY=
MEET_AI_MODEL=gpt-4o-mini
@@ -4,6 +4,7 @@ namespace App\Http\Controllers\Meet;
use App\Http\Controllers\Controller;
use App\Models\Participant;
use App\Models\Recording;
use App\Models\Session;
use App\Services\Meet\ChatService;
use App\Services\Meet\Media\MediaProviderInterface;
@@ -254,9 +255,29 @@ class MeetingRoomController extends Controller
abort_unless($participant->isHost(), 403);
$recording = $session->recordings()->where('status', 'recording')->firstOrFail();
$this->recordings->stop($recording, $participant->user_ref ?? $participant->uuid);
$recording = $this->recordings->stop($recording, $participant->user_ref ?? $participant->uuid);
return response()->json(['status' => 'processing']);
return response()->json([
'status' => 'processing',
'recording_uuid' => $recording->uuid,
]);
}
public function uploadRecording(Request $request, Session $session, Recording $recording): JsonResponse
{
$participant = $this->currentParticipant($request, $session);
abort_unless($participant->isHost(), 403);
abort_unless($recording->session_id === $session->id, 404);
$maxKb = max(1, (int) config('meet.recordings.max_mb', 512)) * 1024;
$request->validate([
'recording' => ['required', 'file', 'mimetypes:video/webm,video/mp4,video/x-matroska,video/quicktime', 'max:'.$maxKb],
]);
$this->recordings->storeUpload($recording, $request->file('recording'));
return response()->json(['status' => 'ready', 'recording_uuid' => $recording->uuid]);
}
public function lock(Request $request, Session $session): JsonResponse
@@ -52,7 +52,7 @@ class RecordingController extends Controller
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.');
return Storage::disk(config('meet.recordings.disk', 'local'))
->download($recording->storage_path, $recording->session->room->title.'.mp4');
->download($recording->storage_path, $recording->session->room->title.'.'.pathinfo($recording->storage_path, PATHINFO_EXTENSION));
}
public function destroy(Request $request, Recording $recording): RedirectResponse
-27
View File
@@ -1,27 +0,0 @@
<?php
namespace App\Jobs;
use App\Models\Recording;
use App\Services\Meet\RecordingService;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
class ProcessRecording implements ShouldQueue
{
use Queueable;
public function __construct(
public int $recordingId,
) {}
public function handle(RecordingService $recordings): void
{
$recording = Recording::find($this->recordingId);
if (! $recording || $recording->status !== 'processing') {
return;
}
$recordings->process($recording);
}
}
+26 -11
View File
@@ -5,8 +5,9 @@ namespace App\Services\Meet;
use App\Models\Recording;
use App\Models\Session;
use App\Models\User;
use App\Jobs\ProcessRecording;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
use RuntimeException;
class RecordingService
{
@@ -61,26 +62,40 @@ class RecordingService
$recording->id,
);
ProcessRecording::dispatch($recording->id);
return $recording->fresh();
}
public function process(Recording $recording): Recording
/** Store a browser-captured recording on Ladill storage (not LiveKit). */
public function storeUpload(Recording $recording, UploadedFile $file): Recording
{
$session = $recording->session;
$filename = 'recordings/'.$session->uuid.'/'.$recording->uuid.'.mp4';
$disk = Storage::disk(config('meet.recordings.disk', 'local'));
if ($recording->status !== 'processing') {
return $recording;
}
// Placeholder until LiveKit Egress writes the file; create empty marker for dev.
if (! $disk->exists($filename)) {
$disk->put($filename, '');
$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' => $disk->size($filename) ?: null,
'file_size' => $size,
'thumbnail_path' => null,
]);
+1
View File
@@ -101,6 +101,7 @@ return [
'recordings' => [
'disk' => env('MEET_RECORDINGS_DISK', 'local'),
'max_mb' => (int) env('MEET_RECORDING_MAX_MB', 512),
],
'ai' => [
+152 -4
View File
@@ -17,6 +17,8 @@ function meetRoom() {
let room = null;
let connectPromise = null;
let initialized = false;
let recordingMediaRecorder = null;
let recordingChunks = [];
return {
isMuted: true,
@@ -50,6 +52,7 @@ function meetRoom() {
breakoutModalOpen: false,
breakoutCount: '4',
endMeetingConfirmOpen: false,
activeRecordingUuid: null,
joinUrl: '',
passcode: '',
roomTitle: '',
@@ -692,13 +695,152 @@ function meetRoom() {
async toggleRecording() {
if (!this.isHost) return;
const url = this.isRecording ? this.config.recordingStopUrl : this.config.recordingStartUrl;
const res = await fetch(url, { method: 'POST', headers: this.headers() });
if (res.ok) {
this.isRecording = !this.isRecording;
if (this.isRecording) {
await this.stopServerAndUploadRecording();
} else {
await this.startServerRecording();
}
},
async startServerRecording() {
const res = await fetch(this.config.recordingStartUrl, { method: 'POST', headers: this.headers() });
if (!res.ok) return;
const data = await res.json();
this.activeRecordingUuid = data.recording_uuid;
if (!this.startClientRecording()) {
this.isRecording = false;
this.activeRecordingUuid = null;
return;
}
this.isRecording = true;
},
startClientRecording() {
const stream = this.buildRecordingStream();
if (!stream || typeof MediaRecorder === 'undefined') {
return false;
}
recordingChunks = [];
const mimeType = MediaRecorder.isTypeSupported('video/webm;codecs=vp9,opus')
? 'video/webm;codecs=vp9,opus'
: (MediaRecorder.isTypeSupported('video/webm') ? 'video/webm' : '');
try {
recordingMediaRecorder = mimeType
? new MediaRecorder(stream, { mimeType, videoBitsPerSecond: 2_500_000 })
: new MediaRecorder(stream, { videoBitsPerSecond: 2_500_000 });
} catch {
return false;
}
recordingMediaRecorder.ondataavailable = (event) => {
if (event.data?.size > 0) {
recordingChunks.push(event.data);
}
};
recordingMediaRecorder.start(1000);
return true;
},
buildRecordingStream() {
if (!room) return null;
const screenEl = document.getElementById('screen-share');
const screenVideo = screenEl && !screenEl.classList.contains('hidden')
? screenEl.querySelector('video')
: null;
const videoSource = screenVideo || document.getElementById('video-grid');
if (!videoSource?.captureStream) {
return null;
}
const videoStream = videoSource.captureStream(25);
const tracks = [...videoStream.getVideoTracks(), ...this.collectMeetingAudioTracks()];
return tracks.length ? new MediaStream(tracks) : null;
},
collectMeetingAudioTracks() {
const tracks = [];
const addFromParticipant = (participant) => {
participant?.audioTrackPublications?.forEach((publication) => {
const mediaTrack = publication.track?.mediaStreamTrack;
if (mediaTrack && mediaTrack.readyState === 'live') {
tracks.push(mediaTrack);
}
});
};
if (room?.localParticipant) {
addFromParticipant(room.localParticipant);
}
room?.remoteParticipants?.forEach(addFromParticipant);
return tracks;
},
async stopServerAndUploadRecording() {
const res = await fetch(this.config.recordingStopUrl, { method: 'POST', headers: this.headers() });
const blob = await this.stopClientRecording();
this.isRecording = false;
if (!res.ok) {
this.activeRecordingUuid = null;
return;
}
const data = await res.json().catch(() => ({}));
const recordingUuid = data.recording_uuid || this.activeRecordingUuid;
if (blob && recordingUuid) {
await this.uploadRecording(blob, recordingUuid);
}
this.activeRecordingUuid = null;
},
stopClientRecording() {
return new Promise((resolve) => {
const recorder = recordingMediaRecorder;
if (!recorder || recorder.state === 'inactive') {
recordingMediaRecorder = null;
recordingChunks = [];
resolve(null);
return;
}
recorder.onstop = () => {
const blob = new Blob(recordingChunks, {
type: recorder.mimeType || 'video/webm',
});
recordingMediaRecorder = null;
recordingChunks = [];
recorder.stream?.getTracks().forEach((track) => track.stop());
resolve(blob.size > 0 ? blob : null);
};
recorder.stop();
});
},
async uploadRecording(blob, recordingUuid) {
const form = new FormData();
form.append('recording', blob, `${recordingUuid}.webm`);
await fetch(`/room/${this.config.sessionUuid}/recordings/${recordingUuid}/upload`, {
method: 'POST',
headers: { 'X-CSRF-TOKEN': this.config.csrf, Accept: 'application/json' },
body: form,
});
},
async toggleLock() {
if (!this.isHost) return;
const url = this.isLocked ? this.config.unlockUrl : this.config.lockUrl;
@@ -767,7 +909,13 @@ function meetRoom() {
},
submitEndMeeting() {
if (this.isRecording) {
this.stopServerAndUploadRecording().finally(() => {
document.getElementById('meet-end-form')?.requestSubmit();
});
} else {
document.getElementById('meet-end-form')?.requestSubmit();
}
this.endMeetingConfirmOpen = false;
},
@@ -8,7 +8,7 @@
<p class="text-sm text-slate-600">Duration: {{ gmdate('H:i:s', $recording->duration_seconds) }}</p>
@endif
@if ($recording->isReady())
<a href="{{ route('meet.recordings.download', $recording) }}" class="btn-primary inline-block">Download MP4</a>
<a href="{{ route('meet.recordings.download', $recording) }}" class="btn-primary inline-block">Download recording</a>
@else
<p class="text-sm text-amber-700">Recording is still processing. Refresh shortly.</p>
@endif
+1
View File
@@ -68,6 +68,7 @@ Route::post('/room/{session}/reaction', [MeetingRoomController::class, 'sendReac
Route::get('/room/{session}/poll', [MeetingRoomController::class, 'poll'])->name('meet.room.poll');
Route::post('/room/{session}/recording/start', [MeetingRoomController::class, 'startRecording'])->name('meet.room.recording.start');
Route::post('/room/{session}/recording/stop', [MeetingRoomController::class, 'stopRecording'])->name('meet.room.recording.stop');
Route::post('/room/{session}/recordings/{recording}/upload', [MeetingRoomController::class, 'uploadRecording'])->name('meet.room.recording.upload');
Route::post('/room/{session}/lock', [MeetingRoomController::class, 'lock'])->name('meet.room.lock');
Route::post('/room/{session}/unlock', [MeetingRoomController::class, 'unlock'])->name('meet.room.unlock');
Route::post('/room/{session}/caption', [MeetingRoomController::class, 'caption'])->name('meet.room.caption');
+115
View File
@@ -0,0 +1,115 @@
<?php
namespace Tests\Feature;
use App\Http\Middleware\EnsurePlatformSession;
use App\Models\Member;
use App\Models\Organization;
use App\Models\Participant;
use App\Models\Recording;
use App\Models\Room;
use App\Models\Session;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Storage;
use Tests\TestCase;
class MeetRecordingUploadTest extends TestCase
{
use RefreshDatabase;
protected User $user;
protected Organization $organization;
protected function setUp(): void
{
parent::setUp();
$this->withoutMiddleware(EnsurePlatformSession::class);
Storage::fake('local');
$this->user = User::create([
'public_id' => 'test-user-recording',
'name' => 'Test User',
'email' => 'recording@example.com',
]);
$this->organization = Organization::create([
'owner_ref' => $this->user->public_id,
'name' => 'Test Org',
'slug' => 'test-org-recording',
'timezone' => 'UTC',
'settings' => ['onboarded' => true],
]);
Member::create([
'owner_ref' => $this->user->public_id,
'organization_id' => $this->organization->id,
'user_ref' => $this->user->public_id,
'role' => 'owner',
]);
$base = rtrim((string) config('billing.api_url'), '/');
Http::fake([
$base.'/can-afford*' => Http::response(['affordable' => true]),
$base.'/debit' => Http::response(['ok' => true]),
]);
}
public function test_host_can_upload_browser_recording_to_ladill_storage(): void
{
$room = Room::create([
'organization_id' => $this->organization->id,
'owner_ref' => $this->user->public_id,
'host_user_ref' => $this->user->public_id,
'title' => 'Recorded meeting',
'type' => 'instant',
'status' => 'live',
'timezone' => 'UTC',
]);
$session = Session::create([
'room_id' => $room->id,
'owner_ref' => $this->user->public_id,
'media_room_name' => 'room-'.$room->uuid,
'status' => 'live',
'started_at' => now()->subMinutes(5),
]);
$participant = Participant::create([
'owner_ref' => $this->user->public_id,
'session_id' => $session->id,
'user_ref' => $this->user->public_id,
'role' => 'host',
'display_name' => 'Test User',
'status' => 'joined',
'joined_at' => now(),
]);
$recording = Recording::create([
'owner_ref' => $this->user->public_id,
'session_id' => $session->id,
'status' => 'processing',
'layout' => 'gallery',
'started_by_ref' => $this->user->public_id,
'started_at' => now()->subMinute(),
'ended_at' => now(),
'duration_seconds' => 60,
]);
$this->withSession(["meet.participant.{$session->uuid}" => $participant->uuid])
->actingAs($this->user)
->post("/room/{$session->uuid}/recordings/{$recording->uuid}/upload", [
'recording' => UploadedFile::fake()->create('meet.webm', 128, 'video/webm'),
])
->assertOk()
->assertJson(['status' => 'ready']);
$recording->refresh();
$this->assertSame('ready', $recording->status);
$this->assertNotNull($recording->storage_path);
Storage::disk('local')->assertExists($recording->storage_path);
}
}