This commit is contained in:
subhajit 2026-07-27 12:06:14 +05:30
parent 178b4a9e4b
commit 541ac6cc41
5 changed files with 312 additions and 43 deletions

View File

@ -393,12 +393,125 @@ public function blockCandidate(Request $request, $id)
]);
}
/**
* Serve Storage File (Recordings & Public Assets) directly with Range Support for Video Streaming.
*/
public function serveStorageFile($path)
{
$sanitizedPath = ltrim(str_replace('..', '', $path), '/');
$candidatePaths = [
storage_path('app/public/' . $sanitizedPath),
storage_path('app/private/public/' . $sanitizedPath),
storage_path('app/private/' . $sanitizedPath),
storage_path('app/' . $sanitizedPath),
public_path('storage/' . $sanitizedPath),
public_path($sanitizedPath),
];
// Also check with alternate extensions (.webm / .mp4) if requested file extension differs
$baseNoExt = preg_replace('/\.(mp4|webm|m4v|mov)$/i', '', $sanitizedPath);
if ($baseNoExt !== $sanitizedPath) {
$candidatePaths[] = storage_path('app/public/' . $baseNoExt . '.webm');
$candidatePaths[] = storage_path('app/public/' . $baseNoExt . '.mp4');
$candidatePaths[] = storage_path('app/private/public/' . $baseNoExt . '.webm');
$candidatePaths[] = storage_path('app/private/public/' . $baseNoExt . '.mp4');
}
$filePath = null;
foreach ($candidatePaths as $cp) {
if (file_exists($cp) && is_file($cp)) {
$filePath = $cp;
break;
}
}
if (!$filePath) {
abort(404, 'Storage file not found.');
}
// Read first 12 bytes to accurately detect container format (WebM vs MP4 vs Image)
$handle = @fopen($filePath, 'rb');
$header = $handle ? fread($handle, 12) : '';
if ($handle) fclose($handle);
$extension = strtolower(pathinfo($filePath, PATHINFO_EXTENSION));
$isWebmHeader = str_starts_with($header, "\x1A\x45\xDF\xA3");
if ($isWebmHeader || $extension === 'webm') {
$mimeType = 'video/webm';
} elseif (str_contains($header, 'ftyp') || in_array($extension, ['mp4', 'm4v'])) {
$mimeType = 'video/mp4';
} elseif ($extension === 'png') {
$mimeType = 'image/png';
} elseif (in_array($extension, ['jpg', 'jpeg'])) {
$mimeType = 'image/jpeg';
} else {
$mimeType = @mime_content_type($filePath) ?: 'application/octet-stream';
}
return response()->file($filePath, [
'Content-Type' => $mimeType,
'Accept-Ranges' => 'bytes',
'Cache-Control' => 'public, max-age=86400',
]);
}
/**
* Get normalized recordings list sorted in descending order of creation timestamp (newest first).
*/
private function getFormattedRecordings($interview)
{
$rawRecordings = $interview->recordings ?? [];
if (!is_array($rawRecordings) || empty($rawRecordings)) {
if (!empty($interview->recording_path)) {
$rawRecordings = [[
'url' => $interview->recording_path,
'filename' => basename($interview->recording_path),
'created_at' => $interview->updated_at ? $interview->updated_at->toIso8601String() : now()->toIso8601String(),
]];
} else {
$rawRecordings = [];
}
}
$formatted = [];
foreach ($rawRecordings as $idx => $rec) {
if (is_string($rec)) {
$rec = [
'url' => $rec,
'filename' => basename($rec),
'created_at' => $interview->updated_at ? $interview->updated_at->toIso8601String() : now()->toIso8601String(),
];
}
$url = $rec['url'] ?? '';
$urlPath = parse_url($url, PHP_URL_PATH) ?? $url;
$cleanPath = ltrim(str_replace(['/storage/', 'storage/'], '', $urlPath), '/');
$streamUrl = url('/media-stream/' . $cleanPath);
$rec['original_index'] = $idx;
$rec['stream_url'] = $streamUrl;
$rec['download_url'] = route('interview.download-recording', $interview->id) . '?index=' . $idx;
$formatted[] = $rec;
}
// Sort descending by created_at timestamp (newest first)
usort($formatted, function ($a, $b) {
$timeA = isset($a['created_at']) ? strtotime($a['created_at']) : 0;
$timeB = isset($b['created_at']) ? strtotime($b['created_at']) : 0;
return $timeB <=> $timeA;
});
return $formatted;
}
/**
* Live Polling Endpoint for Candidate Warnings & HR Monitoring.
*/
public function getPollData($id)
{
$interview = Interview::with(['warnings.sender'])->findOrFail($id);
$formattedRecordings = $this->getFormattedRecordings($interview);
return response()->json([
'status' => $interview->status,
@ -412,7 +525,7 @@ public function getPollData($id)
'blocked_ip' => $interview->blocked_ip,
'proctor_logs' => $interview->proctor_logs ?? [],
'recording_path' => $interview->recording_path,
'recordings' => $interview->recordings ?? [],
'recordings' => $formattedRecordings,
'warnings' => $interview->warnings,
'is_expired' => $interview->isExpired(),
]);
@ -438,9 +551,14 @@ public function uploadRecording(Request $request, $id)
if ($request->hasFile('video')) {
$file = $request->file('video');
$filename = 'interview_' . $interview->submission_unique_id . '_' . time() . '.mp4';
$path = $file->storeAs('public/recordings', $filename);
$url = Storage::url($path);
$ext = strtolower($file->getClientOriginalExtension() ?: 'webm');
if (!in_array($ext, ['mp4', 'webm', 'm4v', 'mov'])) {
$ext = 'webm';
}
$filename = 'interview_' . $interview->submission_unique_id . '_' . time() . '.' . $ext;
$path = $file->storeAs('recordings', $filename, 'public');
$url = Storage::disk('public')->url($path);
$recordings = $interview->recordings ?? [];
if (!is_array($recordings)) {
@ -464,7 +582,7 @@ public function uploadRecording(Request $request, $id)
}
/**
* Download Candidate Video Recording as MP4 File.
* Download Candidate Video Recording as File.
*/
public function downloadRecording(Request $request, $id)
{
@ -486,11 +604,46 @@ public function downloadRecording(Request $request, $id)
return back()->with('error', 'No video recording found for this candidate profile.');
}
$relativePath = str_replace('/storage/', 'public/', $targetUrl);
if (Storage::exists($relativePath)) {
$downloadFilename = 'candidate_' . Str::slug($interview->candidate_name) . '_recording_' . ($index + 1) . '.mp4';
return Storage::download($relativePath, $downloadFilename, [
'Content-Type' => 'video/mp4',
$urlPath = parse_url($targetUrl, PHP_URL_PATH) ?? $targetUrl;
$sanitizedPath = ltrim(str_replace(['/storage/', 'storage/'], '', $urlPath), '/');
$candidatePaths = [
storage_path('app/public/' . $sanitizedPath),
storage_path('app/private/public/' . $sanitizedPath),
storage_path('app/private/' . $sanitizedPath),
storage_path('app/' . $sanitizedPath),
public_path('storage/' . $sanitizedPath),
public_path($sanitizedPath),
];
$baseNoExt = preg_replace('/\.(mp4|webm|m4v|mov)$/i', '', $sanitizedPath);
if ($baseNoExt !== $sanitizedPath) {
$candidatePaths[] = storage_path('app/public/' . $baseNoExt . '.webm');
$candidatePaths[] = storage_path('app/public/' . $baseNoExt . '.mp4');
$candidatePaths[] = storage_path('app/private/public/' . $baseNoExt . '.webm');
$candidatePaths[] = storage_path('app/private/public/' . $baseNoExt . '.mp4');
}
$filePath = null;
foreach ($candidatePaths as $cp) {
if (file_exists($cp) && is_file($cp)) {
$filePath = $cp;
break;
}
}
if ($filePath && file_exists($filePath)) {
$handle = @fopen($filePath, 'rb');
$header = $handle ? fread($handle, 12) : '';
if ($handle) fclose($handle);
$isWebm = str_starts_with($header, "\x1A\x45\xDF\xA3") || str_ends_with($filePath, '.webm');
$ext = $isWebm ? 'webm' : 'mp4';
$mimeType = $isWebm ? 'video/webm' : 'video/mp4';
$downloadFilename = 'candidate_' . Str::slug($interview->candidate_name) . '_recording_' . ($index + 1) . '.' . $ext;
return response()->download($filePath, $downloadFilename, [
'Content-Type' => $mimeType,
]);
}
@ -556,7 +709,7 @@ public function generateReport($id)
$codeScore += 10;
}
}
$codeScore = min(100, $codeScore);
$interview->formatted_recordings = $this->getFormattedRecordings($interview);
return view('interview.report', compact(
'interview',

View File

@ -621,8 +621,8 @@
<!-- Tab 4: Saved Candidate Profile Video Recordings & MP4 Downloads -->
<div id="tab-content-recordings" style="display: none;">
<div style="font-size: 0.75rem; color: #94a3b8; margin-bottom: 6px; font-weight: 600;">Saved Candidate Video Sessions (Downloadable in MP4 format):</div>
<div id="modal-recordings-container" style="background: #050811; border: 1px solid var(--card-border); border-radius: 10px; padding: 12px; min-height: 250px; max-height: 350px; overflow-y: auto;">
<div style="font-size: 0.75rem; color: #94a3b8; margin-bottom: 6px; font-weight: 600;">Saved Candidate Video Sessions (Sorted by Newest First):</div>
<div id="modal-recordings-container" style="background: #050811; border: 1px solid var(--card-border); border-radius: 10px; padding: 12px; min-height: 250px; max-height: 420px; overflow-y: auto;">
<div style="color: #94a3b8; font-size: 0.8rem; text-align: center; padding-top: 40px;">No video recordings saved for this candidate yet.</div>
</div>
</div>
@ -840,27 +840,50 @@ function pollReviewData() {
noDrawing.style.display = 'block';
}
// Render Candidate Saved Video Recordings in Profile Modal
// Render Candidate Saved Video Recordings in Profile Modal (Descending order by timestamp)
const recContainer = document.getElementById('modal-recordings-container');
if (recContainer) {
const recPath = data.recording_path;
const recList = data.recordings || [];
if (recPath || recList.length > 0) {
let recHtml = '';
let allRecs = recList.length > 0 ? recList : [{ url: recPath, created_at: new Date().toISOString() }];
allRecs.forEach((r, idx) => {
const videoUrl = typeof r === 'string' ? r : (r.url || recPath);
const dateStr = r.created_at ? new Date(r.created_at).toLocaleTimeString() : 'Session Recording';
const downloadUrl = `{{ route('interview.download-recording', ':id') }}`.replace(':id', currentInterviewId) + `?index=${idx}`;
let recList = data.recordings || [];
recHtml += `<div style="background: rgba(255,255,255,0.04); border: 1px solid var(--card-border); border-radius: 10px; padding: 12px; margin-bottom: 10px;">
if (recList.length === 0 && recPath) {
recList = [{ url: recPath, created_at: new Date().toISOString(), original_index: 0 }];
}
if (recList.length > 0) {
// Ensure descending order by created_at
recList.sort((a, b) => {
const timeA = a.created_at ? new Date(a.created_at).getTime() : 0;
const timeB = b.created_at ? new Date(b.created_at).getTime() : 0;
return timeB - timeA;
});
let recHtml = `<div style="font-size: 0.75rem; color: #38bdf8; font-weight: 700; margin-bottom: 10px; display: flex; justify-content: space-between; align-items: center;">
<span>📹 Stored Video Sessions (${recList.length} recording${recList.length > 1 ? 's' : ''})</span>
<span style="font-size: 0.65rem; color: #94a3b8;">📜 Scroll to view older recordings</span>
</div>`;
recList.forEach((r, displayIdx) => {
let videoUrl = typeof r === 'string' ? r : (r.stream_url || r.url || recPath);
if (videoUrl && videoUrl.includes('/storage/')) {
videoUrl = videoUrl.replace('/storage/', '/media-stream/');
}
const origIdx = (typeof r === 'object' && r.original_index !== undefined) ? r.original_index : displayIdx;
const dateObj = (r && r.created_at) ? new Date(r.created_at) : new Date();
const dateStr = dateObj.toLocaleDateString() + ' ' + dateObj.toLocaleTimeString();
const downloadUrl = (typeof r === 'object' && r.download_url) ? r.download_url : (`{{ route('interview.download-recording', ':id') }}`.replace(':id', currentInterviewId) + `?index=${origIdx}`);
recHtml += `<div style="background: rgba(255,255,255,0.04); border: 1px solid var(--card-border); border-radius: 10px; padding: 12px; margin-bottom: 12px; transition: all 0.2s ease;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px;">
<strong style="color: white; font-size: 0.8rem;">📹 Video Session Recording #${idx + 1} (${dateStr})</strong>
<a href="${downloadUrl}" target="_blank" download class="btn-nav" style="background: #10b981; color: white; border: none; font-size: 0.75rem; font-weight: 700; padding: 4px 12px; text-decoration: none;">
📥 Download MP4 Video
<div>
<strong style="color: white; font-size: 0.8rem;">📹 Session Recording #${recList.length - displayIdx}</strong>
<span style="font-size: 0.7rem; color: #94a3b8; margin-left: 8px;">📅 ${dateStr}</span>
</div>
<a href="${downloadUrl}" target="_blank" download class="btn-nav" style="background: #10b981; color: white; border: none; font-size: 0.75rem; font-weight: 700; padding: 4px 12px; text-decoration: none; border-radius: 6px; display: inline-flex; align-items: center; gap: 4px;">
📥 Download Video
</a>
</div>
<video src="${videoUrl}" controls style="width: 100%; max-height: 200px; border-radius: 8px; background: #000; outline: none;"></video>
<video src="${videoUrl}" controls preload="metadata" style="width: 100%; max-height: 240px; border-radius: 8px; background: #000; outline: none; border: 1px solid rgba(255,255,255,0.1);"></video>
</div>`;
});
recContainer.innerHTML = recHtml;
@ -1394,10 +1417,16 @@ function startCallRecording() {
adminRecordedChunks = [];
try {
const options = MediaRecorder.isTypeSupported('video/webm;codecs=vp9')
? { mimeType: 'video/webm;codecs=vp9' }
: (MediaRecorder.isTypeSupported('video/webm') ? { mimeType: 'video/webm' } : {});
let mime = 'video/webm';
if (MediaRecorder.isTypeSupported('video/mp4')) {
mime = 'video/mp4';
} else if (MediaRecorder.isTypeSupported('video/webm;codecs=vp9')) {
mime = 'video/webm;codecs=vp9';
} else if (MediaRecorder.isTypeSupported('video/webm')) {
mime = 'video/webm';
}
const options = { mimeType: mime };
adminMediaRecorder = new MediaRecorder(streamToRecord, options);
adminMediaRecorder.ondataavailable = function(e) {
@ -1408,8 +1437,13 @@ function startCallRecording() {
adminMediaRecorder.onstop = function() {
if (adminRecordedChunks.length === 0) return;
const blob = new Blob(adminRecordedChunks, { type: 'video/webm' });
uploadRecordedBlob(blob);
const recMime = adminMediaRecorder.mimeType || mime;
const isMp4 = recMime.includes('mp4');
const ext = isMp4 ? 'mp4' : 'webm';
const blobType = isMp4 ? 'video/mp4' : 'video/webm';
const blob = new Blob(adminRecordedChunks, { type: blobType });
uploadRecordedBlob(blob, ext);
};
adminMediaRecorder.start(1000);
@ -1442,11 +1476,11 @@ function stopCallRecording() {
document.getElementById('btn-stop-recording').style.display = 'none';
}
function uploadRecordedBlob(blob) {
function uploadRecordedBlob(blob, ext = 'webm') {
if (!currentInterviewId) return;
const formData = new FormData();
formData.append('video', blob, `interview_${currentInterviewId}_${Date.now()}.mp4`);
formData.append('video', blob, `interview_${currentInterviewId}_${Date.now()}.${ext}`);
formData.append('_token', '{{ csrf_token() }}');
fetch(`{{ route('interview.upload-recording', ':id') }}`.replace(':id', currentInterviewId), {

View File

@ -402,16 +402,25 @@
@endif
@if(is_array($interview->formatted_recordings) && count($interview->formatted_recordings) > 0)
<div style="font-size: 0.75rem; font-weight: 700; color: #6366f1; margin-bottom: 8px;">📹 STORED CALL SESSION RECORDINGS ({{ count($interview->formatted_recordings) }} RECORDINGS):</div>
@foreach($interview->formatted_recordings as $idx => $rec)
<div style="margin-bottom: 12px; background: white; border: 1px solid var(--border); border-radius: 8px; padding: 10px;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px;">
<span style="font-size: 0.75rem; font-weight: 700; color: var(--primary);">Recording #{{ $idx + 1 }} ({{ $rec['created_at'] }})</span>
<a href="{{ $rec['download_url'] }}" download style="font-size: 0.7rem; color: #6366f1; font-weight: 700; text-decoration: none;">📥 Download MP4 Video</a>
</div>
<video src="{{ $rec['stream_url'] }}" controls style="width: 100%; max-height: 220px; border-radius: 6px; background: #000; outline: none;"></video>
<div style="font-size: 0.75rem; font-weight: 700; color: #6366f1; margin-bottom: 8px; display: flex; justify-content: space-between; align-items: center;">
<span>📹 STORED CALL SESSION RECORDINGS ({{ count($interview->formatted_recordings) }} RECORDINGS - NEWEST FIRST):</span>
<span style="font-size: 0.65rem; color: #94a3b8;">📜 Scroll to view all</span>
</div>
<div style="max-height: 480px; overflow-y: auto; padding-right: 4px; border: 1px solid var(--border); border-radius: 8px; padding: 10px; background: #fafafa; margin-bottom: 12px;">
@foreach($interview->formatted_recordings as $displayIdx => $rec)
<div style="margin-bottom: 12px; background: white; border: 1px solid var(--border); border-radius: 8px; padding: 10px;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px;">
<span style="font-size: 0.75rem; font-weight: 700; color: var(--primary);">Recording #{{ count($interview->formatted_recordings) - $displayIdx }} ({{ $rec['created_at'] }})</span>
<a href="{{ $rec['download_url'] }}" download style="font-size: 0.7rem; color: #6366f1; font-weight: 700; text-decoration: none; padding: 3px 8px; background: rgba(99,102,241,0.1); border-radius: 4px;">📥 Download MP4 Video</a>
</div>
<video controls preload="metadata" style="width: 100%; max-height: 220px; border-radius: 6px; background: #000; outline: none;">
<source src="{{ $rec['stream_url'] }}" type="video/mp4">
<source src="{{ $rec['stream_url'] }}" type="video/webm">
Your browser does not support video playback.
</video>
</div>
@endforeach
</div>
@endforeach
@endif
</div>
@endif

View File

@ -38,6 +38,10 @@
Route::post('/candidate/send-message/{id}', [InterviewController::class, 'candidateSendMessage'])->name('interview.candidate.send-message');
Route::get('/candidate/poll/{id}', [InterviewController::class, 'getPollData'])->name('interview.candidate.poll');
// Public Storage & Media Stream Delivery (Recordings & Video Streaming)
Route::get('/media-stream/{path}', [InterviewController::class, 'serveStorageFile'])->where('path', '.*')->name('media.stream');
Route::get('/storage/{path}', [InterviewController::class, 'serveStorageFile'])->where('path', '.*')->name('storage.file');
// User Dashboard Portal (requires authenticated user)
Route::middleware(['auth', 'role:user', 'verify-ms-session'])->group(function () {
Route::get('/dashboard', [DashboardController::class, 'index'])->name('dashboard');

View File

@ -0,0 +1,69 @@
<?php
namespace Tests\Feature;
use App\Models\Interview;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\File;
use Tests\TestCase;
class RecordingStreamTest extends TestCase
{
use RefreshDatabase;
public function test_media_stream_serves_recorded_video_with_range_and_headers()
{
$dir = storage_path('app/public/recordings');
if (!File::exists($dir)) {
File::makeDirectory($dir, 0755, true);
}
$testFile = $dir . '/test_stream_video.webm';
$fakeVideoContent = "\x1A\x45\xDF\xA3" . str_repeat('0', 100);
File::put($testFile, $fakeVideoContent);
try {
$response = $this->get('/media-stream/recordings/test_stream_video.webm');
$response->assertStatus(200);
$response->assertHeader('Content-Type', 'video/webm');
$response->assertHeader('Accept-Ranges', 'bytes');
} finally {
if (File::exists($testFile)) {
File::delete($testFile);
}
}
}
public function test_poll_returns_media_stream_urls()
{
$user = User::factory()->create();
$interview = Interview::create([
'candidate_name' => 'John Streamer',
'candidate_email' => 'stream@example.com',
'candidate_phone' => '1234567890',
'submission_unique_id' => 'stream_test_123',
'temp_password' => 'secret123',
'language' => 'python',
'status' => 'pending',
'expires_at' => now()->addDays(7),
'recording_path' => '/storage/recordings/stream_test_123.webm',
'recordings' => [
[
'url' => '/storage/recordings/stream_test_123.webm',
'filename' => 'stream_test_123.webm',
'created_at' => now()->toIso8601String(),
]
],
]);
$response = $this->actingAs($user)->get(route('interview.poll', $interview->id));
$response->assertStatus(200);
$data = $response->json();
$this->assertNotEmpty($data['recordings']);
$this->assertStringContainsString('/media-stream/recordings/stream_test_123.webm', $data['recordings'][0]['stream_url']);
}
}