From a4536395c57624050ac55014421fd89a88c51cdd Mon Sep 17 00:00:00 2001 From: Hamza-Ayed Date: Wed, 2 Sep 2026 08:12:57 +0300 Subject: [PATCH] Update Saqel Platform: 2026-09-02 08:12:57 --- .../app/Controllers/CurriculumController.php | 82 ++++++------------- backend/app/Services/VideoService.php | 9 +- backend/app/Views/CurriculumStudio.php | 19 ++++- backend/scripts/video_upload_worker.php | 49 +++++++++++ docs/IMPLEMENTATION_STATUS.md | 8 ++ 5 files changed, 107 insertions(+), 60 deletions(-) create mode 100644 backend/scripts/video_upload_worker.php diff --git a/backend/app/Controllers/CurriculumController.php b/backend/app/Controllers/CurriculumController.php index 04fb1aa..9d90f7b 100644 --- a/backend/app/Controllers/CurriculumController.php +++ b/backend/app/Controllers/CurriculumController.php @@ -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', diff --git a/backend/app/Services/VideoService.php b/backend/app/Services/VideoService.php index 8792955..9f695b7 100644 --- a/backend/app/Services/VideoService.php +++ b/backend/app/Services/VideoService.php @@ -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('فشل حفظ ملف الفيديو على السيرفر، يرجى فحص أذونات المجلد.'); } diff --git a/backend/app/Views/CurriculumStudio.php b/backend/app/Views/CurriculumStudio.php index 16533b1..19694c3 100644 --- a/backend/app/Views/CurriculumStudio.php +++ b/backend/app/Views/CurriculumStudio.php @@ -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(() => { diff --git a/backend/scripts/video_upload_worker.php b/backend/scripts/video_upload_worker.php new file mode 100644 index 0000000..0c2c543 --- /dev/null +++ b/backend/scripts/video_upload_worker.php @@ -0,0 +1,49 @@ + '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()]); +} diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md index 013af1f..30ec561 100644 --- a/docs/IMPLEMENTATION_STATUS.md +++ b/docs/IMPLEMENTATION_STATUS.md @@ -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 بما يتجاوز حجم أكبر فيديو.