Update Saqel Platform: 2026-09-02 08:12:57
This commit is contained in:
@@ -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',
|
||||
|
||||
@@ -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('فشل حفظ ملف الفيديو على السيرفر، يرجى فحص أذونات المجلد.');
|
||||
}
|
||||
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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()]);
|
||||
}
|
||||
@@ -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 بما يتجاوز حجم أكبر فيديو.
|
||||
|
||||
Reference in New Issue
Block a user