'unknown', 'errCode' => 'unknown', 'errText' => 'unknown', 'possibleStatus' => 'unknown', ]; /** * Конструктор * * @param string $id ID плейлиста * @param array $params Описание плейлиста * @param string|null $redirectId ID для переадресации * @throws Exception */ public function __construct( public readonly string $id, array $params, public readonly ?string $redirectId = null ) { empty($params['pls']) && throw new Exception( "Плейлист с ID=$id обязан иметь параметр pls или redirect" ); $this->url = base_url($id); $this->name = empty($params['name']) ? "Плейлист #$id" : $params['name']; $this->desc = empty($params['desc']) ? null : $params['desc']; $this->pls = $params['pls']; $this->src = empty($params['src']) ? null : $params['src']; } /** * Получает содержимое плейлиста с третьей стороны * * @return void */ public function download(): void { $curl = curl_init(); curl_setopt_array($curl, [ CURLOPT_URL => $this->pls, CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 30, CURLOPT_HEADER => false, CURLOPT_FAILONERROR => true, CURLOPT_FOLLOWLOCATION => true, CURLOPT_MAXREDIRS => 5, CURLOPT_USERAGENT => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/133.0.0.0 Safari/537.36', ]); $content = curl_exec($curl); $this->rawContent = $content === false ? null : $content; $this->downloadStatus['httpCode'] = curl_getinfo($curl, CURLINFO_RESPONSE_CODE); $this->downloadStatus['errCode'] = curl_errno($curl); $this->downloadStatus['errText'] = curl_error($curl); $this->downloadStatus['possibleStatus'] = $this->guessStatus($this->downloadStatus['errCode']); curl_close($curl); } /** * Возвращает статус проверки плейлиста по коду ошибки curl * * @param int $curlErrCode * @return string */ protected function guessStatus(int $curlErrCode): string { return match ($curlErrCode) { 0 => 'online', 28 => 'timeout', 5, 6, 7, 22, 35 => 'offline', default => 'error', }; } /** * Парсит полученный от третьей стороны плейлист * * @return array Информация о составе плейлиста * @throws RandomException */ public function parse(): array { if (!empty($this->parsed())) { return $this->parsed(); } $result = [ 'attributes' => [], 'channels' => [], 'groups' => [], 'encoding' => [ 'name' => 'unknown', 'alert' => false, ], ]; if (is_null($this->rawContent)) { return $this->parsedContent = $result; } $enc = mb_detect_encoding($this->rawContent, config('app.pls_encodings')); $result['encoding']['name'] = $enc; if ($enc !== 'UTF-8') { $result['encoding']['alert'] = true; $this->rawContent = mb_convert_encoding($this->rawContent, 'UTF-8', $enc); } $lines = explode("\n", $this->rawContent); $isHeader = $isGroup = $isChannel = false; foreach ($lines as $line) { if (empty($line = trim($line))) { continue; } if (str_starts_with($line, '#EXTM3U ')) { $isHeader = true; $isGroup = $isChannel = false; $result['attributes'] = $this->parseAttributes($line); continue; } if (str_starts_with($line, '#EXTINF:')) { $isChannel = true; $isHeader = $isGroup = false; $combined = trim(substr($line, strpos($line, ',') + 1)); $exploded = explode(',', $line); $attrs = $this->parseAttributes($exploded[0]); $tvgid = empty($attrs['tvg-id']) ? ' неизвестен' : "='{$attrs['tvg-id']}'"; $name = trim($exploded[1] ?? "(канал без названия, tvg-id$tvgid)"); $channel = [ '_id' => md5($name . random_int(1, 99999)), 'name' => trim($name), 'url' => null, 'group' => $attrs['group-title'] ?? null, 'attributes' => $attrs, ]; unset($name, $attrs, $combined, $exploded); continue; } if (str_starts_with($line, '#EXTGRP:')) { $isGroup = true; $isHeader = false; if ($isChannel) { $exploded = explode(':', $line); $channel['group'] = $exploded[1]; } continue; } if ($isChannel) { $channel['url'] = str_starts_with($line, 'http') ? $line : null; $logoUrl = $channel['attributes']['tvg-logo'] ?? null; if (is_string($logoUrl)) { $logo = new ChannelLogo($logoUrl); $logo->readFile(); $channel['logo'] = [ 'base64' => $logo->asBase64(), 'size' => $logo->size(), 'mime-type' => $logo->mimeType(), ]; } $result['channels'][] = $channel; $isChannel = false; unset($channel); } } $groups = []; foreach ($result['channels'] as $channel) { $name = $channel['group'] ?? '(без группы)'; $id = md5($name); if (empty($groups[$id])) { $groups[$id] = [ '_id' => $id, 'name' => $name, 'channels' => [], ]; } $groups[$id]['channels'][] = $channel['_id']; } $result['groups'] = array_values($groups); return $this->parsedContent = $result; } /** * Парсит атрибуты строки и возвращает ассоциативный массив * * @param string $line * @return array */ protected function parseAttributes(string $line): array { if (str_starts_with($line, '#')) { $line = trim(substr($line, strpos($line, ' ') + 1)); } preg_match_all('#(?[a-z-]+)="(?.*)"#U', $line, $matches); return array_combine($matches['key'], $matches['value']); } /** * Возвращает содержимое объекта в виде массива * * @return array */ public function toArray(): array { return [ 'id' => $this->id, 'url' => $this->url, 'name' => $this->name, 'desc' => $this->desc, 'pls' => $this->pls, 'src' => $this->src, 'status' => $this->status(), 'content' => [ ...$this->parsed(), 'channelCount' => count($this->parsed()['channels']) ], ]; } /** * Возвращает ссылку на плейлист в рамках проекта * * @return string */ public function url(): string { return sprintf('%s/%s', base_url(), $this->id); } /** * Возвращает статус скачивания плейлиста * * @return array|string[] */ public function status(): array { return $this->downloadStatus; } /** * Возвращает обработанное содержимое плейлиста * * @return array */ public function parsed(): array { return $this->parsedContent; } }