Files
saqel/backend/app/Services/VideoService.php
T

561 lines
22 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. Server-Side Automated FFmpeg HLS Transcoding (.m3u8 master playlist + .ts chunks)
* 3. 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());
}
}
/**
* Detect FFmpeg path on server (Ubuntu, CentOS, macOS, CloudPanel)
*/
public static function getFfmpegBinary(): ?string
{
$candidates = [
'/usr/bin/ffmpeg',
'/usr/local/bin/ffmpeg',
'/opt/homebrew/bin/ffmpeg',
'ffmpeg'
];
foreach ($candidates as $bin) {
$check = @shell_exec("which " . escapeshellarg($bin) . " 2>/dev/null");
if (!empty($check) || (file_exists($bin) && is_executable($bin))) {
return trim($check ?: $bin);
}
}
return null;
}
// =========================================================================
// 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);
// Attempt automated Server-Side HLS Transcoding
$hlsResult = self::transcodeToHls($targetPath, $courseId, $videoUuid);
return [
'video_uuid' => $videoUuid,
'local_path' => $relativePath,
'hls_url' => $hlsResult['hls_url'] ?? null,
'thumbnail_url' => $hlsResult['thumbnail_url'] ?? null,
'duration' => $hlsResult['duration_seconds'] ?? 0,
'file_size' => $fileSize,
'mime_type' => $mime,
'extension' => $ext,
'storage_type' => 'api_upload',
'encoding_status' => 'ready',
'stream_url' => '/api/videos/stream/' . $videoUuid
];
}
/**
* Transcode MP4 to HLS Chunks (.m3u8 and .ts segments) using Server FFmpeg
*/
public static function transcodeToHls(string $sourceMp4Path, int $courseId, string $videoUuid): array
{
$ffmpeg = self::getFfmpegBinary();
if (!$ffmpeg || !file_exists($sourceMp4Path)) {
return [
'hls_url' => null,
'thumbnail_url' => null,
'duration_seconds' => 0
];
}
$hlsOutputDir = dirname(__DIR__, 2) . '/storage/hls/' . $courseId . '/' . $videoUuid;
if (!is_dir($hlsOutputDir)) {
mkdir($hlsOutputDir, 0755, true);
}
$playlistPath = $hlsOutputDir . '/index.m3u8';
$segmentPattern = $hlsOutputDir . '/segment_%03d.ts';
$thumbnailPath = $hlsOutputDir . '/thumbnail.jpg';
// 1. Generate Thumbnail Screenshot at 2 seconds
$thumbCmd = "{$ffmpeg} -ss 00:00:02 -i " . escapeshellarg($sourceMp4Path) . " -vframes 1 -q:v 2 " . escapeshellarg($thumbnailPath) . " -y 2>/dev/null";
@shell_exec($thumbCmd);
// 2. Generate HLS Segments & Playlist (6-second chunks for fast start)
$hlsCmd = "{$ffmpeg} -i " . escapeshellarg($sourceMp4Path) . " -codec:v libx264 -crf 23 -preset veryfast -codec:a aac -b:a 128k -hls_time 6 -hls_list_size 0 -hls_segment_filename " . escapeshellarg($segmentPattern) . " " . escapeshellarg($playlistPath) . " -y 2>/dev/null";
@shell_exec($hlsCmd);
$hlsUrl = '/api/videos/hls/' . $videoUuid . '/index.m3u8';
$thumbUrl = '/api/videos/hls/' . $videoUuid . '/thumbnail.jpg';
return [
'hls_url' => file_exists($playlistPath) ? $hlsUrl : null,
'thumbnail_url' => file_exists($thumbnailPath) ? $thumbUrl : null,
'duration_seconds' => 600
];
}
/**
* Stream HLS Playlist (.m3u8), Video Segment (.ts), or Thumbnail (.jpg)
*/
public static function streamHlsFile(string $videoUuid, string $filename): void
{
self::ensureSchema();
$lesson = Database::selectOne("SELECT * FROM lessons WHERE video_uuid = ? LIMIT 1", [$videoUuid]);
if (!$lesson) {
http_response_code(404);
header('Content-Type: application/json; charset=utf-8');
echo json_encode(['status' => 'error', 'message' => 'ملف البث غير موجود']);
exit;
}
$courseId = (int)$lesson['course_id'];
$cleanFilename = basename($filename);
$filePath = dirname(__DIR__, 2) . "/storage/hls/{$courseId}/{$videoUuid}/{$cleanFilename}";
if (!file_exists($filePath)) {
http_response_code(404);
header('Content-Type: application/json; charset=utf-8');
echo json_encode(['status' => 'error', 'message' => 'المقطع المطلوب مفقود']);
exit;
}
$ext = strtolower(pathinfo($cleanFilename, PATHINFO_EXTENSION));
$mime = match ($ext) {
'm3u8' => 'application/vnd.apple.mpegurl',
'ts' => 'video/MP2T',
'jpg', 'jpeg' => 'image/jpeg',
'key' => 'application/octet-stream',
default => 'application/octet-stream'
};
if (ob_get_length()) {
ob_clean();
}
header('Content-Type: ' . $mime);
header('Cache-Control: public, max-age=86400');
header('Access-Control-Allow-Origin: *');
header('Content-Length: ' . filesize($filePath));
readfile($filePath);
exit;
}
/**
* Stream Local Video file supporting HTTP 206 Partial Content (Range requests)
*/
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;
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');
header('Access-Control-Allow-Origin: *');
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
// =========================================================================
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'
];
}
public static function createBunnyVideo(string $title, ?string $collectionId = null): array
{
$config = self::getBunnyConfig();
if (empty($config['api_key'])) {
$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));
}
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
]);
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
fclose($fp);
curl_close($ch);
return ($httpCode >= 200 && $httpCode < 300);
}
public static function generateBunnySignedPlayback(string $videoId, int $expiresInSeconds = 7200): array
{
$config = self::getBunnyConfig();
$libraryId = $config['library_id'];
$tokenKey = $config['token_key'];
$expires = time() + $expiresInSeconds;
$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
];
}
// ==========================================
// Cloudflare R2 Object Storage & CDN Engine
// ==========================================
public static function getR2Config(): array
{
return [
'account_id' => getenv('CLOUDFLARE_R2_ACCOUNT_ID') ?: 'fd6e6388f69f6b2d7c073861f9bef4ee',
'access_key' => getenv('CLOUDFLARE_R2_ACCESS_KEY') ?: 'ba4df2726c7e717f2c4b7fff7db0e1ea',
'secret_key' => getenv('CLOUDFLARE_R2_SECRET_KEY') ?: 'ce5dfeef2b89e4bb2760c2e856006cc201e6218018a5d2bf25188a2ced61687b',
'endpoint' => getenv('CLOUDFLARE_R2_ENDPOINT') ?: 'https://fd6e6388f69f6b2d7c073861f9bef4ee.r2.cloudflarestorage.com',
'bucket' => getenv('CLOUDFLARE_R2_BUCKET') ?: 'saqel-media',
'public_url' => getenv('CLOUDFLARE_R2_PUBLIC_URL') ?: 'https://pub-85b40493a03d464cb6c5b4adb43749f5.r2.dev'
];
}
/**
* Upload any file (video, segment, pdf, thumbnail) to Cloudflare R2 with AWS SigV4
*/
public static function uploadToR2(string $localFilePath, string $r2Key, string $contentType = 'application/octet-stream'): ?string
{
if (!file_exists($localFilePath)) {
return null;
}
$config = self::getR2Config();
$bucket = $config['bucket'];
$host = "{$bucket}.{$config['account_id']}.r2.cloudflarestorage.com";
$endpoint = "https://{$host}/" . ltrim($r2Key, '/');
$payload = file_get_contents($localFilePath);
$payloadHash = hash('sha256', $payload);
$date = gmdate('Ymd\THis\Z');
$shortDate = gmdate('Ymd');
$region = 'auto';
$service = 's3';
// AWS SigV4 Canonical Headers
$canonicalHeaders = "host:{$host}\nx-amz-content-sha256:{$payloadHash}\nx-amz-date:{$date}\n";
$signedHeaders = 'host;x-amz-content-sha256;x-amz-date';
$canonicalRequest = "PUT\n/" . ltrim($r2Key, '/') . "\n\n{$canonicalHeaders}\n{$signedHeaders}\n{$payloadHash}";
$stringToSign = "AWS4-HMAC-SHA256\n{$date}\n{$shortDate}/{$region}/{$service}/aws4_request\n" . hash('sha256', $canonicalRequest);
// Calculate Signing Key
$kSecret = 'AWS4' . $config['secret_key'];
$kDate = hash_hmac('sha256', $shortDate, $kSecret, true);
$kRegion = hash_hmac('sha256', $region, $kDate, true);
$kService = hash_hmac('sha256', $service, $kRegion, true);
$kSigning = hash_hmac('sha256', 'aws4_request', $kService, true);
$signature = hash_hmac('sha256', $stringToSign, $kSigning);
$authHeader = "AWS4-HMAC-SHA256 Credential={$config['access_key']}/{$shortDate}/{$region}/{$service}/aws4_request, SignedHeaders={$signedHeaders}, Signature={$signature}";
$ch = curl_init($endpoint);
curl_setopt_array($ch, [
CURLOPT_CUSTOMREQUEST => 'PUT',
CURLOPT_POSTFIELDS => $payload,
CURLOPT_HTTPHEADER => [
"Host: {$host}",
"x-amz-date: {$date}",
"x-amz-content-sha256: {$payloadHash}",
"Authorization: {$authHeader}",
"Content-Type: {$contentType}",
"Content-Length: " . strlen($payload)
],
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 300
]);
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($httpCode >= 200 && $httpCode < 300) {
return rtrim($config['public_url'], '/') . '/' . ltrim($r2Key, '/');
}
return null;
}
}