logger = VLogger::getInstance(); $this->db = VDatabase::getInstance(); $this->baseUrl = $this->getBaseUrl(); $this->streamingPath = _FPATH . 'f_data/processed/'; } /** * Get video streaming URLs for a video * @param string $videoKey Video key * @param string $userId User ID (for access control) * @return array Streaming URLs and metadata */ public function getVideoStreams($videoKey, $userId = null) { try { // Get video information $video = $this->getVideoInfo($videoKey); if (!$video) { throw new Exception('Video not found'); } // Check access permissions if (!$this->checkVideoAccess($video, $userId)) { throw new Exception('Access denied'); } // Get available formats $availableFormats = json_decode($video['available_formats'] ?? '[]', true); $streams = []; // Progressive download streams foreach ($availableFormats as $format) { if ($format === 'hls' || $format === 'thumbnails' || $format === 'preview') { continue; } $streamUrl = $this->getProgressiveStreamUrl($videoKey, $format); if ($streamUrl) { $streams['progressive'][$format] = [ 'url' => $streamUrl, 'format' => $format, 'type' => 'video/mp4', 'quality' => $this->getQualityLabel($format) ]; } } // HLS adaptive streaming if ($video['hls_available']) { $hlsUrl = $this->getHLSStreamUrl($videoKey); if ($hlsUrl) { $streams['hls'] = [ 'url' => $hlsUrl, 'type' => 'application/x-mpegURL', 'adaptive' => true ]; } } // Thumbnails $thumbnails = $this->getThumbnailUrls($videoKey); // Track view $this->trackView($videoKey, $userId); return [ 'success' => true, 'video_key' => $videoKey, 'streams' => $streams, 'thumbnails' => $thumbnails, 'duration' => (float)$video['duration'], 'title' => $video['file_title'], 'description' => $video['file_description'] ]; } catch (Exception $e) { $this->logger->error('Failed to get video streams', [ 'video_key' => $videoKey, 'user_id' => $userId, 'error' => $e->getMessage() ]); return [ 'success' => false, 'error' => $e->getMessage() ]; } } /** * Stream video file with range support * @param string $videoKey Video key * @param string $format Video format * @param string $userId User ID (for access control) */ public function streamVideo($videoKey, $format, $userId = null) { try { // Get video information $video = $this->getVideoInfo($videoKey); if (!$video) { http_response_code(404); exit('Video not found'); } // Check access permissions if (!$this->checkVideoAccess($video, $userId)) { http_response_code(403); exit('Access denied'); } // Get file path $filePath = $this->getVideoFilePath($videoKey, $format); if (!$filePath || !file_exists($filePath)) { http_response_code(404); exit('Video file not found'); } // Track analytics $this->trackStreamRequest($videoKey, $format, $userId); // Stream the file with range support $this->streamFileWithRanges($filePath, $this->getMimeType($format)); } catch (Exception $e) { $this->logger->error('Video streaming failed', [ 'video_key' => $videoKey, 'format' => $format, 'user_id' => $userId, 'error' => $e->getMessage() ]); http_response_code(500); exit('Streaming error'); } } /** * Stream HLS playlist or segment * @param string $videoKey Video key * @param string $path HLS path (playlist or segment) * @param string $userId User ID (for access control) */ public function streamHLS($videoKey, $path, $userId = null) { try { // Get video information $video = $this->getVideoInfo($videoKey); if (!$video || !$video['hls_available']) { http_response_code(404); exit('HLS not available'); } // Check access permissions if (!$this->checkVideoAccess($video, $userId)) { http_response_code(403); exit('Access denied'); } $sanitizedPath = $this->sanitizeHLSPath($path); if (!$sanitizedPath) { http_response_code(400); exit('Invalid HLS path'); } $hlsRoot = $this->streamingPath . $videoKey . '/hls/'; $hlsPath = $hlsRoot . $sanitizedPath; if (!file_exists($hlsPath) || !is_file($hlsPath)) { http_response_code(404); exit('HLS file not found'); } $realRoot = realpath($hlsRoot); $realFile = realpath($hlsPath); if ($realRoot === false || $realFile === false || strpos($realFile, $realRoot) !== 0) { http_response_code(403); exit('Invalid HLS path'); } $extension = strtolower(pathinfo($sanitizedPath, PATHINFO_EXTENSION)); if ($extension === 'm3u8') { $playlist = file_get_contents($hlsPath); if ($playlist === false) { throw new Exception('Failed to read HLS playlist'); } $playlist = $this->rewritePlaylistUrls($playlist, $videoKey, $sanitizedPath); header('Content-Type: application/vnd.apple.mpegurl'); header('Cache-Control: no-cache, max-age=0'); header('Access-Control-Allow-Origin: *'); header('Content-Length: ' . strlen($playlist)); if (basename($sanitizedPath) === 'master.m3u8') { $this->trackStreamRequest($videoKey, 'hls_master', $userId); } echo $playlist; exit; } $contentType = 'application/octet-stream'; if ($extension === 'ts') { $contentType = 'video/MP2T'; } elseif ($extension === 'mp4') { $contentType = 'video/mp4'; } header('Content-Type: ' . $contentType); header('Content-Length: ' . filesize($hlsPath)); header('Cache-Control: max-age=30'); header('Access-Control-Allow-Origin: *'); readfile($hlsPath); } catch (Exception $e) { $this->logger->error('HLS streaming failed', [ 'video_key' => $videoKey, 'path' => $path, 'user_id' => $userId, 'error' => $e->getMessage() ]); http_response_code(500); exit('HLS streaming error'); } } /** * Get video analytics data * @param string $videoKey Video key * @return array Analytics data */ public function getVideoAnalytics($videoKey) { try { $query = "SELECT COUNT(*) as total_views, COUNT(DISTINCT user_id) as unique_viewers, AVG(watch_duration) as avg_watch_duration, SUM(watch_duration) as total_watch_time, DATE(created_at) as view_date, COUNT(*) as daily_views FROM db_video_analytics WHERE video_key = ? GROUP BY DATE(created_at) ORDER BY view_date DESC LIMIT 30"; $result = $this->db->doQuery($query, [$videoKey]); $analytics = []; while ($row = $this->db->doFetch($result)) { $analytics[] = $row; } // Get overall stats $overallQuery = "SELECT COUNT(*) as total_views, COUNT(DISTINCT user_id) as unique_viewers, AVG(watch_duration) as avg_watch_duration, SUM(watch_duration) as total_watch_time FROM db_video_analytics WHERE video_key = ?"; $overallResult = $this->db->doQuery($overallQuery, [$videoKey]); $overallStats = $this->db->doFetch($overallResult); return [ 'success' => true, 'video_key' => $videoKey, 'overall_stats' => $overallStats, 'daily_analytics' => $analytics ]; } catch (Exception $e) { $this->logger->error('Failed to get video analytics', [ 'video_key' => $videoKey, 'error' => $e->getMessage() ]); return [ 'success' => false, 'error' => $e->getMessage() ]; } } /** * Update watch progress for a user * @param string $videoKey Video key * @param string $userId User ID * @param float $currentTime Current playback time * @param float $duration Video duration */ public function updateWatchProgress($videoKey, $userId, $currentTime, $duration) { try { if (!$userId) { return; // Skip for anonymous users } $watchData = [ 'user_id' => $userId, 'video_key' => $videoKey, 'current_time' => $currentTime, 'duration' => $duration, 'watch_percentage' => ($duration > 0) ? ($currentTime / $duration) * 100 : 0, 'updated_at' => date('Y-m-d H:i:s') ]; // Check if record exists $existingQuery = "SELECT id FROM db_watch_progress WHERE user_id = ? AND video_key = ?"; $existingResult = $this->db->doQuery($existingQuery, [$userId, $videoKey]); $existing = $this->db->doFetch($existingResult); if ($existing) { // Update existing record $this->db->doUpdate('db_watch_progress', 'id', $watchData, $existing['id']); } else { // Create new record $watchData['created_at'] = date('Y-m-d H:i:s'); $this->db->doInsert('db_watch_progress', $watchData); } } catch (Exception $e) { $this->logger->error('Failed to update watch progress', [ 'video_key' => $videoKey, 'user_id' => $userId, 'error' => $e->getMessage() ]); } } /** * Get user's watch progress for a video * @param string $videoKey Video key * @param string $userId User ID * @return array|null Watch progress data */ public function getWatchProgress($videoKey, $userId) { if (!$userId) { return null; } try { $query = "SELECT * FROM db_watch_progress WHERE user_id = ? AND video_key = ?"; $result = $this->db->doQuery($query, [$userId, $videoKey]); return $this->db->doFetch($result); } catch (Exception $e) { $this->logger->error('Failed to get watch progress', [ 'video_key' => $videoKey, 'user_id' => $userId, 'error' => $e->getMessage() ]); return null; } } /** * Private helper methods */ private function getVideoInfo($videoKey) { $query = "SELECT * FROM db_videofiles WHERE file_key = ? AND file_active = 1"; $result = $this->db->doQuery($query, [$videoKey]); return $this->db->doFetch($result); } private function checkVideoAccess($video, $userId) { // Check if video is public if ($video['privacy'] === 'public') { return true; } // Check if user owns the video if ($userId && $video['usr_id'] == $userId) { return true; } // Check if video is unlisted and user has direct link if ($video['privacy'] === 'unlisted') { return true; } // Private videos require ownership or special permissions if ($video['privacy'] === 'private') { return $userId && $video['usr_id'] == $userId; } return false; } private function getProgressiveStreamUrl($videoKey, $format) { $filePath = $this->getVideoFilePath($videoKey, $format); if ($filePath && file_exists($filePath)) { return $this->baseUrl . '/stream.php?v=' . urlencode($videoKey) . '&f=' . urlencode($format); } return null; } private function getHLSStreamUrl($videoKey) { $playlistPath = $this->streamingPath . $videoKey . '/hls/master.m3u8'; if (file_exists($playlistPath)) { return $this->baseUrl . '/stream.php?v=' . urlencode($videoKey) . '&hls=master.m3u8'; } return null; } private function getThumbnailUrls($videoKey) { $thumbnailDir = $this->streamingPath . $videoKey . '/thumbnails/'; $thumbnails = []; // Main thumbnail $mainThumb = $thumbnailDir . $videoKey . '_thumb.jpg'; if (file_exists($mainThumb)) { $thumbnails['main'] = $this->baseUrl . '/f_data/processed/' . $videoKey . '/thumbnails/' . $videoKey . '_thumb.jpg'; } // Preview thumbnails $previewThumbs = []; for ($i = 1; $i <= 5; $i++) { $previewThumb = $thumbnailDir . $videoKey . "_preview_{$i}.jpg"; if (file_exists($previewThumb)) { $previewThumbs[] = $this->baseUrl . '/f_data/processed/' . $videoKey . '/thumbnails/' . $videoKey . "_preview_{$i}.jpg"; } } $thumbnails['preview'] = $previewThumbs; return $thumbnails; } private function getVideoFilePath($videoKey, $format) { $safeFormat = $this->sanitizeFormat($format); if (!$safeFormat) { return null; } return $this->streamingPath . $videoKey . '/' . $videoKey . '_' . $safeFormat . '.mp4'; } private function trackView($videoKey, $userId) { try { $viewData = [ 'video_key' => $videoKey, 'user_id' => $userId, 'ip_address' => $this->getRealIpAddress(), 'user_agent' => $_SERVER['HTTP_USER_AGENT'] ?? '', 'created_at' => date('Y-m-d H:i:s') ]; $this->db->doInsert('db_video_views', $viewData); // Update video view count $this->db->doQuery("UPDATE db_videofiles SET file_views = file_views + 1 WHERE file_key = ?", [$videoKey]); } catch (Exception $e) { $this->logger->error('Failed to track view', [ 'video_key' => $videoKey, 'user_id' => $userId, 'error' => $e->getMessage() ]); } } private function trackStreamRequest($videoKey, $format, $userId) { try { $analyticsData = [ 'video_key' => $videoKey, 'user_id' => $userId, 'format' => $format, 'ip_address' => $this->getRealIpAddress(), 'user_agent' => $_SERVER['HTTP_USER_AGENT'] ?? '', 'created_at' => date('Y-m-d H:i:s') ]; $this->db->doInsert('db_video_analytics', $analyticsData); } catch (Exception $e) { $this->logger->error('Failed to track stream request', [ 'video_key' => $videoKey, 'format' => $format, 'error' => $e->getMessage() ]); } } private function streamFileWithRanges($filePath, $contentType) { $fileSize = filesize($filePath); $start = 0; $end = $fileSize - 1; // Handle range requests if (isset($_SERVER['HTTP_RANGE'])) { $range = $_SERVER['HTTP_RANGE']; if (preg_match('/bytes=(\d+)-(\d*)/', $range, $matches)) { $start = intval($matches[1]); if (!empty($matches[2])) { $end = intval($matches[2]); } } http_response_code(206); // Partial Content header('Accept-Ranges: bytes'); header("Content-Range: bytes $start-$end/$fileSize"); } else { http_response_code(200); } // Set headers header('Content-Type: ' . $contentType); header('Content-Length: ' . ($end - $start + 1)); header('Cache-Control: max-age=3600'); header('Access-Control-Allow-Origin: *'); // Stream the file $handle = fopen($filePath, 'rb'); fseek($handle, $start); $chunkSize = 8192; $bytesRemaining = $end - $start + 1; while ($bytesRemaining > 0 && !feof($handle)) { $bytesToRead = min($chunkSize, $bytesRemaining); echo fread($handle, $bytesToRead); $bytesRemaining -= $bytesToRead; if (ob_get_level()) { ob_flush(); } flush(); } fclose($handle); } private function getMimeType($format) { switch (strtolower($format)) { case 'mp4': case '1080p': case '720p': case '480p': case '360p': case '240p': return 'video/mp4'; case 'webm': return 'video/webm'; case 'ogv': return 'video/ogg'; default: return 'video/mp4'; } } private function getQualityLabel($format) { $labels = [ '1080p' => '1080p HD', '720p' => '720p HD', '480p' => '480p', '360p' => '360p', '240p' => '240p' ]; return $labels[$format] ?? $format; } private function sanitizeFormat($format) { $format = trim((string)$format); if ($format === '') { return null; } if (!preg_match('/^[a-zA-Z0-9_\-]+$/', $format)) { return null; } return $format; } private function sanitizeHLSPath($path) { $path = str_replace('\\', '/', (string)$path); $path = trim($path, '/'); if ($path === '') { return null; } $segments = explode('/', $path); $cleanSegments = []; foreach ($segments as $segment) { if ($segment === '' || $segment === '.' || $segment === '..') { return null; } if (!preg_match('/^[a-zA-Z0-9_\-\.]+$/', $segment)) { return null; } $cleanSegments[] = $segment; } return implode('/', $cleanSegments); } private function rewritePlaylistUrls($playlist, $videoKey, $currentPath) { $lines = preg_split('/\r\n|\r|\n/', $playlist); $baseDir = trim(str_replace('\\', '/', dirname($currentPath)), '/'); foreach ($lines as $index => $line) { $trimmed = trim($line); if ($trimmed === '' || strpos($trimmed, '#') === 0) { continue; } if (preg_match('/^https?:\/\//i', $trimmed)) { continue; } if (!preg_match('/\.(m3u8|ts)$/i', $trimmed)) { continue; } $resolved = $this->normalizeHLSRelativePath($baseDir, $trimmed); $safePath = $this->sanitizeHLSPath($resolved); if (!$safePath) { continue; } $lines[$index] = '/stream.php?v=' . rawurlencode($videoKey) . '&hls=' . $this->encodeHLSPath($safePath); } return implode("\n", $lines); } private function normalizeHLSRelativePath($baseDir, $target) { $baseDir = trim($baseDir, '/'); $target = str_replace('\\', '/', (string)$target); if (strpos($target, '/') === 0) { $target = ltrim($target, '/'); } elseif ($baseDir !== '') { $target = $baseDir . '/' . $target; } $parts = []; foreach (explode('/', $target) as $part) { if ($part === '' || $part === '.') { continue; } if ($part === '..') { array_pop($parts); continue; } $parts[] = $part; } return implode('/', $parts); } private function encodeHLSPath($path) { $segments = explode('/', $path); $encodedSegments = array_map('rawurlencode', $segments); return implode('/', $encodedSegments); } private function getBaseUrl() { $protocol = isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on' ? 'https' : 'http'; $host = $_SERVER['HTTP_HOST'] ?? 'localhost'; return $protocol . '://' . $host; } private function getRealIpAddress() { if (!empty($_SERVER['HTTP_CLIENT_IP'])) { return $_SERVER['HTTP_CLIENT_IP']; } elseif (!empty($_SERVER['HTTP_X_FORWARDED_FOR'])) { return $_SERVER['HTTP_X_FORWARDED_FOR']; } else { return $_SERVER['REMOTE_ADDR'] ?? '0.0.0.0'; } } }