Update Saqel Platform: 2026-09-02 08:12:57

This commit is contained in:
Hamza-Ayed
2026-09-02 08:12:57 +03:00
parent 36939a50c1
commit a4536395c5
5 changed files with 107 additions and 60 deletions
@@ -103,6 +103,11 @@ class CurriculumController
}
$processingDir = __DIR__ . '/../../storage/curriculum/processing';
$taskId = basename($taskId);
if (!preg_match('/^task_[A-Za-z0-9_.-]+$|^video_[A-Za-z0-9_.-]+$/', $taskId)) {
$response->status(400)->json(['status' => 'error', 'message' => 'رقم المهمة غير صالح']);
return;
}
$stateFile = $processingDir . '/' . $taskId . '.json';
if (!file_exists($stateFile)) {
@@ -261,65 +266,30 @@ class CurriculumController
}
try {
// Resolve a valid FK-safe course_id for the master ministry curriculum.
// IMPORTANT: course_id = 0 causes SQLSTATE[23000] FK violation because
// the `lessons` table enforces courses(id) ON DELETE CASCADE.
$manifestLesson = CurriculumService::findLessonByFile($file);
$title = $manifestLesson['title'] ?? basename($file, '.md');
$existingLesson = \App\Core\Database::selectOne(
"SELECT id, course_id FROM lessons WHERE curriculum_key = ? OR title = ? OR markdown_content LIKE ? LIMIT 1",
[$file, $title, '%' . $file . '%']
);
$courseId = $existingLesson
? (int)$existingLesson['course_id']
: CurriculumService::getOrCreateSystemCourse();
// 1. Upload & Transcode to HLS & Push to R2 (handled by VideoService)
$uploadResult = \App\Services\VideoService::handleDirectUpload($_FILES['video_file'], $courseId, $title);
$videoUrl = $uploadResult['r2_url'] ?? $uploadResult['hls_url'];
// 2. Bind the upload to the synchronized Manifest lesson when present.
if ($existingLesson) {
$lessonId = (int)$existingLesson['id'];
\App\Core\Database::query(
"UPDATE lessons SET curriculum_key = ?, storage_type = 'api_upload', video_uuid = ?, bunny_video_id = '', local_path = ?, hls_url = ?, thumbnail_url = ?, duration_seconds = ?, encoding_status = 'ready', ai_video_url = ? WHERE id = ?",
[$file, $uploadResult['video_uuid'], $uploadResult['local_path'], $uploadResult['hls_url'], $uploadResult['thumbnail_url'], $uploadResult['duration'] ?? 0, $videoUrl, $lessonId]
);
} else {
$lessonId = \App\Core\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, ai_video_url)
VALUES (?, ?, ?, 1, 'api_upload', ?, '', ?, ?, ?, ?, 0, 'ready', ?)",
[$courseId, $title, $file, $uploadResult['video_uuid'], $uploadResult['local_path'], $uploadResult['hls_url'], $uploadResult['thumbnail_url'], $uploadResult['duration'] ?? 0, $videoUrl]
);
// Video transcoding and R2 upload can take longer than a web request.
// Persist the upload and hand the heavy work to a CLI worker.
$processingDir = __DIR__ . '/../../storage/curriculum/processing';
if (!is_dir($processingDir)) mkdir($processingDir, 0755, true);
$taskId = uniqid('video_', true);
$extension = strtolower(pathinfo($_FILES['video_file']['name'], PATHINFO_EXTENSION)) ?: 'mp4';
$savedVideoPath = $processingDir . '/' . $taskId . '.' . $extension;
if (!move_uploaded_file($_FILES['video_file']['tmp_name'], $savedVideoPath)) {
throw new \RuntimeException('تعذر حفظ ملف الفيديو المؤقت على السيرفر.');
}
file_put_contents($processingDir . '/' . $taskId . '.json', json_encode([
'task_id' => $taskId, 'status' => 'queued', 'progress' => 5,
'message' => 'تم استلام الفيديو، وسيبدأ التحويل والرفع السحابي الآن.',
'file' => $file, 'video_path' => $savedVideoPath,
'original_name' => $_FILES['video_file']['name']
], JSON_UNESCAPED_UNICODE));
// 3. Trigger Autonomous Zero-Touch AI Analysis & Socratic Checkpoint Generation
\App\Services\AiVideoAnalyzerService::processLessonAutonomously($lessonId);
$response->status(202)->json(['status' => 'processing', 'task_id' => $taskId, 'message' => 'تم استلام الفيديو وجاري تحويله ورفعه إلى Cloudflare R2...']);
if (function_exists('fastcgi_finish_request')) fastcgi_finish_request();
elseif (ob_get_level()) { ob_end_flush(); flush(); }
$scriptPath = realpath(__DIR__ . '/../../scripts/video_upload_worker.php');
exec('php ' . escapeshellarg($scriptPath) . ' ' . escapeshellarg($taskId) . ' > /dev/null 2>&1 &');
return;
// 4. Update Lesson AI assets in manifest.json & filesystem
$currentAssets = CurriculumService::getLessonAiAssets($file);
$currentAssets['ai_video_url'] = $videoUrl;
CurriculumService::saveLessonAiAssets($file, $currentAssets);
// 5. Update any existing curriculum lesson rows with this video
try {
\App\Core\Database::query(
"UPDATE lessons SET ai_video_url = ? WHERE title LIKE ? OR markdown_content LIKE ?",
[$videoUrl, "%{$title}%", "%{$file}%"]
);
} catch (\Throwable $dbEx) {
error_log("Video DB sync note: " . $dbEx->getMessage());
}
$response->status(201)->json([
'status' => 'success',
'message' => 'تم رفع وتثبيت فيديو الشرح ومعالجته HLS وربطه بالذكاء الاصطناعي بنجاح!',
'video_url' => $videoUrl,
'course_id' => $courseId,
'lesson_id' => $lessonId,
'data' => $uploadResult
]);
} catch (\Throwable $e) {
$response->status(500)->json([
'status' => 'error',
+8 -1
View File
@@ -147,7 +147,14 @@ class VideoService
$targetFileName = $videoUuid . '.' . $ext;
$targetPath = $storageBase . '/' . $targetFileName;
if (!move_uploaded_file($file['tmp_name'], $targetPath)) {
$moved = is_uploaded_file($file['tmp_name'])
? move_uploaded_file($file['tmp_name'], $targetPath)
: @rename($file['tmp_name'], $targetPath);
if (!$moved && is_file($file['tmp_name'])) {
$moved = @copy($file['tmp_name'], $targetPath);
if ($moved) @unlink($file['tmp_name']);
}
if (!$moved) {
throw new \RuntimeException('فشل حفظ ملف الفيديو على السيرفر، يرجى فحص أذونات المجلد.');
}
+16 -3
View File
@@ -503,8 +503,15 @@ class CurriculumStudio
method: 'POST',
body: formData
});
const data = await res.json();
if (res.ok && data.status === 'success') {
const raw = await res.text();
let data;
try { data = JSON.parse(raw); }
catch (_) { throw new Error(`HTTP ${res.status}: ${raw.slice(0, 240) || 'استجابة غير مفهومة من السيرفر'}`); }
if (res.ok && data.status === 'processing') {
currentUploadTaskId = data.task_id;
statusSpan.textContent = data.message || '⏳ تم استلام الفيديو، جاري تحويله ورفعه إلى Cloudflare...';
pollTaskStatus(data.task_id, file.name);
} else if (res.ok && data.status === 'success') {
document.getElementById('ai_video_url_input').value = data.video_url;
const player = document.getElementById('lesson_video_player');
player.src = data.video_url;
@@ -518,7 +525,7 @@ class CurriculumStudio
} catch(err) {
console.error(err);
statusSpan.textContent = '⚠️ خطأ بالاتصال';
alert('حدث خطأ أثناء رفع الفيديو.');
alert('حدث خطأ أثناء رفع الفيديو: ' + (err.message || 'تعذر الاتصال بالخادم'));
}
}
@@ -721,6 +728,12 @@ class CurriculumStudio
pBar.style.width = '100%';
pPercent.textContent = '100%';
pText.textContent = `🎉 تم تفريغ واعتماد [${fileName}] كمنهاج حي بنجاح!`;
if (state.video_url) {
document.getElementById('ai_video_url_input').value = state.video_url;
const player = document.getElementById('lesson_video_player');
player.src = state.video_url;
player.style.display = 'block';
}
document.getElementById('btn_close_overlay').style.display = 'inline-block';
resetUploadBtn();
setTimeout(() => {
+49
View File
@@ -0,0 +1,49 @@
<?php
require_once __DIR__ . '/../app/bootstrap.php';
use App\Core\Database;
use App\Services\CurriculumService;
use App\Services\VideoService;
use App\Services\AiVideoAnalyzerService;
$taskId = $argv[1] ?? '';
$dir = __DIR__ . '/../storage/curriculum/processing';
$stateFile = $dir . '/' . basename($taskId) . '.json';
function updateVideoTask(string $file, array $changes): void {
$state = file_exists($file) ? (json_decode(file_get_contents($file), true) ?: []) : [];
file_put_contents($file, json_encode(array_merge($state, $changes), JSON_UNESCAPED_UNICODE));
}
if ($taskId === '' || !file_exists($stateFile)) exit(1);
$task = json_decode(file_get_contents($stateFile), true) ?: [];
try {
updateVideoTask($stateFile, ['status' => 'processing', 'progress' => 15, 'message' => 'جاري تحويل الفيديو إلى HLS...']);
$file = (string)($task['file'] ?? '');
$manifestLesson = CurriculumService::findLessonByFile($file);
$title = $manifestLesson['title'] ?? basename($file, '.md');
$existing = Database::selectOne('SELECT id, course_id FROM lessons WHERE curriculum_key = ? OR title = ? OR markdown_content LIKE ? LIMIT 1', [$file, $title, '%' . $file . '%']);
$courseId = $existing ? (int)$existing['course_id'] : CurriculumService::getOrCreateSystemCourse();
$uploaded = VideoService::handleDirectUpload([
'error' => UPLOAD_ERR_OK, 'tmp_name' => $task['video_path'],
'name' => $task['original_name'] ?? 'lesson.mp4', 'size' => filesize($task['video_path'])
], $courseId, $title);
updateVideoTask($stateFile, ['progress' => 80, 'message' => 'تم رفع حزمة HLS إلى Cloudflare R2، جاري تثبيت الدرس...']);
$videoUrl = $uploaded['hls_url'] ?? $uploaded['r2_url'] ?? null;
if ($existing) {
$lessonId = (int)$existing['id'];
Database::query("UPDATE lessons SET curriculum_key = ?, storage_type = 'api_upload', video_uuid = ?, bunny_video_id = '', local_path = ?, hls_url = ?, thumbnail_url = ?, duration_seconds = ?, encoding_status = 'ready', ai_video_url = ? WHERE id = ?", [$file, $uploaded['video_uuid'], $uploaded['local_path'], $uploaded['hls_url'], $uploaded['thumbnail_url'], $uploaded['duration'] ?? 0, $videoUrl, $lessonId]);
} else {
$lessonId = 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, ai_video_url) VALUES (?, ?, ?, 1, 'api_upload', ?, '', ?, ?, ?, ?, 0, 'ready', ?)", [$courseId, $title, $file, $uploaded['video_uuid'], $uploaded['local_path'], $uploaded['hls_url'], $uploaded['thumbnail_url'], $uploaded['duration'] ?? 0, $videoUrl]);
}
try { AiVideoAnalyzerService::processLessonAutonomously((int)$lessonId); } catch (Throwable $e) { error_log('Video AI analysis deferred: ' . $e->getMessage()); }
$assets = CurriculumService::getLessonAiAssets($file);
$assets['ai_video_url'] = $videoUrl;
CurriculumService::saveLessonAiAssets($file, $assets);
updateVideoTask($stateFile, ['status' => 'completed', 'progress' => 100, 'message' => 'تم رفع الفيديو وربطه بالدرس بنجاح.', 'lesson_id' => (int)$lessonId, 'video_url' => $videoUrl]);
} catch (Throwable $e) {
if (!empty($task['video_path']) && is_file($task['video_path'])) @unlink($task['video_path']);
updateVideoTask($stateFile, ['status' => 'error', 'progress' => 100, 'message' => $e->getMessage()]);
}
+8
View File
@@ -10,6 +10,7 @@
- تسجيل الدخول عبر OTP والرقم الوطني مع تخزين آمن للتوكن.
- شجرة مناهج ديناميكية من الخادم ومحتوى فعلي للصف العاشر.
- رفع الفيديو، تحويله إلى HLS عبر FFmpeg، ورفع حزمة HLS إلى Cloudflare R2.
- رفع الفيديو من Curriculum Studio أصبح غير متزامن: الطلب يحفظ الملف ويرجع فورًا، ثم يعالج `backend/scripts/video_upload_worker.php` التحويل والرفع والربط بالدرس.
- تشغيل الفيديو عبر HLS من R2 أو المسار المحلي عند عدم توفر R2.
- فحوص سقراطية مرتبطة بالدرس من قاعدة البيانات.
- بوابات Web للطالب والمعلم وولي الأمر واستوديو المناهج.
@@ -36,3 +37,10 @@
- Storage: Cloudflare R2 لحزمة HLS والملفات، مع تخزين محلي مؤقت أثناء التحويل.
- Apps: `student_app` موحد للطالب وولي الأمر، `teacher_app` مستقل، و`admin_app` مستقل.
- لا يعتمد التشغيل على Laravel أو Docker.
## خط سير الفيديو المعتمد
الربط المنطقي هو: `curriculum_key` للدرس ← `lesson.video_uuid` ← حزمة HLS في R2 على المسار `hls/{course_id}/{video_uuid}/`.
مجلدات R2 القديمة مثل `hls/0` و`hls/1` و`hls/4` ليست فهرس الدروس؛ المرجع المعتمد هو قاعدة البيانات و`curriculum_key`، لذلك لا تُحذف قبل ترحيلها والتحقق من كل درس.
بعد نشر الكود يجب أن يكون عامل PHP قادرًا على الكتابة إلى `backend/storage/curriculum/processing` و`backend/storage/videos` و`backend/storage/hls`، وأن يكون FFmpeg مثبتًا على الخادم. وللفيديوهات الكبيرة يجب ضبط `upload_max_filesize` و`post_max_size` في PHP و`client_max_body_size` في Nginx بما يتجاوز حجم أكبر فيديو.