480 lines
19 KiB
PHP
480 lines
19 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
|
|
];
|
|
}
|
|
}
|