$sessionId, 'temp_dir' => $resolvedTempDir, ]); return null; } $parts = $this->findAndSortParts($resolvedTempDir); if (empty($parts)) { Log::warning('[MergeRecordingChunksAction] No chunk parts found to merge', [ 'session_id' => $sessionId, 'temp_dir' => $resolvedTempDir, ]); return null; } // Validate that part_000000.webm exists and starts with valid EBML container header $firstPart = $parts[0]; $headerCheck = @file_get_contents($firstPart, false, null, 0, 4); if ($headerCheck === false || !str_starts_with($headerCheck, "\x1a\x45\xdf\xa3")) { Log::error('[MergeRecordingChunksAction] First chunk missing EBML container header. Possible incomplete initial chunk upload.', [ 'session_id' => $sessionId, 'first_part' => $firstPart, 'header_hex' => $headerCheck ? bin2hex($headerCheck) : 'null', ]); return null; } $baseFolder = config('interview.recordings.base_folder', 'uploads/candidate_recordings'); $uniqueFolder = $folderName ?: ($interview->submission_unique_id ?: (string) $interview->id); $subDir = trim($baseFolder, '/') . '/' . $uniqueFolder; $destinationDir = public_path($subDir); $this->ensureDestinationDirectory($destinationDir); $filename = 'recording_' . time() . '.webm'; $finalPath = $destinationDir . DIRECTORY_SEPARATOR . $filename; $this->streamMergeFiles($parts, $finalPath); if (!file_exists($finalPath) || filesize($finalPath) === 0) { Log::error('[MergeRecordingChunksAction] Output recording file is empty or was not created', [ 'final_path' => $finalPath, 'session_id' => $sessionId, ]); return null; } $relativePath = '/' . $subDir . '/' . $filename; $url = asset($subDir . '/' . $filename); $this->updateInterviewRecordings($interview, $relativePath, $url, $filename, $duration); $this->cleanupTempParts($parts, $resolvedTempDir); Log::info('[MergeRecordingChunksAction] Recording assembled successfully', [ 'interview_id' => $interview->id, 'session_id' => $sessionId, 'total_parts' => count($parts), 'duration' => $duration, 'path' => $relativePath, ]); return $relativePath; } /** * Locate and sort chunk part files in ascending numerical order. * * @param string $tempDir * @return array */ private function findAndSortParts(string $tempDir): array { $parts = glob($tempDir . DIRECTORY_SEPARATOR . 'part_*.webm') ?: []; natsort($parts); return array_values($parts); } /** * Ensure that the destination directory exists and is writable. * * @param string $dir * @return void */ private function ensureDestinationDirectory(string $dir): void { if (!is_dir($dir)) { if (!mkdir($dir, 0755, true) && !is_dir($dir)) { throw new RuntimeException("Failed to create destination directory: {$dir}"); } } } /** * Stream merge multiple binary chunk files into a single destination file. * * @param array $parts * @param string $finalPath * @return void */ private function streamMergeFiles(array $parts, string $finalPath): void { $out = fopen($finalPath, 'wb'); if ($out === false) { throw new RuntimeException("Cannot open destination file for writing: {$finalPath}"); } try { foreach ($parts as $partFile) { $in = fopen($partFile, 'rb'); if ($in === false) { Log::warning("[MergeRecordingChunksAction] Could not read part: {$partFile}"); continue; } stream_copy_to_stream($in, $out); fclose($in); } } finally { fclose($out); } } /** * Update the interview model with the newly merged recording metadata. * * @param Interview $interview * @param string $relativePath * @param string $url * @param string $filename * @param int|null $duration * @return void */ private function updateInterviewRecordings(Interview $interview, string $relativePath, string $url, string $filename, ?int $duration = null): void { $recordings = $interview->recordings ?? []; if (!is_array($recordings)) { $recordings = $interview->recording_path ? [$interview->recording_path] : []; } $recordings[] = [ 'url' => $relativePath, 'full_url' => $url, 'filename' => $filename, 'created_at' => now()->toIso8601String(), 'duration' => $duration ?: 0, ]; $interview->update([ 'recording_path' => $relativePath, 'recordings' => $recordings, ]); } /** * Clean up temporary parts and remove the session directory. * * @param array $parts * @param string $tempDir * @return void */ private function cleanupTempParts(array $parts, string $tempDir): void { foreach ($parts as $partFile) { @unlink($partFile); } @rmdir($tempDir); } }