sls/app/Actions/Interview/MergeRecordingChunksAction.php
kushal.saha 121b4beaf8 feat(call recording): chuked recoding upload
- upload recording of call in chunks 
- recordings are merged on server side in jobs
- improve UI of the recording tab
2026-08-19 05:43:32 +00:00

202 lines
6.6 KiB
PHP

<?php
namespace App\Actions\Interview;
use App\Models\Interview;
use Illuminate\Support\Facades\Log;
use RuntimeException;
class MergeRecordingChunksAction
{
/**
* Execute the heavy stream merge of recording chunk parts into a single video file.
*
* @param Interview $interview
* @param string $sessionId
* @param string|null $tempDir
* @param string|null $folderName
* @param int|null $duration
* @return string|null The relative path to the merged recording file, or null on failure.
*/
public function execute(
Interview $interview,
string $sessionId,
?string $tempDir = null,
?string $folderName = null,
?int $duration = null
): ?string {
$safeSessionId = preg_replace('/[^a-zA-Z0-9_\-]/', '', $sessionId);
$tempBase = config('interview.recordings.temp_folder', 'app/temp_recordings');
$resolvedTempDir = $tempDir ?: storage_path(trim($tempBase, '/') . '/' . $safeSessionId);
if (!is_dir($resolvedTempDir)) {
Log::warning('[MergeRecordingChunksAction] Temporary chunk directory not found', [
'session_id' => $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<string>
*/
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<string> $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<string> $parts
* @param string $tempDir
* @return void
*/
private function cleanupTempParts(array $parts, string $tempDir): void
{
foreach ($parts as $partFile) {
@unlink($partFile);
}
@rmdir($tempDir);
}
}