Reject empty recording downloads and store WebM with correct MIME.
Deploy Ladill Meet / deploy (push) Successful in 31s
Deploy Ladill Meet / deploy (push) Successful in 31s
Mark legacy zero-byte placeholders as failed, validate files on show/download, and surface upload errors in the meeting room. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -40,6 +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);
|
||||
|
||||
$canManage = app(\App\Services\Meet\MeetPermissions::class)
|
||||
->can($this->member($request), 'meetings.manage');
|
||||
@@ -51,11 +52,23 @@ class RecordingController extends Controller
|
||||
{
|
||||
$this->authorizeAbility($request, 'meetings.view');
|
||||
$this->authorizeOwner($request, $recording);
|
||||
abort_unless($recording->isReady() && $recording->storage_path, 404);
|
||||
$recording = $this->recordings->ensureStorageValid($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.');
|
||||
|
||||
return Storage::disk(config('meet.recordings.disk', 'local'))
|
||||
->download($recording->storage_path, $recording->session->room->title.'.'.pathinfo($recording->storage_path, PATHINFO_EXTENSION));
|
||||
$disk = Storage::disk(config('meet.recordings.disk', 'local'));
|
||||
$extension = strtolower(pathinfo($recording->storage_path, PATHINFO_EXTENSION) ?: 'webm');
|
||||
$mime = match ($extension) {
|
||||
'webm' => 'video/webm',
|
||||
'mp4' => 'video/mp4',
|
||||
'mkv' => 'video/x-matroska',
|
||||
default => 'application/octet-stream',
|
||||
};
|
||||
$filename = \Illuminate\Support\Str::slug($recording->session->room->title).'.'.$extension;
|
||||
|
||||
return response()->streamDownload(function () use ($disk, $recording) {
|
||||
echo $disk->get($recording->storage_path);
|
||||
}, $filename, ['Content-Type' => $mime]);
|
||||
}
|
||||
|
||||
public function destroy(Request $request, Recording $recording): RedirectResponse
|
||||
|
||||
@@ -55,4 +55,11 @@ class Recording extends Model
|
||||
{
|
||||
return $this->status === 'ready';
|
||||
}
|
||||
|
||||
public function hasPlayableFile(): bool
|
||||
{
|
||||
return $this->isReady()
|
||||
&& filled($this->storage_path)
|
||||
&& (int) ($this->file_size ?? 0) > 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -121,6 +121,38 @@ class RecordingService
|
||||
return $recording->fresh();
|
||||
}
|
||||
|
||||
/** 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) {
|
||||
|
||||
@@ -800,7 +800,12 @@ function meetRoom() {
|
||||
const recordingUuid = data.recording_uuid || this.activeRecordingUuid;
|
||||
|
||||
if (blob && recordingUuid) {
|
||||
await this.uploadRecording(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 meeting.');
|
||||
}
|
||||
} else if (recordingUuid) {
|
||||
window.alert('No recording was captured from this meeting. Start recording again while the meeting is live.');
|
||||
}
|
||||
|
||||
this.activeRecordingUuid = null;
|
||||
@@ -834,11 +839,13 @@ function meetRoom() {
|
||||
const form = new FormData();
|
||||
form.append('recording', blob, `${recordingUuid}.webm`);
|
||||
|
||||
await fetch(`/room/${this.config.sessionUuid}/recordings/${recordingUuid}/upload`, {
|
||||
const res = await fetch(`/room/${this.config.sessionUuid}/recordings/${recordingUuid}/upload`, {
|
||||
method: 'POST',
|
||||
headers: { 'X-CSRF-TOKEN': this.config.csrf, Accept: 'application/json' },
|
||||
body: form,
|
||||
});
|
||||
|
||||
return res.ok;
|
||||
},
|
||||
|
||||
async toggleLock() {
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<a href="{{ route('meet.recordings.show', $recording) }}" class="text-sm text-indigo-600 hover:underline">View</a>
|
||||
@if ($recording->isReady())
|
||||
@if ($recording->hasPlayableFile())
|
||||
<a href="{{ route('meet.recordings.download', $recording) }}" class="text-sm text-slate-600 hover:underline">Download</a>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
@@ -70,8 +70,8 @@
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
@if ($recording->isReady())
|
||||
<p class="mt-5 text-sm text-slate-600">Your recording is stored on Ladill and ready to download.</p>
|
||||
@if ($recording->hasPlayableFile())
|
||||
<p class="mt-5 text-sm text-slate-600">Stored on Ladill as WebM — VLC, Chrome, and QuickTime can play it after download.</p>
|
||||
@elseif ($recording->status === 'processing')
|
||||
<div class="mt-5 flex items-start gap-3 rounded-xl border border-amber-200 bg-amber-50 px-4 py-3">
|
||||
<svg class="mt-0.5 h-5 w-5 shrink-0 animate-spin text-amber-600" fill="none" viewBox="0 0 24 24" aria-hidden="true">
|
||||
@@ -90,7 +90,7 @@
|
||||
@endif
|
||||
|
||||
<div class="btn-group mt-6">
|
||||
@if ($recording->isReady())
|
||||
@if ($recording->hasPlayableFile())
|
||||
<a href="{{ route('meet.recordings.download', $recording) }}" class="btn-secondary btn-secondary-sm">
|
||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75V16.5M16.5 12 12 16.5m0 0L7.5 12M12 16.5V3"/>
|
||||
|
||||
@@ -112,4 +112,44 @@ class MeetRecordingUploadTest extends TestCase
|
||||
$this->assertNotNull($recording->storage_path);
|
||||
Storage::disk('local')->assertExists($recording->storage_path);
|
||||
}
|
||||
|
||||
public function test_empty_ready_recording_is_marked_failed(): void
|
||||
{
|
||||
$room = Room::create([
|
||||
'organization_id' => $this->organization->id,
|
||||
'owner_ref' => $this->user->public_id,
|
||||
'host_user_ref' => $this->user->public_id,
|
||||
'title' => 'Empty recording',
|
||||
'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' => 'ended',
|
||||
'started_at' => now()->subHour(),
|
||||
'ended_at' => now(),
|
||||
]);
|
||||
|
||||
$recording = Recording::create([
|
||||
'owner_ref' => $this->user->public_id,
|
||||
'session_id' => $session->id,
|
||||
'status' => 'ready',
|
||||
'layout' => 'gallery',
|
||||
'storage_path' => 'recordings/'.$session->uuid.'/empty.webm',
|
||||
'file_size' => 0,
|
||||
'started_at' => now()->subHour(),
|
||||
'ended_at' => now(),
|
||||
]);
|
||||
|
||||
Storage::disk('local')->put($recording->storage_path, '');
|
||||
|
||||
$recording = app(\App\Services\Meet\RecordingService::class)->ensureStorageValid($recording);
|
||||
|
||||
$this->assertSame('failed', $recording->status);
|
||||
$this->assertNull($recording->storage_path);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user