From 252af502395c7c08aae9a399c4ff3bb518c0b33f Mon Sep 17 00:00:00 2001 From: AnthonyAxenov Date: Mon, 12 May 2025 00:07:43 +0800 Subject: [PATCH] =?UTF-8?q?=D0=9F=D0=B5=D1=80=D0=B5=D1=80=D0=B0=D0=B1?= =?UTF-8?q?=D0=BE=D1=82=D0=BA=D0=B0=20=D0=BF=D0=BE=D0=B4=20iptvc?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 30 +- .gitignore | 4 +- README.md | 190 +++------ app/Controllers/ApiController.php | 105 ++++- app/Controllers/BasicController.php | 52 ++- app/Controllers/WebController.php | 86 ++-- app/Core/IniFile.php | 141 +++--- app/Core/{Core.php => Kernel.php} | 205 ++++----- app/Core/Playlist.php | 368 ---------------- app/Core/TwigExtention.php | 66 ++- app/Errors/ErrorHandler.php | 5 + app/Errors/PlaylistNotFoundException.php | 9 +- app/Middleware/RequestId.php | 5 + app/{Core => Playlists}/ChannelLogo.php | 7 +- app/helpers.php | 149 +++++-- composer.json | 16 +- composer.lock | 275 +++++++++--- config/app.php | 11 +- config/cache.php | 16 + config/http.php | 29 ++ config/redis.php | 11 - config/routes.php | 46 +- config/twig.php | 7 +- public/index.php | 9 +- views/details.twig | 452 ++++++++++++++++---- views/faq.twig | 519 ++++++++++++----------- views/list.twig | 74 +++- views/notfound.twig | 6 + views/template.twig | 37 +- 29 files changed, 1662 insertions(+), 1268 deletions(-) rename app/Core/{Core.php => Kernel.php} (63%) delete mode 100644 app/Core/Playlist.php rename app/{Core => Playlists}/ChannelLogo.php (95%) create mode 100644 config/cache.php create mode 100644 config/http.php delete mode 100644 config/redis.php diff --git a/.env.example b/.env.example index da185b7..e6dfed8 100644 --- a/.env.example +++ b/.env.example @@ -1,18 +1,24 @@ +###################################### +# Поменяй эти значения на необходимые +###################################### + # config/app.php +APP_URL="http://localhost:8080" APP_DEBUG=false -APP_ENV=prod -APP_URL=http://localhost:8080 -APP_TITLE='IPTV Плейлисты' -USER_AGENT='Mozilla/5.0 (Windows NT 10.0; Win64; x99) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/133.0.0.0 Safari/537.36' +APP_ENV="prod" +APP_TITLE="IPTV Плейлисты" +APP_TIMEZONE=Europe/Moscow PAGE_SIZE=10 -# config/redis.php -REDIS_HOST='keydb' -REDIS_PORT=6379 -REDIS_PASSWORD= -REDIS_DB=0 -REDIS_TTL_DAYS=14 +# config/http.php +USER_AGENT="Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/133.0.0.0 Safari/537.36" -# config/redis.php +# config/cache.php +CACHE_HOST="keydb" +CACHE_PORT=6379 +CACHE_PASSWORD= +CACHE_DB=0 +CACHE_TTL=14 + +# config/twig.php TWIG_USE_CACHE=true -TWIG_DEBUG=false diff --git a/.gitignore b/.gitignore index cbb5b65..d77ce60 100644 --- a/.gitignore +++ b/.gitignore @@ -2,11 +2,13 @@ /.vscode /vendor /cache/* -/config/playlists.ini /views/custom.twig +*.log .env .env.* +playlists.ini +channels.json !.env.example !/**/.gitkeep diff --git a/README.md b/README.md index ee56027..460956f 100644 --- a/README.md +++ b/README.md @@ -1,163 +1,71 @@ -# Основной бэкенд iptv.axenov.dev +# Веб-сервис iptv.axenov.dev -> **Web-версия**: https://iptv.axenov.dev -> **FAQ**: https://iptv.axenov.dev/faq -> **Исходный код**: https://git.axenov.dev/IPTV +Содержит исходный код веб-сервиса и консольные инструменты проверки плейлистов и каналов. -Проект, содержащий в себе инструменты для работы с IPTV-плейлистами: +Использует [playlists.ini](https://git.axenov.dev/IPTV/playlists) с описанием плейлистов для своей работы. -* список автообновляемых плейлистов, которые найдены в открытых источниках; -* скрипты для поиска каналов в этом списке, создания своего плейлиста; -* веб-сервис, предоставляющий короткие ссылки на эти плейлисты и отображающий список каналов. +> **Веб-сайт:** [iptv.axenov.dev](https://iptv.axenov.dev) +> **Зеркало:** [m3u.su](https://m3u.su) +> Исходный код: [git.axenov.dev/IPTV/web](https://git.axenov.dev/IPTV/web) +> Telegram-канал: [@iptv_aggregator](https://t.me/iptv_aggregator) +> Обсуждение: [@iptv_aggregator_chat](https://t.me/iptv_aggregator_chat) +> Дополнительные сведения: [git.axenov.dev/IPTV/.profile](https://git.axenov.dev/IPTV/.profile) -Плейлисты подбираются преимущественно для РФ и любых стран бывшего СНГ, но этими странами список не ограничивается. +## О веб-сервисе -Поддержкой этих плейлистов занимаются сервисы и ресурсы, указанные как источник. -Вопросы работоспособности плейлистов адресуйте тем, кто несёт за них ответственность. +Решает две задачи: +* отображение списка плейлистов, которые можно использовать в своём плеере; +* сокращение длинных ссылок для удобства ввода с пульта на ТВ и лёгкого запоминания. -Они бесплатны для использования. -Список проверяется и обновляется мной вручную. -Гарантию работоспособности никто не даёт. +## Установка и настройка -* [Как использовать этот список?](#как-использовать-этот-список) -* [Формат `playlists.ini`](#формат-playlistsini) -* [API](#api) -* [Развёртывание проекта](#развёртывание-проекта) - * [Apache](#apache) - * [Nginx](#nginx) -* [Расширенные возможности](#расширенные-возможности) - * [Собственный код html/css/js](#собственный-код-htmlcssjs) - * [Очистка кеша twig](#очистка-кеша-twig) - * [Скачать все плейлисты](#скачать-все-плейлисты) - * [Проверить каналы плейлиста](#проверить-каналы-плейлиста) - * [Поиск каналов в одном плейлисте](#поиск-каналов-в-одном-плейлисте) - * [Поиск каналов во всех плейлистах](#поиск-каналов-во-всех-плейлистах) - * [Создать плейлист из нужных каналов](#создать-плейлист-из-нужных-каналов) -* [Как создать свой собственный плейлист?](#как-создать-свой-собственный-плейлист) -* [Использованный стек](#использованный-стек) -* [Лицензия](#лицензия) +1. Развернуть [docker-среду](https://git.axenov.dev/IPTV/docker) +2. `cd iptv-docker; cp .env.example .env` +3. В файле `.env` поменять необходимые значения +4. `cd ..; docker compose up -d --build` -## API +### Описание переменных окружения -Можно получать состояние плейлистов из этого сборника при помощи метода: +* `APP_URL` -- адрес, на котором расположен сервис: используется для генерации ссылок на плейлисты; +* `APP_DEBUG` -- признак отладки сервиса (включает служебный вывод в некоторых местах); +* `APP_ENV` -- окружение (см. ниже); +* `APP_TITLE` -- название сервиса: используется для вывода на страницах сайта и в их заголовках; +* `APP_TIMEZONE` -- часовой пояс, в котором расположен сервер; +* `PAGE_SIZE` -- размер страницы для постраничной навигации на главной странице; +* `USER_AGENT` -- user-agent для http-клиента, котоырй будет использоваться при подключении к внешним ресурсам; +* `CACHE_HOST`, `CACHE_PORT`, `CACHE_PASSWORD`, `CACHE_DB` -- реквизиты подключения к cache/keydb; +* `CACHE_TTL` -- количество часов для кэширования информации; +* `TWIG_USE_CACHE` -- признак использования кэша компиляции шаблонов Twig. -``` -GET https://iptv.axenov.dev//json -``` +У каждой переменной есть умолчание на случай отсутствия файла `.env` или её отсутствия в нём. +Но некорректные значения некоторых переменных могут привести к фатальным ошибкам. -где `ID` -- один из идентификаторов, указанных в [`playlists.ini`](playlists.ini) в квадратных скобках. +### Перегрузка переменных окружения -В случае успеха вернётся JSON следующего содержания: +В разных средах можно использовать разные env-файлы для своего удобства. -```json -{ - "id": "p1", - "url": "localhost:8080/p1", - "name": "Каналы в SD и HD качестве (smarttvnews.ru)", - "desc": "Рабочий и актуальный IPTV плейлист M3U — на июнь 2022 года", - "pls": "https://smarttvnews.ru/apps/iptvchannels.m3u", - "src": "https://smarttvnews.ru/rabochiy-i-aktualnyiy-iptv-pleylist-m3u-kanalyi-v-sd-i-hd-kachestve/", - "status": "online", - "encoding": { - "name": "UTF-8", - "alert": false - }, - "channels": [ - "Channel1", - "Channel2", - "ChannelX" - ], - "count": 3 -} -``` +Для этого следует: +1. в файле `.env` переменной `APP_ENV` указать значение, например, `custom` или любое другое; +2. скопировать файл `.env` в файл `.env.custom`; +3. в файле `.env.custom` указать все или только необходимые переменные со произвольными значениями. -где: +Отсутствие файла `.env.custom` не вызовет ошибку. -* `id` -- идентификатор плейлиста -* `name` -- название плейлиста -* `url` -- короткая ссылка, которую можно использовать для добавления плейлиста в плеер -* `desc` -- краткое описание -* `pls` -- прямая ссылка на m3u/m3u8 плейлист -* `src` -- ссылка на источник, откуда взят плейлист -* `status` -- статус плейлиста (`"online"|"timeout"|"offline"|"error"`) -* `encoding` -- данные о кодировке файла плейлиста - * `name` -- название кодировки (`"UTF-8"|"Windows-1251"`) - * `alert` -- признак отличия кодировки от `UTF-8`, названия каналов сконвертированы в `UTF-8`, могут быть ошибки - в отображении -* `channels` -- массив названий каналов -* `count` -- количество каналов >= 0 - -> Название кодировки `encoding.name` может определяться неточно! - -В случае ошибки вернётся JSON в следующем формате: - -```json -{ - "id": "p1", - "url": "localhost:8080/p1", - "name": "Каналы в SD и HD качестве (smarttvnews.ru)", - "desc": "Рабочий и актуальный IPTV плейлист M3U — на июнь 2022 года", - "pls": "https://smarttvnews.ru/apps/iptvchannels.m3u", - "src": "https://smarttvnews.ru/rabochiy-i-aktualnyiy-iptv-pleylist-m3u-kanalyi-v-sd-i-hd-kachestve/", - "status": "offline", - "error": { - "code": 22, - "message": "The requested URL returned error: 404 Not Found" - } -} -``` - -где: - -* `id` -- идентификатор плейлиста -* `name` -- название плейлиста -* `url` -- короткая ссылка, которую можно использовать для добавления плейлиста в плеер -* `desc` -- краткое описание -* `pls` -- прямая ссылка на m3u/m3u8 плейлист -* `src` -- ссылка на источник, откуда взят плейлист -* `status` -- статус плейлиста (`"online"|"timeout"|"offline"|"error"`) -* `error` -- данные об ошибке при проверке плейлиста - * `code` -- [код ошибки curl](https://curl.se/libcurl/c/libcurl-errors.html) - * `message` -- текст ошибки curl - -## Расширенные возможности - -### Собственный код html/css/js - -В проекте есть директория `src/views/custom`. -Там можно размещать собственный код, который будет вставляться на каждой странице. - -Для этого, в первую очередь, нужно выполнить: - -``` -cp src/views/custom/custom.twig.example src/views/custom/custom.twig -``` - -Между тегами `{% block ... %} сюда {% endblock %}` следует вставить желаемый код или текст. -Можно создавать новые twig-файлы рядом и подключать их внутри `custom.twig`. -Git будет их игнорировать, хотя можно убрать директорию из `.gitignore` и добавлять эти файлы репозиторий. - -В общем случае, это можно выполнять на том сервере, на коем установлен и работает веб-сервис. - -После всех правок следует очистить кеш twig (см. далее). - -### Очистка кеша twig - -Если в файле `./src/.env` параметр `TWIG_CACHE=1`, то макеты страниц компилируются однажды и потом переиспользуются. -Изменённые макеты не будут перекомпилироваться пока не будет очищен кеш прежних. - -Для этого следует выполнить: - -``` -cd src && composer clear-views -``` +При подгрузке файла `.env.custom` будут перегружены только те переменные окружения, которые в нём объявлены. ## Использованный стек -* [php8.3-fpm](https://www.php.net/releases/8.3/ru.php) +* [php8.4-fpm](https://www.php.net/releases/8.4/ru.php) * [SlimPHP v4](https://www.slimframework.com/docs/v4/) -* [Bootstrap 5](https://getbootstrap.com/docs/5.0/getting-started/introduction/) +* [Guzzle v7](https://docs.guzzlephp.org/en/latest/) +* [Twig v3](https://twig.symfony.com/doc/3.x/) +* [symfony/console](https://symfony.com/doc/current/components/console.html) +* [focusim/php-qrcode](https://github.com/Focusim/php-qrcode) +* [Bootstrap v5.2](https://getbootstrap.com/docs/5.2/getting-started/introduction/) +* [Ionicons](https://ionic.io/ionicons) +* [List.js](https://listjs.com) ## Лицензия -[The MIT License](LICENSE) +Исходный код распространяется на условиях лицензии MIT. +См. файл [LICENSE](LICENSE) для подробностей. diff --git a/app/Controllers/ApiController.php b/app/Controllers/ApiController.php index 11ac970..7ace9c4 100644 --- a/app/Controllers/ApiController.php +++ b/app/Controllers/ApiController.php @@ -1,36 +1,117 @@ getAttribute('code'); + $codes = array_keys(ini()->getPlaylists()); + if (!in_array($code, $codes, true)) { + return $response->withStatus(404); + } + + $filePath = cache_path("qr-codes/$code.jpg"); + if (file_exists($filePath)) { + $raw = file_get_contents($filePath); + } else { + $options = new QROptions([ + 'version' => 5, + 'outputType' => QRCode::OUTPUT_IMAGE_JPG, + 'eccLevel' => QRCode::ECC_L, + ]); + $data = base_url("$code"); + $raw = (new QRCode($options))->render($data, $filePath); + $raw = base64_decode(str_replace('data:image/jpg;base64,', '', $raw)); + } + + $mime = mime_content_type($filePath); + $response->getBody()->write($raw); + return $response->withStatus(200) + ->withHeader('Content-Type', $mime); + } + + /** + * Возвращает информацию о плейлисте + * + * @param ServerRequestInterface $request + * @param ResponseInterface $response + * @return ResponseInterface + * @throws LoaderError + */ + public function getPlaylist(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface { $code = $request->getAttributes()['code']; - $playlist = $this->getPlaylist($code, true); - $playlist->fetchContent(); - $playlist->parse(); - $json = json_encode($playlist->toArray(), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); - $response->getBody()->write($json); + try { + $playlist = ini()->getPlaylist($code); + return $this->responseJson($response, 200, $playlist); + } catch (PlaylistNotFoundException $e) { + return $this->responseJsonError($response, 404, $e); + } + } - return $response - ->withHeader('Content-Type', 'application/json') - ->withHeader('Content-Length', strlen($json)); + /** + * Возвращает логотип канала + * + * @param ServerRequestInterface $request + * @param ResponseInterface $response + * @return ResponseInterface + * @throws LoaderError + * @throws PlaylistNotFoundException + * @todo логотипы каналов + */ + public function logo(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface + { + $code = $request->getAttributes()['code']; + $playlist = ini()->getPlaylist($code); + $channelHash = $request->getAttributes()['hash']; + $channel = $playlist['channels'][$channelHash]; + $url = $channel['attributes']['tvg-logo'] ?? ''; + + $logo = new ChannelLogo($url); + if (!$logo->readFile()) { + $logo->fetch(); + if ($logo->size() === 0) { + $logo->setDefault(); + } else { + $logo->store(); + } + } + + $body = $logo->raw(); + $size = $logo->size(); + $mime = $logo->mimeType(); + + $response->getBody()->write($body); + return $response->withHeader('Content-Type', $mime) + ->withHeader('Content-Length', $size); } } diff --git a/app/Controllers/BasicController.php b/app/Controllers/BasicController.php index c4dc24c..aab299f 100644 --- a/app/Controllers/BasicController.php +++ b/app/Controllers/BasicController.php @@ -1,25 +1,29 @@ time()], $data); + $json = json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + + $response->getBody()->write($json); + return $response->withStatus($status) + ->withHeader('Content-Type', 'application/json'); + } + + /** + * Возвращает ответ с ошибкой в формате json + * + * @param ResponseInterface $response + * @param int $status + * @param Throwable $t + * @return ResponseInterface + */ + protected function responseJsonError(ResponseInterface $response, int $status, Throwable $t): ResponseInterface + { + $data = [ + 'error' => [ + 'code' => array_last(explode('\\', $t::class)), + 'message' => $t->getMessage(), + ], + ]; + + return $this->responseJson($response, $status, $data); + } + + /** + * Возвращает ответ в формате html + * * @param ServerRequestInterface $request * @param ResponseInterface $response * @param string $template diff --git a/app/Controllers/WebController.php b/app/Controllers/WebController.php index b346865..f07a1ea 100644 --- a/app/Controllers/WebController.php +++ b/app/Controllers/WebController.php @@ -1,24 +1,31 @@ load(); + $playlists = ini()->getPlaylists(); - $playlists = ini()->playlists(false); $count = count($playlists); - $page = (int)($request->getAttributes()['page'] ?? $request->getQueryParams()['page'] ?? 1); + $onlineCount = count(array_filter($playlists, static fn (array $playlist) => $playlist['isOnline'] === true)); + $uncheckedCount = count(array_filter($playlists, static fn (array $playlist) => $playlist['isOnline'] === null)); + $offlineCount = $count - $onlineCount - $uncheckedCount; + $pageSize = config('app.page_size'); - $pageCount = ceil($count / $pageSize); - $offset = max(0, ($page - 1) * $pageSize); - $list = array_slice($playlists, $offset, $pageSize, true); + if ($pageSize > 0) { + $pageCurrent = (int)($request->getAttributes()['page'] ?? $request->getQueryParams()['page'] ?? 1); + $pageCount = ceil($count / $pageSize); + $offset = max(0, ($pageCurrent - 1) * $pageSize); + $playlists = array_slice($playlists, $offset, $pageSize, true); + } return $this->view($request, $response, 'list.twig', [ - 'updated_at' => ini()->updatedAt(), - 'playlists' => $list, + 'updatedAt' => ini()->updatedAt(), + 'playlists' => $playlists, 'count' => $count, - 'pageCount' => $pageCount, - 'pageCurrent' => $page, + 'onlineCount' => $onlineCount, + 'uncheckedCount' => $uncheckedCount, + 'offlineCount' => $offlineCount, + 'pageCount' => $pageCount ?? 1, + 'pageCurrent' => $pageCurrent ?? 1, ]); } /** + * Возвращает страницу FAQ + * * @param ServerRequestInterface $request * @param ResponseInterface $response * @return ResponseInterface @@ -62,67 +79,46 @@ class WebController extends BasicController } /** + * Переадресует запрос на прямую ссылку плейлиста + * * @param ServerRequestInterface $request * @param ResponseInterface $response * @return ResponseInterface + * @throws LoaderError + * @throws RuntimeError + * @throws SyntaxError */ public function redirect(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface { - ini()->load(); $code = $request->getAttributes()['code']; + try { $playlist = ini()->getPlaylist($code); - return $response->withHeader('Location', $playlist->pls); - } catch (PlaylistNotFoundException) { + return $response->withHeader('Location', $playlist['url']); + } catch (Throwable) { return $this->notFound($request, $response); } } /** + * Возвращает страницу с описанием плейлиста + * * @param ServerRequestInterface $request * @param ResponseInterface $response * @return ResponseInterface - * @throws \Random\RandomException * @throws LoaderError * @throws RuntimeError * @throws SyntaxError */ public function details(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface { - ini()->load(); $code = $request->getAttributes()['code']; + try { $playlist = ini()->getPlaylist($code); - $response->withHeader('Location', $playlist->pls); + return $this->view($request, $response, 'details.twig', ['playlist' => $playlist]); } catch (PlaylistNotFoundException) { return $this->notFound($request, $response); } - - $playlist->fetchContent(); - $playlist->parse(); - - return $this->view($request, $response, 'details.twig', $playlist->toArray()); - } - - /** - * @param ServerRequestInterface $request - * @param ResponseInterface $response - * @return ResponseInterface - */ - public function logo(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface - { - $input = $request->getQueryParams()['url'] ?? null; - - $logo = new ChannelLogo($input); - $logo->readFile() || $logo->fetch(); - $logo->size() === 0 && $logo->setDefault(); - $logo->store(); - $body = $logo->raw(); - $size = $logo->size(); - $mime = $logo->mimeType(); - - $response->getBody()->write($body); - return $response->withHeader('Content-Type', $mime) - ->withHeader('Content-Length', $size); } } diff --git a/app/Core/IniFile.php b/app/Core/IniFile.php index 8c99a3f..83d8bf8 100644 --- a/app/Core/IniFile.php +++ b/app/Core/IniFile.php @@ -1,4 +1,9 @@ hGetAll('_playlists_'); - if (empty($ini)) { - $filepath = config_path('playlists.ini'); - $ini = parse_ini_file($filepath, true); - $this->updated_at = date('d.m.Y h:i', filemtime($filepath)); - $order = array_keys($ini); + $filepath = config_path('playlists.ini'); + $ini = parse_ini_file($filepath, true); + $this->updatedAt = date('d.m.Y h:i', filemtime($filepath)); + + // сохраняем порядок + foreach (array_keys($ini) as $code) { + $data = redis()->get($code); + if ($data === false) { + $raw = $ini[$code]; + $data = [ + 'code' => $code, + 'name' => $raw['name'], + 'description' => $raw['desc'], + 'url' => $raw['pls'], + 'source' => $raw['src'], + 'content' => null, + 'isOnline' => null, + 'attributes' => [], + 'groups' => [], + 'channels' => [], + 'onlineCount' => 0, + 'offlineCount' => 0, + 'checkedAt' => null, + ]; + } else if (!isset($data['attributes'])) { + $data['attributes'] = []; + } + + $data['hasTvg'] = !empty($data['asttributes']['url-tvg']); + $data['hasCatchup'] = str_contains($data['content'] ?? '', 'catchup'); + + $data['tags'] = []; + foreach ($data['channels'] ?? [] as $channel) { + $data['tags'] = array_merge($data['tags'], $channel['tags']); + } + $data['tags'] = array_values(array_unique($data['tags'])); + sort($data['tags']); + + $this->playlists[$code] = $data; } - $order ??= redis()->get('_order_'); - $this->ini ??= $ini; - $this->updated_at ??= redis()->get('_updated_at_'); - $transaction = redis()->multi(); - foreach ($order as $id) { - $data = $this->ini[$id]; - $this->playlists[(string)$id] = $pls = $this->makePlaylist($id, $data); - $transaction->hSet('_playlists_', $id, $pls); - } - - $expireAfter = config('redis.ttl_days'); - $transaction - ->expire('_playlists_', $expireAfter) - ->set('_order_', $order, ['EX' => $expireAfter]) - ->set('_updated_at_', $this->updated_at, ['EX' => $expireAfter]) - ->exec(); + return $this->playlists; } /** - * Возвращает объекты плейлистов + * Возвращает плейлисты * - * @param bool $all true - получить все, false - получить только НЕпереадресованные - * @return Playlist[] + * @return array[] + * @throws Exception */ - public function playlists(bool $all = true): array + public function getPlaylists(): array { - return $all - ? $this->playlists - : array_filter($this->playlists, static fn ($playlist) => is_null($playlist->redirectId)); + return $this->playlists ??= $this->load(); } /** @@ -86,50 +102,23 @@ class IniFile */ public function updatedAt(): string { - return $this->updated_at; + return $this->updatedAt; } /** - * Возвращает 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 + * @param string $code Код плейлиста + * @return array|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 + public function getPlaylist(string $code): ?array { - $id = (string)$id; - if (isset($params['redirect'])) { - $this->redirections[$id] = $redirectId = (string)$params['redirect']; - $params = $this->ini[$redirectId]; - return $this->makePlaylist($id, $params, $redirectId); + if (empty($this->playlists)) { + $this->load(); } - return new Playlist($id, $params, $redirectId); + return $this->playlists[$code] ?? throw new PlaylistNotFoundException($code); } } diff --git a/app/Core/Core.php b/app/Core/Kernel.php similarity index 63% rename from app/Core/Core.php rename to app/Core/Kernel.php index 9d2d876..24f479f 100644 --- a/app/Core/Core.php +++ b/app/Core/Kernel.php @@ -1,4 +1,9 @@ app = AppFactory::create(); + $this->loadSettings(); + $this->loadRoutes(); + $this->loadTwig(); + + return $this->app; } /** * Возвращает объект приложения * - * @return Core + * @return Kernel */ - public static function get(): Core + public static function instance(): Kernel { return self::$instance ??= new self(); } - /** - * Загружает приложение - * - * @return App - * @throws LoaderError - */ - public function boot(): App - { - $this->app = AppFactory::create(); - - $this->bootSettings(); - $this->bootRoutes(); - $this->bootTwig(); - $this->bootRedis(); - $this->bootIni(); - - return $this->app; - } - - /** - * Возвращает значение из конфига - * - * @param string $key Ключ в формате "config.key" - * @param mixed|null $default Значение по умолчанию - * @return mixed - */ - public function config(string $key, mixed $default = null): mixed - { - $parts = explode('.', $key); - return $this->config[$parts[0]][$parts[1]] ?? $default; - } - - /** - * @return Redis - */ - public function redis(): Redis - { - return $this->redis; - } - - /** - * @return IniFile - */ - public function ini(): IniFile - { - return $this->iniFile; - } - - /** - * @return App - */ - public function app(): App - { - return $this->app; - } - /** * Загружает файл .env или .env.$env * @@ -139,10 +108,9 @@ final class Core * * @return void */ - protected function bootSettings(): void + protected function loadSettings(): void { $env = $this->loadDotEnvFile(); - if (!empty($env['APP_ENV'])) { $this->loadDotEnvFile($env['APP_ENV']); } @@ -151,6 +119,8 @@ final class Core $key = basename($file, '.php'); $this->config += [$key => require_once $file]; } + + date_default_timezone_set($this->config['app']['timezone'] ?? 'GMT'); } /** @@ -159,18 +129,19 @@ final class Core * @return void * @see https://www.slimframework.com/docs/v4/objects/routing.html */ - protected function bootRoutes(): void + protected function loadRoutes(): void { foreach ($this->config['routes'] as $route) { if (is_array($route['method'])) { $definition = $this->app->map($route['method'], $route['path'], $route['handler']); } else { - $isPossible = in_array($route['method'], ['GET', 'POST', 'OPTIONS', 'PUT', 'PATCH', 'DELETE']); + $method = trim($route['method']); + $isPossible = in_array($method, ['GET', 'POST', 'OPTIONS', 'PUT', 'PATCH', 'DELETE']); $func = match (true) { - $route['method'] === '*' => 'any', - $isPossible => strtolower($route['method']), - default => throw new InvalidArgumentException(sprintf('Неверный HTTP метод %s', $route['method'])) + $method === '*' => 'any', + $isPossible => strtolower($method), + default => throw new InvalidArgumentException(sprintf('Неверный HTTP метод %s', $method)) }; $definition = $this->app->$func($route['path'], $route['handler']); @@ -189,42 +160,84 @@ final class Core * @throws LoaderError * @see https://www.slimframework.com/docs/v4/features/twig-view.html */ - protected function bootTwig(): void + protected function loadTwig(): void { $twig = Twig::create(root_path('views'), $this->config['twig']); $twig->addExtension(new IptvTwigExtension()); $this->app->add(TwigMiddleware::create($this->app, $twig)); + if ($this->config['twig']['debug']) { + $twig->addExtension(new DebugExtension()); + } } /** - * Инициализирует подключение к Redis + * Возвращает объект подключения к Redis * - * @return void + * @return Redis * @see https://github.com/phpredis/phpredis/?tab=readme-ov-file */ - protected function bootRedis(): void + public function redis(): Redis { - $options = [ - 'host' => $this->config['redis']['host'], - 'port' => (int)$this->config['redis']['port'], - ]; - - if (!empty($this->config['redis']['password'])) { - $options['auth'] = $this->config['redis']['password']; + if (!empty($this->cache)) { + return $this->cache; } - $this->redis = new Redis($options); - $this->redis->select((int)$this->config['redis']['db']); - $this->redis->setOption(Redis::OPT_SERIALIZER, Redis::SERIALIZER_JSON); + $options = [ + 'host' => $this->config['cache']['host'], + 'port' => (int)$this->config['cache']['port'], + ]; + + if (!empty($this->config['cache']['password'])) { + $options['auth'] = $this->config['cache']['password']; + } + + $this->cache = new Redis($options); + $this->cache->select((int)$this->config['cache']['db']); + $this->cache->setOption(Redis::OPT_SERIALIZER, Redis::SERIALIZER_JSON); + + return $this->cache; } /** - * Инициализирует объект ini-файла + * Возвращает объект http-клиента * - * @return void + * @return Client */ - protected function bootIni(): void + public function guzzle(): Client { - $this->iniFile = new IniFile(); + return $this->httpClient ??= new Client($this->config['http']); + } + + /** + * Возвращает значение из конфига + * + * @param string $key Ключ в формате "config.key" + * @param mixed|null $default Значение по умолчанию + * @return mixed + */ + public function config(string $key, mixed $default = null): mixed + { + $parts = explode('.', $key); + return $this->config[$parts[0]][$parts[1]] ?? $default; + } + + /** + * Возвращает объект приложения + * + * @return App + */ + public function app(): App + { + return $this->app; + } + + /** + * Возвращает объект ini-файла + * + * @return IniFile + */ + public function ini(): IniFile + { + return $this->iniFile ??= new IniFile(); } } diff --git a/app/Core/Playlist.php b/app/Core/Playlist.php deleted file mode 100644 index e4349df..0000000 --- a/app/Core/Playlist.php +++ /dev/null @@ -1,368 +0,0 @@ - '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 fetchContent(): void - { - $cached = redis()->get($this->id); - if (is_array($cached)) { - $this->downloadStatus['httpCode'] = $cached['httpCode']; - $this->downloadStatus['errCode'] = $cached['errCode']; - $this->downloadStatus['errText'] = $cached['errText']; - $this->downloadStatus['possibleStatus'] = $cached['possibleStatus']; - $this->rawContent = $cached['content']; - return; - } - - $curl = $this->makeCurl(); - $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); - - if ($cached === false) { - redis()->set($this->id, [ - 'httpCode' => $this->downloadStatus['httpCode'], - 'errCode' => $this->downloadStatus['errCode'], - 'errText' => $this->downloadStatus['errText'], - 'possibleStatus' => $this->downloadStatus['possibleStatus'], - 'content' => $this->rawContent, - ], ['EX' => config('redis.ttl_days')]); - } - } - - /** - * Возвращает статус проверки плейлиста по коду ошибки 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; - } - - public function check(): bool - { - $curl = $this->makeCurl([ - CURLOPT_RETURNTRANSFER => true, - CURLOPT_NOBODY => true, - CURLOPT_HEADER => true, - CURLOPT_CUSTOMREQUEST => 'HEAD', - ]); - - $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); - - return $this->downloadStatus['httpCode'] < 400; - } - - protected function makeCurl(array $customOptions = []): CurlHandle - { - $options = [ - CURLOPT_URL => $this->pls, - CURLOPT_RETURNTRANSFER => true, - CURLOPT_TIMEOUT => 30, - CURLOPT_HEADER => false, - CURLOPT_FAILONERROR => true, - CURLOPT_FOLLOWLOCATION => true, - CURLOPT_MAXREDIRS => 5, - CURLOPT_USERAGENT => config('app.user_agent'), - ]; - - $curl = curl_init(); - - foreach ($options as $option => $value) { - curl_setopt($curl, $option, $value); - } - - // array_merge($options, $customOptions) loses keys - foreach ($customOptions as $option => $value) { - curl_setopt($curl, $option, $value); - } - - return $curl; - } - - /** - * Парсит атрибуты строки и возвращает ассоциативный массив - * - * @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; - } -} diff --git a/app/Core/TwigExtention.php b/app/Core/TwigExtention.php index 909efa7..c1d0060 100644 --- a/app/Core/TwigExtention.php +++ b/app/Core/TwigExtention.php @@ -1,41 +1,91 @@ config($key, $default); } - public function commit(): string + /** + * Возвращает версию приложения + * + * @return string + */ + public function version(): string { - return file_get_contents(root_path('commit')); + return Kernel::VERSION; } - public function base_url(string $path = ''): string + /** + * Возвращает базовый URL приложения + * + * @param string $path + * @return string + */ + public function baseUrl(string $path = ''): string { return base_url($path); } - public function is_file(string $path): bool + /** + * Проверячет существование файла + * + * @param string $path Полный путь к файлу + * @return bool + */ + public function isFile(string $path): bool { return is_file($path); } + + /** + * Конвертирует unix timestamp в дату и время + * + * @param float|null $timestamp + * @param string $format + * @return string + */ + public function toDate(?float $timestamp, string $format = 'd.m.Y H:i:s'): string + { + return $timestamp === null ? '(неизвестно)' : date($format, (int)$timestamp); + } } diff --git a/app/Errors/ErrorHandler.php b/app/Errors/ErrorHandler.php index 3d9d4e2..fdb5d67 100644 --- a/app/Errors/ErrorHandler.php +++ b/app/Errors/ErrorHandler.php @@ -1,4 +1,9 @@ render($template, $data); -} - -/** - * Returns core object - * - * @return Core - */ -function core(): Core -{ - return Core::get(); + return Kernel::instance(); } /** @@ -105,25 +95,7 @@ function core(): Core */ function app(): App { - return Core::get()->app(); -} - -/** - * Returns any value as boolean - * - * @param mixed $value - * @return bool - */ -function bool(mixed $value): bool -{ - is_string($value) && $value = strtolower(trim($value)); - if (in_array($value, [true, 1, '1', '+', 'y', 'yes', 'on', 'true', 'enable', 'enabled'], true)) { - return true; - } - if (in_array($value, [false, 0, '0', '-', 'n', 'no', 'off', 'false', 'disable', 'disabled'], true)) { - return false; - } - return (bool)$value; + return Kernel::instance()->app(); } /** @@ -135,7 +107,7 @@ function bool(mixed $value): bool */ function config(string $key, mixed $default = null): mixed { - return Core::get()->config($key, $default); + return Kernel::instance()->config($key, $default); } /** @@ -145,7 +117,100 @@ function config(string $key, mixed $default = null): mixed */ function redis(): Redis { - return Core::get()->redis(); + return Kernel::instance()->redis(); +} + +/** + * Returns any value as boolean + * + * @param mixed $value + * @return bool + */ +function bool(mixed $value): bool +{ + is_string($value) && $value = strtolower(trim($value)); + + $positives = [true, 1, '1', '+', 'yes', 'on', 'true', 'enable', 'enabled']; + if (in_array($value, $positives, true)) { + return true; + } + + $negatives = [false, 0, '0', '-', 'no', 'off', 'false', 'disable', 'disabled']; + if (in_array($value, $negatives, true)) { + return false; + } + + return (bool)$value; +} + +/** + * Проверяет значениен на пустоту + * + * @param $value + * @return bool + */ +function is_blank($value): bool +{ + if (is_null($value)) { + return true; + } + + if (is_string($value)) { + return trim($value) === ''; + } + + if (is_numeric($value) || is_bool($value)) { + return false; + } + + if ($value instanceof Countable) { + return count($value) === 0; + } + + return empty($value); +} + +/** + * Возвращает натуральное представление значения переменной или null + * + * @param mixed $value + * @return int|null + */ +function int(mixed $value): ?int +{ + if (is_blank($value)) { + return null; + } + $filtered = filter_var($value, FILTER_VALIDATE_INT); + return $filtered === false ? (int)$value : $filtered; +} + +/** + * Возвращает первый элемент массива без перемотки указателя + * + * @param array $array Входной массив + * @param callable|null $callback Замыкание для предварительной фильтрации вх. массива + * @return mixed + */ +function array_first(array $array, ?callable $callback = null): mixed +{ + is_null($callback) || $array = array_filter($array, $callback); + + return $array[array_key_first($array)] ?? null; +} + +/** + * Возвращает последний элемент массива без перемотки указателя + * + * @param array $array Входной массив + * @param callable|null $callback Замыкание для предварительной фильтрации вх. массива + * @return mixed + */ +function array_last(array $array, ?callable $callback = null): mixed +{ + is_null($callback) || $array = array_filter($array, $callback); + + return $array[array_key_last($array)] ?? null; } /** @@ -155,5 +220,5 @@ function redis(): Redis */ function ini(): IniFile { - return Core::get()->ini(); + return Kernel::instance()->ini(); } diff --git a/composer.json b/composer.json index d51e580..c71b5d8 100644 --- a/composer.json +++ b/composer.json @@ -12,17 +12,18 @@ "license": "MIT", "require": { "php": "^8.3", - "ext-json": "*", "ext-curl": "*", - "ext-redis": "*", "ext-fileinfo": "*", + "ext-json": "*", "ext-mbstring": "*", - "nesbot/carbon": "^3.8", + "ext-redis": "*", + "focusim/php-qrcode": "^4.3", "guzzlehttp/guzzle": "^7.9", + "nesbot/carbon": "^3.8", "nyholm/psr7": "^1.8", - "vlucas/phpdotenv": "^5.6", "slim/slim": "^4.14", - "slim/twig-view": "^3.4" + "slim/twig-view": "^3.4", + "vlucas/phpdotenv": "^5.6" }, "autoload": { "psr-4": { @@ -41,7 +42,10 @@ "config": { "optimize-autoloader": true, "preferred-install": "dist", - "sort-packages": true + "sort-packages": true, + "allow-plugins": { + "yiisoft/yii2-composer": false + } }, "minimum-stability": "dev", "prefer-stable": true diff --git a/composer.lock b/composer.lock index 06df31f..7677115 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "d149d6555d367f3abeee22b27cf971b7", + "content-hash": "eb7c9751c009420f33c222a768c1797a", "packages": [ { "name": "carbonphp/carbon-doctrine-types", @@ -75,6 +75,154 @@ ], "time": "2024-02-09T16:56:22+00:00" }, + { + "name": "chillerlan/php-settings-container", + "version": "2.1.6", + "source": { + "type": "git", + "url": "https://github.com/chillerlan/php-settings-container.git", + "reference": "5553558bd381fce5108c6d0343c12e488cfec6bb" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/chillerlan/php-settings-container/zipball/5553558bd381fce5108c6d0343c12e488cfec6bb", + "reference": "5553558bd381fce5108c6d0343c12e488cfec6bb", + "shasum": "" + }, + "require": { + "ext-json": "*", + "php": "^7.4 || ^8.0" + }, + "require-dev": { + "phpmd/phpmd": "^2.15", + "phpstan/phpstan": "^1.11", + "phpstan/phpstan-deprecation-rules": "^1.2", + "phpunit/phpunit": "^9.6", + "squizlabs/php_codesniffer": "^3.10" + }, + "type": "library", + "autoload": { + "psr-4": { + "chillerlan\\Settings\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Smiley", + "email": "smiley@chillerlan.net", + "homepage": "https://github.com/codemasher" + } + ], + "description": "A container class for immutable settings objects. Not a DI container. PHP 7.4+", + "homepage": "https://github.com/chillerlan/php-settings-container", + "keywords": [ + "PHP7", + "Settings", + "configuration", + "container", + "helper" + ], + "support": { + "issues": "https://github.com/chillerlan/php-settings-container/issues", + "source": "https://github.com/chillerlan/php-settings-container" + }, + "funding": [ + { + "url": "https://www.paypal.com/donate?hosted_button_id=WLYUNAT9ZTJZ4", + "type": "custom" + }, + { + "url": "https://ko-fi.com/codemasher", + "type": "ko_fi" + } + ], + "time": "2024-07-17T01:04:28+00:00" + }, + { + "name": "focusim/php-qrcode", + "version": "4.3.4.3", + "source": { + "type": "git", + "url": "https://github.com/Focusim/php-qrcode.git", + "reference": "b610ffc2609f7fe6a9a94edbedfed3c91e24c8a8" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Focusim/php-qrcode/zipball/b610ffc2609f7fe6a9a94edbedfed3c91e24c8a8", + "reference": "b610ffc2609f7fe6a9a94edbedfed3c91e24c8a8", + "shasum": "" + }, + "require": { + "chillerlan/php-settings-container": "^2.1.4", + "ext-mbstring": "*", + "php": "^7.4 || ^8.0" + }, + "require-dev": { + "phan/phan": "^5.3", + "phpunit/phpunit": "^9.5", + "setasign/fpdf": "^1.8.2" + }, + "suggest": { + "chillerlan/php-authenticator": "Yet another Google authenticator! Also creates URIs for mobile apps.", + "setasign/fpdf": "Required to use the QR FPDF output." + }, + "type": "library", + "autoload": { + "psr-4": { + "chillerlan\\QRCode\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Kazuhiko Arase", + "homepage": "https://github.com/kazuhikoarase" + }, + { + "name": "Smiley", + "email": "smiley@chillerlan.net", + "homepage": "https://github.com/codemasher" + }, + { + "name": "Contributors", + "homepage": "https://github.com/chillerlan/php-qrcode/graphs/contributors" + }, + { + "name": "Roman Garanin", + "homepage": "https://github.com/focusim/" + } + ], + "description": "A QR code generator. PHP 8.1+", + "homepage": "https://github.com/focusim/php-qrcode", + "keywords": [ + "phpqrcode", + "qr", + "qr code", + "qrcode", + "qrcode-generator" + ], + "support": { + "source": "https://github.com/Focusim/php-qrcode/tree/4.3.4.3" + }, + "funding": [ + { + "url": "https://www.paypal.com/donate?hosted_button_id=WLYUNAT9ZTJZ4", + "type": "custom" + }, + { + "url": "https://ko-fi.com/codemasher", + "type": "ko_fi" + } + ], + "time": "2023-05-16T10:44:13+00:00" + }, { "name": "graham-campbell/result-type", "version": "v1.1.3", @@ -139,16 +287,16 @@ }, { "name": "guzzlehttp/guzzle", - "version": "7.9.2", + "version": "7.9.3", "source": { "type": "git", "url": "https://github.com/guzzle/guzzle.git", - "reference": "d281ed313b989f213357e3be1a179f02196ac99b" + "reference": "7b2f29fe81dc4da0ca0ea7d42107a0845946ea77" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/guzzle/guzzle/zipball/d281ed313b989f213357e3be1a179f02196ac99b", - "reference": "d281ed313b989f213357e3be1a179f02196ac99b", + "url": "https://api.github.com/repos/guzzle/guzzle/zipball/7b2f29fe81dc4da0ca0ea7d42107a0845946ea77", + "reference": "7b2f29fe81dc4da0ca0ea7d42107a0845946ea77", "shasum": "" }, "require": { @@ -245,7 +393,7 @@ ], "support": { "issues": "https://github.com/guzzle/guzzle/issues", - "source": "https://github.com/guzzle/guzzle/tree/7.9.2" + "source": "https://github.com/guzzle/guzzle/tree/7.9.3" }, "funding": [ { @@ -261,20 +409,20 @@ "type": "tidelift" } ], - "time": "2024-07-24T11:22:20+00:00" + "time": "2025-03-27T13:37:11+00:00" }, { "name": "guzzlehttp/promises", - "version": "2.0.4", + "version": "2.2.0", "source": { "type": "git", "url": "https://github.com/guzzle/promises.git", - "reference": "f9c436286ab2892c7db7be8c8da4ef61ccf7b455" + "reference": "7c69f28996b0a6920945dd20b3857e499d9ca96c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/guzzle/promises/zipball/f9c436286ab2892c7db7be8c8da4ef61ccf7b455", - "reference": "f9c436286ab2892c7db7be8c8da4ef61ccf7b455", + "url": "https://api.github.com/repos/guzzle/promises/zipball/7c69f28996b0a6920945dd20b3857e499d9ca96c", + "reference": "7c69f28996b0a6920945dd20b3857e499d9ca96c", "shasum": "" }, "require": { @@ -328,7 +476,7 @@ ], "support": { "issues": "https://github.com/guzzle/promises/issues", - "source": "https://github.com/guzzle/promises/tree/2.0.4" + "source": "https://github.com/guzzle/promises/tree/2.2.0" }, "funding": [ { @@ -344,20 +492,20 @@ "type": "tidelift" } ], - "time": "2024-10-17T10:06:22+00:00" + "time": "2025-03-27T13:27:01+00:00" }, { "name": "guzzlehttp/psr7", - "version": "2.7.0", + "version": "2.7.1", "source": { "type": "git", "url": "https://github.com/guzzle/psr7.git", - "reference": "a70f5c95fb43bc83f07c9c948baa0dc1829bf201" + "reference": "c2270caaabe631b3b44c85f99e5a04bbb8060d16" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/guzzle/psr7/zipball/a70f5c95fb43bc83f07c9c948baa0dc1829bf201", - "reference": "a70f5c95fb43bc83f07c9c948baa0dc1829bf201", + "url": "https://api.github.com/repos/guzzle/psr7/zipball/c2270caaabe631b3b44c85f99e5a04bbb8060d16", + "reference": "c2270caaabe631b3b44c85f99e5a04bbb8060d16", "shasum": "" }, "require": { @@ -444,7 +592,7 @@ ], "support": { "issues": "https://github.com/guzzle/psr7/issues", - "source": "https://github.com/guzzle/psr7/tree/2.7.0" + "source": "https://github.com/guzzle/psr7/tree/2.7.1" }, "funding": [ { @@ -460,20 +608,20 @@ "type": "tidelift" } ], - "time": "2024-07-18T11:15:46+00:00" + "time": "2025-03-27T12:30:47+00:00" }, { "name": "nesbot/carbon", - "version": "3.8.6", + "version": "3.9.1", "source": { "type": "git", "url": "https://github.com/CarbonPHP/carbon.git", - "reference": "ff2f20cf83bd4d503720632ce8a426dc747bf7fd" + "reference": "ced71f79398ece168e24f7f7710462f462310d4d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/CarbonPHP/carbon/zipball/ff2f20cf83bd4d503720632ce8a426dc747bf7fd", - "reference": "ff2f20cf83bd4d503720632ce8a426dc747bf7fd", + "url": "https://api.github.com/repos/CarbonPHP/carbon/zipball/ced71f79398ece168e24f7f7710462f462310d4d", + "reference": "ced71f79398ece168e24f7f7710462f462310d4d", "shasum": "" }, "require": { @@ -566,7 +714,7 @@ "type": "tidelift" } ], - "time": "2025-02-20T17:33:38+00:00" + "time": "2025-05-01T19:51:51+00:00" }, { "name": "nikic/fast-route", @@ -1563,7 +1711,7 @@ }, { "name": "symfony/polyfill-ctype", - "version": "v1.31.0", + "version": "v1.32.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-ctype.git", @@ -1622,7 +1770,7 @@ "portable" ], "support": { - "source": "https://github.com/symfony/polyfill-ctype/tree/v1.31.0" + "source": "https://github.com/symfony/polyfill-ctype/tree/v1.32.0" }, "funding": [ { @@ -1642,19 +1790,20 @@ }, { "name": "symfony/polyfill-mbstring", - "version": "v1.31.0", + "version": "v1.32.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-mbstring.git", - "reference": "85181ba99b2345b0ef10ce42ecac37612d9fd341" + "reference": "6d857f4d76bd4b343eac26d6b539585d2bc56493" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/85181ba99b2345b0ef10ce42ecac37612d9fd341", - "reference": "85181ba99b2345b0ef10ce42ecac37612d9fd341", + "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/6d857f4d76bd4b343eac26d6b539585d2bc56493", + "reference": "6d857f4d76bd4b343eac26d6b539585d2bc56493", "shasum": "" }, "require": { + "ext-iconv": "*", "php": ">=7.2" }, "provide": { @@ -1702,7 +1851,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.31.0" + "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.32.0" }, "funding": [ { @@ -1718,20 +1867,20 @@ "type": "tidelift" } ], - "time": "2024-09-09T11:45:10+00:00" + "time": "2024-12-23T08:48:59+00:00" }, { "name": "symfony/polyfill-php80", - "version": "v1.31.0", + "version": "v1.32.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-php80.git", - "reference": "60328e362d4c2c802a54fcbf04f9d3fb892b4cf8" + "reference": "0cc9dd0f17f61d8131e7df6b84bd344899fe2608" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/60328e362d4c2c802a54fcbf04f9d3fb892b4cf8", - "reference": "60328e362d4c2c802a54fcbf04f9d3fb892b4cf8", + "url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/0cc9dd0f17f61d8131e7df6b84bd344899fe2608", + "reference": "0cc9dd0f17f61d8131e7df6b84bd344899fe2608", "shasum": "" }, "require": { @@ -1782,7 +1931,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php80/tree/v1.31.0" + "source": "https://github.com/symfony/polyfill-php80/tree/v1.32.0" }, "funding": [ { @@ -1798,11 +1947,11 @@ "type": "tidelift" } ], - "time": "2024-09-09T11:45:10+00:00" + "time": "2025-01-02T08:10:11+00:00" }, { "name": "symfony/polyfill-php81", - "version": "v1.31.0", + "version": "v1.32.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-php81.git", @@ -1858,7 +2007,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php81/tree/v1.31.0" + "source": "https://github.com/symfony/polyfill-php81/tree/v1.32.0" }, "funding": [ { @@ -1878,7 +2027,7 @@ }, { "name": "symfony/polyfill-php83", - "version": "v1.31.0", + "version": "v1.32.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-php83.git", @@ -1934,7 +2083,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php83/tree/v1.31.0" + "source": "https://github.com/symfony/polyfill-php83/tree/v1.32.0" }, "funding": [ { @@ -1954,16 +2103,16 @@ }, { "name": "symfony/translation", - "version": "v7.2.4", + "version": "v7.2.6", "source": { "type": "git", "url": "https://github.com/symfony/translation.git", - "reference": "283856e6981286cc0d800b53bd5703e8e363f05a" + "reference": "e7fd8e2a4239b79a0fd9fb1fef3e0e7f969c6dc6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/translation/zipball/283856e6981286cc0d800b53bd5703e8e363f05a", - "reference": "283856e6981286cc0d800b53bd5703e8e363f05a", + "url": "https://api.github.com/repos/symfony/translation/zipball/e7fd8e2a4239b79a0fd9fb1fef3e0e7f969c6dc6", + "reference": "e7fd8e2a4239b79a0fd9fb1fef3e0e7f969c6dc6", "shasum": "" }, "require": { @@ -2029,7 +2178,7 @@ "description": "Provides tools to internationalize your application", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/translation/tree/v7.2.4" + "source": "https://github.com/symfony/translation/tree/v7.2.6" }, "funding": [ { @@ -2045,7 +2194,7 @@ "type": "tidelift" } ], - "time": "2025-02-13T10:27:23+00:00" + "time": "2025-04-07T19:09:28+00:00" }, { "name": "symfony/translation-contracts", @@ -2127,16 +2276,16 @@ }, { "name": "twig/twig", - "version": "v3.20.0", + "version": "v3.21.1", "source": { "type": "git", "url": "https://github.com/twigphp/Twig.git", - "reference": "3468920399451a384bef53cf7996965f7cd40183" + "reference": "285123877d4dd97dd7c11842ac5fb7e86e60d81d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/twigphp/Twig/zipball/3468920399451a384bef53cf7996965f7cd40183", - "reference": "3468920399451a384bef53cf7996965f7cd40183", + "url": "https://api.github.com/repos/twigphp/Twig/zipball/285123877d4dd97dd7c11842ac5fb7e86e60d81d", + "reference": "285123877d4dd97dd7c11842ac5fb7e86e60d81d", "shasum": "" }, "require": { @@ -2190,7 +2339,7 @@ ], "support": { "issues": "https://github.com/twigphp/Twig/issues", - "source": "https://github.com/twigphp/Twig/tree/v3.20.0" + "source": "https://github.com/twigphp/Twig/tree/v3.21.1" }, "funding": [ { @@ -2202,20 +2351,20 @@ "type": "tidelift" } ], - "time": "2025-02-13T08:34:43+00:00" + "time": "2025-05-03T07:21:55+00:00" }, { "name": "vlucas/phpdotenv", - "version": "v5.6.1", + "version": "v5.6.2", "source": { "type": "git", "url": "https://github.com/vlucas/phpdotenv.git", - "reference": "a59a13791077fe3d44f90e7133eb68e7d22eaff2" + "reference": "24ac4c74f91ee2c193fa1aaa5c249cb0822809af" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/vlucas/phpdotenv/zipball/a59a13791077fe3d44f90e7133eb68e7d22eaff2", - "reference": "a59a13791077fe3d44f90e7133eb68e7d22eaff2", + "url": "https://api.github.com/repos/vlucas/phpdotenv/zipball/24ac4c74f91ee2c193fa1aaa5c249cb0822809af", + "reference": "24ac4c74f91ee2c193fa1aaa5c249cb0822809af", "shasum": "" }, "require": { @@ -2274,7 +2423,7 @@ ], "support": { "issues": "https://github.com/vlucas/phpdotenv/issues", - "source": "https://github.com/vlucas/phpdotenv/tree/v5.6.1" + "source": "https://github.com/vlucas/phpdotenv/tree/v5.6.2" }, "funding": [ { @@ -2286,7 +2435,7 @@ "type": "tidelift" } ], - "time": "2024-07-20T21:52:34+00:00" + "time": "2025-04-30T23:37:27+00:00" } ], "packages-dev": [], @@ -2297,11 +2446,11 @@ "prefer-lowest": false, "platform": { "php": "^8.3", - "ext-json": "*", "ext-curl": "*", - "ext-redis": "*", "ext-fileinfo": "*", - "ext-mbstring": "*" + "ext-json": "*", + "ext-mbstring": "*", + "ext-redis": "*" }, "platform-dev": {}, "plugin-api-version": "2.6.0" diff --git a/config/app.php b/config/app.php index c7395b7..224c299 100644 --- a/config/app.php +++ b/config/app.php @@ -1,4 +1,9 @@ env('APP_URL', 'http://localhost:8080'), 'debug' => bool(env('APP_DEBUG', false)), 'env' => env('APP_ENV', env('IPTV_ENV', 'prod')), - 'title' => env('APP_TITLE', 'IPTV Плейлисты'), - 'user_agent' => env('USER_AGENT'), - 'page_size' => (int)env('PAGE_SIZE', 10), + 'title' => 'IPTV Плейлисты', + 'timezone' => env('APP_TIMEZONE', 'UTC'), + 'page_size' => int(env('PAGE_SIZE', 10)), 'pls_encodings' => [ 'UTF-8', 'CP1251', diff --git a/config/cache.php b/config/cache.php new file mode 100644 index 0000000..735e0fd --- /dev/null +++ b/config/cache.php @@ -0,0 +1,16 @@ + env('CACHE_HOST', 'keydb'), + 'port' => int(env('CACHE_PORT', 6379)), + 'password' => env('CACHE_PASSWORD'), + 'db' => int(env('CACHE_DB', 0)), + 'ttl' => int(env('CACHE_TTL', 600)), +]; diff --git a/config/http.php b/config/http.php new file mode 100644 index 0000000..3f57b25 --- /dev/null +++ b/config/http.php @@ -0,0 +1,29 @@ + env('APP_URL', 'http://localhost:8080'), + 'timeout' => 25, + 'connect_timeout' => 7, + 'http_errors' => true, + 'synchronous' => false, + 'headers' => [ + 'User-Agent' => env('USER_AGENT'), + ], + 'allow_redirects' => [ + 'max' => 5, + 'strict' => false, + 'referer' => false, + 'protocols' => ['http', 'https'], + 'track_redirects' => false + ] +]; diff --git a/config/redis.php b/config/redis.php deleted file mode 100644 index c97ab7d..0000000 --- a/config/redis.php +++ /dev/null @@ -1,11 +0,0 @@ - env('REDIS_HOST', 'keydb'), - 'port' => (int)env('REDIS_PORT', 6379), - 'password' => env('REDIS_PASSWORD'), - 'db' => (int)env('REDIS_DB', 0), - 'ttl_days' => (int)env('REDIS_TTL_DAYS', 14) * 60 * 60 * 24, // 2 недели -]; diff --git a/config/routes.php b/config/routes.php index d547ec1..4d4cb73 100644 --- a/config/routes.php +++ b/config/routes.php @@ -1,10 +1,21 @@ 'GET', 'path' => '/[page/{page:[0-9]+}]', @@ -19,13 +30,7 @@ return [ ], [ 'method' => 'GET', - 'path' => '/logo', - 'handler' => [WebController::class, 'logo'], - 'name' => 'logo', - ], - [ - 'method' => 'GET', - 'path' => '/{code:[0-9a-zA-Z]+}', + 'path' => '/{code:[0-9a-zA-Z]+}[.m3u[8]]', 'handler' => [WebController::class, 'redirect'], 'name' => 'redirect', ], @@ -35,18 +40,43 @@ return [ 'handler' => [WebController::class, 'details'], 'name' => 'details', ], + + /* + |-------------------------------------------------------------------------- + | API routes + |-------------------------------------------------------------------------- + */ + [ 'method' => 'GET', 'path' => '/{code:[0-9a-zA-Z]+}/json', 'handler' => [ApiController::class, 'json'], 'name' => 'json', ], + [ + 'method' => 'GET', + 'path' => '/{code:[0-9a-zA-Z]+}/qrcode', + 'handler' => [ApiController::class, 'makeQrCode'], + 'name' => 'api::makeQrCode', + ], +// [ +// 'method' => 'GET', +// 'path' => '/{code:[0-9a-zA-Z]+}/logo/{hash:[0-9a-z]+}', +// 'handler' => [ApiController::class, 'logo'], +// 'name' => 'api::getChannelLogo', +// ], + + /* + |-------------------------------------------------------------------------- + | Other routes + |-------------------------------------------------------------------------- + */ + [ 'method' => '*', 'path' => '/{path:.*}', 'handler' => [BasicController::class, 'notFound'], 'name' => 'not-found', ], - // ... ]; diff --git a/config/twig.php b/config/twig.php index d3aed55..e365483 100644 --- a/config/twig.php +++ b/config/twig.php @@ -1,8 +1,13 @@ bool(env('TWIG_USE_CACHE', true)) ? cache_path() . '/views' : false, - 'debug' => bool(env('TWIG_DEBUG', false)), + 'debug' => bool(env('APP_DEBUG', false)), ]; diff --git a/public/index.php b/public/index.php index 58acd6d..9dabe09 100644 --- a/public/index.php +++ b/public/index.php @@ -1,7 +1,14 @@ boot()->run(); +Kernel::instance()->app()->run(); diff --git a/views/details.twig b/views/details.twig index 792e2c0..4529a51 100644 --- a/views/details.twig +++ b/views/details.twig @@ -1,6 +1,12 @@ +{########################################################################### +# Copyright (c) 2025, Антон Аксенов +# This file is part of iptv.axenov.dev web interface +# MIT License: https://git.axenov.dev/IPTV/web/src/branch/master/LICENSE +###########################################################################} + {% extends "template.twig" %} -{% block title %}[{{ id }}] {{ name }} - {{ config('app.title') }}{% endblock %} +{% block title %}[{{ playlist.code }}] {{ playlist.name }} - {{ config('app.title') }}{% endblock %} {% block head %} + {% endblock %} {% block header %} -

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

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

О плейлисте: {{ playlist.name }}

+ {% if (playlist.channels|length > 500) %} {% endif %} - {% if (status.errCode > 0) %} + {% if playlist.isOnline is same as(false) %} {% endif %} {% endblock %} -{% block footer %} - - -{% endblock %} - {% block content %}
- - - - - - - - - - - - - - - - - - - - - - - -
ID - {{ id }} {% if status.possibleStatus == 'online' %} - online - {% elseif status.possibleStatus == 'offline' %} - offline - {% elseif status.possibleStatus == 'timeout' %} - timeout - {% elseif status.possibleStatus == 'error' %} - error - {% endif %} -
Описание

{{ desc }}

Ccылка для ТВ{{ url }}
M3U{{ pls }}
Источник{{ src }}
+ +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {% if playlist.isOnline is same as(false) %} + + + + + {% endif %} + +
Код + {% if playlist.isOnline is same as(true) %} + {{ playlist.code }} + онлайн + {% elseif playlist.isOnline is same as(false) %} + {{ playlist.code }} + оффлайн + {% elseif playlist.isOnline is same as(null) %} + {{ playlist.code }} + unknown + {% endif %} +
Описание

{{ playlist.description }}

Ccылка для ТВm3u.su/{{ playlist.code }}
Источник{{ playlist.source }}
Наполнение + группы: {{ playlist.groups|length }}, + каналы: {{ playlist.channels|length }} + ({{ playlist.onlineCount }} + {{ playlist.offlineCount }}) +
Возможности +  Программа передач: {{ playlist.hasTvg ? 'есть' : 'нет' }}
+  Перемотка (архив): {{ playlist.hasCatchup ? 'есть' : 'нет' }} +
M3U{{ playlist.url }}
Проверка плейлиста + + {{ to_date(playlist.checkedAt) }} + +
Ошибка проверки{{ playlist.content }}
- {% if (content.attributes) %} -

Дополнительные атрибуты

- - - {% for attribute,value in content.attributes %} - - - - - {% endfor %} - -
{{ attribute }}{{ value }}
- {% endif %} + {% if (playlist.content.attributes) %} +

Дополнительные атрибуты

+ + + {% for attribute,value in playlist.attributes %} + + + + + {% endfor %} + +
{{ attribute }}{{ value }}
+ {% endif %} +
+
+ + + + +
+
-

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

- {% if (content.channelCount > 0) %} -
- +

Список каналов: {{ playlist.channels|length }}

+ {% if (playlist.channels|length > 0) %} + {% if (playlist.groups|length > 1) %} +
+
+
+ + + +
+
+
+ {% endif %} +
+
+
+ + + + + + + + + + + + +
+
+
+
- +
- {% for channel in content.channels %} - + {% for channel in playlist.channels %} + + - + + {% endfor %}
{{ loop.index }}{{ channel.name }} +  {{ channel.title }} +
+ {% if (channel.attributes['tvg-id']) %} +
+  {{ channel.attributes['tvg-id'] }} +
+ {% endif %} + {% if (channel.contentType != null) %} +
+  {{ channel.contentType }} +
+ {% endif %} + {% if channel.tags|length > 0 %} + + {% for tag in channel.tags %} + #{{ tag }} + {% endfor %} + {% endif %} +
+
-
{% endif %}
{% endblock %} + +{% block footer %} + + +{% endblock %} diff --git a/views/faq.twig b/views/faq.twig index 114ad6e..211dffd 100644 --- a/views/faq.twig +++ b/views/faq.twig @@ -1,5 +1,16 @@ +{########################################################################### +# Copyright (c) 2025, Антон Аксенов +# This file is part of iptv.axenov.dev web interface +# MIT License: https://git.axenov.dev/IPTV/web/src/branch/master/LICENSE +###########################################################################} + {% extends "template.twig" %} +{% block head %} + + +{% endblock %} + {% block header %}

FAQ

{% endblock %} @@ -7,19 +18,20 @@ {% block content %}
-

+

В этом сервисе собраны ссылки на IPTV-плейлисты, которые находятся в открытом доступе. - Они отбираются вручную и постоянно проверяются здесь автоматически. + Они отбираются вручную и периодически проверяются здесь автоматически.

-

+ +

Сервис "{{ config('app.title') }}" ({{ base_url() }}) не предназначен для хранения или трансляции видео/аудио потоков, программ телепередач, плейлистов и их поддержки. Этим занимаются администраторы ресурсов, указанные как источник, и те, с чьих ресурсов ведётся трансляция.

-

- За содержимое плейлистов и их качество отвечают авторы плейлистов. На стороне сервиса управляются сами - плейлисты. + +

+

Сервис "{{ config('app.title') }}" ({{ base_url() }}) предоставляет только информацию об активности плейлистов, найденных в открытом доступе, и короткие ссылки на них для удобства использования в ПО. @@ -27,270 +39,269 @@ тем, кто несёт за них ответственность (см. источники плейлистов).

-
-
-

- -

-
-
-

Изначально сервис создавался "для себя", чтобы:

-
    -
  • сократить ссылки на сторонние плейлисты и их было проще вводить с пульта;
  • -
  • собрать в одном месте наиболее годные плейлисты.
  • -
-
-
+

+ Автор не занимается созданием, изменением, размещением и хранением плейлистов на сайте + "{{ config('app.title') }}" ({{ base_url() }}). Ни бесплатно, ни за деньги, ни бартером, ни за спасибо. + Все плейлисты, которые отображаются на сайте "{{ config('app.title') }}" ({{ base_url() }}), созданы + и размещены третьими лицами на чужих серверах. +

-
+

+ Проект "{{ config('app.title') }}" ({{ base_url() }}) является бесплатным проектом с открытым исходным + кодом, он публичен и открыт для всех. Весь его исходный код размещён в публичных репозиториях под + лицензией MIT. +

-
-

- -

-
-

- На главной странице отображается список доступных в плейлистов, их идентификаторы, статусы, - количество каналов и короткие ссылки. - Для просмотра списка каналов следует нажать на ссылку "Подробнее..." под интересующим плейлистом. - Для добавления плейлиста в свой медиаплеер удобно использовать "Ссылку для ТВ". - Это делается для удобства ввода, например, на телевизоре с пульта. - На странице детальной информации также есть прямая ссылка на сам плейлист от источника. - Можно использовать и её. -

-
-
+

+ Автор не взимает плату за размещение ссылок на сторонние плейлисты на сайте "{{ config('app.title') }}" + ({{ base_url() }}). За содержимое плейлистов и их качество отвечают авторы плейлистов. +

-
-

- -

-
-

- - Добавь в свой медиаплеер "Ссылку для ТВ". -

-
-
+

+ Автор не зарабатывает на проекте "{{ config('app.title') }}" ({{ base_url() }}) и не собирается. + Всё, что ты видишь по этому адресу, сделано бесплатно и на энтузиазме. + Но ты можешь сделать добровольное пожертвование, которое поможет мне компенсировать затраты на + поддержку и техническое развитие проекта. Ссылки в шапке сайта. +

-
-

- -

-
-

- Возможно. По крайней мере, так утверждают источники. Но гарантий никаких никто не даёт. -

-
-
+ +
+

Для чего нужен сервис?

+

Изначально сервис создавался "для себя", чтобы:

+
    +
  • сократить ссылки на сторонние плейлисты и их было проще вводить с пульта;
  • +
  • собрать в одном месте наиболее годные плейлисты.
  • +
+

+ Сейчас я сам им не пользуюсь, но им пользуются сотни людей ежедневно, чтобы найти + плейлист себе по душе или по необходимости. Например, чтобы смотреть заблокированные российские + телеканалы в свободной демократической европе. +

+
-
-

- -

-
-

- Всё это (не) указывается внутри плейлиста его авторами. - Но в некоторых плеерах можно вручную указывать программу передач (см. ниже). -

-
-
+ +
+

Как пользоваться сервисом?

+

+ На главной странице отображается список доступных в плейлистов, их идентификаторы, статусы, + количество каналов и короткие ссылки. + Для просмотра списка каналов следует нажать на ссылку "Подробнее..." под интересующим + плейлистом. + Для добавления плейлиста в свой медиаплеер удобно использовать "Ссылку для ТВ". + На странице детальной информации также есть прямая ссылка на сам плейлист от источника. + Можно использовать и её. +

+
-
-

- -

-
-
-

Есть некоторые критерии, по которым плейлисты отбираются в этот список:

-
    -
  • Прежде всего -- каналы РФ и бывшего СНГ, но не только
  • -
  • Открытый источник
  • -
  • Прямая ссылка на плейлист
  • -
  • Автообновление плейлиста
  • -
-

- В основном, в плейлистах именно трансляции телеканалов, но могут быть просто список каких-то - (мульт)фильмов и передач, находящихся на чужих дисках (как если бы вы сами составили плейлист с музыкой, - например). -

-
-
-
+ +
+

Какие плейлисты попадают сюда?

+

Есть некоторые критерии, по которым плейлисты отбираются в этот список:

+
    +
  • Прежде всего -- каналы РФ и бывшего СНГ, но не только
  • +
  • Открытый источник
  • +
  • Прямая ссылка на плейлист
  • +
  • Автообновление плейлиста
  • +
+

+ В основном, в плейлистах именно трансляции телеканалов, но могут быть просто список каких-то + (мульт)фильмов и передач, находящихся на чужих дисках (как если бы вы сами составили плейлист с музыкой, + например). +

+
-
-

- -

-
-
-
    -
  • - loading - Загрузка данных, нужно немного подождать. -
  • -
  • - online - Плейлист, возможно, активен. Если каналов 0, значит, вероятно, источник поставил - редирект с плейлиста на куда ему вздумалось. То есть плейлист, наверное, отсутствует - и, возможно, больше никогда не появится по текущему адресу. -
  • -
  • - timeout - Не удалось вовремя проверить плейлист, сервер с плейлистом слишком долго запрягает. -
  • -
  • - offline - Плейлист недоступен, вообще. -
  • -
  • - error - Ошибка при проверке плейлиста. Пора удалять плейлист отсюда. -
  • -
-
-
-
+ +
+

Что означают статусы?

+

Плейлист может быть в одном из трёх статусов:

+
    +
  • + unknown + Плейлист ещё не проверялся, можно зайти позже. +
  • +
  • + online + Плейлист активен. Это не значит, что он работает. В нём может быть 0 каналов. +
  • +
  • + offline + Плейлист недоступен, вообще никак. Главный кандидат на удаление с сайта. +
  • +
+

Каждый канал в плейлисте может быть в одном из трёх статусов:

+
    +
  • + + Канал активен. Это не значит, что он работает. Там может транслироваться какая-нибудь заглушка (например, от Wink). +
  • +
  • + + Канал не работает. +
  • +
+

+ Я не гарантирую корректность и актуальность информации, которую ты увидишь здесь. + Хотя я и стараюсь улучшать качество проверок, но всё же рекомендую проверять желаемые + плейлисты самостоятельно вручную, ибо нет никаких гарантий: +

+
    +
  • что плейлисты по разным ссылкам не дублируют друг друга и отличаются каналами хотя бы на четверть;
  • +
  • что плейлист работоспособен (каналы работают, корректно названы, имеют аудио, etc.);
  • +
  • что подгрузится корректное количество каналов и их список.
  • +
+
-
-

- -

-
-
-

- Я не гарантирую корректность и актуальность информации, которую ты увидишь здесь. - Хотя я и стараюсь улучшать качество проверок, но всё же рекомендую проверять желаемые - плейлисты самостоятельно вручную, ибо нет никаких гарантий: -

-
    -
  • что это вообще плейлисты, а не чьи-то архивы с мокрыми кисками;
  • -
  • что плейлисты по разным ссылкам не дублируют друг друга и отличаются каналами хотя бы на четверть;
  • -
  • что плейлист работоспособен (каналы работают, корректно названы, имеют аудио, etc.);
  • -
  • что подгрузится корректное количество каналов и их список (хотя на это я ещё могу влиять и стараюсь как-то улучшить).
  • -
-
-
-
+ +
+

Как часто обновляется список плейлистов?

+

+ Время от времени. + Иногда я захожу сюда и проверяю всё ли на месте, иногда занимаюсь какими-то доработками. + Если есть кандидаты на добавление, то читай ниже. +

+
-
-

- -

-
-

- Никакова. - Мёртвые плейлисты я периодически вычищаю, реже -- добавляю новые. - ID плейлистов могут меняться, поэтому вполне может произойти внезапная подмена одного другим, однако - намеренно я так не делаю. - Если один плейлист переезжает на новый адрес, то я ставлю временное перенаправление со старого ID на - новый. - Плюс читай выше про доверие результатам проверки (проблема может быть не стороне сервиса). -

-
-
+ +
+

Как часто обновляется содержимое плейлистов?

+

Зависит от источника. Я этим не занимаюсь.

+
-
-

- -

-
-

Ну штош ¯\_(ツ)_/¯

-
-
+ +
+

Есть приложение?

+

Нет, и не планируется. Ищи плеер и добавляй плейлист туда по ссылке.

+
-
-

- -

-
-
-
    -
  • https://iptvx.one/viewtopic.php?f=12&t=4
  • -
  • https://iptvmaster.ru/epg-for-iptv
  • -
  • https://google.com
  • -
-
-
-
+ +
+

Эти плейлисты и каналы в них -- бесплатны?

+

+ Возможно. По крайней мере, так утверждают источники, которые их распространяют. + Но гарантий никаких никто не даёт. Любой плейлист и любой канал в любом плейлисте может сдохнуть + навсегда в любой момент. Или показывать заглушку. +

+
-
-

- -

-
-

- Время от времени. - Иногда я захожу сюда и проверяю всё ли на месте, иногда занимаюсь какими-то доработками. - Если есть кандидаты на добавление, то читай ниже. -

-
-
+ +
+

+ На канале отображается заглушка:

+ "Уважаемый клиент! Для возобновления просмотра Вам необходимо использовать не более 2 устройств"

+ или

+ "Ваша подписка не активна" +

+

Кто-то воткнул платный канал в плейлист и распространил его как бесплатный.

+

Забудь про этот плейлист. Ищи другой. Без вариантов. Такова цена халявы.

+

Нет, я не буду это исправлять.

+
-
-

- -

-
-

- Зависит от источника. Я этим не занимаюсь. -

-
-
+ +
+

+ На канале отображается заглушка:

+ "Просмотр ТВ-каналов, фильмов и сериалов доступен только в официальных приложения Wink и на территории России" +

+

Кто-то воткнул платный канал в плейлист и распространил его как бесплатный.

+

Попробуй использовать плеер, который позволяет указать User-Agent, и указать User-Agent:

+
Mozilla/5.0 WINK/1.31.1 (AndroidTV/9) HlsWinkPlayer
+

Или подключи Wink. Или забудь про этот плейлист и ищи другой.

+

Нет, я не буду это исправлять.

+
-
-

- -

-
-

- Есть, подробности здесь. -

-
-
+ +
+

Добавь канал!

+

Нет.

+
-
-

- -

-
-

- Сделать pull-request в репозиторий. - Я проверю плейлист и добавлю его в общий список, если всё ок. -

-
-
+ +
+

Сделай плейлист!

+

Нет.

+
+ +
+

Откуда берутся логотипы каналов и программы передач?

+

+ Всё это (не) указывается внутри плейлиста его авторами. + Но в некоторых плеерах можно вручную указывать программу передач (см. ниже). +

+
+ + + + + +
+

Где спортивные каналы? Почему они не работают?

+

+ Спортивные телеканалы очень пристально следят за тем, куда текут их трансляции. Они зарабатывают + на спорте и активно защищают свои права на трансляцию каких-то уникальных спортивных состязаний и + событий. Они активно рубят все левые источники, приходят к авторам плейлистов и любезно + просят удалить любые упоминания, ссылки и трансляции их каналов из паблика. Поэтому некоторые + авторы сразу предупреждают, что в плейлистах таких каналов нет. Судиться потом, вот это всё... + нафиг надо. +

+

+ Нет, я не буду добавлять каналы в плейлисты. + Если будет спортивный рабочий плейлист -- добавлю на сайт. +

+
+ + +
+

Какова гарантия, что я добавлю себе плейлист отсюда и он будет работать?

+

Никакова.

+

Мёртвые плейлисты я периодически вычищаю, реже -- добавляю новые. + ID плейлистов могут меняться, поэтому вполне может произойти внезапная подмена одного другим, однако + это происходит редко.

+

Если один плейлист переезжает на новый адрес, то я ставлю временное перенаправление со старого ID на + новый.

+

Плюс читай выше про доверие результатам проверки (проблема может быть не стороне сервиса).

+
+ + +
+

У меня перестал работать/исчез любимый канал/плейлист!

+

Ну штош ¯\_(ツ)_/¯

+
+ + +
+

Где взять программу передач (EPG)?

+
    +
  • https://iptvx.one/viewtopic.php?f=12&t=4
  • +
  • https://iptvmaster.ru/epg-for-iptv
  • +
  • https://google.com
  • +
+
+ + +
+

В плейлистах одна порнуха!

+

Ну, бывает, да. Смотри сколько хочешь. Или не смотри. Или не хоти.

+

Но у меня же дети! Яжмать! Яжотец!

+

Я вот детям порнуху не показываю. Ты тоже не показывай.

+
+ + +{#
#} +{#

Есть ли API? Как им пользоваться?

#} +{#

Есть, подробности здесь.

#} +{#
#} + + +
+

Как добавить плейлист в список?

+

+ Сделать pull-request в репозиторий. + Я проверю плейлист и добавлю его в общий список, если всё ок. +

diff --git a/views/list.twig b/views/list.twig index 5178c47..f3809cd 100644 --- a/views/list.twig +++ b/views/list.twig @@ -1,10 +1,30 @@ +{########################################################################### +# Copyright (c) 2025, Антон Аксенов +# This file is part of iptv.axenov.dev web interface +# MIT License: https://git.axenov.dev/IPTV/web/src/branch/master/LICENSE +###########################################################################} + {% extends "template.twig" %} {% block header %} -

- Обновлено: {{ updated_at }} МСК
- Плейлистов в списке: {{ count }} -

+
+
+ Состояние проверки:
+ + online{{ onlineCount }} + + + offline{{ offlineCount }} + + + unknown{{ uncheckedCount }} + +
+
+ Обновлено: {{ updatedAt }} МСК
+ Плейлистов в списке: {{ count }} +
+

{% endblock %} @@ -15,34 +35,58 @@ ID Информация о плейлисте + Каналов Ссылка для ТВ - {% for id, playlist in playlists %} - - {{ id }} + {% for code, playlist in playlists %} + + {{ code }} - {{ playlist.name }} + {% if playlist.isOnline is same as(true) %} + online + {% elseif playlist.isOnline is same as(false) %} + offline + {% elseif playlist.isOnline is same as(null) %} + unknown + {% endif %} + {{ playlist.name }}
- {% if playlist.desc|length > 0 %} -

{{ playlist.desc }}

+ {% if playlist.description|length > 0 %} +

{{ playlist.description }}

{% endif %} - Подробнее... + {% if playlist.tags|length > 0 %} +

+ + {% for tag in playlist.tags %} + #{{ tag }} + {% endfor %} +

+ {% endif %} + Подробнее...
+ + {% if (playlist.isOnline is not same as(null)) %} + {{ playlist.channels|length }} + {% else %} + ? + {% endif %} + - - {{ playlist.url }} + class="font-monospace cursor-pointer" + > + m3u.su/{{ playlist.code }} {% endfor %} - {% if pageCount > 0 %} + {% if pageCount > 1 %}
    {% for page in range(1, pageCount) %} diff --git a/views/notfound.twig b/views/notfound.twig index 1b7f199..6c5a71a 100644 --- a/views/notfound.twig +++ b/views/notfound.twig @@ -1,3 +1,9 @@ +{########################################################################### +# Copyright (c) 2025, Антон Аксенов +# This file is part of iptv.axenov.dev web interface +# MIT License: https://git.axenov.dev/IPTV/web/src/branch/master/LICENSE +###########################################################################} + {% extends "template.twig" %} {% block header %} diff --git a/views/template.twig b/views/template.twig index e6e53cb..647e3a9 100644 --- a/views/template.twig +++ b/views/template.twig @@ -1,3 +1,9 @@ +{########################################################################### +# Copyright (c) 2025, Антон Аксенов +# This file is part of iptv.axenov.dev web interface +# MIT License: https://git.axenov.dev/IPTV/web/src/branch/master/LICENSE +###########################################################################} + @@ -6,7 +12,12 @@ - + + + @@ -16,14 +27,13 @@ - {% block head %}{% endblock %} -
    +
@@ -70,12 +83,16 @@ {% block footer %}{% endblock %} FAQ | GitHub | Gitea | Исходники | axenov.dev | Telegram
+ v{{ version() }}
+ {% include("custom.twig") ignore missing %}