Files
saqel/backend/app/Controllers/VideoController.php
T

1176 lines
60 KiB
PHP

<?php
/**
* ==============================================================================
* SAQEL ENTERPRISE (EDTECH 2.0) - VIDEO STREAMING & SOCRATIC PLAYBACK CONTROLLER
* ==============================================================================
*
* ملف: VideoController.php
* الهدف المعماري:
* إدارة منظومة بث الفيديو الرقمي، وتوليد الفحوصات السقراطية، وبث HLS المجزأ عبر Cloudflare R2.
* يتولى هذا الملف المهام التالية:
* 1. رفع ملفات الفيديو المباشرة وتحويلها وتجزئتها إلى مقاطع HLS مشفرة لحماية الملكية الفكرية.
* 2. تشغيل التحليل السمعي-البصري بالذكاء الاصطناعي (AiVideoAnalyzerService) لتوليد الفصول الزمنية ونقاط الفحص السقراطي.
* 3. بث الفيديو عبر روابط مؤقتة آمنة (Byte-Range Streaming و HLS Playlists).
* 4. تزويد مشغل الفيديو في فلاتر ببيانات التشغيل (الروابط، الفصول، المعلم المعتمد، ونقاط التوقف السقراطية).
* 5. حفظ تقدم المشاهدة اللحظي لكل طالب في جدول (lesson_progress) وإدارة الاستئناف التلقائي.
*/
namespace App\Controllers;
use App\Core\Request;
use App\Core\Response;
use App\Core\Database;
use App\Core\Security;
use App\Services\VideoService;
use App\Services\AiVideoAnalyzerService;
use App\Services\CurriculumService;
use App\Services\TeacherSubmissionService;
use App\Services\VideoReviewService;
class VideoController
{
/** POST /api/video-versions/{versionId}/watch-sessions */
public function startWatchSession(Request $request, Response $response): void
{
try {
$result = \App\Services\WatchSessionService::start((int)$request->user_id, trim((string)$request->getParam('versionId','')), $request->getHeader('x-national-id'));
$response->status((int)$result['http_status'])->json(array_diff_key($result,['http_status'=>true]));
} catch (\Throwable $e) { error_log('Watch session start failed: '.$e->getMessage()); $response->status(503)->json(['status'=>'unavailable','message'=>'تعذر بدء جلسة المشاهدة.']); }
}
/** POST /api/watch-sessions/{sessionId}/events */
public function recordWatchEvent(Request $request, Response $response): void
{
$body=$request->getBody();
try {
$result = \App\Services\WatchSessionService::event((int)$request->user_id,trim((string)$request->getParam('sessionId','')),(int)($body['sequence_no']??0),trim((string)($body['event_type']??'')),(int)($body['position_ms']??-1),is_array($body['payload']??null)?$body['payload']:[]);
$response->status((int)$result['http_status'])->json(array_diff_key($result,['http_status'=>true]));
} catch (\Throwable $e) { error_log('Watch event failed: '.$e->getMessage()); $response->status(503)->json(['status'=>'unavailable','message'=>'تعذر حفظ حدث المشاهدة.']); }
}
/** GET /api/video-versions/{versionId}/playback */
public function getVideoVersionPlayback(Request $request, Response $response): void
{
$versionUuid = trim((string)$request->getParam('versionId', ''));
if (!preg_match('/^[0-9a-f-]{36}$/i', $versionUuid)) {
$response->status(400)->json(['status'=>'error','message'=>'هوية نسخة الفيديو غير صالحة.']); return;
}
try {
$row = Database::selectOne(
"SELECT vv.uuid AS video_version_id, l.id AS lesson_id, l.course_id, l.storage_type, l.video_uuid, l.hls_url, l.ai_video_url, l.bunny_video_id, l.duration_seconds,
cl.uuid AS curriculum_lesson_id, cl.title, cl.grade_key, ts.uuid AS submission_id
FROM video_versions vv
JOIN teacher_submissions ts ON ts.id=vv.teacher_submission_id AND ts.current_published_video_version_id=vv.id AND ts.status='published'
JOIN curriculum_lessons cl ON cl.id=ts.curriculum_lesson_id
JOIN lessons l ON l.id=vv.source_lesson_id AND l.encoding_status='ready'
WHERE vv.uuid=? AND vv.status='published' LIMIT 1", [$versionUuid]
);
if (!$row) { $response->status(404)->json(['status'=>'error','message'=>'نسخة الفيديو المطلوبة غير منشورة أو سُحبت.']); return; }
$course=Database::selectOne('SELECT grade_level, price_jod FROM courses WHERE id=? LIMIT 1',[$row['course_id']]);
$effectiveGrade = \App\Services\StudentAccessControlService::normalizeGrade($row['grade_key'] ?? $course['grade_level'] ?? 'grade_10');
$access=\App\Services\StudentAccessControlService::validateLessonAccess((int)$request->user_id, $request->getHeader('x-national-id'), $effectiveGrade, (int)$row['course_id'], (int)$row['lesson_id']);
if (empty($access['allowed'])) { $response->status(403)->json(['status'=>'forbidden','message'=>$access['message'] ?? 'غير مصرح بمشاهدة هذه الحصة.']); return; }
if ($row['storage_type']==='api_upload') $playback=['storage_type'=>'api_upload','video_url'=>$row['hls_url'] ?: '/api/videos/stream/'.$row['video_uuid'],'hls_url'=>$row['hls_url'] ?: '/api/videos/hls/'.$row['video_uuid'].'/index.m3u8'];
elseif (!empty($row['bunny_video_id'])) $playback=array_merge(['storage_type'=>'bunny_stream'],VideoService::generateBunnySignedPlayback($row['bunny_video_id'],10800));
elseif (!empty($row['ai_video_url'])) $playback=['storage_type'=>'direct_url','video_url'=>$row['ai_video_url'],'hls_url'=>$row['ai_video_url']];
elseif (!empty($row['hls_url'])) $playback=['storage_type'=>'cdn_hls','video_url'=>$row['hls_url'],'hls_url'=>$row['hls_url']];
else { $response->status(409)->json(['status'=>'error','message'=>'تخزين نسخة الفيديو غير جاهز.']); return; }
$response->json(['status'=>'success','data'=>['video_version_id'=>$row['video_version_id'],'submission_id'=>$row['submission_id'],'curriculum_lesson_id'=>$row['curriculum_lesson_id'],'lesson_id'=>(int)$row['lesson_id'],'title'=>$row['title'],'duration_seconds'=>(int)$row['duration_seconds'],'playback'=>$playback,'checkpoints'=>[]]]);
} catch (\Throwable $e) { error_log('Version playback failed: '.$e->getMessage()); $response->status(503)->json(['status'=>'unavailable','message'=>'تعذر تجهيز تشغيل نسخة الفيديو.']); }
}
/** GET /api/teacher/submissions */
public function listTeacherSubmissions(Request $request, Response $response): void
{
$teacherId = (int)$request->user_id;
if (!empty($request->identity_id)) {
$t = Database::selectOne("SELECT id FROM teachers WHERE identity_id = ? LIMIT 1", [$request->identity_id]);
if ($t) {
$teacherId = (int)$t['id'];
}
}
try {
$rows = Database::select(
"SELECT ts.id AS submission_id, ts.uuid AS submission_uuid, ts.status AS submission_status,
ts.current_published_video_version_id, ts.created_at, ts.updated_at,
cl.id AS lesson_id, cl.uuid AS curriculum_lesson_uuid, cl.title AS lesson_title,
cl.subject_key, cl.grade_key, cl.grade_key AS grade_level, cl.semester_key, cl.unit_key, cl.lesson_key,
vv.id AS video_version_id, vv.uuid AS video_version_uuid, vv.version_number,
vv.status AS video_status, vv.published_at, vv.created_at AS version_created_at
FROM teacher_submissions ts
JOIN curriculum_lessons cl ON cl.id = ts.curriculum_lesson_id
LEFT JOIN video_versions vv ON vv.teacher_submission_id = ts.id
WHERE ts.teacher_id = ?
ORDER BY ts.updated_at DESC, vv.version_number DESC",
[$teacherId]
);
$submissions = [];
foreach ($rows as $row) {
$subId = (string)$row['submission_uuid'];
if (!isset($submissions[$subId])) {
$submissions[$subId] = [
'submission_uuid' => $row['submission_uuid'],
'submission_status' => $row['submission_status'],
'lesson_title' => $row['lesson_title'],
'subject_key' => $row['subject_key'],
'grade_level' => $row['grade_level'],
'semester_key' => $row['semester_key'],
'unit_key' => $row['unit_key'],
'lesson_key' => $row['lesson_key'],
'created_at' => $row['created_at'],
'updated_at' => $row['updated_at'],
'versions' => [],
];
}
if (!empty($row['video_version_uuid'])) {
$submissions[$subId]['versions'][] = [
'video_version_uuid' => $row['video_version_uuid'],
'version_number' => (int)$row['version_number'],
'video_status' => $row['video_status'],
'is_current_published' => ($row['current_published_video_version_id'] == $row['video_version_id']),
'published_at' => $row['published_at'],
'created_at' => $row['version_created_at'],
];
}
}
$response->json([
'status' => 'success',
'data' => [
'submissions' => array_values($submissions),
'total' => count($submissions),
]
]);
} catch (\Throwable $e) {
error_log('listTeacherSubmissions failed: ' . $e->getMessage());
$response->status(500)->json([
'status' => 'error',
'message' => 'تعذر جلب قائمة الحصص المرفوعة للمعلم.'
]);
}
}
/** GET /api/curriculum/lessons/{lessonId}/videos?cursor=&limit= */
public function listPublishedLessonVideos(Request $request, Response $response): void
{
$lessonUuid = trim((string)$request->getParam('lessonId', ''));
$cursor = max(0, (int)$request->getQuery('cursor', 0));
$limit = min(50, max(1, (int)$request->getQuery('limit', 20)));
if (!preg_match('/^[0-9a-f-]{36}$/i', $lessonUuid)) {
$response->status(400)->json(['status' => 'error', 'message' => 'هوية الدرس غير صالحة.']); return;
}
try {
$lesson = Database::selectOne("SELECT id, uuid, title, grade_key FROM curriculum_lessons WHERE uuid = ? AND source_status = 'approved' LIMIT 1", [$lessonUuid]);
if (!$lesson) { $response->status(404)->json(['status'=>'error','message'=>'الدرس غير منشور.']); return; }
$rows = Database::select(
"SELECT vv.id, vv.uuid AS video_version_id, vv.source_lesson_id, ts.uuid AS submission_id,
cl.grade_key, c.grade_level AS course_grade_level, l.course_id, t.full_name AS teacher_name, COALESCE(pm.weighted_student_rating, 0) AS rating,
COALESCE(pm.total_reviews_count, 0) AS rating_count
FROM teacher_submissions ts
JOIN curriculum_lessons cl ON cl.id = ts.curriculum_lesson_id
JOIN video_versions vv ON vv.id = ts.current_published_video_version_id AND vv.status = 'published'
JOIN lessons l ON l.id = vv.source_lesson_id AND l.encoding_status = 'ready'
JOIN courses c ON c.id = l.course_id
JOIN teachers t ON t.id = ts.teacher_id
LEFT JOIN teacher_performance_metrics pm ON pm.teacher_id = t.id
WHERE ts.curriculum_lesson_id = ? AND ts.status = 'published'
ORDER BY (rating_count > 0) DESC, rating DESC, vv.id ASC LIMIT 101",
[$lesson['id']]
);
// A list is also protected content: do not disclose a teacher, count,
// or version that the student cannot play. A curriculum lesson may
// legitimately have versions attached to different courses.
$accessible = [];
foreach ($rows as $row) {
$targetGrade = \App\Services\StudentAccessControlService::normalizeGrade($row['grade_key'] ?? $lesson['grade_key'] ?? 'grade_10');
$access = \App\Services\StudentAccessControlService::validateLessonAccess(
(int)$request->user_id,
$request->getHeader('x-national-id'),
$targetGrade,
(int)$row['course_id'],
(int)$row['source_lesson_id'],
false
);
if (!empty($access['allowed'])) $accessible[] = $row;
}
// `cursor` is an offset in the stable rating order, rather than a
// database id (an id seek would skip records after rating changes).
$page = array_slice($accessible, $cursor, $limit);
$hasMore = count($accessible) > ($cursor + count($page));
$items = array_map(static fn($r) => ['video_version_id'=>$r['video_version_id'],'submission_id'=>$r['submission_id'],'teacher_name'=>$r['teacher_name'],'rating'=>(float)$r['rating'],'rating_count'=>(int)$r['rating_count'],'is_new'=>(int)$r['rating_count']===0], $page);
$response->json(['status'=>'success','data'=>['curriculum_lesson_id'=>$lesson['uuid'],'title'=>$lesson['title'],'available_count'=>count($accessible),'items'=>$items,'next_cursor'=>$hasMore ? $cursor + count($page) : null]]);
} catch (\Throwable $e) { error_log('Lesson videos list failed: '.$e->getMessage()); $response->status(503)->json(['status'=>'unavailable','message'=>'تعذر تحميل حصص هذا الدرس.']); }
}
/** POST /api/teacher/submissions/preflight */
public function preflightSubmission(Request $request, Response $response): void
{
$body = $request->getBody();
$lessonId = trim((string)($body['curriculum_lesson_id'] ?? ''));
$replacement = !empty($body['replacement']);
$submissionId = isset($body['submission_id']) ? trim((string)$body['submission_id']) : null;
$key = trim((string)$request->getHeader('idempotency-key', ''));
try {
$result = TeacherSubmissionService::preflight((int)$request->user_id, $lessonId, $key, $replacement, $submissionId);
$response->status((int)$result['http_status'])->json(array_diff_key($result, ['http_status' => true]));
} catch (\Throwable $e) {
error_log('Teacher submission preflight failed: ' . $e->getMessage());
$response->status(503)->json(['status' => 'unavailable', 'message' => 'تعذر إنشاء نسخة الحصة حالياً.']);
}
}
/** POST /api/admin/video-review/publish; super-admin only route. */
public function publishReviewedVersion(Request $request, Response $response): void
{
$body = $request->getBody();
$jobId = trim((string)($body['review_job_id'] ?? ''));
$expected = isset($body['expected_current_version_id']) && $body['expected_current_version_id'] !== ''
? trim((string)$body['expected_current_version_id']) : null;
try {
$result = VideoReviewService::publishApproved($jobId, $expected);
$response->status((int)$result['http_status'])->json(array_diff_key($result, ['http_status' => true]));
} catch (\Throwable $e) {
error_log('Video publication failed: ' . $e->getMessage());
$response->status(503)->json(['status' => 'unavailable', 'message' => 'تعذر نشر نسخة الفيديو حالياً.']);
}
}
/** POST /api/admin/video-review/decision; super-admin only route. */
public function recordVideoReviewDecision(Request $request, Response $response): void
{
$body=$request->getBody();
try {
$result=VideoReviewService::recordHumanReview(trim((string)($body['review_job_id']??'')),(int)$request->user_id,trim((string)($body['recommendation']??'')),trim((string)($body['decision']??'')),is_array($body['report']??null)?$body['report']:[]);
$response->status((int)$result['http_status'])->json(array_diff_key($result,['http_status'=>true]));
} catch(\Throwable $e){error_log('Video review decision failed: '.$e->getMessage());$response->status(503)->json(['status'=>'unavailable','message'=>'تعذر حفظ قرار المراجعة.']);}
}
/** POST /api/admin/video-review/evidence; super-admin only route. */
public function submitVideoReviewEvidence(Request $request, Response $response): void
{
$body = $request->getBody();
try {
$result = VideoReviewService::submitEvidence(
trim((string)($body['review_job_id'] ?? '')),
(int)$request->user_id,
trim((string)($body['transcript_asset_id'] ?? '')),
trim((string)($body['visual_evidence_asset_id'] ?? '')),
is_array($body['coverage'] ?? null) ? $body['coverage'] : []
);
$response->status((int)$result['http_status'])->json(array_diff_key($result, ['http_status' => true]));
} catch (\Throwable $e) {
error_log('Video review evidence failed: ' . $e->getMessage());
$response->status(503)->json(['status'=>'unavailable','message'=>'تعذر حفظ أدلة فحص الفيديو.']);
}
}
/**
* رفع فيديو الشرح مباشرة عبر واجهة البرمجة وتوليد HLS وبدء التحليل السقراطي بالذكاء الاصطناعي
* POST /api/teacher/videos/upload-direct
*
* @param Request $request طلب الـ HTTP المحتوي على ملف الفيديو والبيانات الوصفية
* @param Response $response كائن الاستجابة بحالة الرفع ومعرف الدرس ورابط المشاهدة
*/
public function uploadDirect(Request $request, Response $response): void
{
VideoService::ensureSchema();
CurriculumService::ensureSchema();
// PHP discards both multipart fields and files when post_max_size is
// exceeded. Detect that case before reporting a misleading missing title.
$contentLength = (int)($_SERVER['CONTENT_LENGTH'] ?? 0);
if ($contentLength > 0 && empty($_POST) && empty($_FILES)) {
$response->status(413)->json([
'status' => 'error',
'message' => 'حجم الفيديو تجاوز حد الرفع المسموح على الخادم (' . ini_get('post_max_size') . '). ارفع حدّي post_max_size وupload_max_filesize ثم أعد المحاولة.',
]);
return;
}
$courseId = (int)($request->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);
$curriculumKey = trim((string)($request->getBody()['curriculum_key'] ?? $_POST['curriculum_key'] ?? ''));
$videoVersionId = trim((string)($request->getBody()['video_version_id'] ?? $_POST['video_version_id'] ?? ''));
if (empty($title)) {
$response->status(400)->json([
'status' => 'error',
'message' => 'عنوان الدرس مطلوب'
]);
return;
}
if ($videoVersionId === '') {
$response->status(409)->json([
'status' => 'submission_preflight_required',
'message' => 'أنشئ نسخة حصة عبر preflight قبل رفع الفيديو.',
]);
return;
}
try {
if (!TeacherSubmissionService::reserveUpload((int)$request->user_id, $videoVersionId)) {
$response->status(409)->json([
'status' => 'video_version_unavailable',
'message' => 'نسخة الفيديو استُخدمت أو لم تعد متاحة للرفع. أنشئ نسخة مرشحة جديدة.',
]);
return;
}
} catch (\Throwable $e) {
error_log('Video version reservation failed: ' . $e->getMessage());
$response->status(503)->json(['status' => 'unavailable', 'message' => 'تعذر حجز نسخة الفيديو للرفع.']);
return;
}
// Resolve a teacher-owned course when the app did not explicitly select one.
$course = null;
if ($courseId > 0) {
$course = Database::selectOne("SELECT id, teacher_id FROM courses WHERE id = ? LIMIT 1", [$courseId]);
if (!$course) {
$response->status(404)->json(['status' => 'error', 'message' => 'الدورة المطلوبة غير موجودة']);
return;
}
if ((int)$course['teacher_id'] !== (int)$request->user_id && $request->role !== 'super_admin') {
$response->status(403)->json(['status' => 'error', 'message' => 'لا تملك صلاحية رفع محتوى لهذه الدورة']);
return;
}
} else {
$subjectName = trim((string)($request->getBody()['subject'] ?? $_POST['subject'] ?? ''));
$rawGrade = trim((string)($request->getBody()['grade_level'] ?? $_POST['grade_level'] ?? ''));
$gradeLevel = !empty($rawGrade) ? \App\Services\StudentAccessControlService::normalizeGrade($rawGrade) : 'grade_10';
// The production schema uses subjects.name (not name_ar/name_en).
// Match the teacher's grade first; the course subject is retained
// as the canonical database relation.
$existingCourse = Database::selectOne(
"SELECT id, teacher_id, grade_level FROM courses WHERE teacher_id = ? AND (grade_level = ? OR grade_level = ?) LIMIT 1",
[$request->user_id, $gradeLevel, $rawGrade]
);
if ($existingCourse) {
$courseId = (int)$existingCourse['id'];
$course = $existingCourse;
if (!empty($gradeLevel) && ($existingCourse['grade_level'] ?? '') !== $gradeLevel) {
Database::query("UPDATE courses SET grade_level = ? WHERE id = ?", [$gradeLevel, $courseId]);
}
} else {
$subject = Database::selectOne(
"SELECT id FROM subjects WHERE name = ? ORDER BY id LIMIT 1",
[$subjectName]
);
if (!$subject && $subjectName !== '') {
// The curriculum tree is the source of truth. When its
// subject has not yet been seeded into SQL, create the
// minimal canonical subject record instead of attaching
// the teacher's lesson to an unrelated first subject.
$subjectCode = 'CURR-' . strtoupper(substr(hash('sha256', $subjectName), 0, 12));
try {
$subjectId = (int)Database::insert(
"INSERT INTO subjects (name, code, stream, is_active) VALUES (?, ?, 'common', 1)",
[$subjectName, $subjectCode]
);
$subject = ['id' => $subjectId];
} catch (\Throwable $e) {
$subject = Database::selectOne("SELECT id FROM subjects WHERE name = ? LIMIT 1", [$subjectName]);
}
}
if (!$subject) {
$response->status(409)->json(['status' => 'error', 'message' => 'لا يوجد مبحث معرف لربط الفيديو به']);
return;
}
$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, grade_level)
VALUES (?, ?, ?, ?, '', 'first', 0.00, 0, ?)",
[$cUuid, $request->user_id, (int)$subject['id'], $title, $gradeLevel]
);
$courseId = $newCourseId;
$course = ['id' => $newCourseId, 'teacher_id' => $request->user_id];
}
}
if (empty($_FILES['video'])) {
$response->status(400)->json([
'status' => 'error',
'message' => 'يرجى إرفاق ملف الفيديو في الطلب (key: video)'
]);
return;
}
// No Cloudflare R2 write occurs before this fail-closed media gate.
// The report is generated from the real uploaded file while it remains
// in PHP's temporary storage.
$preflight = AiVideoAnalyzerService::auditUploadBeforeStorage(
$_FILES['video'],
$title,
trim((string)($request->getBody()['subject'] ?? $_POST['subject'] ?? ''))
);
$auditId = $this->recordUploadAudit($request, $courseId, $_FILES['video'], $preflight);
if (($preflight['decision'] ?? '') !== 'approved') {
// The candidate contains no accepted media yet, so make it reusable.
// Do not leave a failed local quality gate blocking a future upload.
TeacherSubmissionService::releaseUploadReservation((int)$request->user_id, $videoVersionId);
$response->status(422)->json([
'status' => 'needs_review',
'message' => 'لم يتم حفظ الفيديو في Cloudflare R2 قبل اجتياز تدقيق الجودة.',
'data' => ['audit_id' => $auditId, 'preflight_report' => $preflight],
]);
return;
}
$reviewQueued = false;
try {
// Fast direct upload: save file locally and slice HLS instantly via stream copy (-c copy)
$uploadResult = VideoService::handleDirectUpload($_FILES['video'], $courseId, $title, false);
// Insert lesson record with HLS references and processing status
$lessonId = (int)Database::insert(
"INSERT INTO lessons (course_id, title, curriculum_key, 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, 'processing')",
[
$courseId,
$title,
$curriculumKey !== '' ? $curriculumKey : null,
$seqOrder,
$uploadResult['video_uuid'],
$uploadResult['local_path'],
$uploadResult['hls_url'],
$uploadResult['thumbnail_url'],
$uploadResult['duration'] ?? 0
]
);
if ($auditId) {
Database::query('UPDATE video_upload_audits SET lesson_id = ? WHERE id = ?', [$lessonId, $auditId]);
}
TeacherSubmissionService::attachUploadedLesson(
(int)$request->user_id,
$videoVersionId,
$lessonId,
hash_file('sha256', (string)$_FILES['video']['tmp_name'])
);
$reviewQueued = true;
// Immediately send HTTP 201 response to Flutter client and close connection
$responsePayload = [
'status' => 'success',
'message' => 'تم رفع الفيديو إلى طابور المراجعة. لن يظهر للطلاب قبل اكتمال الأدلة وقرار المراجع البشري.',
'data' => array_merge($uploadResult, [
'lesson_id' => $lessonId,
'title' => $title,
'course_id' => $courseId,
'preflight_report' => $preflight,
'audit_id' => $auditId,
])
];
$response->jsonAndFinish($responsePayload, 201);
// -------------------------------------------------------------------------
// ASYNCHRONOUS BACKGROUND WORKER (PHP-FPM continues running after client exit)
// -------------------------------------------------------------------------
@ignore_user_abort(true);
@set_time_limit(600);
try {
// 1. Sync MP4, HLS segments, and thumbnail to Cloudflare R2
VideoService::syncLessonToR2(
$lessonId,
$courseId,
$uploadResult['target_path'],
$uploadResult['target_file_name'],
$uploadResult['video_uuid'],
$uploadResult['mime_type']
);
// 2. Storage readiness is not publication. The evidence-bound
// review job controls any later release of this version.
Database::query("UPDATE lessons SET encoding_status = 'ready' WHERE id = ?", [$lessonId]);
} catch (\Throwable $bgError) {
error_log("Background sync/analysis error for lesson {$lessonId}: " . $bgError->getMessage());
}
exit;
} catch (\Throwable $e) {
if (!$reviewQueued) {
TeacherSubmissionService::releaseUploadReservation((int)$request->user_id, $videoVersionId);
}
$response->status(500)->json([
'status' => 'error',
'message' => $e->getMessage()
]);
}
}
private function recordUploadAudit(Request $request, int $courseId, array $file, array $report): ?int
{
try {
$hex = bin2hex(random_bytes(16));
$uuid = substr($hex, 0, 8) . '-' . substr($hex, 8, 4) . '-4' . substr($hex, 13, 3) . '-a' . substr($hex, 17, 3) . '-' . substr($hex, 20);
return (int)Database::insert(
'INSERT INTO video_upload_audits (uuid, teacher_id, course_id, file_sha256, original_filename, decision, report_json) VALUES (?, ?, ?, ?, ?, ?, ?)',
[$uuid, (int)$request->user_id, $courseId ?: null, hash_file('sha256', (string)($file['tmp_name'] ?? '')), (string)($file['name'] ?? 'video'), (string)($report['decision'] ?? 'needs_manual_review'), json_encode($report, JSON_UNESCAPED_UNICODE)]
);
} catch (\Throwable $e) {
error_log('Video upload audit persistence failed: ' . $e->getMessage());
return null;
}
}
/**
* 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') ?? ''));
$curriculumKeyNoExt = preg_replace('/\.md$/i', '', $curriculumKey);
$title = trim((string)($request->getQuery('title') ?? ''));
$rawId = $curriculumKey !== '' ? $curriculumKey : ($request->getParam('id') ?? '');
$lesson = null;
$candidates = [];
// 1. Match by curriculum_key if provided
if ($curriculumKey !== '') {
if (str_contains($curriculumKeyNoExt, '/')) {
// Structured hierarchical key: match exact or qualified path suffix
$matched = Database::select(
"SELECT * FROM lessons
WHERE curriculum_key = ? OR curriculum_key = ?
OR curriculum_key LIKE ? OR local_path LIKE ? OR markdown_content LIKE ?
ORDER BY (hls_url IS NOT NULL AND hls_url != '') DESC, id DESC LIMIT 5",
[$curriculumKey, $curriculumKeyNoExt, "%{$curriculumKeyNoExt}%", "%{$curriculumKeyNoExt}%", "%{$curriculumKeyNoExt}%"]
);
} else {
$matched = Database::select(
"SELECT * FROM lessons
WHERE curriculum_key = ? OR curriculum_key LIKE ?
ORDER BY (hls_url IS NOT NULL AND hls_url != '') DESC, id DESC LIMIT 5",
[$curriculumKey, "%/{$curriculumKeyNoExt}%"]
);
}
if (!empty($matched)) {
$candidates = array_merge($candidates, $matched);
}
}
// 2. Match by title if provided
if (!empty($title)) {
$cleanTitle = trim(preg_replace('/^(الدرس\s*\d+:\s*|معملُ\s*[^:]+:\s*|مقدمة\s*[^:]+:\s*)/u', '', $title));
if (mb_strlen($cleanTitle) >= 6) {
$matchedTitle = Database::select(
"SELECT * FROM lessons
WHERE title = ? OR title LIKE ?
ORDER BY (hls_url IS NOT NULL AND hls_url != '') DESC, id DESC LIMIT 5",
[$title, "%{$cleanTitle}%"]
);
if (!empty($matchedTitle)) {
$candidates = array_merge($candidates, $matchedTitle);
}
}
}
// 3. Match by numeric ID
if (is_numeric($rawId) && (int)$rawId > 0) {
$matchedId = Database::select("SELECT * FROM lessons WHERE id = ? LIMIT 1", [(int)$rawId]);
if (!empty($matchedId)) {
$candidates = array_merge($candidates, $matchedId);
}
}
// 4. Manifest slug lookup
if (empty($candidates) && !empty($rawId)) {
$manifestLesson = CurriculumService::findLessonById($rawId);
if ($manifestLesson && !empty($manifestLesson['file'])) {
$fileNoExt = preg_replace('/\.md$/i', '', $manifestLesson['file']);
$matchedManifest = Database::select(
"SELECT * FROM lessons
WHERE curriculum_key = ? OR curriculum_key = ? OR local_path LIKE ?
ORDER BY (hls_url IS NOT NULL AND hls_url != '') DESC, id DESC LIMIT 5",
[$manifestLesson['file'], $fileNoExt, "%{$fileNoExt}%"]
);
if (!empty($matchedManifest)) {
$candidates = array_merge($candidates, $matchedManifest);
}
}
}
// Pick best candidate: prioritize one with valid hls_url
if (!empty($candidates)) {
foreach ($candidates as $cand) {
if (!empty($cand['hls_url'])) {
$lesson = $cand;
break;
}
}
}
// Strict verification: if no matching lesson with an uploaded video is found, return 404
if (!$lesson || empty($lesson['hls_url'])) {
$response->status(404)->json([
'status' => 'error',
'message' => 'لم يتم رفع ونشر فيديو شرح لهذا الدرس بعد.'
]);
return;
}
$lessonId = (int)$lesson['id'];
// Strict Academic Access Control & Institutional Free / CliQ Paid Validation
$course = Database::selectOne("SELECT * FROM courses WHERE id = ? LIMIT 1", [$lesson['course_id']]);
$targetGrade = \App\Services\StudentAccessControlService::normalizeGrade($course['grade_level'] ?? 'grade_10');
$studentId = $request->user_id ? (int)$request->user_id : null;
$nationalId = $request->getHeader('x-national-id') ?: ($request->getQuery('national_id') ?? null);
$access = \App\Services\StudentAccessControlService::validateLessonAccess(
$studentId,
$nationalId,
$targetGrade,
(int)$lesson['course_id'],
$lessonId
);
if (!$access['allowed']) {
$response->status(403)->json([
'status' => 'forbidden',
'access_denied' => true,
'reason' => $access['reason'],
'message' => $access['message'] ?? 'غير مصرح بمشاهدة هذا الدرس',
'student_grade' => $access['student_grade'] ?? null,
'target_grade' => $access['target_grade'] ?? null,
'payment_info' => (($access['reason'] ?? '') === 'payment_required') ? [
'payment_method' => 'CliQ (نظام كليك الأردني للمدفوعات الفورية)',
'cliq_alias' => \App\Services\CliqPaymentService::DEFAULT_PLATFORM_CLIQ_ALIAS,
'price_jod' => (float)($course['price_jod'] ?? 35.0),
'initiate_url' => '/api/payment/cliq/initiate'
] : null
]);
return;
}
// Self-Healing Curriculum Guard: Purge any obsolete/mismatched calculus questions
// for non-calculus lessons (e.g. Grade 10 Systems of Equations)
$isCalculusLesson = (str_contains($lesson['title'], 'اشتقاق') || str_contains($lesson['title'], 'تفاضل'));
if (!$isCalculusLesson) {
try {
$mismatched = Database::selectOne(
"SELECT q.id FROM questions q
JOIN exams e ON q.exam_id = e.id
WHERE e.lesson_id = ? AND (q.question_text LIKE '%مشتق%' OR q.question_text LIKE '%f\'(x)%')
LIMIT 1",
[$lessonId]
);
if ($mismatched) {
$badExams = Database::select("SELECT id FROM exams WHERE lesson_id = ? AND scope = 'in_video_checkpoint'", [$lessonId]);
foreach ($badExams as $be) {
Database::query("DELETE FROM exams WHERE id = ?", [$be['id']]);
}
}
} catch (\Throwable $e) {
error_log("Curriculum self-healing notice: " . $e->getMessage());
}
}
// Playback is read-only. Checkpoints are published only by the review
// workflow after it verifies the video transcript and lesson Markdown.
// 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']]);
if (!$q) {
continue;
}
$opts = [];
$opts = Database::select("SELECT id, option_text, is_correct, feedback_text FROM question_options WHERE question_id = ?", [$q['id']]);
if (count($opts) < 2) {
continue;
}
$checkpoints[] = [
'exam_id' => (int)$ex['exam_id'],
'question_id' => (int)$q['id'],
'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' && !empty($lesson['video_uuid'])) {
$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_url' => $lesson['hls_url'] ?: ('/api/videos/stream/' . $lesson['video_uuid']),
'video_uuid' => $lesson['video_uuid'],
'is_direct' => true,
'is_ready' => true,
];
} elseif (!empty($lesson['bunny_video_id'])) {
// Bunny Stream Signed Playback
$bunnyId = $lesson['bunny_video_id'];
$signedData = VideoService::generateBunnySignedPlayback($bunnyId, 10800); // 3-hour token
$playbackInfo = array_merge(['storage_type' => 'bunny_stream', 'is_ready' => true], $signedData);
} elseif (!empty($lesson['ai_video_url'])) {
$playbackInfo = [
'storage_type' => 'direct_url',
'video_url' => $lesson['ai_video_url'],
'hls_url' => $lesson['ai_video_url'],
'stream_url' => $lesson['ai_video_url'],
'is_ready' => true,
];
} elseif (!empty($lesson['hls_url'])) {
$playbackInfo = [
'storage_type' => 'hls_stream',
'video_url' => $lesson['hls_url'],
'hls_url' => $lesson['hls_url'],
'stream_url' => $lesson['hls_url'],
'is_ready' => true,
];
} else {
$response->status(409)->json(['status' => 'error', 'message' => 'لم يتم ربط فيديو R2 جاهز بهذا الدرس بعد']);
return;
}
$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;
} else {
$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
];
}
if (empty($availableVersions)) {
$availableVersions[] = [
'lesson_id' => $lessonId,
'is_ai' => true,
'teacher_name' => 'منصة صَقِل الرقمية',
'school_name' => 'المركز التعليمي المعتمد',
'label' => 'الشرح الرقمي الرسمي المعتمد 🤖',
'is_recommended' => true,
'playback' => $playbackInfo
];
}
// 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']);
}
}