feat: Implement Dual Video Pipeline (Direct Server API Upload & Bunny.net Stream Cloud CDN + DRM) with Socratic interactive player
This commit is contained in:
@@ -0,0 +1,265 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controllers;
|
||||
|
||||
use App\Core\Request;
|
||||
use App\Core\Response;
|
||||
use App\Core\Database;
|
||||
use App\Core\Security;
|
||||
use App\Services\VideoService;
|
||||
|
||||
class VideoController
|
||||
{
|
||||
/**
|
||||
* Upload Video directly via API and create/attach to Lesson
|
||||
* POST /api/teacher/videos/upload-direct
|
||||
*/
|
||||
public function uploadDirect(Request $request, Response $response): void
|
||||
{
|
||||
VideoService::ensureSchema();
|
||||
|
||||
$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);
|
||||
|
||||
if (!$courseId || empty($title)) {
|
||||
$response->status(400)->json([
|
||||
'status' => 'error',
|
||||
'message' => 'معرف الدورة وعنوان الدرس مطلوبان'
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
// Verify Course Ownership
|
||||
$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;
|
||||
}
|
||||
|
||||
if (empty($_FILES['video'])) {
|
||||
$response->status(400)->json([
|
||||
'status' => 'error',
|
||||
'message' => 'يرجى إرفاق ملف الفيديو في الطلب (key: video)'
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$uploadResult = VideoService::handleDirectUpload($_FILES['video'], $courseId, $title);
|
||||
|
||||
// Insert lesson record
|
||||
$lessonId = Database::insert(
|
||||
"INSERT INTO lessons (course_id, title, sequence_order, storage_type, video_uuid, bunny_video_id, local_path, duration_seconds, is_free_preview, encoding_status)
|
||||
VALUES (?, ?, ?, 'api_upload', ?, '', ?, 0, 0, 'ready')",
|
||||
[
|
||||
$courseId,
|
||||
$title,
|
||||
$seqOrder,
|
||||
$uploadResult['video_uuid'],
|
||||
$uploadResult['local_path']
|
||||
]
|
||||
);
|
||||
|
||||
$response->status(201)->json([
|
||||
'status' => 'success',
|
||||
'message' => 'تم رفع وحفظ ملف الفيديو بنجاح عبر الـ API المباشر!',
|
||||
'data' => array_merge($uploadResult, [
|
||||
'lesson_id' => $lessonId,
|
||||
'title' => $title,
|
||||
'course_id' => $courseId
|
||||
])
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
$response->status(500)->json([
|
||||
'status' => 'error',
|
||||
'message' => $e->getMessage()
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 an existing or newly created Bunny Video ID to a Course Lesson
|
||||
* POST /api/teacher/videos/bunny-link
|
||||
*/
|
||||
public function linkBunnyLesson(Request $request, Response $response): void
|
||||
{
|
||||
VideoService::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'] ?? 0);
|
||||
$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]
|
||||
);
|
||||
|
||||
$response->status(201)->json([
|
||||
'status' => 'success',
|
||||
'message' => 'تم ربط درس Bunny Stream بنجاح!',
|
||||
'data' => [
|
||||
'lesson_id' => $lessonId,
|
||||
'bunny_video_id' => $bunnyVideoId,
|
||||
'storage_type' => 'bunny_stream'
|
||||
]
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Lesson Playback Data with Signed DRM Tokens and Socratic Checkpoints
|
||||
* GET /api/lessons/{id}/playback
|
||||
*/
|
||||
public function getPlaybackData(Request $request, Response $response): void
|
||||
{
|
||||
VideoService::ensureSchema();
|
||||
|
||||
$lessonId = (int)$request->getParam('id');
|
||||
if (!$lessonId) {
|
||||
$response->status(400)->json(['status' => 'error', 'message' => 'معرف الدرس مطلوب']);
|
||||
return;
|
||||
}
|
||||
|
||||
$lesson = Database::selectOne("SELECT * FROM lessons WHERE id = ? LIMIT 1", [$lessonId]);
|
||||
if (!$lesson) {
|
||||
$response->status(404)->json(['status' => 'error', 'message' => 'الدرس غير موجود']);
|
||||
return;
|
||||
}
|
||||
|
||||
// Fetch attached in-video Socratic Checkpoints
|
||||
$checkpoints = Database::select(
|
||||
"SELECT e.id as exam_id, e.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]
|
||||
);
|
||||
|
||||
$storageType = $lesson['storage_type'] ?? 'bunny_stream';
|
||||
$playbackInfo = [];
|
||||
|
||||
if ($storageType === 'api_upload') {
|
||||
$playbackInfo = [
|
||||
'storage_type' => 'api_upload',
|
||||
'stream_url' => '/api/videos/stream/' . $lesson['video_uuid'],
|
||||
'video_uuid' => $lesson['video_uuid'],
|
||||
'is_direct' => true
|
||||
];
|
||||
} else {
|
||||
// Bunny Stream Signed Playback
|
||||
$bunnyId = $lesson['bunny_video_id'] ?: 'mock-bunny-guid-2026';
|
||||
$signedData = VideoService::generateBunnySignedPlayback($bunnyId, 10800); // 3-hour token
|
||||
$playbackInfo = array_merge(['storage_type' => 'bunny_stream'], $signedData);
|
||||
}
|
||||
|
||||
$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,
|
||||
'checkpoints' => $checkpoints ?: []
|
||||
]
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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); // 3 = Finished/Ready, 4 = Failed
|
||||
|
||||
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']);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user