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 / Permissions (or auto-resolve/create for teacher) $course = Database::selectOne("SELECT id, teacher_id FROM courses WHERE id = ? LIMIT 1", [$courseId]); if (!$course) { $existingCourse = Database::selectOne("SELECT id, teacher_id FROM courses WHERE teacher_id = ? LIMIT 1", [$request->user_id]); if ($existingCourse) { $courseId = (int)$existingCourse['id']; $course = $existingCourse; } else { $cUuid = 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)); $newCourseId = Database::insert( "INSERT INTO courses (uuid, teacher_id, subject_id, title, description, semester, price_jod, is_published) VALUES (?, ?, 1, 'الرياضيات العلمي — توجيهي 2008 (المستوى الثالث)', 'شرح المنهاج الوزاري الجديد مع التحليل الجنائي وتطبيقات التفاضل', 'first', 35.00, 1)", [$cUuid, $request->user_id] ); $courseId = $newCourseId; $course = ['id' => $newCourseId, 'teacher_id' => $request->user_id]; } } // If authenticated teacher, bind course ownership if ($request->role === 'teacher' && $course['teacher_id'] != $request->user_id) { Database::query("UPDATE courses SET teacher_id = ? WHERE id = ?", [$request->user_id, $courseId]); } 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 ] ); // Autonomous Zero-Touch AI Analysis & Socratic Checkpoint Generation (Silent Background Execution) $aiReport = AiVideoAnalyzerService::processLessonAutonomously($lessonId); $response->status(201)->json([ 'status' => 'success', 'message' => 'تم رفع الفيديو وتقطيعه بتقنية HLS وتوليد الفحص السقراطي الذكي تلقائياً بنجاح!', 'data' => array_merge($uploadResult, [ 'lesson_id' => $lessonId, 'title' => $title, 'course_id' => $courseId, 'ai_analysis' => $aiReport ]) ]); } 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 Bunny Video ID to a Course Lesson with Autonomous AI Socratic Generation * POST /api/teacher/videos/bunny-link */ public function linkBunnyLesson(Request $request, Response $response): void { VideoService::ensureSchema(); CurriculumService::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'] ?? 600); $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] ); // Autonomous AI Analysis for Bunny Lessons $aiReport = AiVideoAnalyzerService::processLessonAutonomously($lessonId); $response->status(201)->json([ 'status' => 'success', 'message' => 'تم ربط درس Bunny Stream وتوليد نقاط الفحص السقراطي تلقائياً!', 'data' => [ 'lesson_id' => $lessonId, 'bunny_video_id' => $bunnyVideoId, 'storage_type' => 'bunny_stream', 'ai_analysis' => $aiReport ] ]); } /** * 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 ] ]); } /** * List all published lessons for student portal * GET /api/student/lessons */ public function getStudentLessons(Request $request, Response $response): void { VideoService::ensureSchema(); CurriculumService::ensureSchema(); $courseId = (int)($request->getQuery('course_id') ?: 0); // Extract student grade/stream from JWT-decoded context (populated by auth middleware) $gradeLevel = $request->getQuery('grade_level') ?: ($request->user['grade_level'] ?? null); $stream = $request->getQuery('stream') ?: ($request->user['stream'] ?? null); $schoolId = $request->user['school_id'] ?? null; // Build WHERE clause dynamically $conditions = []; $params = []; if ($courseId > 0) { $conditions[] = 'l.course_id = ?'; $params[] = $courseId; } // Grade/stream filter: include lessons where the course matches the student's // grade/stream, OR where the course is the system curriculum (AI videos). // This ensures AI ministry content is always visible to all students. if ($gradeLevel || $stream) { $gradeCond = '(c.is_system_curriculum = 1'; if ($gradeLevel) { $gradeCond .= ' OR c.grade_level = ?'; $params[] = $gradeLevel; } if ($stream) { $gradeCond .= ' OR c.stream = ?'; $params[] = $stream; } $gradeCond .= ')'; $conditions[] = $gradeCond; } $whereClause = !empty($conditions) ? ('WHERE ' . implode(' AND ', $conditions)) : ''; $lessons = Database::select( "SELECT l.id, l.course_id, l.title, l.sequence_order, l.storage_type, l.video_uuid, l.hls_url, l.thumbnail_url, l.ai_video_url, l.duration_seconds, l.is_free_preview, l.created_at, COALESCE(c.title, 'توجيهي 2008 — المنهاج المعتمد') as course_title, COALESCE(c.is_system_curriculum, 0) as is_ai_version, c.teacher_id, u.full_name as teacher_name, c.school_id, CASE WHEN c.school_id = ? THEN 1 ELSE 0 END as is_my_school, (SELECT COUNT(*) FROM exams WHERE lesson_id = l.id AND scope = 'in_video_checkpoint') as checkpoints_count FROM lessons l LEFT JOIN courses c ON l.course_id = c.id LEFT JOIN teachers u ON c.teacher_id = u.id {$whereClause} ORDER BY is_ai_version DESC, is_my_school DESC, l.sequence_order ASC, l.id DESC", array_merge([$schoolId ?? 0], $params) ); // Separate AI/ministry lessons from teacher lessons for the carousel $aiLessons = array_filter($lessons, fn($l) => (bool)$l['is_ai_version']); $teacherLessons = array_filter($lessons, fn($l) => !(bool)$l['is_ai_version']); $response->json([ 'status' => 'success', 'data' => array_values($lessons), 'meta' => [ 'grade_level' => $gradeLevel, 'stream' => $stream, 'ai_count' => count($aiLessons), 'teacher_count' => count($teacherLessons), ] ]); } /** * Get Lesson Playback Data with Chapters Roadmap and Socratic Checkpoints * GET /api/lessons/{id}/playback */ public function getPlaybackData(Request $request, Response $response): void { VideoService::ensureSchema(); CurriculumService::ensureSchema(); $curriculumKey = trim((string)($request->getQuery('curriculum_key') ?? '')); $rawId = $curriculumKey !== '' ? $curriculumKey : ($request->getParam('id') ?? ''); $lesson = null; if ($curriculumKey !== '') { $lesson = Database::selectOne("SELECT * FROM lessons WHERE curriculum_key = ? LIMIT 1", [$curriculumKey]); } elseif (is_numeric($rawId) && (int)$rawId > 0) { $lesson = Database::selectOne("SELECT * FROM lessons WHERE id = ? LIMIT 1", [(int)$rawId]); } elseif (!empty($rawId)) { $lesson = Database::selectOne("SELECT * FROM lessons WHERE curriculum_key = ? OR title LIKE ? OR local_path LIKE ? OR markdown_content LIKE ? LIMIT 1", [$rawId, "%{$rawId}%", "%{$rawId}%", "%{$rawId}%"]); // Flutter curriculum lessons use the manifest slug (e.g. u1_l1_*), // while the database stores the canonical lesson title. if (!$lesson) { $manifestLesson = CurriculumService::findLessonById($rawId); if ($manifestLesson && !empty($manifestLesson['title'])) { $lesson = Database::selectOne( "SELECT * FROM lessons WHERE title = ? OR markdown_content LIKE ? LIMIT 1", [$manifestLesson['title'], '%' . ($manifestLesson['file'] ?? '') . '%'] ); } } } if (!$lesson) { // Fallback to latest available lesson in DB $lesson = Database::selectOne("SELECT * FROM lessons ORDER BY id DESC LIMIT 1"); } if (!$lesson) { $response->status(404)->json([ 'status' => 'error', 'message' => 'الدرس غير موجود أو لم يتم نشر الفيديو الخاص به بعد' ]); return; } $lessonId = (int)$lesson['id']; // If lesson has no checkpoints or missing questions, generate them autonomously $existingCount = Database::selectOne("SELECT COUNT(*) as cnt FROM exams WHERE lesson_id = ? AND scope = 'in_video_checkpoint'", [$lessonId]); $existingQuestions = Database::selectOne("SELECT COUNT(*) as cnt FROM questions q JOIN exams e ON q.exam_id = e.id WHERE e.lesson_id = ?", [$lessonId]); if (empty($existingCount['cnt']) || empty($existingQuestions['cnt'])) { try { AiVideoAnalyzerService::processLessonAutonomously($lessonId); $lesson = Database::selectOne("SELECT * FROM lessons WHERE id = ? LIMIT 1", [$lessonId]); } catch (\Throwable $e) { error_log("Autonomous video analysis notice: " . $e->getMessage()); } } // 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'], 'question_id' => $q ? (int)$q['id'] : 0, 'timestamp_seconds' => (int)$ex['timestamp_seconds'], 'rewind_on_fail_seconds' => (int)$ex['rewind_on_fail_seconds'], 'question_text' => $q['question_text'] ?? 'سؤال فحص فهم الفكرة:', 'explanation' => $q['explanation_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'] ?: ''; if ($bunnyId === '') { $response->status(409)->json(['status' => 'error', 'message' => 'الفيديو لم يجهز للبث بعد']); return; } $signedData = VideoService::generateBunnySignedPlayback($bunnyId, 10800); // 3-hour token $playbackInfo = array_merge(['storage_type' => 'bunny_stream'], $signedData); } $chapters = !empty($lesson['timeline_chapters_json']) ? json_decode($lesson['timeline_chapters_json'], true) : []; // Find available versions (AI vs Teacher specific) $lessonTitle = $lesson['title']; $versionsRaw = Database::select( "SELECT l.id, l.course_id, l.title, l.storage_type, l.video_uuid, l.hls_url, l.ai_video_url, l.bunny_video_id, c.teacher_id, u.full_name as teacher_name, s.name as school_name FROM lessons l LEFT JOIN courses c ON l.course_id = c.id LEFT JOIN teachers u ON c.teacher_id = u.id LEFT JOIN schools s ON c.school_id = s.id WHERE l.title = ? AND l.encoding_status = 'ready'", [$lessonTitle] ); $availableVersions = []; $studentSchool = $request->user['school_name'] ?? ''; // if student's school is in the token foreach ($versionsRaw as $ver) { $isAi = ($ver['course_id'] == 0 || $ver['course_id'] == null); $vPlayback = []; if ($ver['storage_type'] === 'api_upload') { $vPlayback = [ 'storage_type' => 'api_upload', 'stream_url' => '/api/videos/stream/' . $ver['video_uuid'], 'hls_url' => $ver['hls_url'] ?: ('/api/videos/hls/' . $ver['video_uuid'] . '/index.m3u8'), 'video_url' => $ver['ai_video_url'] ?: ($ver['hls_url'] ?: ('/api/videos/stream/' . $ver['video_uuid'])) ]; } else { $bId = $ver['bunny_video_id'] ?: ''; if ($bId === '') continue; $signed = VideoService::generateBunnySignedPlayback($bId, 10800); $vPlayback = array_merge(['storage_type' => 'bunny_stream', 'video_url' => $signed['hls_url']], $signed); } $label = $isAi ? 'فيديو الذكاء الاصطناعي الأساسي 🤖' : 'شرح الأستاذ ' . $ver['teacher_name']; $isRecommended = (!$isAi && !empty($studentSchool) && $ver['school_name'] === $studentSchool); if ($isRecommended) { $label .= ' (مدرستك 🏫)'; } $availableVersions[] = [ 'lesson_id' => (int)$ver['id'], 'is_ai' => $isAi, 'teacher_name' => $ver['teacher_name'], 'school_name' => $ver['school_name'], 'label' => $label, 'is_recommended' => $isRecommended, 'playback' => $vPlayback ]; } // Sort: Recommended first, then AI, then others usort($availableVersions, function($a, $b) { if ($a['is_recommended'] && !$b['is_recommended']) return -1; if (!$a['is_recommended'] && $b['is_recommended']) return 1; if ($a['is_ai'] && !$b['is_ai']) return -1; if (!$a['is_ai'] && $b['is_ai']) return 1; return 0; }); $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, 'available_versions' => $availableVersions, 'chapters' => $chapters ?: [], 'checkpoints' => $checkpoints ?: [] ] ]); } /** Save real playback progress; the client never owns the readiness score. */ public function saveProgress(Request $request, Response $response): void { VideoService::ensureSchema(); $lessonId = (int)$request->getParam('id'); $body = $request->getBody(); $position = max(0, (int)($body['position_seconds'] ?? 0)); $watched = max(0, (int)($body['watched_seconds'] ?? $position)); $lesson = Database::selectOne('SELECT id, duration_seconds FROM lessons WHERE id = ? LIMIT 1', [$lessonId]); if (!$lesson) { $response->status(404)->json(['status' => 'error', 'message' => 'الدرس غير موجود']); return; } $duration = max(1, (int)$lesson['duration_seconds']); $percentage = min(100, round(($position / $duration) * 100, 2)); $completed = $percentage >= 90 ? 1 : 0; Database::query( "INSERT INTO lesson_progress (student_id, lesson_id, position_seconds, watched_seconds, completion_percentage, is_completed, last_seen_at, completed_at) VALUES (?, ?, ?, ?, ?, ?, NOW(), CASE WHEN ? = 1 THEN NOW() ELSE NULL END) ON DUPLICATE KEY UPDATE position_seconds = VALUES(position_seconds), watched_seconds = GREATEST(watched_seconds, VALUES(watched_seconds)), completion_percentage = VALUES(completion_percentage), is_completed = GREATEST(is_completed, VALUES(is_completed)), last_seen_at = NOW(), completed_at = CASE WHEN is_completed = 1 OR VALUES(is_completed) = 1 THEN COALESCE(completed_at, NOW()) ELSE completed_at END", [$request->user_id, $lessonId, $position, $watched, $percentage, $completed, $completed] ); $response->json(['status' => 'success', 'data' => ['completion_percentage' => $percentage, 'is_completed' => (bool)$completed]]); } /** * 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']); } }