diff --git a/docker/php/dev.php.ini b/docker/php/dev.php.ini index e7e6b6f..5fa8984 100644 --- a/docker/php/dev.php.ini +++ b/docker/php/dev.php.ini @@ -1,6 +1,8 @@ [PHP] error_reporting = E_ALL file_uploads = Off +memory_limit=-1 +max_execution_time=-1 ; upload_max_filesize=10M ; post_max_size=10M diff --git a/src/app/Controllers/Controller.php b/src/app/Controllers/Controller.php index cbb6e03..9d2a736 100644 --- a/src/app/Controllers/Controller.php +++ b/src/app/Controllers/Controller.php @@ -4,8 +4,12 @@ declare(strict_types=1); namespace App\Controllers; +use App\Core\IniFile; +use App\Core\Playlist; +use App\Exceptions\PlaylistNotFoundException; use Exception; use Flight; +use Random\RandomException; /** * Абстрактный контроллер для расширения @@ -13,15 +17,69 @@ use Flight; abstract class Controller { /** - * Перебрасывает на страницу 404 при ненайденном плейлисте + * @var IniFile Класс для работы с ini-файлом плейлистов + */ + protected IniFile $ini; + + /** + * Конструктор + */ + public function __construct() + { + $this->ini = Flight::get('ini'); + } + + /** + * Возвращает плейлист по его ID для обработки * * @param string $id + * @param bool $asJson + * @return Playlist + * @throws Exception + */ + protected function getPlaylist(string $id, bool $asJson = false): Playlist + { + if ($this->ini->getRedirection($id)) { + Flight::redirect(base_url($this->ini->getRedirection($id) . ($asJson ? '/json' : '/details'))); + die; + } + + try { + return $this->ini->getPlaylist($id); + } catch (PlaylistNotFoundException) { + $this->notFound($id, $asJson); + die; + } + } + + /** + * Возвращает обработанный плейлист для ответа + * + * @param string $id ID плейлиста + * @param bool $asJson Обрабатывать как json + * @return array + * @throws RandomException + * @throws Exception + */ + protected function getPlaylistResponse(string $id, bool $asJson = false): array + { + $playlist = $this->getPlaylist($id, $asJson); + $playlist->download(); + $playlist->parse(); + return $playlist->toArray(); + } + + /** + * Перебрасывает на страницу 404 при ненайденном плейлисте + * + * @param string $id ID плейлиста + * @param bool $asJson Обрабатывать как json * @return void * @throws Exception */ - public function notFound(string $id): void + public function notFound(string $id, bool $asJson = false): void { Flight::response()->status(404)->sendHeaders(); - view('notfound', ['id' => $id]); + $asJson || view('notfound', ['id' => $id]); } } diff --git a/src/app/Controllers/HomeController.php b/src/app/Controllers/HomeController.php index bed4388..60b5338 100644 --- a/src/app/Controllers/HomeController.php +++ b/src/app/Controllers/HomeController.php @@ -4,8 +4,6 @@ declare(strict_types=1); namespace App\Controllers; -use App\Core\PlaylistProcessor; -use App\Core\RedirectedPlaylist; use Exception; use Flight; @@ -15,22 +13,9 @@ use Flight; class HomeController extends Controller { /** - * @var PlaylistProcessor Обработчик ini-списка - */ - protected PlaylistProcessor $ini; - - /** - * Конструктор - */ - public function __construct() - { - $this->ini = new PlaylistProcessor(); - } - - /** - * Отображает главную страницу на указанной странице списка плейлистов + * Отображает главную страницу с учётом пагинации списка плейлистов * - * @param int $page + * @param int $page Текущая страница списка * @return void * @throws Exception */ @@ -44,19 +29,21 @@ class HomeController extends Controller } // иначе формируем и сортируем список при необходимости, рисуем страницу - $per_page = 10; - $list = $this->ini->playlists - ->filter(static fn ($playlist) => !($playlist instanceof RedirectedPlaylist)) - ->forPage($page, $per_page); + $perPage = 10; + $playlists = $this->ini->playlists(false); + $count = count($playlists); + $pageCount = ceil($count / $perPage); + $offset = max(0, ($page - 1) * $perPage); + $list = array_slice($playlists, $offset, $perPage, true); view('list', [ 'updated_at' => $this->ini->updatedAt(), - 'count' => $this->ini->playlists->count(), + 'count' => $count, 'pages' => [ - 'count' => ceil($this->ini->playlists->count() / $per_page), + 'count' => $pageCount, 'current' => $page, ], - 'playlists' => $list->toArray(), + 'playlists' => $list, ]); } diff --git a/src/app/Controllers/PlaylistController.php b/src/app/Controllers/PlaylistController.php index e850647..0699c5a 100644 --- a/src/app/Controllers/PlaylistController.php +++ b/src/app/Controllers/PlaylistController.php @@ -4,9 +4,6 @@ declare(strict_types=1); namespace App\Controllers; -use App\Core\{ - PlaylistProcessor, - RedirectedPlaylist}; use App\Exceptions\PlaylistNotFoundException; use Exception; use Flight; @@ -16,34 +13,17 @@ use Flight; */ class PlaylistController extends Controller { - /** - * @var PlaylistProcessor Обработчик ini-списка - */ - protected PlaylistProcessor $ini; - - /** - * Конструктор - */ - public function __construct() - { - $this->ini = new PlaylistProcessor(); - } - /** * Отправляет запрос с клиента по прямой ссылке плейлиста * - * @param $id + * @param string $id ID плейлиста * @return void * @throws Exception */ - public function download($id): void + public function download(string $id): void { try { - $playlist = $this->ini->playlist($id); - if ($playlist instanceof RedirectedPlaylist) { - Flight::redirect(base_url($playlist->redirect_id)); - die; - } + $playlist = $this->ini->getPlaylist($id); Flight::redirect($playlist->pls); } catch (PlaylistNotFoundException) { $this->notFound($id); @@ -54,49 +34,27 @@ class PlaylistController extends Controller /** * Отображает страницу описания плейлиста * - * @param string $id + * @param string $id ID плейлиста * @return void * @throws Exception */ public function details(string $id): void { - try { - $playlist = $this->ini->playlist($id); - if ($playlist instanceof RedirectedPlaylist) { - Flight::redirect(base_url($playlist->redirect_id . '/details')); - die; - } - view('details', [ - ...$playlist->toArray(), - ...$this->ini->parse($id), - ]); - } catch (PlaylistNotFoundException) { - $this->notFound($id); - } + $result = $this->getPlaylistResponse($id); + + view('details', $result); } /** * Возвращает JSON с описанием плейлиста * - * @param string $id + * @param string $id ID плейлиста * @return void * @throws Exception */ public function json(string $id): void { - try { - $playlist = $this->ini->playlist($id); - if ($playlist instanceof RedirectedPlaylist) { - Flight::redirect(base_url($playlist->redirect_id . '/json')); - die; - } - Flight::json([ - ...$playlist->toArray(), - ...$this->ini->parse($id), - ]); - } catch (PlaylistNotFoundException) { - Flight::response()->status(404)->sendHeaders(); - Flight::json(['error' => ['message' => 'Playlist not found']]); - } + $result = $this->getPlaylistResponse($id, true); + Flight::json($result); } } diff --git a/src/app/Core/BasicPlaylist.php b/src/app/Core/BasicPlaylist.php deleted file mode 100644 index 8a958fe..0000000 --- a/src/app/Core/BasicPlaylist.php +++ /dev/null @@ -1,28 +0,0 @@ -id); - } -} diff --git a/src/app/Core/Bootstrapper.php b/src/app/Core/Bootstrapper.php index c0f2291..703f180 100644 --- a/src/app/Core/Bootstrapper.php +++ b/src/app/Core/Bootstrapper.php @@ -4,10 +4,8 @@ declare(strict_types=1); namespace App\Core; -use App\Controllers\AjaxController; use App\Extensions\TwigFunctions; use Flight; -use Illuminate\Support\Arr; use Twig\Environment; use Twig\Extension\DebugExtension; use Twig\Loader\FilesystemLoader; @@ -24,11 +22,19 @@ final class Bootstrapper */ public static function bootSettings(): void { - $settings = Arr::dot(require_once config_path('app.php')); - Arr::map($settings, function ($value, $key) { - Flight::set("flight.$key", $value); - }); - Flight::set('config', $settings); + $config = require_once config_path('app.php'); + foreach ($config as $key => $value) { + Flight::set($key, $value); + } + Flight::set('config', $config); + } + + public static function bootIni(): void + { + $loader = new IniFile(); + $loader->load(); + + Flight::set('ini', $loader); } /** @@ -38,18 +44,20 @@ final class Bootstrapper */ public static function bootTwig(): void { - $filesystemLoader = new FilesystemLoader(config('views.path')); - Flight::register( - 'view', - Environment::class, - [$filesystemLoader, config('twig')], - function ($twig) { - /** @var Environment $twig */ - Flight::set('twig', $twig); - $twig->addExtension(new TwigFunctions()); - $twig->addExtension(new DebugExtension()); - } - ); + $twigCfg = [ + 'cache' => config('twig.cache'), + 'debug' => config('twig.debug'), + ]; + + $closure = static function ($twig) { + /** @var Environment $twig */ + Flight::set('twig', $twig); + $twig->addExtension(new TwigFunctions()); + $twig->addExtension(new DebugExtension()); + }; + + $loader = new FilesystemLoader(config('flight.views.path')); + Flight::register('view', Environment::class, [$loader, $twigCfg], $closure); } /** diff --git a/src/app/Core/IniFile.php b/src/app/Core/IniFile.php new file mode 100644 index 0000000..42c5983 --- /dev/null +++ b/src/app/Core/IniFile.php @@ -0,0 +1,120 @@ +updated_at = date('d.m.Y h:i', filemtime($filepath)); + + $this->rawIni = parse_ini_file($filepath, true); + foreach ($this->rawIni as $id => $data) { + $this->playlists[(string)$id] = $this->makePlaylist($id, $data); + } + } + + /** + * Возвращает объекты плейлистов + * + * @param bool $all true - получить все, false - получить только НЕпереадресованные + * @return Playlist[] + */ + public function playlists(bool $all = true): array + { + if ($all) { + return $this->playlists; + } + + return array_filter($this->playlists, static fn ($playlist) => is_null($playlist->redirectId)); + } + + /** + * Возвращает дату обновления ini-файла + * + * @return string + */ + public function updatedAt(): string + { + return $this->updated_at; + } + + /** + * Возвращает ID плейлиста, на который нужно переадресовать указанный + * + * @param string $id ID плейлиста + * @return string|null + */ + public function getRedirection(string $id): ?string + { + return $this->redirections[$id] ?? null; + } + + /** + * Возвращает объект плейлиста + * + * @param string $id ID плейлиста + * @return Playlist|null + * @throws PlaylistNotFoundException + */ + public function getPlaylist(string $id): ?Playlist + { + return $this->playlists[$id] ?? throw new PlaylistNotFoundException($id); + } + + /** + * Создаёт объекты плейлистов, рекурсивно определяя переадресации + * + * @param int|string $id ID плейлиста + * @param array $params Описание плейлиста + * @param string|null $redirectId ID для переадресации + * @return Playlist + * @throws Exception + */ + protected function makePlaylist(int|string $id, array $params, ?string $redirectId = null): Playlist + { + $id = (string)$id; + if (isset($params['redirect'])) { + $this->redirections[$id] = $redirectId = (string)$params['redirect']; + $params = $this->rawIni[$redirectId]; + return $this->makePlaylist($id, $params, $redirectId); + } + + return new Playlist($id, $params, $redirectId); + } +} diff --git a/src/app/Core/Playlist.php b/src/app/Core/Playlist.php index 33534cc..c1c53dd 100644 --- a/src/app/Core/Playlist.php +++ b/src/app/Core/Playlist.php @@ -5,11 +5,12 @@ declare(strict_types=1); namespace App\Core; use Exception; +use Random\RandomException; /** * Плейлист без редиректа */ -class Playlist extends BasicPlaylist +class Playlist { /** * @var string|null Название плейлиста @@ -36,18 +37,43 @@ class Playlist extends BasicPlaylist */ public string $url; + /** + * @var string|null Сырое содержимое плейлиста + */ + protected ?string $rawContent = null; + + /** + * @var array Обработанное содержимое плейлиста + */ + protected array $parsedContent = []; + + /** + * @var array Статус скачивания плейлиста + */ + protected array $downloadStatus = [ + 'httpCode' => 'unknown', + 'errCode' => 'unknown', + 'errText' => 'unknown', + 'possibleStatus' => 'unknown', + ]; + /** * Конструктор * - * @param string $id - * @param array $params + * @param string $id ID плейлиста + * @param array $params Описание плейлиста + * @param string|null $redirectId ID для переадресации * @throws Exception */ - public function __construct(public string $id, array $params) - { + 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']; @@ -56,7 +82,172 @@ class Playlist extends BasicPlaylist } /** - * @inheritDoc + * Получает содержимое плейлиста с третьей стороны + * + * @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, + ]); + + $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; + $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 { @@ -67,6 +258,41 @@ class Playlist extends BasicPlaylist '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; + } } diff --git a/src/app/Core/PlaylistProcessor.php b/src/app/Core/PlaylistProcessor.php deleted file mode 100644 index c4ae2d1..0000000 --- a/src/app/Core/PlaylistProcessor.php +++ /dev/null @@ -1,175 +0,0 @@ -updated_at = date('d.m.Y h:i', filemtime($filepath)); - $this->playlists = collect(parse_ini_file($filepath, true)) - ->transform(static fn ($playlist, $id) => empty($playlist['redirect']) - ? new Playlist((string)$id, $playlist) - : new RedirectedPlaylist((string)$id, $playlist['redirect']) - ); - } - - /** - * Проверяет есть ли в списке плейлист по его id - * - * @param string $id - * @return bool - */ - public function hasId(string $id): bool - { - return $this->playlists->keys()->contains($id); - } - - /** - * Возвращает из коллекции указанный плейлист, если он существует - * - * @param string $id - * @return Playlist|RedirectedPlaylist - * @throws PlaylistNotFoundException - */ - public function playlist(string $id): Playlist|RedirectedPlaylist - { - !$this->hasId($id) && throw new PlaylistNotFoundException($id); - return $this->playlists[$id]; - } - - /** - * Проверяет доступность плейлиста на третьей стороне - * - * @param string $id - * @return bool - * @throws PlaylistNotFoundException - */ - public function check(string $id): bool - { - $curl = curl_init(); - curl_setopt_array($curl, [ - CURLOPT_URL => $this->playlist($id)->pls, - CURLOPT_RETURNTRANSFER => true, - CURLOPT_TIMEOUT => 5, - CURLOPT_HEADER => false, - CURLOPT_NOBODY => true, - ]); - curl_exec($curl); - $code = curl_getinfo($curl, CURLINFO_RESPONSE_CODE); - curl_close($curl); - return $code < 400; - } - - /** - * Получает содержимое плейлиста с третьей стороны - * - * @param string $id - * @return array - * @throws PlaylistNotFoundException - */ - protected function fetch(string $id): array - { - $curl = curl_init(); - curl_setopt_array($curl, [ - CURLOPT_URL => $this->playlist($id)->pls, - CURLOPT_RETURNTRANSFER => true, - CURLOPT_TIMEOUT => 30, - CURLOPT_HEADER => false, - CURLOPT_FAILONERROR => true, - ]); - $content = curl_exec($curl); - $http_code = curl_getinfo($curl, CURLINFO_RESPONSE_CODE); - $err_code = curl_errno($curl); - $err_text = curl_error($curl); - curl_close($curl); - return [ - 'content' => $content, - 'http_code' => $http_code, - 'err_code' => $err_code, - 'err_text' => $err_text, - ]; - } - - /** - * Возвращает статус проверки плейлиста по коду ошибки curl - * - * @param int $curl_err_code - * @return string - */ - protected function guessStatus(int $curl_err_code): string - { - return match ($curl_err_code) { - 0 => 'online', - 28 => 'timeout', - 5, 6, 7, 22, 35 => 'offline', - default => 'error', - }; - } - - /** - * Парсит полученный от третьей стороны плейлист - * - * @param string $id - * @return array Информация о составе плейлиста - * @throws PlaylistNotFoundException - */ - public function parse(string $id): array - { - $fetched = $this->fetch($id); - if ($fetched['err_code'] > 0) { - return [ - 'status' => $this->guessStatus($fetched['err_code']), - 'error' => [ - 'code' => $fetched['err_code'], - 'message' => $fetched['err_text'], - ], - ]; - } - $result['status'] = $this->guessStatus($fetched['err_code']); - $result['encoding']['name'] = 'UTF-8'; - $result['encoding']['alert'] = false; - if (($enc = mb_detect_encoding($fetched['content'], config('app.pls_encodings'))) !== 'UTF-8') { - $fetched['content'] = mb_convert_encoding($fetched['content'], 'UTF-8', $enc); - $result['encoding']['name'] = $enc; - $result['encoding']['alert'] = true; - } - $matches = []; - preg_match_all("/^#EXTINF:-?\d.*,\s*(.*)/m", $fetched['content'], $matches); - $result['channels'] = array_map('trim', $matches[1]); - $result['count'] = $fetched['http_code'] < 400 ? count($result['channels']) : 0; - return $result; - } - - /** - * Возвращает дату последнего обновления списка плейлистов - * - * @return string - */ - public function updatedAt(): string - { - return $this->updated_at; - } -} diff --git a/src/app/Core/RedirectedPlaylist.php b/src/app/Core/RedirectedPlaylist.php deleted file mode 100644 index 6c55947..0000000 --- a/src/app/Core/RedirectedPlaylist.php +++ /dev/null @@ -1,34 +0,0 @@ - $this->id, - 'redirect_id' => $this->redirect_id, - ]; - } -} diff --git a/src/app/Exceptions/PlaylistNotFoundException.php b/src/app/Exceptions/PlaylistNotFoundException.php index f8864b1..bae0833 100644 --- a/src/app/Exceptions/PlaylistNotFoundException.php +++ b/src/app/Exceptions/PlaylistNotFoundException.php @@ -8,8 +8,8 @@ use Exception; class PlaylistNotFoundException extends Exception { - public function __construct(string $pls_code) + public function __construct(string $id) { - parent::__construct("Плейлист $pls_code не найден!"); + parent::__construct("Плейлист $id не найден!"); } } diff --git a/src/app/helpers.php b/src/app/helpers.php index e3b1f1f..0f784f8 100644 --- a/src/app/helpers.php +++ b/src/app/helpers.php @@ -84,6 +84,7 @@ function env(string $key, mixed $default = null): mixed function view(mixed $template, array $data = []): void { $template = str_contains($template, '.twig') ? $template : "$template.twig"; + /** @noinspection PhpVoidFunctionResultUsedInspection */ echo Flight::view()->render($template, $data); } @@ -134,16 +135,5 @@ function bool(mixed $value): bool */ function config(string $key, mixed $default = null): mixed { - $config = Flight::get('config'); - if (isset($config["flight.$key"])) { - return $config["flight.$key"]; - } - if (isset($config[$key])) { - return $config[$key]; - } - $config = Arr::undot($config); - if (Arr::has($config, $key)) { - return Arr::get($config, $key); - } - return $default; + return Flight::get('config')[$key] ?? $default; } diff --git a/src/composer.json b/src/composer.json index 7d4b1b3..e177096 100644 --- a/src/composer.json +++ b/src/composer.json @@ -2,7 +2,7 @@ "require": { "php": "^8.2", "ext-json": "*", - "illuminate/collections": "^11.23", + "ext-curl": "*", "mikecao/flight": "^3.12", "symfony/dotenv": "^7.1", "twig/twig": "^3.14" diff --git a/src/composer.lock b/src/composer.lock index ef0cac3..8257e3f 100644 --- a/src/composer.lock +++ b/src/composer.lock @@ -4,203 +4,8 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "8e2b70162003ee57e4e033d98f833457", + "content-hash": "3cbd8253b2f0790d682e38f308df6e7f", "packages": [ - { - "name": "illuminate/collections", - "version": "v11.23.5", - "source": { - "type": "git", - "url": "https://github.com/illuminate/collections.git", - "reference": "cbea9d7a82984bbc1a9376498533cc77513f9a09" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/illuminate/collections/zipball/cbea9d7a82984bbc1a9376498533cc77513f9a09", - "reference": "cbea9d7a82984bbc1a9376498533cc77513f9a09", - "shasum": "" - }, - "require": { - "illuminate/conditionable": "^11.0", - "illuminate/contracts": "^11.0", - "illuminate/macroable": "^11.0", - "php": "^8.2" - }, - "suggest": { - "symfony/var-dumper": "Required to use the dump method (^7.0)." - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "11.x-dev" - } - }, - "autoload": { - "files": [ - "helpers.php" - ], - "psr-4": { - "Illuminate\\Support\\": "" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Taylor Otwell", - "email": "taylor@laravel.com" - } - ], - "description": "The Illuminate Collections package.", - "homepage": "https://laravel.com", - "support": { - "issues": "https://github.com/laravel/framework/issues", - "source": "https://github.com/laravel/framework" - }, - "time": "2024-09-12T14:50:04+00:00" - }, - { - "name": "illuminate/conditionable", - "version": "v11.23.5", - "source": { - "type": "git", - "url": "https://github.com/illuminate/conditionable.git", - "reference": "362dd761b9920367bca1427a902158225e9e3a23" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/illuminate/conditionable/zipball/362dd761b9920367bca1427a902158225e9e3a23", - "reference": "362dd761b9920367bca1427a902158225e9e3a23", - "shasum": "" - }, - "require": { - "php": "^8.0.2" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "11.x-dev" - } - }, - "autoload": { - "psr-4": { - "Illuminate\\Support\\": "" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Taylor Otwell", - "email": "taylor@laravel.com" - } - ], - "description": "The Illuminate Conditionable package.", - "homepage": "https://laravel.com", - "support": { - "issues": "https://github.com/laravel/framework/issues", - "source": "https://github.com/laravel/framework" - }, - "time": "2024-06-28T20:10:30+00:00" - }, - { - "name": "illuminate/contracts", - "version": "v11.23.5", - "source": { - "type": "git", - "url": "https://github.com/illuminate/contracts.git", - "reference": "5a4c6dcf633c1f69e1b70bbea1ef1b7d2186d3da" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/illuminate/contracts/zipball/5a4c6dcf633c1f69e1b70bbea1ef1b7d2186d3da", - "reference": "5a4c6dcf633c1f69e1b70bbea1ef1b7d2186d3da", - "shasum": "" - }, - "require": { - "php": "^8.2", - "psr/container": "^1.1.1|^2.0.1", - "psr/simple-cache": "^1.0|^2.0|^3.0" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "11.x-dev" - } - }, - "autoload": { - "psr-4": { - "Illuminate\\Contracts\\": "" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Taylor Otwell", - "email": "taylor@laravel.com" - } - ], - "description": "The Illuminate Contracts package.", - "homepage": "https://laravel.com", - "support": { - "issues": "https://github.com/laravel/framework/issues", - "source": "https://github.com/laravel/framework" - }, - "time": "2024-09-12T15:25:08+00:00" - }, - { - "name": "illuminate/macroable", - "version": "v11.23.5", - "source": { - "type": "git", - "url": "https://github.com/illuminate/macroable.git", - "reference": "e1cb9e51b9ed5d3c9bc1ab431d0a52fe42a990ed" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/illuminate/macroable/zipball/e1cb9e51b9ed5d3c9bc1ab431d0a52fe42a990ed", - "reference": "e1cb9e51b9ed5d3c9bc1ab431d0a52fe42a990ed", - "shasum": "" - }, - "require": { - "php": "^8.2" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "11.x-dev" - } - }, - "autoload": { - "psr-4": { - "Illuminate\\Support\\": "" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Taylor Otwell", - "email": "taylor@laravel.com" - } - ], - "description": "The Illuminate Macroable package.", - "homepage": "https://laravel.com", - "support": { - "issues": "https://github.com/laravel/framework/issues", - "source": "https://github.com/laravel/framework" - }, - "time": "2024-06-28T20:10:30+00:00" - }, { "name": "mikecao/flight", "version": "v3.12.0", @@ -272,110 +77,6 @@ }, "time": "2024-08-22T17:05:34+00:00" }, - { - "name": "psr/container", - "version": "2.0.2", - "source": { - "type": "git", - "url": "https://github.com/php-fig/container.git", - "reference": "c71ecc56dfe541dbd90c5360474fbc405f8d5963" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/php-fig/container/zipball/c71ecc56dfe541dbd90c5360474fbc405f8d5963", - "reference": "c71ecc56dfe541dbd90c5360474fbc405f8d5963", - "shasum": "" - }, - "require": { - "php": ">=7.4.0" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "2.0.x-dev" - } - }, - "autoload": { - "psr-4": { - "Psr\\Container\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "PHP-FIG", - "homepage": "https://www.php-fig.org/" - } - ], - "description": "Common Container Interface (PHP FIG PSR-11)", - "homepage": "https://github.com/php-fig/container", - "keywords": [ - "PSR-11", - "container", - "container-interface", - "container-interop", - "psr" - ], - "support": { - "issues": "https://github.com/php-fig/container/issues", - "source": "https://github.com/php-fig/container/tree/2.0.2" - }, - "time": "2021-11-05T16:47:00+00:00" - }, - { - "name": "psr/simple-cache", - "version": "3.0.0", - "source": { - "type": "git", - "url": "https://github.com/php-fig/simple-cache.git", - "reference": "764e0b3939f5ca87cb904f570ef9be2d78a07865" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/php-fig/simple-cache/zipball/764e0b3939f5ca87cb904f570ef9be2d78a07865", - "reference": "764e0b3939f5ca87cb904f570ef9be2d78a07865", - "shasum": "" - }, - "require": { - "php": ">=8.0.0" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "3.0.x-dev" - } - }, - "autoload": { - "psr-4": { - "Psr\\SimpleCache\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "PHP-FIG", - "homepage": "https://www.php-fig.org/" - } - ], - "description": "Common interfaces for simple caching", - "keywords": [ - "cache", - "caching", - "psr", - "psr-16", - "simple-cache" - ], - "support": { - "source": "https://github.com/php-fig/simple-cache/tree/3.0.0" - }, - "time": "2021-10-29T13:26:27+00:00" - }, { "name": "symfony/deprecation-contracts", "version": "v3.5.0", @@ -445,16 +146,16 @@ }, { "name": "symfony/dotenv", - "version": "v7.1.3", + "version": "v7.1.5", "source": { "type": "git", "url": "https://github.com/symfony/dotenv.git", - "reference": "a26be30fd61678dab694a18a85084cea7673bbf3" + "reference": "6d966200b399fa59759286f3fc7c919f0677c449" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/dotenv/zipball/a26be30fd61678dab694a18a85084cea7673bbf3", - "reference": "a26be30fd61678dab694a18a85084cea7673bbf3", + "url": "https://api.github.com/repos/symfony/dotenv/zipball/6d966200b399fa59759286f3fc7c919f0677c449", + "reference": "6d966200b399fa59759286f3fc7c919f0677c449", "shasum": "" }, "require": { @@ -499,7 +200,7 @@ "environment" ], "support": { - "source": "https://github.com/symfony/dotenv/tree/v7.1.3" + "source": "https://github.com/symfony/dotenv/tree/v7.1.5" }, "funding": [ { @@ -515,7 +216,7 @@ "type": "tidelift" } ], - "time": "2024-07-09T19:36:07+00:00" + "time": "2024-09-17T09:16:35+00:00" }, { "name": "symfony/polyfill-ctype", @@ -840,7 +541,8 @@ "prefer-lowest": false, "platform": { "php": "^8.2", - "ext-json": "*" + "ext-json": "*", + "ext-curl": "*" }, "platform-dev": [], "plugin-api-version": "2.6.0" diff --git a/src/config/app.php b/src/config/app.php index 7188cd0..64b81ef 100644 --- a/src/config/app.php +++ b/src/config/app.php @@ -3,28 +3,20 @@ declare(strict_types=1); return [ - 'flight' => [ - // https://flightphp.com/learn#configuration - 'base_url' => env('APP_URL', 'http://localhost:8080'), - 'case_sensitive' => bool(env('FLIGHT_CASE_SENSITIVE', false)), - 'handle_errors' => bool(env('FLIGHT_HANDLE_ERRORS', true)), - 'log_errors' => bool(env('FLIGHT_LOG_ERRORS', true)), - 'views' => [ - 'path' => views_path(), - 'extension' => '.twig', - ], - ], - 'twig' => [ - 'cache' => bool(env('TWIG_CACHE', true)) ? cache_path() . '/views' : false, - 'debug' => bool(env('TWIG_DEBUG', false)), - ], - 'app' => [ - 'title' => env('APP_TITLE', 'IPTV Playlists'), - 'pls_encodings' => [ - 'UTF-8', - 'CP1251', - // 'CP866', - // 'ISO-8859-5', - ], + // https://flightphp.com/learn#configuration + 'flight.base_url' => env('APP_URL', 'http://localhost:8080'), + 'flight.case_sensitive' => bool(env('FLIGHT_CASE_SENSITIVE', false)), + 'flight.handle_errors' => bool(env('FLIGHT_HANDLE_ERRORS', true)), + 'flight.log_errors' => bool(env('FLIGHT_LOG_ERRORS', true)), + 'flight.views.path' => views_path(), + 'flight.views.extension' => '.twig', + 'twig.cache' => bool(env('TWIG_CACHE', true)) ? cache_path() . '/views' : false, + 'twig.debug' => bool(env('TWIG_DEBUG', false)), + 'app.title' => env('APP_TITLE', 'IPTV Playlists'), + 'app.pls_encodings' => [ + 'UTF-8', + 'CP1251', + // 'CP866', + // 'ISO-8859-5', ], ]; diff --git a/src/config/routes.php b/src/config/routes.php index 0ae2acb..75735ac 100644 --- a/src/config/routes.php +++ b/src/config/routes.php @@ -6,11 +6,11 @@ use App\Controllers\HomeController; use App\Controllers\PlaylistController; return [ - 'GET /' => (new HomeController())->index(...), - 'GET /page/@page:[0-9]+' => (new HomeController())->index(...), - 'GET /faq' => (new HomeController())->faq(...), - 'GET /@id:[a-zA-Z0-9_-]+' => (new PlaylistController())->download(...), - 'GET /?[a-zA-Z0-9_-]+' => (new PlaylistController())->download(...), - 'GET /@id:[a-zA-Z0-9_-]+/details' => (new PlaylistController())->details(...), - 'GET /@id:[a-zA-Z0-9_-]+/json' => (new PlaylistController())->json(...), + 'GET /' => [HomeController::class, 'index'], + 'GET /page/@page:[0-9]+' => [HomeController::class, 'index'], + 'GET /faq' => [HomeController::class, 'faq'], + 'GET /@id:[a-zA-Z0-9_-]+' => [PlaylistController::class, 'download'], + 'GET /?[a-zA-Z0-9_-]+' => [PlaylistController::class, 'download'], + 'GET /@id:[a-zA-Z0-9_-]+/details' => [PlaylistController::class, 'details'], + 'GET /@id:[a-zA-Z0-9_-]+/json' => [PlaylistController::class, 'json'], ]; diff --git a/src/public/index.php b/src/public/index.php index b9db04e..be10d71 100644 --- a/src/public/index.php +++ b/src/public/index.php @@ -11,20 +11,10 @@ use Symfony\Component\Dotenv\Dotenv; |-------------------------------------------------------------------------- */ -// autoload composer packages require '../vendor/autoload.php'; - -// load .env parameters (new Dotenv())->loadEnv(root_path() . '/.env'); - -// set up framework according to its config Bootstrapper::bootSettings(); - -// set up Twig template engine Bootstrapper::bootTwig(); - -// set up routes defined in config file +Bootstrapper::bootIni(); Bootstrapper::bootRoutes(); - -// start application Flight::start(); diff --git a/src/public/js/list.min.js b/src/public/js/list.min.js new file mode 100644 index 0000000..8131881 --- /dev/null +++ b/src/public/js/list.min.js @@ -0,0 +1,2 @@ +var List;List=function(){var t={"./src/add-async.js":function(t){t.exports=function(t){return function e(r,n,s){var i=r.splice(0,50);s=(s=s||[]).concat(t.add(i)),r.length>0?setTimeout((function(){e(r,n,s)}),1):(t.update(),n(s))}}},"./src/filter.js":function(t){t.exports=function(t){return t.handlers.filterStart=t.handlers.filterStart||[],t.handlers.filterComplete=t.handlers.filterComplete||[],function(e){if(t.trigger("filterStart"),t.i=1,t.reset.filter(),void 0===e)t.filtered=!1;else{t.filtered=!0;for(var r=t.items,n=0,s=r.length;nv.page,a=new g(t[s],void 0,n),v.items.push(a),r.push(a)}return v.update(),r}m(t.slice(0),e)}},this.show=function(t,e){return this.i=t,this.page=e,v.update(),v},this.remove=function(t,e,r){for(var n=0,s=0,i=v.items.length;s-1&&r.splice(n,1),v},this.trigger=function(t){for(var e=v.handlers[t].length;e--;)v.handlers[t][e](v);return v},this.reset={filter:function(){for(var t=v.items,e=t.length;e--;)t[e].filtered=!1;return v},search:function(){for(var t=v.items,e=t.length;e--;)t[e].found=!1;return v}},this.update=function(){var t=v.items,e=t.length;v.visibleItems=[],v.matchingItems=[],v.templater.clear();for(var r=0;r=v.i&&v.visibleItems.lengthe},innerWindow:function(t,e,r){return t>=e-r&&t<=e+r},dotted:function(t,e,r,n,s,i,a){return this.dottedLeft(t,e,r,n,s,i)||this.dottedRight(t,e,r,n,s,i,a)},dottedLeft:function(t,e,r,n,s,i){return e==r+1&&!this.innerWindow(e,s,i)&&!this.right(e,n)},dottedRight:function(t,e,r,n,s,i,a){return!t.items[a-1].values().dotted&&(e==n&&!this.innerWindow(e,s,i)&&!this.right(e,n))}};return function(e){var n=new i(t.listContainer.id,{listClass:e.paginationClass||"pagination",item:e.item||"
  • ",valueNames:["page","dotted"],searchClass:"pagination-search-that-is-not-supposed-to-exist",sortClass:"pagination-sort-that-is-not-supposed-to-exist"});s.bind(n.listContainer,"click",(function(e){var r=e.target||e.srcElement,n=t.utils.getAttribute(r,"data-page"),s=t.utils.getAttribute(r,"data-i");s&&t.show((s-1)*n+1,n)})),t.on("updated",(function(){r(n,e)})),r(n,e)}}},"./src/parse.js":function(t,e,r){t.exports=function(t){var e=r("./src/item.js")(t),n=function(r,n){for(var s=0,i=r.length;s0?setTimeout((function(){e(r,s)}),1):(t.update(),t.trigger("parseComplete"))};return t.handlers.parseComplete=t.handlers.parseComplete||[],function(){var e=function(t){for(var e=t.childNodes,r=[],n=0,s=e.length;n]/g.exec(t)){var e=document.createElement("tbody");return e.innerHTML=t,e.firstElementChild}if(-1!==t.indexOf("<")){var r=document.createElement("div");return r.innerHTML=t,r.firstElementChild}}},a=function(e,r,n){var s=void 0,i=function(e){for(var r=0,n=t.valueNames.length;r=1;)t.list.removeChild(t.list.firstChild)},function(){var r;if("function"!=typeof t.item){if(!(r="string"==typeof t.item?-1===t.item.indexOf("<")?document.getElementById(t.item):i(t.item):s()))throw new Error("The list needs to have at least one item on init otherwise you'll have to add a template.");r=n(r,t.valueNames),e=function(){return r.cloneNode(!0)}}else e=function(e){var r=t.item(e);return i(r)}}()};t.exports=function(t){return new e(t)}},"./src/utils/classes.js":function(t,e,r){var n=r("./src/utils/index-of.js"),s=/\s+/;Object.prototype.toString;function i(t){if(!t||!t.nodeType)throw new Error("A DOM element reference is required");this.el=t,this.list=t.classList}t.exports=function(t){return new i(t)},i.prototype.add=function(t){if(this.list)return this.list.add(t),this;var e=this.array();return~n(e,t)||e.push(t),this.el.className=e.join(" "),this},i.prototype.remove=function(t){if(this.list)return this.list.remove(t),this;var e=this.array(),r=n(e,t);return~r&&e.splice(r,1),this.el.className=e.join(" "),this},i.prototype.toggle=function(t,e){return this.list?(void 0!==e?e!==this.list.toggle(t,e)&&this.list.toggle(t):this.list.toggle(t),this):(void 0!==e?e?this.add(t):this.remove(t):this.has(t)?this.remove(t):this.add(t),this)},i.prototype.array=function(){var t=(this.el.getAttribute("class")||"").replace(/^\s+|\s+$/g,"").split(s);return""===t[0]&&t.shift(),t},i.prototype.has=i.prototype.contains=function(t){return this.list?this.list.contains(t):!!~n(this.array(),t)}},"./src/utils/events.js":function(t,e,r){var n=window.addEventListener?"addEventListener":"attachEvent",s=window.removeEventListener?"removeEventListener":"detachEvent",i="addEventListener"!==n?"on":"",a=r("./src/utils/to-array.js");e.bind=function(t,e,r,s){for(var o=0,l=(t=a(t)).length;o32)return!1;var a=n,o=function(){var t,r={};for(t=0;t=p;b--){var j=o[t.charAt(b-1)];if(C[b]=0===m?(C[b+1]<<1|1)&j:(C[b+1]<<1|1)&j|(v[b+1]|v[b])<<1|1|v[b+1],C[b]&d){var x=l(m,b-1);if(x<=u){if(u=x,!((c=b-1)>a))break;p=Math.max(1,2*a-c)}}}if(l(m+1,a)>u)break;v=C}return!(c<0)}},"./src/utils/get-attribute.js":function(t){t.exports=function(t,e){var r=t.getAttribute&&t.getAttribute(e)||null;if(!r)for(var n=t.attributes,s=n.length,i=0;i=48&&t<=57}function i(t,e){for(var i=(t+="").length,a=(e+="").length,o=0,l=0;o=i&&l=a?-1:l>=a&&o=i?1:i-a}i.caseInsensitive=i.i=function(t,e){return i((""+t).toLowerCase(),(""+e).toLowerCase())},Object.defineProperties(i,{alphabet:{get:function(){return e},set:function(t){r=[];var s=0;if(e=t)for(;s.tvg-logo-background{max-width:100px;max-height:100px;background:white;padding:2px;border-radius:5px} +{% endblock %} {% block header %} -

    {{ name }}

    - {% if (encoding.alert) %} +

    О плейлисте {{ name }}

    + {% if (content.encoding.alert) %} {% endif %} - {% if (error) %} + {% if (status.errCode > 0) %} {% endif %} {% endblock %} +{% block footer %} + + +{% endblock %} + {% block content %}
    -
    -

    О плейлисте

    +
    @@ -61,20 +71,31 @@
    ID - {{ id }} {% if status == 'online' %} + {{ id }} {% if status.possibleStatus == 'online' %} online - {% elseif status == 'offline' %} + {% elseif status.possibleStatus == 'offline' %} offline - {% elseif status == 'timeout' %} + {% elseif status.possibleStatus == 'timeout' %} timeout - {% elseif status == 'error' %} + {% elseif status.possibleStatus == 'error' %} error {% endif %}
    -
    -

    Список каналов ({{ count ?? 0 }})

    -
    - - - {% for channel in channels %} - - - - - {% endfor %} - -
    {{ loop.index }}{{ channel }}
    + +
    +

    Список каналов ({{ content.channelCount ?? 0 }})

    + {% if (content.channelCount > 0) %} +
    + +
    + + + {% for channel in content.channels %} + + + + + + {% endfor %} + +
    {{ loop.index }} + {% if (channel.attributes['tvg-logo']) %} + + {% endif %} + {{ channel.name }}
    +
    + {% endif %}
    {% endblock %} diff --git a/src/views/list.twig b/src/views/list.twig index 3f2a2a4..93973b4 100644 --- a/src/views/list.twig +++ b/src/views/list.twig @@ -26,14 +26,13 @@ {{ id }} - {{ playlist.name }} loading + {{ playlist.name }}
    {% if playlist.desc|length > 0 %}

    {{ playlist.desc }}

    {% endif %} - Подробнее... -{# Подробнее...#} + Подробнее...
    @@ -87,9 +86,9 @@ if (xhr.readyState === XMLHttpRequest.DONE) { console.log('[' + id + '] DONE', xhr.response) el_status.classList.remove('bg-secondary') - el_status.innerText = xhr.response.status - el_count.innerText = xhr.response?.count ?? 0 - switch (xhr.response.status) { + el_status.innerText = xhr.response.status.possibleStatus + el_count.innerText = xhr.response?.content.channelCount ?? 0 + switch (xhr.response.status.possibleStatus) { case 'online': el_status.classList.add('bg-success') break diff --git a/src/views/notfound.twig b/src/views/notfound.twig index d31a704..1b7f199 100644 --- a/src/views/notfound.twig +++ b/src/views/notfound.twig @@ -10,7 +10,7 @@

    Плейлист {{ id }} не найден

    - + Перейти к списку
    diff --git a/src/views/template.twig b/src/views/template.twig index 0206068..30936a3 100644 --- a/src/views/template.twig +++ b/src/views/template.twig @@ -1,7 +1,7 @@ - {{ config('app.title') }} + {% block title %}{{ config('app.title') }}{% endblock %} @@ -55,10 +55,10 @@ -
    +
    {% block header %}{% endblock %} {% block content %}{% endblock %} -
    +