Update Saqel Platform: 2026-09-02 00:55:56

This commit is contained in:
Hamza-Ayed
2026-09-02 00:55:56 +03:00
parent 1c0cd88a98
commit f4ab43d49a
18 changed files with 409 additions and 214 deletions
@@ -25,27 +25,6 @@ class GuardianController
[$guardianId]
);
// If no children linked (for demo purposes, link up to 3 students so the multi-child UI can be tested)
if (empty($children)) {
$demoStudents = Database::select("SELECT id FROM students LIMIT 3");
if (!empty($demoStudents)) {
foreach ($demoStudents as $demoStudent) {
Database::insert(
"INSERT IGNORE INTO guardian_students (guardian_id, student_id, relationship_type) VALUES (?, ?, 'father')",
[$guardianId, $demoStudent['id']]
);
}
// Re-fetch
$children = Database::select(
"SELECT s.id, s.uuid, s.full_name, s.national_id, s.grade_level, s.stream
FROM students s
JOIN guardian_students gs ON gs.student_id = s.id
WHERE gs.guardian_id = ?",
[$guardianId]
);
}
}
$dashboardData = [];
foreach ($children as $child) {
+44 -92
View File
@@ -400,67 +400,9 @@ class VideoController
}
if (!$lesson) {
// Construct a complete, standalone live playback payload directly from the requested lesson
$cleanTitle = str_replace(['_', '-'], ' ', $rawId);
$response->json([
'status' => 'success',
'data' => [
'lesson' => [
'id' => 1,
'course_id' => 0,
'title' => !empty($rawId) ? $cleanTitle : 'الدرس التفاعلي المعتمد',
'duration_seconds' => 900,
'is_free_preview' => true,
'storage_type' => 'api_upload'
],
'playback' => [
'storage_type' => 'api_upload',
'stream_url' => 'https://saqel.intaleqapp.com/api/videos/stream/demo-vector-lesson',
'hls_url' => 'https://saqel.intaleqapp.com/api/videos/stream/demo-vector-lesson',
'video_url' => 'https://saqel.intaleqapp.com/api/videos/stream/demo-vector-lesson',
'is_direct' => true
],
'available_versions' => [
[
'lesson_id' => 1,
'is_ai' => true,
'teacher_name' => 'الذكاء الاصطناعي الوزاري',
'school_name' => 'وزارة التربية والتعليم',
'label' => 'شرح الذكاء الاصطناعي المعتمد 🤖',
'is_recommended' => true,
'playback' => [
'video_url' => 'https://saqel.intaleqapp.com/api/videos/stream/demo-vector-lesson'
]
]
],
'chapters' => [],
'checkpoints' => [
[
'exam_id' => 1,
'timestamp_seconds' => 15,
'rewind_on_fail_seconds' => 30,
'question_text' => 'فحص الفهم السقراطي: ما هو المفهوم الأساسي المشروح في هذا الدرس؟',
'explanation' => 'التركيز على نتاجات التعلم يضمن استيعاب المتطلبات الوزارية بدقة.',
'options' => [
['id' => 1, 'text' => 'تطبيق القواعد والمفاهيم العلمية/الرياضية بدقة', 'is_correct' => true],
['id' => 2, 'text' => 'تجاهل الخطوات التحليلية', 'is_correct' => false],
['id' => 3, 'text' => 'الحفظ المجرد دون فهم سياقي', 'is_correct' => false],
]
],
[
'exam_id' => 2,
'timestamp_seconds' => 45,
'rewind_on_fail_seconds' => 25,
'question_text' => 'فحص الفهم التعمقي: كيف يتم التحقق من صحة الحل وتجنب الأخطاء الشائعة؟',
'explanation' => 'التعويض المباشر والتحليل المنطقي يثبتان صحة النتائج.',
'options' => [
['id' => 4, 'text' => 'بالتعويض المباشر في المسألة الأصلية والتحقق من التكافؤ', 'is_correct' => true],
['id' => 5, 'text' => 'بالاعتماد على التخمين', 'is_correct' => false],
]
]
]
]
$response->status(404)->json([
'status' => 'error',
'message' => 'الدرس غير موجود أو لم يتم نشر الفيديو الخاص به بعد'
]);
return;
}
@@ -497,7 +439,8 @@ class VideoController
}
$checkpoints[] = [
'exam_id' => (int)$ex['exam_id'],
'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'] ?? 'سؤال فحص فهم الفكرة:',
@@ -512,34 +455,6 @@ class VideoController
];
}
if (empty($checkpoints)) {
$checkpoints = [
[
'exam_id' => 1,
'timestamp_seconds' => 15,
'rewind_on_fail_seconds' => 30,
'question_text' => 'فحص الفهم الأولي: ما هو المفهوم الأساسي المشروح في هذه الفقرة من الدرس؟',
'explanation' => 'التركيز على نتاجات التعلم يضمن استيعاب المتطلبات الوزارية بدقة.',
'options' => [
['id' => 1, 'text' => 'تطبيق القواعد والمفاهيم العلمية/الرياضية بدقة', 'is_correct' => true],
['id' => 2, 'text' => 'تجاهل الخطوات التحليلية', 'is_correct' => false],
['id' => 3, 'text' => 'الحفظ المجرد دون فهم سياقي', 'is_correct' => false],
]
],
[
'exam_id' => 2,
'timestamp_seconds' => 45,
'rewind_on_fail_seconds' => 25,
'question_text' => 'فحص الفهم التعمقي: كيف يتم التحقق من صحة الحل وتجنب الأخطاء الشائعة؟',
'explanation' => 'التعويض المباشر والتحليل المنطقي يثبتان صحة النتائج.',
'options' => [
['id' => 4, 'text' => 'بالتعويض المباشر في المسألة الأصلية والتحقق من التكافؤ', 'is_correct' => true],
['id' => 5, 'text' => 'بالاعتماد على التخمين', 'is_correct' => false],
]
]
];
}
$storageType = $lesson['storage_type'] ?? 'bunny_stream';
$playbackInfo = [];
@@ -553,7 +468,11 @@ class VideoController
];
} else {
// Bunny Stream Signed Playback
$bunnyId = $lesson['bunny_video_id'] ?: 'mock-bunny-guid-2026';
$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);
}
@@ -587,7 +506,8 @@ class VideoController
'video_url' => $ver['ai_video_url'] ?: ($ver['hls_url'] ?: ('/api/videos/stream/' . $ver['video_uuid']))
];
} else {
$bId = $ver['bunny_video_id'] ?: 'mock-bunny-guid-2026';
$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);
}
@@ -637,6 +557,38 @@ class VideoController
]);
}
/** 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
+38 -1
View File
@@ -158,6 +158,17 @@ class VideoService
$r2Key = "videos/{$courseId}/{$targetFileName}";
$r2VideoUrl = self::uploadToR2($targetPath, $r2Key, $mime);
// The player must receive a complete HLS package, not only the source MP4.
// Upload the playlist and every segment using the same relative layout so
// relative segment references in index.m3u8 continue to resolve on R2.
if (!empty($hlsResult['hls_url'])) {
$hlsDir = dirname(__DIR__, 2) . '/storage/hls/' . $courseId . '/' . $videoUuid;
$r2HlsPlaylist = self::uploadHlsDirectoryToR2($hlsDir, $courseId, $videoUuid);
if ($r2HlsPlaylist) {
$hlsResult['hls_url'] = $r2HlsPlaylist;
}
}
$thumbnailPath = dirname(__DIR__, 2) . '/storage/hls/' . $courseId . '/' . $videoUuid . '/thumbnail.jpg';
if (file_exists($thumbnailPath)) {
$r2ThumbKey = "hls/{$courseId}/{$videoUuid}/thumbnail.jpg";
@@ -393,6 +404,8 @@ class VideoService
{
$config = self::getBunnyConfig();
if (empty($config['api_key'])) {
throw new \RuntimeException('Bunny Stream غير مهيأ: أضف مفاتيح الوصول قبل إنشاء فيديو سحابي.');
/*
$mockGuid = sprintf('%04x%04x-%04x-%04x-%04x-%04x%04x%04x',
mt_rand(0, 0xffff), mt_rand(0, 0xffff),
mt_rand(0, 0xffff),
@@ -409,6 +422,7 @@ class VideoService
'direct_embed' => "https://{$config['embed_host']}/embed/{$config['library_id']}/{$mockGuid}",
'hls_playlist' => "https://{$config['pull_zone_host']}/{$mockGuid}/playlist.m3u8"
];
*/
}
$url = "https://video.bunnycdn.com/library/{$config['library_id']}/videos";
@@ -581,10 +595,33 @@ class VideoService
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($httpCode >= 200 && $httpCode < 300) {
if ($httpCode >= 200 && $httpCode < 300 && !empty($config['public_url'])) {
return rtrim($config['public_url'], '/') . '/' . ltrim($r2Key, '/');
}
return null;
}
/** Upload a complete HLS directory and return the public playlist URL. */
private static function uploadHlsDirectoryToR2(string $directory, int $courseId, string $videoUuid): ?string
{
if (!is_dir($directory)) return null;
$playlistUrl = null;
foreach (glob($directory . '/*') ?: [] as $filePath) {
if (!is_file($filePath)) continue;
$extension = strtolower(pathinfo($filePath, PATHINFO_EXTENSION));
$contentType = match ($extension) {
'm3u8' => 'application/vnd.apple.mpegurl',
'ts' => 'video/mp2t',
'jpg', 'jpeg' => 'image/jpeg',
default => 'application/octet-stream',
};
$key = "hls/{$courseId}/{$videoUuid}/" . basename($filePath);
$url = self::uploadToR2($filePath, $key, $contentType);
if ($extension === 'm3u8' && $url) $playlistUrl = $url;
}
return $playlistUrl;
}
}