388 lines
15 KiB
PHP
388 lines
15 KiB
PHP
<?php
|
|
|
|
namespace App\Services;
|
|
|
|
use App\Core\Database;
|
|
use App\Core\Security;
|
|
|
|
/**
|
|
* Universal Video Management & Streaming Service
|
|
* Supports:
|
|
* 1. Direct Server API Upload & HTTP 206 Partial Content Range Streaming
|
|
* 2. Bunny.net Stream Cloud Video CDN, TUS upload, and SHA256 DRM Token Signing
|
|
*/
|
|
class VideoService
|
|
{
|
|
private static bool $schemaChecked = false;
|
|
|
|
/**
|
|
* Ensure database columns for dual storage exist in `lessons` table
|
|
*/
|
|
public static function ensureSchema(): void
|
|
{
|
|
if (self::$schemaChecked) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
// Check if storage_type column exists
|
|
$cols = Database::select("SHOW COLUMNS FROM lessons LIKE 'storage_type'");
|
|
if (empty($cols)) {
|
|
Database::query("ALTER TABLE lessons ADD COLUMN storage_type ENUM('bunny_stream', 'api_upload', 'external_url') NOT NULL DEFAULT 'bunny_stream' AFTER sequence_order");
|
|
}
|
|
|
|
$colsUuid = Database::select("SHOW COLUMNS FROM lessons LIKE 'video_uuid'");
|
|
if (empty($colsUuid)) {
|
|
Database::query("ALTER TABLE lessons ADD COLUMN video_uuid CHAR(36) NULL AFTER storage_type");
|
|
}
|
|
|
|
$colsPath = Database::select("SHOW COLUMNS FROM lessons LIKE 'local_path'");
|
|
if (empty($colsPath)) {
|
|
Database::query("ALTER TABLE lessons ADD COLUMN local_path VARCHAR(500) NULL AFTER bunny_video_id");
|
|
}
|
|
|
|
$colsHls = Database::select("SHOW COLUMNS FROM lessons LIKE 'hls_url'");
|
|
if (empty($colsHls)) {
|
|
Database::query("ALTER TABLE lessons ADD COLUMN hls_url VARCHAR(500) NULL AFTER local_path");
|
|
}
|
|
|
|
$colsThumb = Database::select("SHOW COLUMNS FROM lessons LIKE 'thumbnail_url'");
|
|
if (empty($colsThumb)) {
|
|
Database::query("ALTER TABLE lessons ADD COLUMN thumbnail_url VARCHAR(500) NULL AFTER hls_url");
|
|
}
|
|
|
|
$colsStatus = Database::select("SHOW COLUMNS FROM lessons LIKE 'encoding_status'");
|
|
if (empty($colsStatus)) {
|
|
Database::query("ALTER TABLE lessons ADD COLUMN encoding_status ENUM('pending', 'processing', 'ready', 'failed') NOT NULL DEFAULT 'ready' AFTER is_free_preview");
|
|
}
|
|
|
|
self::$schemaChecked = true;
|
|
} catch (\Throwable $e) {
|
|
error_log("VideoService schema ensure note: " . $e->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
|
|
];
|
|
}
|
|
}
|