diffInSeconds(Carbon::parse($modTime))) > $originExpiredSeconds) { $data = self::saveToLocalByUrl($url, $filePath); } else { if ($isFromCache !== false) { $data = file_get_contents($filePath); } else { // 不走本地缓存也不保存. $data = self::getRemoteFileRaw($url); } } } else { $data = self::saveToLocalByUrl($url, $filePath); } return $data; } /** * 清理所有文件. * @return bool */ public static function clearAll() { $m3u8Path = self::path(); if (is_dir($m3u8Path)) { return rmdir($m3u8Path); } return false; } public static function path() { $path = base_path('storage/cache/m3u8'); return $path; } public static function m3u8Urlpath() { $path = base_path('storage/cache/m3u8_url'); return $path; } public static function childDir($strHash) { $dir1 = substr($strHash, 0, 4); $dir2 = substr($strHash, 4, 4); return $dir1 . '/' . $dir2; } public static function getCdnUrl($fieldUrl, $addAuth = true) { $querySign = ''; if (strripos($fieldUrl, '|')) { list($flag, $uri) = explode('|', $fieldUrl); $servers = self::getAll(); if (!isset($servers[$flag])) { return ''; } $domain = $servers[$flag]['url']; $querySign = self::getCdnRepo()->getSignKeyValue($uri); $url = rtrim($domain, '/') . '/'. ltrim($uri, '/'); } else { $arrUrl = parse_url($fieldUrl); if (!empty($arrUrl['path'])) { $uri = $arrUrl['path']; $querySign = self::getCdnRepo()->getSignKeyValue($uri); } $url = $fieldUrl; } $fullUrl = $url; if ($addAuth) { if (!empty($querySign)) { if (strripos($url, '?')) { $fullUrl .= '&'. $querySign; } else { $fullUrl .= '?' . $querySign; } } } return $fullUrl; } public static function getM3u8Url($fieldUrl, $addAuth = true) { $querySign = ''; if (strripos($fieldUrl, '|')) { list($flag, $uri) = explode('|', $fieldUrl); $servers = self::getAll(); if (!isset($servers[$flag])) { return ''; } if (!empty($servers[$flag]['source_url'])) { $domain = $servers[$flag]['source_url']; } else { $domain = $servers[$flag]['url']; } // $domain = $servers[$flag]['url']; $querySign = self::getCdnRepo()->getSignKeyValue($uri); $url = rtrim($domain, '/') . '/'. ltrim($uri, '/'); } else { $arrUrl = parse_url($fieldUrl); if (!empty($arrUrl['path'])) { $uri = $arrUrl['path']; $querySign = self::getCdnRepo()->getSignKeyValue($uri); } $url = $fieldUrl; } $fullUrl = $url; if ($addAuth) { if (!empty($querySign)) { if (strripos($url, '?')) { $fullUrl .= '&'. $querySign; } else { $fullUrl .= '?' . $querySign; } } } return $fullUrl; } /** * @param mixed $info * @param string $userCode * @param string $pkId * @param string $module * @return mixed|string * @throws \Psr\SimpleCache\InvalidArgumentException */ public static function getVideoInnerUrl($info, $userCode = '', $pkId = '', $module = '') { if (is_array($info)) { $url = $info['url']; $isPreview = $info['is_preview']; $previewTime = isset($info['preview_time']) ? $info['preview_time'] : VideoConstant::PREVIEW_TIME; } else { $url = $info; $isPreview = false; $previewTime = VideoConstant::PREVIEW_TIME; } $userInfo = make(UserRepository::class)->getUserInfoFromCache($userCode); $authUrl = ''; if (strripos($url, '.m3u8')) { $configRepo = make(ConfigRepoistory::class); // 路由基础前缀. $routerBasePrefix = '/app/api'; // 取内容的url,从配置读取,解决swoole 无法获取端口问题. $safeProxyUrl = rtrim($configRepo->get('safe_proxy_url', 'sys', ''), '/'); if (!empty($safeProxyUrl)) { $baseUrl = $safeProxyUrl; } else { $currenUrl = request()->url(); $arrUrl = parse_url($currenUrl); $baseUrl = $arrUrl['scheme'] . '://' . $arrUrl['host']; if (!empty($arrUrl['port'])) { $baseUrl .= ':'. $arrUrl['port']; } } $previewFlag = 't5'; // 默认预览 if ($isPreview !== true) { $previewFlag = 'k4'; // 标识 k4=完整,t5=预览 } $originZuhe = $previewFlag . $userInfo['device_type'] . Carbon::now()->subDays(54)->format('md') . $userInfo['device_no']; $encodeZuhe = caesar_encode($originZuhe, 'l,n', 3); // 偏移了3 $timestamp = Carbon::now()->timestamp; $sign = self::generateSign($originZuhe, $timestamp); if (!empty($pkId)) { $authUrl = $baseUrl . $routerBasePrefix . '/m3u8/raw'; $queryParmas = [ 'tk' => $encodeZuhe, // 偏移了6 'module' => $module, 'pk' => $pkId, 'sn' => $sign, 'tm' => $timestamp, // 'rate_type' => '', // 暂时注释,多分辨率 ]; $authUrl = $authUrl . '?' . http_build_query($queryParmas); } else { $authUrl = $baseUrl . $routerBasePrefix . '/m3u8/play/raw'; $queryParmas = [ 'tk' => $encodeZuhe, // 偏移了6 'module' => $module, 'sn' => $sign, 'tm' => $timestamp, // 'rate_type' => '', // 暂时注释,多分辨率 ]; $authUrl = $authUrl . '?' . http_build_query($queryParmas); } } else { $authUrl = $url; } return $authUrl; } /** * 获取视频url( 2小时有效,主要考虑query hash变动,影响缓存m3u8) * @param string $url * @param bool $isFromCache * @return mixed|string */ public static function getVideoUrl($url, $isFromCache = true) { $authUrl = ''; if (strripos($url, '.m3u8')) { $strHash = md5($url); $fileName = $strHash . '.url'; $savePath = self::m3u8Urlpath(); $cachePath = $savePath . '/'. self::childDir($strHash); $filePath = $cachePath . '/'. $fileName; if (!is_dir($cachePath)) { mkdir($cachePath, 0777, true); } // 设置超时时间. // set_time_limit(0); if (file_exists($filePath)) { $expiredSeconds = 2 * 60 * 60; // 原始文件保存2小时. $modTime = filemtime($filePath); if ($modTime && abs(Carbon::now()->diffInSeconds(Carbon::parse($modTime))) > $expiredSeconds) { $authUrl = M3u8CacheRepository::getCdnUrl($url, true); unlink($filePath); file_put_contents($filePath, $authUrl); } else { if ($isFromCache !== false) { $authUrl = file_get_contents($filePath); } else { // 不走本地缓存也不保存. $authUrl = M3u8CacheRepository::getCdnUrl($url, true); } } } else { $authUrl = M3u8CacheRepository::getCdnUrl($url, true); file_put_contents($filePath, $authUrl); } // $authUrl = M3u8CacheRepository::getCdnUrl($url, true); } else { $authUrl = $url; } return $authUrl; } /** * 获取图片url. * @param $url * @return mixed|string */ public static function getImageUrl($url) { return self::getCdnUrl($url, true); } /** * 变量url * @param $url * @param array $params * @return array|mixed|string|string[] */ public static function getLinkUrl($url, $params = []) { $originUrl = $url; $search = []; $replace = []; if (isset($params['user_code'])) { $search[] = '{user_code}'; $replace[] = $params['user_code']; } if (!empty($search) && !empty($replace)) { $url = str_replace($search, $replace, $url); } return $url; } /** * 获取所有服务组信息(不缓存) * @return array */ public static function getAllWithouCache() { $servers = []; $configRepo = make(ConfigRepoistory::class); $arrConfig = $configRepo->gets(['upload_url'], 'sys'); $token = env('UPLOAD_TOKEN', 'GAkucH8'); $url = rtrim($arrConfig['upload_url'], '/') . '/sg_server_config.php'; $options = [ 'query' => [ 'upload_token' => $token, 'need_source_url' => 'yes_i_do', // 需要源url+port ], ]; $raw = self::httpGet($url, $options); if (!empty($raw)) { $arrJson = json_decode($raw, true); if ($arrJson['code'] == 200) { $servers = $arrJson['data']['servers']; } } return $servers; } /** * 获取所有服务组信息(走缓存). * @return mixed */ public static function getAll() { if (empty(self::$servers)) { $ttl = 3 * 60 * 60; $key = 'static_server'; $cache = make(CacheInterface::class); self::$servers = $cache->remember($key, $ttl, function () { return self::getAllWithouCache(); }); } return self::$servers; } /** * 清除缓存. * @return bool * @throws \Psr\SimpleCache\InvalidArgumentException */ public static function clearServersCache() { $key = 'static_server'; $cache = make(CacheInterface::class); return $cache->delete($key); } /** * 获取m3u8的内容(带鉴权), 缓存query参数,优化目的浏览器本地缓存. * @param array $info * @param bool $isFromCache 是否从本地缓存. * @return string */ public static function getMu38Raw($info, $isFromCache = true) { if (is_array($info)) { $url = $info['url']; $isPreview = $info['is_preview']; $previewTime = isset($info['preview_time']) ? $info['preview_time'] : VideoConstant::PREVIEW_TIME; $info['preview_time'] = $previewTime; } else { $url = $info; $isPreview = false; $previewTime = VideoConstant::PREVIEW_TIME; $info = [ 'url' => $url, 'is_preview' => $isPreview, 'preview_time' => $previewTime, ]; } $m3u8OriginUrl = self::getM3u8Url($url); $info2 = $info; $info2['origin_url'] = $m3u8OriginUrl; $fullUrl = self::getCdnUrl($url); $arrUrl = parse_url($fullUrl); $domain = $arrUrl['scheme'] . '://'. $arrUrl['host']; // eg:http://cn01.v.waawf.com $uriDir = dirname($arrUrl['path']); // eg:/20211130/Wc96M7Xr/1000kb/hls $uriPath = $arrUrl['path']; // eg:/20211130/Wc96M7Xr/1000kb/hls/index.m3u8 if (!empty($arrUrl['port'])) { $domain .= ':'. $arrUrl['port'];// eg:http://cn01.v.waawf.com:8001 } $urlPrefix = $domain . $uriDir; // eg:http://cn01.v.waawf.com/20211130/Wc96M7Xr/1000kb/hls $n = stripos($m3u8OriginUrl, '.m3u8'); // 拿到没有query参数的url地址. $noQueryUrl = substr($m3u8OriginUrl, 0, $n + strlen('.m3u8')); $strHash = md5($noQueryUrl); $fileName = $strHash . '.cdn.m3u8'; $hashPreviewTime = substr(md5($previewTime), 0, 6); $previewFileName = $strHash . '.cdn.preview.m3u8'; $savePath = self::path(); $cachePath = $savePath . '/'. self::childDir($strHash); $filePath = $cachePath . '/'. $fileName; $previewFilePath = $cachePath . '/'. $previewFileName; if (!is_dir($cachePath)) { mkdir($cachePath, 0777, true); } $getFilePath = $isPreview === true ? $previewFilePath : $filePath; // 设置超时时间. // set_time_limit(0); if (file_exists($filePath)) { $modTime = filemtime($filePath); if ($modTime && abs(Carbon::now()->diffInSeconds(Carbon::parse($modTime))) > self::$expiredSecond) { $data = self::saveToLocalByAuthRaw($info2, $filePath, $domain, $uriDir, $urlPrefix); } else { if ($isFromCache !== false) { $data = file_get_contents($getFilePath); } else { // 不走本地缓存也不保存. $data = self::getM3u8OriginRaw($m3u8OriginUrl); if ($isPreview) { $data = self::convertAuthPreviewRaw($data, $previewTime, $domain, $uriDir, $urlPrefix); } else { $data = self::convertAuthRaw($data, $domain, $uriDir, $urlPrefix); } } } } else { $data = self::saveToLocalByAuthRaw($info2, $filePath, $domain, $uriDir, $urlPrefix); } return $data; } /** * 生成签名 * @param $originZu * @param $timestamp * @return string */ public static function generateSign($originZu, $timestamp) { return md5($originZu . ':'. self::$signKey . ':'. strval($timestamp)); } private static function checkUrl($url) { if (!preg_match("/^(http|https):\/\/[A-Za-z0-9_\-]+\.[A-Za-z0-9_\-]+[\/=\?%\-&_~`@[\]\':+!]*([^<>\"\"])*$/", $url)) { return false; } else { return true; } } /** * 移除阿里云CDN自带的auth_key参数. * @param $data * @return array|mixed|string|string[]|null */ private static function removeAliCdnOriginAuthQueryParams($data) { if (strripos($data, '?auth_key=')) { $pattern = '/(.+)\?auth_key=\d+\-[a-zA-Z0-9]+\-[a-zA-Z0-9]+\-[a-zA-Z0-9]+/'; $data = preg_replace_callback($pattern, function ($matches) { return $matches[1]; }, $data); } return $data; } private static function getRemoteFileRaw($url) { $options = []; $data = self::httpGet($url, $options); $data = self::removeAliCdnOriginAuthQueryParams($data); return $data; } private static function convertAuthRaw($content, $domain, $uriDir, $urlPrefix) { $pattern = "/\#EXTINF:([0-9\.]+),\n(.+)\.ts/i"; $content = preg_replace_callback($pattern, function ($matches) use ($domain, $uriDir, $urlPrefix) { $relativeTsPath = $uriDir . '/' . $matches[2] . '.ts'; // 不针对ts鉴权了. $querySign = self::getCdnRepo()->getSignKeyValue($relativeTsPath); $tsUrl = $domain . $relativeTsPath . '?'. $querySign; // $tsUrl = $domain . $relativeTsPath; $result = "#EXTINF:{$matches[1]},\n{$tsUrl}"; return $result; }, $content); $pattern2 = "/\#EXT-X-KEY:METHOD=AES-128,URI=\"(.+)\"/i"; $keyUrl = $urlPrefix . '/key.key'; $replacement2 = "#EXT-X-KEY:METHOD=AES-128,URI=\"{$keyUrl}\""; $content = preg_replace([$pattern2],[$replacement2], $content, -1, $count); return $content; } /** * 转换预览视频内容(带鉴权) * @param $content * @param string $previewTime * @param $domain * @param $uriDir * @param $urlPrefix * @return array|string|string[]|null */ private static function convertAuthPreviewRaw($content, $previewTime, $domain, $uriDir, $urlPrefix) { $pattern = "/\#EXTINF:([0-9\.]+),\n(.+)\.ts/i"; $totalSeconds = 0; $startSeconds = 0; $endSeconds = 0; if (empty($previewTime) || $previewTime == '00:00:00-00:00:00') { // 没设置预览 $endSeconds = 15; } else { $arrTimes = explode('-', $previewTime); if (count($arrTimes) != 2) { return ''; } $startTime = $arrTimes[0]; $endTime = $arrTimes[1]; if (empty($startTime || empty($endTime))) { return ''; } $baseStartObj = Carbon::parse('2022-11-11 00:00:00'); $startObj = Carbon::parse('2022-11-11 '. $startTime); $endObj = Carbon::parse('2022-11-11 '. $endTime); if ($startObj >= $endObj) { return ''; } $diffSeconds = $endObj->diffInSeconds($startObj); $startSeconds = abs($baseStartObj->diffInSeconds($startObj)); $endSeconds = $startSeconds + $diffSeconds; } $content = preg_replace_callback($pattern, function ($matches) use ($domain, $uriDir, $urlPrefix, &$totalSeconds, $startSeconds, $endSeconds) { $result = ''; $tsSecond = !empty($matches[1]) ? floatval($matches[1]) : 0; $totalSeconds = $totalSeconds + $tsSecond; if ($totalSeconds >= $startSeconds && $totalSeconds < $endSeconds) { $relativeTsPath = $uriDir . '/' . $matches[2] . '.ts'; // 不针对ts鉴权了. $querySign = self::getCdnRepo()->getSignKeyValue($relativeTsPath); $tsUrl = $domain . $relativeTsPath . '?'. $querySign; // $tsUrl = $domain . $relativeTsPath; $result = "#EXTINF:{$matches[1]},\n{$tsUrl}"; } return $result; }, $content); $content = preg_replace(['/[\n]{2,}/', '/\#EXT\-X\-ENDLIST/'], ['', "\n#EXT-X-ENDLIST"], $content); // 去掉多余的空行. $pattern2 = "/\#EXT-X-KEY:METHOD=AES-128,URI=\"(.+)\"/i"; $keyUrl = $urlPrefix . '/key.key'; $replacement2 = "#EXT-X-KEY:METHOD=AES-128,URI=\"{$keyUrl}\"\n"; $content = preg_replace([$pattern2],[$replacement2], $content, -1, $count); return $content; } private static function getCdnRepo() { if (!self::$cdnRepo instanceof CdnRepository) { self::$cdnRepo = new CdnRepository(); } return self::$cdnRepo; } /** * 保存至本地. * @param $url * @param $filePath * @return array|mixed|string|string[]|null */ private static function saveToLocalByUrl($url, $filePath) { $data = self::getRemoteFileRaw($url); if (!empty($data)) { file_put_contents($filePath, $data); } return $data; } /** * @param array $info * @param $filePath * @param $domain * @param $uriDir * @param $urlPrefix * @return array|false|mixed|string|string[]|null */ private static function saveToLocalByAuthRaw(array $info, $filePath, $domain, $uriDir, $urlPrefix) { $url = $info['origin_url']; $isPreview = $info['is_preview']; $previewTime = $info['preview_time']; $originContent = self::getM3u8OriginRaw($url, true); if (!empty($originContent)) { // 拿到没有query参数的url地址. $n = stripos($filePath, '.m3u8'); $pathPrefix = substr($filePath, 0, $n); $hashPreviewTime = substr(md5($previewTime), 0, 6); $previewFilePath = $pathPrefix . '.preview.m3u8'; $content = self::convertAuthRaw($originContent, $domain, $uriDir, $urlPrefix); file_put_contents($filePath, $content); $previewContent = self::convertAuthPreviewRaw($originContent, $previewTime, $domain, $uriDir, $urlPrefix); file_put_contents($previewFilePath, $previewContent); if ($isPreview) { return $previewContent; } else { return $content; } } else { return $originContent; } } /** * 获取http客户端. * @return Client|null */ private static function getHttpClientRepo() { if (!self::$httpClient instanceof Client) { $options = array( 'timeout' => 20, // 超时,单位秒. 'verify' => false, // 是否校验SSL证书. 'http_errors' => false, // 如果跟随http异常头部是否中断. 'debug' => false, // 是否启用调试模式. 'allow_redirects' => false, // 不跟随301/302跳转. ); self::$httpClient = new Client($options); } return self::$httpClient; } /** * GET获取. * @param $url * @param array $options * @return string * @throws \GuzzleHttp\Exception\GuzzleException */ private static function httpGet($url, $options = []) { $raw = ''; $ret = self::getHttpClientRepo()->get($url, $options); $httpStatus = $ret->getStatusCode(); if ($httpStatus == 200) { $raw = $ret->getBody()->getContents(); } return $raw; } }