- upload recording of call in chunks - recordings are merged on server side in jobs - improve UI of the recording tab
202 lines
6.6 KiB
PHP
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);
|
|
}
|
|
}
|