getMessage()); } } // ========================================================================= // METHOD 1: Direct Server API Upload & HTTP 206 Range Streaming // ========================================================================= /** * Handles direct file upload from multipart request * * @param array $file $_FILES['video'] * @param int $courseId * @param string $title * @return array */ public static function handleDirectUpload(array $file, int $courseId, string $title): array { self::ensureSchema(); if (empty($file) || $file['error'] !== UPLOAD_ERR_OK) { $errorMsg = match ($file['error'] ?? -1) { UPLOAD_ERR_INI_SIZE => 'حجم الفيديو يتجاوز الحد المسموح في إعدادات السيرفر (upload_max_filesize)', UPLOAD_ERR_FORM_SIZE => 'حجم الفيديو يتجاوز الحد المسموح في النموذج', UPLOAD_ERR_PARTIAL => 'تم رفع جزء من الملف فقط، يرجى إعادة المحاولة', UPLOAD_ERR_NO_FILE => 'لم يتم تحديد أي ملف فيديو', default => 'حدث خطأ أثناء استلام ملف الفيديو على السيرفر' }; throw new \RuntimeException($errorMsg); } // Validate Extension & Mime $allowedMimes = ['video/mp4', 'video/webm', 'video/quicktime', 'video/x-matroska', 'video/ogg']; $finfo = finfo_open(FILEINFO_MIME_TYPE); $mime = finfo_file($finfo, $file['tmp_name']); finfo_close($finfo); $ext = strtolower(pathinfo($file['name'], PATHINFO_EXTENSION)); $allowedExts = ['mp4', 'webm', 'mov', 'mkv', 'ogg']; if (!in_array($ext, $allowedExts) || !in_array($mime, $allowedMimes)) { throw new \InvalidArgumentException('نوع الملف غير مدعوم. الصيغ المدعومة هي: MP4, WebM, MOV, MKV.'); } // Generate UUID & Target Directory $videoUuid = sprintf('%04x%04x-%04x-%04x-%04x-%04x%04x%04x', mt_rand(0, 0xffff), mt_rand(0, 0xffff), mt_rand(0, 0xffff), mt_rand(0, 0x0fff) | 0x4000, mt_rand(0, 0x3fff) | 0x8000, mt_rand(0, 0xffff), mt_rand(0, 0xffff), mt_rand(0, 0xffff) ); $storageBase = dirname(__DIR__, 2) . '/storage/videos/' . $courseId; if (!is_dir($storageBase)) { mkdir($storageBase, 0755, true); } $targetFileName = $videoUuid . '.' . $ext; $targetPath = $storageBase . '/' . $targetFileName; if (!move_uploaded_file($file['tmp_name'], $targetPath)) { throw new \RuntimeException('فشل حفظ ملف الفيديو على السيرفر، يرجى فحص أذونات المجلد.'); } $relativePath = 'storage/videos/' . $courseId . '/' . $targetFileName; $fileSize = filesize($targetPath); return [ 'video_uuid' => $videoUuid, 'local_path' => $relativePath, 'file_size' => $fileSize, 'mime_type' => $mime, 'extension' => $ext, 'storage_type' => 'api_upload', 'encoding_status' => 'ready', 'stream_url' => '/api/videos/stream/' . $videoUuid ]; } /** * Stream Local Video file supporting HTTP 206 Partial Content (Range requests) * Allows seamless scrubbing/seeking in browser without loading full video */ public static function streamLocalVideo(string $videoUuid): void { self::ensureSchema(); $lesson = Database::selectOne("SELECT * FROM lessons WHERE video_uuid = ? LIMIT 1", [$videoUuid]); if (!$lesson || empty($lesson['local_path'])) { http_response_code(404); header('Content-Type: application/json; charset=utf-8'); echo json_encode(['status' => 'error', 'message' => 'ملف الفيديو غير موجود']); exit; } $fullPath = dirname(__DIR__, 2) . '/' . ltrim($lesson['local_path'], '/'); if (!file_exists($fullPath)) { http_response_code(404); header('Content-Type: application/json; charset=utf-8'); echo json_encode(['status' => 'error', 'message' => 'الملف المحفوظ مفقود على القرص']); exit; } $fileSize = filesize($fullPath); $fp = @fopen($fullPath, 'rb'); if (!$fp) { http_response_code(500); echo json_encode(['status' => 'error', 'message' => 'تعذر فتح ملف الفيديو']); exit; } $mime = 'video/mp4'; $ext = strtolower(pathinfo($fullPath, PATHINFO_EXTENSION)); if ($ext === 'webm') $mime = 'video/webm'; if ($ext === 'mov') $mime = 'video/quicktime'; if ($ext === 'mkv') $mime = 'video/x-matroska'; $start = 0; $end = $fileSize - 1; // Clean previous buffers if (ob_get_length()) { ob_clean(); } header('Content-Type: ' . $mime); header('Accept-Ranges: bytes'); header('Cache-Control: public, max-age=3600'); header('X-Content-Type-Options: nosniff'); // Check if Range header is requested by video player if (isset($_SERVER['HTTP_RANGE'])) { $range = $_SERVER['HTTP_RANGE']; if (preg_match('/bytes=\h*(\d+)-(\d*)[\D.*]?/i', $range, $matches)) { $start = (int)$matches[1]; if (!empty($matches[2])) { $end = (int)$matches[2]; } } if ($start > $end || $start >= $fileSize) { http_response_code(416); header("Content-Range: bytes */{$fileSize}"); fclose($fp); exit; } http_response_code(206); header("Content-Range: bytes {$start}-{$end}/{$fileSize}"); $length = ($end - $start) + 1; header("Content-Length: {$length}"); } else { http_response_code(200); header("Content-Length: {$fileSize}"); } fseek($fp, $start); $bufferSize = 1024 * 128; // 128KB chunk stream while (!feof($fp) && ($pos = ftell($fp)) <= $end) { if ($pos + $bufferSize > $end) { $bufferSize = $end - $pos + 1; } if ($bufferSize <= 0) break; echo fread($fp, $bufferSize); flush(); } fclose($fp); exit; } // ========================================================================= // METHOD 2: Bunny.net Stream Cloud Video CDN & Token DRM // ========================================================================= /** * Get Bunny Stream API Credentials from Environment */ private static function getBunnyConfig(): array { return [ 'library_id' => getenv('BUNNY_STREAM_LIBRARY_ID') ?: '285491', 'api_key' => getenv('BUNNY_STREAM_API_KEY') ?: '', 'token_key' => getenv('BUNNY_STREAM_TOKEN_KEY') ?: getenv('JWT_SECRET') ?: 'SaqelSecureBunnyToken2026', 'pull_zone_host' => getenv('BUNNY_STREAM_HOST') ?: 'vz-saqel.b-cdn.net', 'embed_host' => 'iframe.mediadelivery.net' ]; } /** * Create a new Video placeholder in Bunny Stream Library * POST https://video.bunnycdn.com/library/{libraryId}/videos */ public static function createBunnyVideo(string $title, ?string $collectionId = null): array { $config = self::getBunnyConfig(); if (empty($config['api_key'])) { // Mock response if API key is not yet set in production .env $mockGuid = sprintf('%04x%04x-%04x-%04x-%04x-%04x%04x%04x', mt_rand(0, 0xffff), mt_rand(0, 0xffff), mt_rand(0, 0xffff), mt_rand(0, 0x0fff) | 0x4000, mt_rand(0, 0x3fff) | 0x8000, mt_rand(0, 0xffff), mt_rand(0, 0xffff), mt_rand(0, 0xffff) ); return [ 'video_id' => $mockGuid, 'library_id' => $config['library_id'], 'title' => $title, 'status' => 'created', 'is_mock' => true, '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"; $data = ['title' => $title]; if ($collectionId) { $data['collectionId'] = $collectionId; } $ch = curl_init($url); curl_setopt_array($ch, [ CURLOPT_POST => true, CURLOPT_POSTFIELDS => json_encode($data), CURLOPT_HTTPHEADER => [ 'AccessKey: ' . $config['api_key'], 'Content-Type: application/json', 'Accept: application/json' ], CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 15 ]); $response = curl_exec($ch); $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); curl_close($ch); $json = json_decode($response, true); if ($httpCode >= 200 && $httpCode < 300 && !empty($json['guid'])) { $guid = $json['guid']; return [ 'video_id' => $guid, 'library_id' => $config['library_id'], 'title' => $json['title'] ?? $title, 'status' => 'created', 'direct_embed' => "https://{$config['embed_host']}/embed/{$config['library_id']}/{$guid}", 'hls_playlist' => "https://{$config['pull_zone_host']}/{$guid}/playlist.m3u8" ]; } throw new \RuntimeException("Bunny Stream API Error ({$httpCode}): " . ($json['message'] ?? $response)); } /** * Upload binary video to an existing Bunny Video ID * PUT https://video.bunnycdn.com/library/{libraryId}/videos/{videoId} */ public static function uploadBunnyVideo(string $videoId, string $filePath): bool { $config = self::getBunnyConfig(); if (empty($config['api_key']) || !file_exists($filePath)) { return false; } $url = "https://video.bunnycdn.com/library/{$config['library_id']}/videos/{$videoId}"; $fp = fopen($filePath, 'r'); $fileSize = filesize($filePath); $ch = curl_init($url); curl_setopt_array($ch, [ CURLOPT_PUT => true, CURLOPT_INFILE => $fp, CURLOPT_INFILESIZE => $fileSize, CURLOPT_HTTPHEADER => [ 'AccessKey: ' . $config['api_key'], 'Content-Type: application/octet-stream' ], CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 600 // 10 minutes for large videos ]); $response = curl_exec($ch); $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); fclose($fp); curl_close($ch); return ($httpCode >= 200 && $httpCode < 300); } /** * Generate Signed DRM Playback URL using Bunny Token Authentication (SHA256 HMAC) * Protects video from hotlinking, unauthorized sharing, and downloading * * @param string $videoId Bunny Video GUID * @param int $expiresInSeconds Token validity window (default: 2 hours) * @return array */ public static function generateBunnySignedPlayback(string $videoId, int $expiresInSeconds = 7200): array { $config = self::getBunnyConfig(); $libraryId = $config['library_id']; $tokenKey = $config['token_key']; $expires = time() + $expiresInSeconds; // Bunny Stream Token Hash Formula: // token = SHA256(securityToken + videoId + expirationTime) $hashable = $tokenKey . $videoId . $expires; $token = hash('sha256', $hashable); $embedUrl = "https://{$config['embed_host']}/embed/{$libraryId}/{$videoId}?token={$token}&expires={$expires}"; $hlsUrl = "https://{$config['pull_zone_host']}/{$videoId}/playlist.m3u8?token={$token}&expires={$expires}"; $thumbUrl = "https://{$config['pull_zone_host']}/{$videoId}/thumbnail.jpg?token={$token}&expires={$expires}"; return [ 'video_id' => $videoId, 'library_id' => $libraryId, 'embed_url' => $embedUrl, 'hls_url' => $hlsUrl, 'thumb_url' => $thumbUrl, 'token' => $token, 'expires_at' => $expires ]; } }