Store meet recordings on Ladill servers via browser upload.
Deploy Ladill Meet / deploy (push) Successful in 53s
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:
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
]);
|
||||
|
||||
|
||||
@@ -101,6 +101,7 @@ return [
|
||||
|
||||
'recordings' => [
|
||||
'disk' => env('MEET_RECORDINGS_DISK', 'local'),
|
||||
'max_mb' => (int) env('MEET_RECORDING_MAX_MB', 512),
|
||||
],
|
||||
|
||||
'ai' => [
|
||||
|
||||
+152
-4
@@ -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
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user