diff --git a/database/migrations/2026_04_27_091100_add_processing_video_to_meet_recording_table.php b/database/migrations/2026_04_27_091100_add_processing_video_to_meet_recording_table.php new file mode 100644 index 0000000..7d74900 --- /dev/null +++ b/database/migrations/2026_04_27_091100_add_processing_video_to_meet_recording_table.php @@ -0,0 +1,32 @@ +boolean('processing_video')->default(false); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::table('meet_recordings', function (Blueprint $table) { + $table->dropColumn('processing_video'); + }); + } +}; diff --git a/src/Http/Resources/MeetRecordingResource.php b/src/Http/Resources/MeetRecordingResource.php index cb37f6b..adbc943 100644 --- a/src/Http/Resources/MeetRecordingResource.php +++ b/src/Http/Resources/MeetRecordingResource.php @@ -17,6 +17,7 @@ public function toArray($request): array 'term' => $this->resource->term, 'url' => $this->resource->is_url_valid ? $this->resource->url : null, 'url_expires_at' => $this->resource->is_url_valid ? $this->resource->url_expires_at : null, + 'processing_video' => $this->resource->processing_video, ]; } } diff --git a/src/Jobs/ProcessingMeetingFramesJob.php b/src/Jobs/ProcessingMeetingFramesJob.php index f406095..fbe8ad6 100644 --- a/src/Jobs/ProcessingMeetingFramesJob.php +++ b/src/Jobs/ProcessingMeetingFramesJob.php @@ -31,91 +31,97 @@ public function handle() { if (!$this->meetRecording->url) return; + $this->meetRecording->update(['processing_video' => true]); + try { $html = Http::get($this->meetRecording->url)->body(); preg_match('/DOWNLOAD_RECORDING_URLS = "\[(.*?)\]";/', $html, $matches); if (empty($matches[1])) return; $directVideoUrl = str_contains($matches[1], ',') ? explode(',', $matches[1])[0] : $matches[1]; - } catch (\Exception $e) { - return; - } - try { if (Http::head($directVideoUrl)->failed()) { Log::error("Direct video link expired or returned 404: {$directVideoUrl}"); return; } - } catch (\Exception $e) { - Log::error("Connection error to direct video URL: " . $e->getMessage()); - return; - } - - $duration = (float) shell_exec("ffprobe -v error -show_entries format=duration -of default=noprint_wrappers=1:nokey=1 " . escapeshellarg($directVideoUrl)); - if ($duration <= 0) return; - - $tempDir = storage_path('app/temp/f_' . $this->meetRecording->getKey() . '_' . Str::random(5)); - if (!file_exists($tempDir)) mkdir($tempDir, 0777, true); - - $outputPattern = $tempDir . '/f_%03d.jpg'; - $mainCommand = "ffmpeg -reconnect 1 -reconnect_at_eof 1 -reconnect_streamed 1 -i " . - escapeshellarg($directVideoUrl) . - " -vf \"select='not(mod(t,15))',setpts=N/FRAME_RATE/TB\" -vsync vfr -an -sn -t " . ($duration + 0.5) . - " " . escapeshellarg($outputPattern) . " > /dev/null 2>&1"; - exec($mainCommand); - - $lastFrameFile = $tempDir . '/f_last.jpg'; - $lastCommand = "ffmpeg -reconnect 1 -reconnect_at_eof 1 -reconnect_streamed 1 -sseof -1 -i " . - escapeshellarg($directVideoUrl) . " -update 1 -frames:v 1 -an -sn " . - escapeshellarg($lastFrameFile) . " > /dev/null 2>&1"; - exec($lastCommand); - $files = glob($tempDir . '/*.jpg'); - sort($files); - - $folder = "{$this->meetRecording->model_type}/{$this->meetRecording->model_id}/" . - $this->meetRecording->term->getTimestamp() . "/presentation"; - - foreach ($files as $index => $file) { - $name = basename($file); - - if ($name === 'f_last.jpg') { - $offset = (int)$duration; - } else { - preg_match('/f_(\d+)\.jpg/', $name, $m); - $idx = isset($m[1]) ? (int)$m[1] - 1 : 0; - $offset = $idx * 15; + $duration = (float) shell_exec("ffprobe -v error -show_entries format=duration -of default=noprint_wrappers=1:nokey=1 " . escapeshellarg($directVideoUrl)); + if ($duration <= 0) return; + + $tempDir = storage_path('app/temp/f_' . $this->meetRecording->getKey() . '_' . Str::random(5)); + if (!file_exists($tempDir)) mkdir($tempDir, 0777, true); + + $outputPattern = $tempDir . '/f_%03d.jpg'; + $mainCommand = "ffmpeg -reconnect 1 -reconnect_at_eof 1 -reconnect_streamed 1 -i " . + escapeshellarg($directVideoUrl) . + " -vf \"select='not(mod(t,15))',setpts=N/FRAME_RATE/TB\" -vsync vfr -an -sn -t " . ($duration + 0.5) . + " " . escapeshellarg($outputPattern) . " > /dev/null 2>&1"; + exec($mainCommand); + + $lastFrameFile = $tempDir . '/f_last.jpg'; + $lastCommand = "ffmpeg -reconnect 1 -reconnect_at_eof 1 -reconnect_streamed 1 -sseof -1 -i " . + escapeshellarg($directVideoUrl) . " -update 1 -frames:v 1 -an -sn " . + escapeshellarg($lastFrameFile) . " > /dev/null 2>&1"; + exec($lastCommand); + + $files = glob($tempDir . '/*.jpg'); + sort($files); + + $folder = "{$this->meetRecording->model_type}/{$this->meetRecording->model_id}/" . + $this->meetRecording->term->getTimestamp() . "/presentation"; + + foreach ($files as $index => $file) { + $name = basename($file); + + if ($name === 'f_last.jpg') { + $offset = (int)$duration; + } else { + preg_match('/f_(\d+)\.jpg/', $name, $m); + $idx = isset($m[1]) ? (int)$m[1] - 1 : 0; + $offset = $idx * 15; + } + + if ($name !== 'f_last.jpg' && $offset >= (int)$duration) { + unlink($file); + continue; + } + + $currentFrameTimestamp = $this->meetRecording->start_at->copy()->addSeconds($offset); + $fullS3Path = "{$folder}/" . $currentFrameTimestamp->getTimestamp() . ".jpg"; + + try { + DB::transaction(function () use ($file, $fullS3Path, $currentFrameTimestamp) { + Storage::put($fullS3Path, fopen($file, 'r+')); + + MeetRecordingScreen::query()->updateOrCreate( + ['file_path' => $fullS3Path], + [ + 'model_type' => $this->meetRecording->model_type, + 'model_id' => $this->meetRecording->model_id, + 'term' => $this->meetRecording->term, + 'file_timestamp' => $currentFrameTimestamp, + 'meet_recording_id' => $this->meetRecording->getKey(), + ] + ); + }); + } catch (\Exception $e) { + Log::error("S3 Error: " . $e->getMessage()); + } + + if (file_exists($file)) unlink($file); } - if ($name !== 'f_last.jpg' && $offset >= (int)$duration) { - unlink($file); - continue; - } + if (is_dir($tempDir)) rmdir($tempDir); - $currentFrameTimestamp = $this->meetRecording->start_at->copy()->addSeconds($offset); - $fullS3Path = "{$folder}/" . $currentFrameTimestamp->getTimestamp() . ".jpg"; - - try { - DB::transaction(function () use ($file, $fullS3Path, $currentFrameTimestamp) { - Storage::put($fullS3Path, fopen($file, 'r+')); - - MeetRecordingScreen::query()->updateOrCreate( - ['file_path' => $fullS3Path], - [ - 'model_type' => $this->meetRecording->model_type, - 'model_id' => $this->meetRecording->model_id, - 'term' => $this->meetRecording->term, - 'file_timestamp' => $currentFrameTimestamp, - 'meet_recording_id' => $this->meetRecording->getKey(), - ] - ); - }); - } catch (\Exception $e) { - Log::error("S3 Error: " . $e->getMessage()); - } - - if (file_exists($file)) unlink($file); + } catch (\Exception $e) { + Log::error("General Job Error: " . $e->getMessage()); + throw $e; + } finally { + $this->meetRecording->update(['processing_video' => false]); } + } - if (is_dir($tempDir)) rmdir($tempDir); + public function failed(\Throwable $exception) + { + $this->meetRecording->update(['processing_video' => false]); } } diff --git a/src/Models/MeetRecording.php b/src/Models/MeetRecording.php index f40e6c0..2ce7f22 100644 --- a/src/Models/MeetRecording.php +++ b/src/Models/MeetRecording.php @@ -66,6 +66,7 @@ class MeetRecording extends Model 'end_at', 'url', 'url_expires_at', + 'processing_video', ]; protected $casts = [ diff --git a/src/Services/RecommenderService.php b/src/Services/RecommenderService.php index d28dac9..062ab12 100644 --- a/src/Services/RecommenderService.php +++ b/src/Services/RecommenderService.php @@ -356,6 +356,7 @@ public function meetRecording(MeetRecordingDto $dto): MeetRecording $data = []; if ($dto->getUrlExpirationTimeMillis() !== null) { $data['url_expires_at'] = Carbon::now()->addMilliseconds($dto->getUrlExpirationTimeMillis()); + $data['processing_video'] = true; } $meetRecording->update(array_merge($dto->toArray(), $data)); diff --git a/tests/Api/MeetRecordingTest.php b/tests/Api/MeetRecordingTest.php index e744beb..f004b6c 100644 --- a/tests/Api/MeetRecordingTest.php +++ b/tests/Api/MeetRecordingTest.php @@ -6,6 +6,7 @@ use EscolaLms\Core\Tests\CreatesUsers; use EscolaLms\Recommender\Enum\MeetRecordingEnum; use EscolaLms\Recommender\EscolaLmsRecommenderServiceProvider; +use EscolaLms\Recommender\Jobs\ProcessingMeetingFramesJob; use EscolaLms\Recommender\Models\MeetRecording; use EscolaLms\Recommender\Models\TermAnalytic; use EscolaLms\Recommender\Tests\CreatesCourse; @@ -13,6 +14,7 @@ use Illuminate\Foundation\Testing\WithFaker; use Illuminate\Http\UploadedFile; use Illuminate\Support\Carbon; +use Illuminate\Support\Facades\Bus; use Illuminate\Support\Facades\Config; use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Storage; @@ -86,6 +88,10 @@ public function testCreateNewMeetRecording(): void public function testUpdateMeetRecording(): void { + Bus::fake([ + ProcessingMeetingFramesJob::class + ]); + Config::set(EscolaLmsRecommenderServiceProvider::CONFIG_KEY . '.frames_microservice_url', 'http://localhost-frames'); Http::fake(['http://localhost-frames/api/frames/satisfaction' => Http::response(null, 204)]); @@ -139,6 +145,8 @@ public function testUpdateMeetRecording(): void 'url' => 'http://test-recording.com', 'url_expires_at' => Carbon::now()->addMilliseconds(123456) ]); + + Bus::assertDispatched(ProcessingMeetingFramesJob::class); } public function testMeetRecordingScreen(): void