getBody()['course_id'] ?? $_POST['course_id'] ?? 0); $title = trim((string)($request->getBody()['title'] ?? $_POST['title'] ?? '')); $seqOrder = (int)($request->getBody()['sequence_order'] ?? $_POST['sequence_order'] ?? 1); if (!$courseId || empty($title)) { $response->status(400)->json([ 'status' => 'error', 'message' => 'معرف الدورة وعنوان الدرس مطلوبان' ]); return; } // Verify Course Ownership $course = Database::selectOne("SELECT id, teacher_id FROM courses WHERE id = ? LIMIT 1", [$courseId]); if (!$course || ($course['teacher_id'] != $request->user_id && $request->role !== 'super_admin')) { $response->status(403)->json([ 'status' => 'error', 'message' => 'غير مصرح: لا تملك صلاحية التعديل على هذه الدورة' ]); return; } if (empty($_FILES['video'])) { $response->status(400)->json([ 'status' => 'error', 'message' => 'يرجى إرفاق ملف الفيديو في الطلب (key: video)' ]); return; } try { $uploadResult = VideoService::handleDirectUpload($_FILES['video'], $courseId, $title); // 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, 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['hls_url'], $uploadResult['thumbnail_url'], $uploadResult['duration'] ?? 0 ] ); $response->status(201)->json([ 'status' => 'success', 'message' => 'تم رفع وحفظ ملف الفيديو وتقطيعه بتقنية HLS بنجاح!', 'data' => array_merge($uploadResult, [ 'lesson_id' => $lessonId, 'title' => $title, 'course_id' => $courseId ]) ]); } catch (\Throwable $e) { $response->status(500)->json([ 'status' => 'error', 'message' => $e->getMessage() ]); } } /** * Create video entity on Bunny Stream * POST /api/teacher/videos/bunny-create */ public function createBunnyVideo(Request $request, Response $response): void { VideoService::ensureSchema(); $body = $request->getBody(); $title = trim((string)($body['title'] ?? 'درس جديد')); $courseId = (int)($body['course_id'] ?? 0); if (!$courseId) { $response->status(400)->json(['status' => 'error', 'message' => 'معرف الدورة مطلوب']); return; } try { $result = VideoService::createBunnyVideo($title); $response->status(201)->json([ 'status' => 'success', 'message' => 'تم إنشاء الفيديو في Bunny Stream بنجاح', 'data' => $result ]); } catch (\Throwable $e) { $response->status(500)->json([ 'status' => 'error', 'message' => $e->getMessage() ]); } } /** * Link an existing or newly created Bunny Video ID to a Course Lesson * POST /api/teacher/videos/bunny-link */ public function linkBunnyLesson(Request $request, Response $response): void { VideoService::ensureSchema(); $body = $request->getBody(); $courseId = (int)($body['course_id'] ?? 0); $title = trim((string)($body['title'] ?? '')); $bunnyVideoId = trim((string)($body['bunny_video_id'] ?? '')); $duration = (int)($body['duration_seconds'] ?? 0); $sequenceOrder = (int)($body['sequence_order'] ?? 1); if (!$courseId || empty($title) || empty($bunnyVideoId)) { $response->status(400)->json([ 'status' => 'error', 'message' => 'معرف الدورة، عنوان الدرس، ومعرف فيديو Bunny Stream مطلوبين' ]); return; } $course = Database::selectOne("SELECT id, teacher_id FROM courses WHERE id = ? LIMIT 1", [$courseId]); if (!$course || ($course['teacher_id'] != $request->user_id && $request->role !== 'super_admin')) { $response->status(403)->json([ 'status' => 'error', 'message' => 'غير مصرح: لا تملك هذه الدورة' ]); return; } $lessonId = Database::insert( "INSERT INTO lessons (course_id, title, sequence_order, storage_type, bunny_video_id, duration_seconds, is_free_preview, encoding_status) VALUES (?, ?, ?, 'bunny_stream', ?, ?, 0, 'ready')", [$courseId, $title, $sequenceOrder, $bunnyVideoId, $duration] ); $response->status(201)->json([ 'status' => 'success', 'message' => 'تم ربط درس Bunny Stream بنجاح!', 'data' => [ 'lesson_id' => $lessonId, 'bunny_video_id' => $bunnyVideoId, 'storage_type' => 'bunny_stream' ] ]); } /** * Stream Local Video via HTTP 206 Range Streaming * GET /api/videos/stream/{uuid} */ public function streamLocalVideo(Request $request, Response $response): void { $uuid = $request->getParam('uuid'); if (empty($uuid)) { $response->status(400)->json(['status' => 'error', 'message' => 'معرف الفيديو مطلوب']); return; } 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 */ public function getPlaybackData(Request $request, Response $response): void { VideoService::ensureSchema(); $lessonId = (int)$request->getParam('id'); if (!$lessonId) { $response->status(400)->json(['status' => 'error', 'message' => 'معرف الدرس مطلوب']); return; } $lesson = Database::selectOne("SELECT * FROM lessons WHERE id = ? LIMIT 1", [$lessonId]); if (!$lesson) { $response->status(404)->json(['status' => 'error', 'message' => 'الدرس غير موجود']); return; } // 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 = []; if ($storageType === 'api_upload') { $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 ]; } else { // Bunny Stream Signed Playback $bunnyId = $lesson['bunny_video_id'] ?: 'mock-bunny-guid-2026'; $signedData = VideoService::generateBunnySignedPlayback($bunnyId, 10800); // 3-hour token $playbackInfo = array_merge(['storage_type' => 'bunny_stream'], $signedData); } $response->json([ 'status' => 'success', 'data' => [ 'lesson' => [ 'id' => (int)$lesson['id'], 'course_id' => (int)$lesson['course_id'], 'title' => $lesson['title'], 'duration_seconds' => (int)$lesson['duration_seconds'], 'is_free_preview' => (bool)$lesson['is_free_preview'], 'storage_type' => $storageType ], 'playback' => $playbackInfo, 'checkpoints' => $checkpoints ?: [] ] ]); } /** * Webhook listener for Bunny Stream encoding notifications * POST /api/webhooks/bunny */ public function handleBunnyWebhook(Request $request, Response $response): void { VideoService::ensureSchema(); $body = $request->getBody(); $videoId = trim((string)($body['VideoGuid'] ?? $body['videoId'] ?? '')); $status = (int)($body['Status'] ?? 0); if (!empty($videoId)) { $encodingStatus = ($status === 3) ? 'ready' : (($status === 4) ? 'failed' : 'processing'); Database::query( "UPDATE lessons SET encoding_status = ? WHERE bunny_video_id = ?", [$encodingStatus, $videoId] ); } $response->json(['status' => 'received']); } }