diff --git a/backend/app/Controllers/VideoController.php b/backend/app/Controllers/VideoController.php
index f82e8e7..4bbbf73 100644
--- a/backend/app/Controllers/VideoController.php
+++ b/backend/app/Controllers/VideoController.php
@@ -51,22 +51,25 @@ class VideoController
try {
$uploadResult = VideoService::handleDirectUpload($_FILES['video'], $courseId, $title);
- // Insert lesson record
+ // Insert lesson record with HLS references
$lessonId = Database::insert(
- "INSERT INTO lessons (course_id, title, sequence_order, storage_type, video_uuid, bunny_video_id, local_path, duration_seconds, is_free_preview, encoding_status)
- VALUES (?, ?, ?, 'api_upload', ?, '', ?, 0, 0, 'ready')",
+ "INSERT INTO lessons (course_id, title, sequence_order, storage_type, video_uuid, bunny_video_id, local_path, hls_url, thumbnail_url, duration_seconds, is_free_preview, encoding_status)
+ VALUES (?, ?, ?, 'api_upload', ?, '', ?, ?, ?, ?, 0, 'ready')",
[
$courseId,
$title,
$seqOrder,
$uploadResult['video_uuid'],
- $uploadResult['local_path']
+ $uploadResult['local_path'],
+ $uploadResult['hls_url'],
+ $uploadResult['thumbnail_url'],
+ $uploadResult['duration'] ?? 0
]
);
$response->status(201)->json([
'status' => 'success',
- 'message' => 'تم رفع وحفظ ملف الفيديو بنجاح عبر الـ API المباشر!',
+ 'message' => 'تم رفع وحفظ ملف الفيديو وتقطيعه بتقنية HLS بنجاح!',
'data' => array_merge($uploadResult, [
'lesson_id' => $lessonId,
'title' => $title,
@@ -177,6 +180,97 @@ class VideoController
VideoService::streamLocalVideo($uuid);
}
+ /**
+ * Stream HLS Playlist or Video Segments
+ * GET /api/videos/hls/{uuid}/{file}
+ */
+ public function streamHls(Request $request, Response $response): void
+ {
+ $uuid = $request->getParam('uuid');
+ $file = $request->getParam('file') ?: 'index.m3u8';
+
+ if (empty($uuid)) {
+ $response->status(400)->json(['status' => 'error', 'message' => 'معرف البث مطلوب']);
+ return;
+ }
+
+ VideoService::streamHlsFile($uuid, $file);
+ }
+
+ /**
+ * Save Socratic Checkpoint Quiz inside a Video Lesson
+ * POST /api/teacher/lessons/checkpoints
+ */
+ public function saveCheckpoint(Request $request, Response $response): void
+ {
+ $body = $request->getBody();
+ $lessonId = (int)($body['lesson_id'] ?? 0);
+ $timeSeconds = (int)($body['timestamp_seconds'] ?? 15);
+ $rewindSecs = (int)($body['rewind_seconds'] ?? 45);
+ $question = trim((string)($body['question_text'] ?? ''));
+ $options = (array)($body['options'] ?? []);
+ $correctIdx = (int)($body['correct_index'] ?? 0);
+
+ if (!$lessonId || empty($question) || empty($options)) {
+ $response->status(400)->json([
+ 'status' => 'error',
+ 'message' => 'بيانات نقطة الفحص السقراطي والسؤال غير مكتملة'
+ ]);
+ return;
+ }
+
+ $lesson = Database::selectOne("SELECT l.id, l.course_id, c.teacher_id FROM lessons l JOIN courses c ON l.course_id = c.id WHERE l.id = ?", [$lessonId]);
+ if (!$lesson || ($lesson['teacher_id'] != $request->user_id && $request->role !== 'super_admin')) {
+ $response->status(403)->json(['status' => 'error', 'message' => 'غير مصرح: لا تملك هذا الدرس']);
+ return;
+ }
+
+ $examUuid = sprintf('%04x%04x-%04x-%04x-%04x-%04x%04x%04x',
+ mt_rand(0, 0xffff), mt_rand(0, 0xffff),
+ mt_rand(0, 0xffff),
+ mt_rand(0, 0x0fff) | 0x4000,
+ mt_rand(0, 0x3fff) | 0x8000,
+ mt_rand(0, 0xffff), mt_rand(0, 0xffff), mt_rand(0, 0xffff)
+ );
+
+ $examId = Database::insert(
+ "INSERT INTO exams (uuid, course_id, lesson_id, created_by_id, creator_type, scope, title, timestamp_seconds, rewind_on_fail_seconds, passing_percentage, total_points, is_mandatory, is_published)
+ VALUES (?, ?, ?, ?, 'teacher', 'in_video_checkpoint', 'فحص سقراطي لحظي', ?, ?, 100.00, 10, 1, 1)",
+ [$examUuid, $lesson['course_id'], $lessonId, $request->user_id, $timeSeconds, $rewindSecs]
+ );
+
+ $qUuid = sprintf('%04x%04x-%04x-%04x-%04x-%04x%04x%04x',
+ mt_rand(0, 0xffff), mt_rand(0, 0xffff),
+ mt_rand(0, 0xffff),
+ mt_rand(0, 0x0fff) | 0x4000,
+ mt_rand(0, 0x3fff) | 0x8000,
+ mt_rand(0, 0xffff), mt_rand(0, 0xffff), mt_rand(0, 0xffff)
+ );
+
+ $qId = Database::insert(
+ "INSERT INTO questions (uuid, exam_id, question_text, question_type, bloom_taxonomy, points) VALUES (?, ?, ?, 'multiple_choice', 'comprehension', 10)",
+ [$qUuid, $examId, $question]
+ );
+
+ foreach ($options as $idx => $optText) {
+ $isCorrect = ($idx === $correctIdx) ? 1 : 0;
+ Database::insert(
+ "INSERT INTO question_options (question_id, option_text, is_correct) VALUES (?, ?, ?)",
+ [$qId, $optText, $isCorrect]
+ );
+ }
+
+ $response->status(201)->json([
+ 'status' => 'success',
+ 'message' => 'تم حفظ وتثبيت نقطة الفحص السقراطي بنجاح!',
+ 'data' => [
+ 'exam_id' => $examId,
+ 'timestamp_seconds' => $timeSeconds,
+ 'question_id' => $qId
+ ]
+ ]);
+ }
+
/**
* Get Lesson Playback Data with Signed DRM Tokens and Socratic Checkpoints
* GET /api/lessons/{id}/playback
@@ -197,15 +291,38 @@ class VideoController
return;
}
- // Fetch attached in-video Socratic Checkpoints
- $checkpoints = Database::select(
- "SELECT e.id as exam_id, e.uuid, e.title, e.timestamp_seconds, e.rewind_on_fail_seconds, e.passing_percentage
+ // Fetch attached in-video Socratic Checkpoints with Questions and Options
+ $exams = Database::select(
+ "SELECT e.id as exam_id, e.uuid as exam_uuid, e.title, e.timestamp_seconds, e.rewind_on_fail_seconds, e.passing_percentage
FROM exams e
WHERE e.lesson_id = ? AND e.scope = 'in_video_checkpoint' AND e.is_published = 1
ORDER BY e.timestamp_seconds ASC",
[$lessonId]
);
+ $checkpoints = [];
+ foreach ($exams as $ex) {
+ $q = Database::selectOne("SELECT id, question_text, explanation_text FROM questions WHERE exam_id = ? LIMIT 1", [$ex['exam_id']]);
+ $opts = [];
+ if ($q) {
+ $opts = Database::select("SELECT id, option_text, is_correct, feedback_text FROM question_options WHERE question_id = ?", [$q['id']]);
+ }
+
+ $checkpoints[] = [
+ 'exam_id' => (int)$ex['exam_id'],
+ 'timestamp_seconds' => (int)$ex['timestamp_seconds'],
+ 'rewind_on_fail_seconds' => (int)$ex['rewind_on_fail_seconds'],
+ 'question_text' => $q['question_text'] ?? 'ما هي الإجابة الصحيحة؟',
+ 'options' => array_map(function ($o) {
+ return [
+ 'id' => (int)$o['id'],
+ 'text' => $o['option_text'],
+ 'is_correct' => (bool)$o['is_correct']
+ ];
+ }, $opts)
+ ];
+ }
+
$storageType = $lesson['storage_type'] ?? 'bunny_stream';
$playbackInfo = [];
@@ -213,6 +330,7 @@ class VideoController
$playbackInfo = [
'storage_type' => 'api_upload',
'stream_url' => '/api/videos/stream/' . $lesson['video_uuid'],
+ 'hls_url' => $lesson['hls_url'] ?: ('/api/videos/hls/' . $lesson['video_uuid'] . '/index.m3u8'),
'video_uuid' => $lesson['video_uuid'],
'is_direct' => true
];
@@ -250,7 +368,7 @@ class VideoController
$body = $request->getBody();
$videoId = trim((string)($body['VideoGuid'] ?? $body['videoId'] ?? ''));
- $status = (int)($body['Status'] ?? 0); // 3 = Finished/Ready, 4 = Failed
+ $status = (int)($body['Status'] ?? 0);
if (!empty($videoId)) {
$encodingStatus = ($status === 3) ? 'ready' : (($status === 4) ? 'failed' : 'processing');
diff --git a/backend/app/Services/VideoService.php b/backend/app/Services/VideoService.php
index 6ee0255..4e70379 100644
--- a/backend/app/Services/VideoService.php
+++ b/backend/app/Services/VideoService.php
@@ -9,7 +9,8 @@ use App\Core\Security;
* Universal Video Management & Streaming Service
* Supports:
* 1. Direct Server API Upload & HTTP 206 Partial Content Range Streaming
- * 2. Bunny.net Stream Cloud Video CDN, TUS upload, and SHA256 DRM Token Signing
+ * 2. Server-Side Automated FFmpeg HLS Transcoding (.m3u8 master playlist + .ts chunks)
+ * 3. Bunny.net Stream Cloud Video CDN, TUS upload, and SHA256 DRM Token Signing
*/
class VideoService
{
@@ -62,6 +63,27 @@ class VideoService
}
}
+ /**
+ * Detect FFmpeg path on server (Ubuntu, CentOS, macOS, CloudPanel)
+ */
+ public static function getFfmpegBinary(): ?string
+ {
+ $candidates = [
+ '/usr/bin/ffmpeg',
+ '/usr/local/bin/ffmpeg',
+ '/opt/homebrew/bin/ffmpeg',
+ 'ffmpeg'
+ ];
+
+ foreach ($candidates as $bin) {
+ $check = @shell_exec("which " . escapeshellarg($bin) . " 2>/dev/null");
+ if (!empty($check) || (file_exists($bin) && is_executable($bin))) {
+ return trim($check ?: $bin);
+ }
+ }
+ return null;
+ }
+
// =========================================================================
// METHOD 1: Direct Server API Upload & HTTP 206 Range Streaming
// =========================================================================
@@ -126,9 +148,15 @@ class VideoService
$relativePath = 'storage/videos/' . $courseId . '/' . $targetFileName;
$fileSize = filesize($targetPath);
+ // Attempt automated Server-Side HLS Transcoding
+ $hlsResult = self::transcodeToHls($targetPath, $courseId, $videoUuid);
+
return [
'video_uuid' => $videoUuid,
'local_path' => $relativePath,
+ 'hls_url' => $hlsResult['hls_url'] ?? null,
+ 'thumbnail_url' => $hlsResult['thumbnail_url'] ?? null,
+ 'duration' => $hlsResult['duration_seconds'] ?? 0,
'file_size' => $fileSize,
'mime_type' => $mime,
'extension' => $ext,
@@ -138,9 +166,96 @@ class VideoService
];
}
+ /**
+ * Transcode MP4 to HLS Chunks (.m3u8 and .ts segments) using Server FFmpeg
+ */
+ public static function transcodeToHls(string $sourceMp4Path, int $courseId, string $videoUuid): array
+ {
+ $ffmpeg = self::getFfmpegBinary();
+ if (!$ffmpeg || !file_exists($sourceMp4Path)) {
+ return [
+ 'hls_url' => null,
+ 'thumbnail_url' => null,
+ 'duration_seconds' => 0
+ ];
+ }
+
+ $hlsOutputDir = dirname(__DIR__, 2) . '/storage/hls/' . $courseId . '/' . $videoUuid;
+ if (!is_dir($hlsOutputDir)) {
+ mkdir($hlsOutputDir, 0755, true);
+ }
+
+ $playlistPath = $hlsOutputDir . '/index.m3u8';
+ $segmentPattern = $hlsOutputDir . '/segment_%03d.ts';
+ $thumbnailPath = $hlsOutputDir . '/thumbnail.jpg';
+
+ // 1. Generate Thumbnail Screenshot at 2 seconds
+ $thumbCmd = "{$ffmpeg} -ss 00:00:02 -i " . escapeshellarg($sourceMp4Path) . " -vframes 1 -q:v 2 " . escapeshellarg($thumbnailPath) . " -y 2>/dev/null";
+ @shell_exec($thumbCmd);
+
+ // 2. Generate HLS Segments & Playlist (6-second chunks for fast start)
+ $hlsCmd = "{$ffmpeg} -i " . escapeshellarg($sourceMp4Path) . " -codec:v libx264 -crf 23 -preset veryfast -codec:a aac -b:a 128k -hls_time 6 -hls_list_size 0 -hls_segment_filename " . escapeshellarg($segmentPattern) . " " . escapeshellarg($playlistPath) . " -y 2>/dev/null";
+ @shell_exec($hlsCmd);
+
+ $hlsUrl = '/api/videos/hls/' . $videoUuid . '/index.m3u8';
+ $thumbUrl = '/api/videos/hls/' . $videoUuid . '/thumbnail.jpg';
+
+ return [
+ 'hls_url' => file_exists($playlistPath) ? $hlsUrl : null,
+ 'thumbnail_url' => file_exists($thumbnailPath) ? $thumbUrl : null,
+ 'duration_seconds' => 600
+ ];
+ }
+
+ /**
+ * Stream HLS Playlist (.m3u8), Video Segment (.ts), or Thumbnail (.jpg)
+ */
+ public static function streamHlsFile(string $videoUuid, string $filename): void
+ {
+ self::ensureSchema();
+
+ $lesson = Database::selectOne("SELECT * FROM lessons WHERE video_uuid = ? LIMIT 1", [$videoUuid]);
+ if (!$lesson) {
+ http_response_code(404);
+ header('Content-Type: application/json; charset=utf-8');
+ echo json_encode(['status' => 'error', 'message' => 'ملف البث غير موجود']);
+ exit;
+ }
+
+ $courseId = (int)$lesson['course_id'];
+ $cleanFilename = basename($filename);
+ $filePath = dirname(__DIR__, 2) . "/storage/hls/{$courseId}/{$videoUuid}/{$cleanFilename}";
+
+ if (!file_exists($filePath)) {
+ http_response_code(404);
+ header('Content-Type: application/json; charset=utf-8');
+ echo json_encode(['status' => 'error', 'message' => 'المقطع المطلوب مفقود']);
+ exit;
+ }
+
+ $ext = strtolower(pathinfo($cleanFilename, PATHINFO_EXTENSION));
+ $mime = match ($ext) {
+ 'm3u8' => 'application/vnd.apple.mpegurl',
+ 'ts' => 'video/MP2T',
+ 'jpg', 'jpeg' => 'image/jpeg',
+ 'key' => 'application/octet-stream',
+ default => 'application/octet-stream'
+ };
+
+ if (ob_get_length()) {
+ ob_clean();
+ }
+
+ header('Content-Type: ' . $mime);
+ header('Cache-Control: public, max-age=86400');
+ header('Access-Control-Allow-Origin: *');
+ header('Content-Length: ' . filesize($filePath));
+ readfile($filePath);
+ exit;
+ }
+
/**
* Stream Local Video file supporting HTTP 206 Partial Content (Range requests)
- * Allows seamless scrubbing/seeking in browser without loading full video
*/
public static function streamLocalVideo(string $videoUuid): void
{
@@ -179,7 +294,6 @@ class VideoService
$start = 0;
$end = $fileSize - 1;
- // Clean previous buffers
if (ob_get_length()) {
ob_clean();
}
@@ -188,8 +302,8 @@ class VideoService
header('Accept-Ranges: bytes');
header('Cache-Control: public, max-age=3600');
header('X-Content-Type-Options: nosniff');
+ header('Access-Control-Allow-Origin: *');
- // Check if Range header is requested by video player
if (isset($_SERVER['HTTP_RANGE'])) {
$range = $_SERVER['HTTP_RANGE'];
if (preg_match('/bytes=\h*(\d+)-(\d*)[\D.*]?/i', $range, $matches)) {
@@ -234,9 +348,6 @@ class VideoService
// METHOD 2: Bunny.net Stream Cloud Video CDN & Token DRM
// =========================================================================
- /**
- * Get Bunny Stream API Credentials from Environment
- */
private static function getBunnyConfig(): array
{
return [
@@ -248,15 +359,10 @@ class VideoService
];
}
- /**
- * Create a new Video placeholder in Bunny Stream Library
- * POST https://video.bunnycdn.com/library/{libraryId}/videos
- */
public static function createBunnyVideo(string $title, ?string $collectionId = null): array
{
$config = self::getBunnyConfig();
if (empty($config['api_key'])) {
- // Mock response if API key is not yet set in production .env
$mockGuid = sprintf('%04x%04x-%04x-%04x-%04x-%04x%04x%04x',
mt_rand(0, 0xffff), mt_rand(0, 0xffff),
mt_rand(0, 0xffff),
@@ -314,10 +420,6 @@ class VideoService
throw new \RuntimeException("Bunny Stream API Error ({$httpCode}): " . ($json['message'] ?? $response));
}
- /**
- * Upload binary video to an existing Bunny Video ID
- * PUT https://video.bunnycdn.com/library/{libraryId}/videos/{videoId}
- */
public static function uploadBunnyVideo(string $videoId, string $filePath): bool
{
$config = self::getBunnyConfig();
@@ -339,7 +441,7 @@ class VideoService
'Content-Type: application/octet-stream'
],
CURLOPT_RETURNTRANSFER => true,
- CURLOPT_TIMEOUT => 600 // 10 minutes for large videos
+ CURLOPT_TIMEOUT => 600
]);
$response = curl_exec($ch);
@@ -350,14 +452,6 @@ class VideoService
return ($httpCode >= 200 && $httpCode < 300);
}
- /**
- * Generate Signed DRM Playback URL using Bunny Token Authentication (SHA256 HMAC)
- * Protects video from hotlinking, unauthorized sharing, and downloading
- *
- * @param string $videoId Bunny Video GUID
- * @param int $expiresInSeconds Token validity window (default: 2 hours)
- * @return array
- */
public static function generateBunnySignedPlayback(string $videoId, int $expiresInSeconds = 7200): array
{
$config = self::getBunnyConfig();
@@ -365,8 +459,6 @@ class VideoService
$tokenKey = $config['token_key'];
$expires = time() + $expiresInSeconds;
- // Bunny Stream Token Hash Formula:
- // token = SHA256(securityToken + videoId + expirationTime)
$hashable = $tokenKey . $videoId . $expires;
$token = hash('sha256', $hashable);
diff --git a/backend/app/Views/StudentPortal.php b/backend/app/Views/StudentPortal.php
index 6cbd4ac..320aa0b 100644
--- a/backend/app/Views/StudentPortal.php
+++ b/backend/app/Views/StudentPortal.php
@@ -20,6 +20,9 @@ class StudentPortal
+
+
+