628 lines
25 KiB
PHP
628 lines
25 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);
|
|
|
|
// Attempt automated sync to Cloudflare R2
|
|
$r2Config = self::getR2Config();
|
|
$r2VideoUrl = null;
|
|
if (!empty($r2Config['access_key']) && !empty($r2Config['secret_key']) && !empty($r2Config['bucket'])) {
|
|
$r2Key = "videos/{$courseId}/{$targetFileName}";
|
|
$r2VideoUrl = self::uploadToR2($targetPath, $r2Key, $mime);
|
|
|
|
// The player must receive a complete HLS package, not only the source MP4.
|
|
// Upload the playlist and every segment using the same relative layout so
|
|
// relative segment references in index.m3u8 continue to resolve on R2.
|
|
if (!empty($hlsResult['hls_url'])) {
|
|
$hlsDir = dirname(__DIR__, 2) . '/storage/hls/' . $courseId . '/' . $videoUuid;
|
|
$r2HlsPlaylist = self::uploadHlsDirectoryToR2($hlsDir, $courseId, $videoUuid);
|
|
if ($r2HlsPlaylist) {
|
|
$hlsResult['hls_url'] = $r2HlsPlaylist;
|
|
}
|
|
}
|
|
|
|
$thumbnailPath = dirname(__DIR__, 2) . '/storage/hls/' . $courseId . '/' . $videoUuid . '/thumbnail.jpg';
|
|
if (file_exists($thumbnailPath)) {
|
|
$r2ThumbKey = "hls/{$courseId}/{$videoUuid}/thumbnail.jpg";
|
|
$r2ThumbUrl = self::uploadToR2($thumbnailPath, $r2ThumbKey, 'image/jpeg');
|
|
if ($r2ThumbUrl) {
|
|
$hlsResult['thumbnail_url'] = $r2ThumbUrl;
|
|
}
|
|
}
|
|
}
|
|
|
|
return [
|
|
'video_uuid' => $videoUuid,
|
|
'local_path' => $relativePath,
|
|
'r2_url' => $r2VideoUrl,
|
|
'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';
|
|
|
|
// Measure real video duration from input file
|
|
$durationSeconds = 10;
|
|
$probeCmd = "{$ffmpeg} -i " . escapeshellarg($sourceMp4Path) . " 2>&1";
|
|
$probeOutput = @shell_exec($probeCmd);
|
|
if ($probeOutput && preg_match('/Duration:\s*(\d{2}):(\d{2}):(\d{2})/i', $probeOutput, $m)) {
|
|
$durationSeconds = ($m[1] * 3600) + ($m[2] * 60) + (int)$m[3];
|
|
}
|
|
if ($durationSeconds <= 0) {
|
|
$durationSeconds = 10;
|
|
}
|
|
|
|
// 1. Generate Thumbnail Screenshot at 1 second (or 00:00:01)
|
|
$thumbTime = ($durationSeconds > 2) ? '00:00:02' : '00:00:01';
|
|
$thumbCmd = "{$ffmpeg} -ss {$thumbTime} -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' => $durationSeconds
|
|
];
|
|
}
|
|
|
|
/**
|
|
* 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') ?: '',
|
|
'api_key' => getenv('BUNNY_STREAM_API_KEY') ?: '',
|
|
'token_key' => getenv('BUNNY_STREAM_TOKEN_KEY') ?: getenv('JWT_SECRET') ?: '',
|
|
'pull_zone_host' => getenv('BUNNY_STREAM_HOST') ?: '',
|
|
'embed_host' => 'iframe.mediadelivery.net'
|
|
];
|
|
}
|
|
|
|
public static function createBunnyVideo(string $title, ?string $collectionId = null): array
|
|
{
|
|
$config = self::getBunnyConfig();
|
|
if (empty($config['api_key'])) {
|
|
throw new \RuntimeException('Bunny Stream غير مهيأ: أضف مفاتيح الوصول قبل إنشاء فيديو سحابي.');
|
|
/*
|
|
$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') ?: '',
|
|
'access_key' => getenv('CLOUDFLARE_R2_ACCESS_KEY') ?: '',
|
|
'secret_key' => getenv('CLOUDFLARE_R2_SECRET_KEY') ?: '',
|
|
'endpoint' => getenv('CLOUDFLARE_R2_ENDPOINT') ?: '',
|
|
'bucket' => getenv('CLOUDFLARE_R2_BUCKET') ?: '',
|
|
'public_url' => getenv('CLOUDFLARE_R2_PUBLIC_URL') ?: ''
|
|
];
|
|
}
|
|
|
|
/**
|
|
* 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 && !empty($config['public_url'])) {
|
|
return rtrim($config['public_url'], '/') . '/' . ltrim($r2Key, '/');
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/** Upload a complete HLS directory and return the public playlist URL. */
|
|
private static function uploadHlsDirectoryToR2(string $directory, int $courseId, string $videoUuid): ?string
|
|
{
|
|
if (!is_dir($directory)) return null;
|
|
|
|
$playlistUrl = null;
|
|
foreach (glob($directory . '/*') ?: [] as $filePath) {
|
|
if (!is_file($filePath)) continue;
|
|
$extension = strtolower(pathinfo($filePath, PATHINFO_EXTENSION));
|
|
$contentType = match ($extension) {
|
|
'm3u8' => 'application/vnd.apple.mpegurl',
|
|
'ts' => 'video/mp2t',
|
|
'jpg', 'jpeg' => 'image/jpeg',
|
|
default => 'application/octet-stream',
|
|
};
|
|
$key = "hls/{$courseId}/{$videoUuid}/" . basename($filePath);
|
|
$url = self::uploadToR2($filePath, $key, $contentType);
|
|
if ($extension === 'm3u8' && $url) $playlistUrl = $url;
|
|
}
|
|
|
|
return $playlistUrl;
|
|
}
|
|
}
|