Переработка под iptvc
This commit is contained in:
30
.env.example
30
.env.example
@@ -1,18 +1,24 @@
|
|||||||
|
######################################
|
||||||
|
# Поменяй эти значения на необходимые
|
||||||
|
######################################
|
||||||
|
|
||||||
# config/app.php
|
# config/app.php
|
||||||
|
APP_URL="http://localhost:8080"
|
||||||
APP_DEBUG=false
|
APP_DEBUG=false
|
||||||
APP_ENV=prod
|
APP_ENV="prod"
|
||||||
APP_URL=http://localhost:8080
|
APP_TITLE="IPTV Плейлисты"
|
||||||
APP_TITLE='IPTV Плейлисты'
|
APP_TIMEZONE=Europe/Moscow
|
||||||
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'
|
|
||||||
PAGE_SIZE=10
|
PAGE_SIZE=10
|
||||||
|
|
||||||
# config/redis.php
|
# config/http.php
|
||||||
REDIS_HOST='keydb'
|
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"
|
||||||
REDIS_PORT=6379
|
|
||||||
REDIS_PASSWORD=
|
|
||||||
REDIS_DB=0
|
|
||||||
REDIS_TTL_DAYS=14
|
|
||||||
|
|
||||||
# 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_USE_CACHE=true
|
||||||
TWIG_DEBUG=false
|
|
||||||
|
|||||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -2,11 +2,13 @@
|
|||||||
/.vscode
|
/.vscode
|
||||||
/vendor
|
/vendor
|
||||||
/cache/*
|
/cache/*
|
||||||
/config/playlists.ini
|
|
||||||
/views/custom.twig
|
/views/custom.twig
|
||||||
|
|
||||||
|
*.log
|
||||||
.env
|
.env
|
||||||
.env.*
|
.env.*
|
||||||
|
playlists.ini
|
||||||
|
channels.json
|
||||||
!.env.example
|
!.env.example
|
||||||
|
|
||||||
!/**/.gitkeep
|
!/**/.gitkeep
|
||||||
|
|||||||
190
README.md
190
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)
|
||||||
|
|
||||||
Плейлисты подбираются преимущественно для РФ и любых стран бывшего СНГ, но этими странами список не ограничивается.
|
## О веб-сервисе
|
||||||
|
|
||||||
Поддержкой этих плейлистов занимаются сервисы и ресурсы, указанные как источник.
|
Решает две задачи:
|
||||||
Вопросы работоспособности плейлистов адресуйте тем, кто несёт за них ответственность.
|
* отображение списка плейлистов, которые можно использовать в своём плеере;
|
||||||
|
* сокращение длинных ссылок для удобства ввода с пульта на ТВ и лёгкого запоминания.
|
||||||
|
|
||||||
Они бесплатны для использования.
|
## Установка и настройка
|
||||||
Список проверяется и обновляется мной вручную.
|
|
||||||
Гарантию работоспособности никто не даёт.
|
|
||||||
|
|
||||||
* [Как использовать этот список?](#как-использовать-этот-список)
|
1. Развернуть [docker-среду](https://git.axenov.dev/IPTV/docker)
|
||||||
* [Формат `playlists.ini`](#формат-playlistsini)
|
2. `cd iptv-docker; cp .env.example .env`
|
||||||
* [API](#api)
|
3. В файле `.env` поменять необходимые значения
|
||||||
* [Развёртывание проекта](#развёртывание-проекта)
|
4. `cd ..; docker compose up -d --build`
|
||||||
* [Apache](#apache)
|
|
||||||
* [Nginx](#nginx)
|
|
||||||
* [Расширенные возможности](#расширенные-возможности)
|
|
||||||
* [Собственный код html/css/js](#собственный-код-htmlcssjs)
|
|
||||||
* [Очистка кеша twig](#очистка-кеша-twig)
|
|
||||||
* [Скачать все плейлисты](#скачать-все-плейлисты)
|
|
||||||
* [Проверить каналы плейлиста](#проверить-каналы-плейлиста)
|
|
||||||
* [Поиск каналов в одном плейлисте](#поиск-каналов-в-одном-плейлисте)
|
|
||||||
* [Поиск каналов во всех плейлистах](#поиск-каналов-во-всех-плейлистах)
|
|
||||||
* [Создать плейлист из нужных каналов](#создать-плейлист-из-нужных-каналов)
|
|
||||||
* [Как создать свой собственный плейлист?](#как-создать-свой-собственный-плейлист)
|
|
||||||
* [Использованный стек](#использованный-стек)
|
|
||||||
* [Лицензия](#лицензия)
|
|
||||||
|
|
||||||
## 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.
|
||||||
|
|
||||||
```
|
У каждой переменной есть умолчание на случай отсутствия файла `.env` или её отсутствия в нём.
|
||||||
GET https://iptv.axenov.dev/<ID>/json
|
Но некорректные значения некоторых переменных могут привести к фатальным ошибкам.
|
||||||
```
|
|
||||||
|
|
||||||
где `ID` -- один из идентификаторов, указанных в [`playlists.ini`](playlists.ini) в квадратных скобках.
|
### Перегрузка переменных окружения
|
||||||
|
|
||||||
В случае успеха вернётся JSON следующего содержания:
|
В разных средах можно использовать разные env-файлы для своего удобства.
|
||||||
|
|
||||||
```json
|
Для этого следует:
|
||||||
{
|
1. в файле `.env` переменной `APP_ENV` указать значение, например, `custom` или любое другое;
|
||||||
"id": "p1",
|
2. скопировать файл `.env` в файл `.env.custom`;
|
||||||
"url": "localhost:8080/p1",
|
3. в файле `.env.custom` указать все или только необходимые переменные со произвольными значениями.
|
||||||
"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
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
где:
|
Отсутствие файла `.env.custom` не вызовет ошибку.
|
||||||
|
|
||||||
* `id` -- идентификатор плейлиста
|
При подгрузке файла `.env.custom` будут перегружены только те переменные окружения, которые в нём объявлены.
|
||||||
* `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
|
|
||||||
```
|
|
||||||
|
|
||||||
## Использованный стек
|
## Использованный стек
|
||||||
|
|
||||||
* [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/)
|
* [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) для подробностей.
|
||||||
|
|||||||
@@ -1,36 +1,117 @@
|
|||||||
<?php
|
<?php
|
||||||
|
/*
|
||||||
|
* 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
|
||||||
|
*/
|
||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace App\Controllers;
|
namespace App\Controllers;
|
||||||
|
|
||||||
|
use App\Errors\PlaylistNotFoundException;
|
||||||
|
use App\Playlists\ChannelLogo;
|
||||||
|
use chillerlan\QRCode\QRCode;
|
||||||
|
use chillerlan\QRCode\QROptions;
|
||||||
use Psr\Http\Message\ResponseInterface;
|
use Psr\Http\Message\ResponseInterface;
|
||||||
use Psr\Http\Message\ServerRequestInterface;
|
use Psr\Http\Message\ServerRequestInterface;
|
||||||
use Random\RandomException;
|
use Twig\Error\LoaderError;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
*
|
* Контроллер методов API
|
||||||
*/
|
*/
|
||||||
class ApiController extends BasicController
|
class ApiController extends BasicController
|
||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
|
* Возвращает информацию о каналов плейлиста
|
||||||
|
*
|
||||||
* @param ServerRequestInterface $request
|
* @param ServerRequestInterface $request
|
||||||
* @param ResponseInterface $response
|
* @param ResponseInterface $response
|
||||||
* @return ResponseInterface
|
* @return ResponseInterface
|
||||||
* @throws RandomException
|
* @throws LoaderError
|
||||||
*/
|
*/
|
||||||
public function json(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
|
public function makeQrCode(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
|
||||||
|
{
|
||||||
|
$code = $request->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'];
|
$code = $request->getAttributes()['code'];
|
||||||
$playlist = $this->getPlaylist($code, true);
|
|
||||||
$playlist->fetchContent();
|
|
||||||
$playlist->parse();
|
|
||||||
|
|
||||||
$json = json_encode($playlist->toArray(), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
try {
|
||||||
$response->getBody()->write($json);
|
$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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,25 +1,29 @@
|
|||||||
<?php
|
<?php
|
||||||
|
/*
|
||||||
|
* 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
|
||||||
|
*/
|
||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace App\Controllers;
|
namespace App\Controllers;
|
||||||
|
|
||||||
use App\Core\Playlist;
|
|
||||||
use App\Errors\PlaylistNotFoundException;
|
|
||||||
use Psr\Http\Message\ResponseInterface;
|
use Psr\Http\Message\ResponseInterface;
|
||||||
use Psr\Http\Message\ServerRequestInterface;
|
use Psr\Http\Message\ServerRequestInterface;
|
||||||
use Slim\Views\Twig;
|
use Slim\Views\Twig;
|
||||||
|
use Throwable;
|
||||||
use Twig\Error\LoaderError;
|
use Twig\Error\LoaderError;
|
||||||
use Twig\Error\RuntimeError;
|
use Twig\Error\RuntimeError;
|
||||||
use Twig\Error\SyntaxError;
|
use Twig\Error\SyntaxError;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
*
|
* Базовый класс контроллера
|
||||||
*/
|
*/
|
||||||
class BasicController
|
class BasicController
|
||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
* Отправляет сообщение о том, что метод не найден с кодом страницы 404
|
* Отображает страницу 404
|
||||||
*
|
*
|
||||||
* @param ServerRequestInterface $request
|
* @param ServerRequestInterface $request
|
||||||
* @param ResponseInterface $response
|
* @param ResponseInterface $response
|
||||||
@@ -37,6 +41,46 @@ class BasicController
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
* Возвращает ответ в формате json
|
||||||
|
*
|
||||||
|
* @param ResponseInterface $response
|
||||||
|
* @param int $status
|
||||||
|
* @param array $data
|
||||||
|
* @return ResponseInterface
|
||||||
|
*/
|
||||||
|
protected function responseJson(ResponseInterface $response, int $status, array $data): ResponseInterface
|
||||||
|
{
|
||||||
|
$data = array_merge(['timestamp' => 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 ServerRequestInterface $request
|
||||||
* @param ResponseInterface $response
|
* @param ResponseInterface $response
|
||||||
* @param string $template
|
* @param string $template
|
||||||
|
|||||||
@@ -1,24 +1,31 @@
|
|||||||
<?php
|
<?php
|
||||||
|
/*
|
||||||
|
* 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
|
||||||
|
*/
|
||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace App\Controllers;
|
namespace App\Controllers;
|
||||||
|
|
||||||
use App\Core\ChannelLogo;
|
|
||||||
use App\Errors\PlaylistNotFoundException;
|
use App\Errors\PlaylistNotFoundException;
|
||||||
use Exception;
|
use Exception;
|
||||||
use Psr\Http\Message\ResponseInterface;
|
use Psr\Http\Message\ResponseInterface;
|
||||||
use Psr\Http\Message\ServerRequestInterface;
|
use Psr\Http\Message\ServerRequestInterface;
|
||||||
|
use Throwable;
|
||||||
use Twig\Error\LoaderError;
|
use Twig\Error\LoaderError;
|
||||||
use Twig\Error\RuntimeError;
|
use Twig\Error\RuntimeError;
|
||||||
use Twig\Error\SyntaxError;
|
use Twig\Error\SyntaxError;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
*
|
* Контроллер маршрутов web
|
||||||
*/
|
*/
|
||||||
class WebController extends BasicController
|
class WebController extends BasicController
|
||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
|
* Возвращает главную страницу со списком плейлистов
|
||||||
|
*
|
||||||
* @param ServerRequestInterface $request
|
* @param ServerRequestInterface $request
|
||||||
* @param ResponseInterface $response
|
* @param ResponseInterface $response
|
||||||
* @return ResponseInterface
|
* @return ResponseInterface
|
||||||
@@ -29,26 +36,36 @@ class WebController extends BasicController
|
|||||||
*/
|
*/
|
||||||
public function home(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
|
public function home(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
|
||||||
{
|
{
|
||||||
ini()->load();
|
$playlists = ini()->getPlaylists();
|
||||||
|
|
||||||
$playlists = ini()->playlists(false);
|
|
||||||
$count = count($playlists);
|
$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');
|
$pageSize = config('app.page_size');
|
||||||
$pageCount = ceil($count / $pageSize);
|
if ($pageSize > 0) {
|
||||||
$offset = max(0, ($page - 1) * $pageSize);
|
$pageCurrent = (int)($request->getAttributes()['page'] ?? $request->getQueryParams()['page'] ?? 1);
|
||||||
$list = array_slice($playlists, $offset, $pageSize, true);
|
$pageCount = ceil($count / $pageSize);
|
||||||
|
$offset = max(0, ($pageCurrent - 1) * $pageSize);
|
||||||
|
$playlists = array_slice($playlists, $offset, $pageSize, true);
|
||||||
|
}
|
||||||
|
|
||||||
return $this->view($request, $response, 'list.twig', [
|
return $this->view($request, $response, 'list.twig', [
|
||||||
'updated_at' => ini()->updatedAt(),
|
'updatedAt' => ini()->updatedAt(),
|
||||||
'playlists' => $list,
|
'playlists' => $playlists,
|
||||||
'count' => $count,
|
'count' => $count,
|
||||||
'pageCount' => $pageCount,
|
'onlineCount' => $onlineCount,
|
||||||
'pageCurrent' => $page,
|
'uncheckedCount' => $uncheckedCount,
|
||||||
|
'offlineCount' => $offlineCount,
|
||||||
|
'pageCount' => $pageCount ?? 1,
|
||||||
|
'pageCurrent' => $pageCurrent ?? 1,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
* Возвращает страницу FAQ
|
||||||
|
*
|
||||||
* @param ServerRequestInterface $request
|
* @param ServerRequestInterface $request
|
||||||
* @param ResponseInterface $response
|
* @param ResponseInterface $response
|
||||||
* @return ResponseInterface
|
* @return ResponseInterface
|
||||||
@@ -62,67 +79,46 @@ class WebController extends BasicController
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
* Переадресует запрос на прямую ссылку плейлиста
|
||||||
|
*
|
||||||
* @param ServerRequestInterface $request
|
* @param ServerRequestInterface $request
|
||||||
* @param ResponseInterface $response
|
* @param ResponseInterface $response
|
||||||
* @return ResponseInterface
|
* @return ResponseInterface
|
||||||
|
* @throws LoaderError
|
||||||
|
* @throws RuntimeError
|
||||||
|
* @throws SyntaxError
|
||||||
*/
|
*/
|
||||||
public function redirect(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
|
public function redirect(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
|
||||||
{
|
{
|
||||||
ini()->load();
|
|
||||||
$code = $request->getAttributes()['code'];
|
$code = $request->getAttributes()['code'];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$playlist = ini()->getPlaylist($code);
|
$playlist = ini()->getPlaylist($code);
|
||||||
return $response->withHeader('Location', $playlist->pls);
|
return $response->withHeader('Location', $playlist['url']);
|
||||||
} catch (PlaylistNotFoundException) {
|
} catch (Throwable) {
|
||||||
return $this->notFound($request, $response);
|
return $this->notFound($request, $response);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
* Возвращает страницу с описанием плейлиста
|
||||||
|
*
|
||||||
* @param ServerRequestInterface $request
|
* @param ServerRequestInterface $request
|
||||||
* @param ResponseInterface $response
|
* @param ResponseInterface $response
|
||||||
* @return ResponseInterface
|
* @return ResponseInterface
|
||||||
* @throws \Random\RandomException
|
|
||||||
* @throws LoaderError
|
* @throws LoaderError
|
||||||
* @throws RuntimeError
|
* @throws RuntimeError
|
||||||
* @throws SyntaxError
|
* @throws SyntaxError
|
||||||
*/
|
*/
|
||||||
public function details(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
|
public function details(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
|
||||||
{
|
{
|
||||||
ini()->load();
|
|
||||||
$code = $request->getAttributes()['code'];
|
$code = $request->getAttributes()['code'];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$playlist = ini()->getPlaylist($code);
|
$playlist = ini()->getPlaylist($code);
|
||||||
$response->withHeader('Location', $playlist->pls);
|
return $this->view($request, $response, 'details.twig', ['playlist' => $playlist]);
|
||||||
} catch (PlaylistNotFoundException) {
|
} catch (PlaylistNotFoundException) {
|
||||||
return $this->notFound($request, $response);
|
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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,9 @@
|
|||||||
<?php
|
<?php
|
||||||
|
/*
|
||||||
|
* 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
|
||||||
|
*/
|
||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
@@ -8,7 +13,7 @@ use App\Errors\PlaylistNotFoundException;
|
|||||||
use Exception;
|
use Exception;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Класс для работы с ini-файлом плейлистов
|
* Класс для работы со списком плейлистов
|
||||||
*/
|
*/
|
||||||
class IniFile
|
class IniFile
|
||||||
{
|
{
|
||||||
@@ -18,65 +23,76 @@ class IniFile
|
|||||||
protected array $ini;
|
protected array $ini;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @var Playlist[] Коллекция подгруженных плейлистов
|
* @var array[] Коллекция подгруженных плейлистов
|
||||||
*/
|
*/
|
||||||
protected array $playlists = [];
|
protected array $playlists;
|
||||||
|
|
||||||
/**
|
|
||||||
* @var string[] Карта переадресаций плейлистов
|
|
||||||
*/
|
|
||||||
protected array $redirections = [];
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @var string Дата последнего обновления списка
|
* @var string Дата последнего обновления списка
|
||||||
*/
|
*/
|
||||||
protected string $updated_at;
|
protected string $updatedAt;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Считывает ini-файл и инициализирует объекты плейлистов
|
* Считывает ini-файл и инициализирует плейлисты
|
||||||
*
|
*
|
||||||
* @return void
|
* @return array
|
||||||
* @throws Exception
|
* @throws Exception
|
||||||
*/
|
*/
|
||||||
public function load(): void
|
public function load(): array
|
||||||
{
|
{
|
||||||
$ini = redis()->hGetAll('_playlists_');
|
$filepath = config_path('playlists.ini');
|
||||||
if (empty($ini)) {
|
$ini = parse_ini_file($filepath, true);
|
||||||
$filepath = config_path('playlists.ini');
|
$this->updatedAt = date('d.m.Y h:i', filemtime($filepath));
|
||||||
$ini = parse_ini_file($filepath, true);
|
|
||||||
$this->updated_at = date('d.m.Y h:i', filemtime($filepath));
|
// сохраняем порядок
|
||||||
$order = array_keys($ini);
|
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_');
|
return $this->playlists;
|
||||||
$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();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Возвращает объекты плейлистов
|
* Возвращает плейлисты
|
||||||
*
|
*
|
||||||
* @param bool $all true - получить все, false - получить только НЕпереадресованные
|
* @return array[]
|
||||||
* @return Playlist[]
|
* @throws Exception
|
||||||
*/
|
*/
|
||||||
public function playlists(bool $all = true): array
|
public function getPlaylists(): array
|
||||||
{
|
{
|
||||||
return $all
|
return $this->playlists ??= $this->load();
|
||||||
? $this->playlists
|
|
||||||
: array_filter($this->playlists, static fn ($playlist) => is_null($playlist->redirectId));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -86,50 +102,23 @@ class IniFile
|
|||||||
*/
|
*/
|
||||||
public function updatedAt(): string
|
public function updatedAt(): string
|
||||||
{
|
{
|
||||||
return $this->updated_at;
|
return $this->updatedAt;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Возвращает ID плейлиста, на который нужно переадресовать указанный
|
* Возвращает плейлист по его коду
|
||||||
*
|
*
|
||||||
* @param string $id ID плейлиста
|
* @param string $code Код плейлиста
|
||||||
* @return string|null
|
* @return array|null
|
||||||
*/
|
|
||||||
public function getRedirection(string $id): ?string
|
|
||||||
{
|
|
||||||
return $this->redirections[$id] ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Возвращает объект плейлиста
|
|
||||||
*
|
|
||||||
* @param string $id ID плейлиста
|
|
||||||
* @return Playlist|null
|
|
||||||
* @throws PlaylistNotFoundException
|
* @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
|
* @throws Exception
|
||||||
*/
|
*/
|
||||||
protected function makePlaylist(int|string $id, array $params, ?string $redirectId = null): Playlist
|
public function getPlaylist(string $code): ?array
|
||||||
{
|
{
|
||||||
$id = (string)$id;
|
if (empty($this->playlists)) {
|
||||||
if (isset($params['redirect'])) {
|
$this->load();
|
||||||
$this->redirections[$id] = $redirectId = (string)$params['redirect'];
|
|
||||||
$params = $this->ini[$redirectId];
|
|
||||||
return $this->makePlaylist($id, $params, $redirectId);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return new Playlist($id, $params, $redirectId);
|
return $this->playlists[$code] ?? throw new PlaylistNotFoundException($code);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,9 @@
|
|||||||
<?php
|
<?php
|
||||||
|
/*
|
||||||
|
* 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
|
||||||
|
*/
|
||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
@@ -6,6 +11,7 @@ namespace App\Core;
|
|||||||
|
|
||||||
use App\Core\TwigExtention as IptvTwigExtension;
|
use App\Core\TwigExtention as IptvTwigExtension;
|
||||||
use Dotenv\Dotenv;
|
use Dotenv\Dotenv;
|
||||||
|
use GuzzleHttp\Client;
|
||||||
use InvalidArgumentException;
|
use InvalidArgumentException;
|
||||||
use Redis;
|
use Redis;
|
||||||
use Slim\App;
|
use Slim\App;
|
||||||
@@ -13,110 +19,73 @@ use Slim\Factory\AppFactory;
|
|||||||
use Slim\Views\Twig;
|
use Slim\Views\Twig;
|
||||||
use Slim\Views\TwigMiddleware;
|
use Slim\Views\TwigMiddleware;
|
||||||
use Twig\Error\LoaderError;
|
use Twig\Error\LoaderError;
|
||||||
|
use Twig\Extension\DebugExtension;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Загрузчик приложения
|
* Загрузчик приложения
|
||||||
*/
|
*/
|
||||||
final class Core
|
final class Kernel
|
||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
* @var Core
|
* Версия приложения
|
||||||
*/
|
*/
|
||||||
private static Core $instance;
|
public const string VERSION = '1.0.0';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var Kernel
|
||||||
|
*/
|
||||||
|
private static Kernel $instance;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @var App
|
* @var App
|
||||||
*/
|
*/
|
||||||
protected App $app;
|
protected App $app;
|
||||||
|
|
||||||
/**
|
|
||||||
* @var array Конфигурация приложения
|
|
||||||
*/
|
|
||||||
protected array $config = [];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @var Redis
|
|
||||||
*/
|
|
||||||
protected Redis $redis;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @var IniFile
|
* @var IniFile
|
||||||
*/
|
*/
|
||||||
protected IniFile $iniFile;
|
protected IniFile $iniFile;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var array Конфигурация приложения
|
||||||
|
*/
|
||||||
|
protected array $config = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var Redis|null
|
||||||
|
*/
|
||||||
|
protected ?Redis $cache = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var Client|null
|
||||||
|
*/
|
||||||
|
protected ?Client $httpClient = null;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Закрытый конструктор
|
* Закрытый конструктор
|
||||||
|
*
|
||||||
|
* @throws LoaderError
|
||||||
*/
|
*/
|
||||||
private function __construct()
|
private function __construct()
|
||||||
{
|
{
|
||||||
|
$this->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 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
|
* Загружает файл .env или .env.$env
|
||||||
*
|
*
|
||||||
@@ -139,10 +108,9 @@ final class Core
|
|||||||
*
|
*
|
||||||
* @return void
|
* @return void
|
||||||
*/
|
*/
|
||||||
protected function bootSettings(): void
|
protected function loadSettings(): void
|
||||||
{
|
{
|
||||||
$env = $this->loadDotEnvFile();
|
$env = $this->loadDotEnvFile();
|
||||||
|
|
||||||
if (!empty($env['APP_ENV'])) {
|
if (!empty($env['APP_ENV'])) {
|
||||||
$this->loadDotEnvFile($env['APP_ENV']);
|
$this->loadDotEnvFile($env['APP_ENV']);
|
||||||
}
|
}
|
||||||
@@ -151,6 +119,8 @@ final class Core
|
|||||||
$key = basename($file, '.php');
|
$key = basename($file, '.php');
|
||||||
$this->config += [$key => require_once $file];
|
$this->config += [$key => require_once $file];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
date_default_timezone_set($this->config['app']['timezone'] ?? 'GMT');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -159,18 +129,19 @@ final class Core
|
|||||||
* @return void
|
* @return void
|
||||||
* @see https://www.slimframework.com/docs/v4/objects/routing.html
|
* @see https://www.slimframework.com/docs/v4/objects/routing.html
|
||||||
*/
|
*/
|
||||||
protected function bootRoutes(): void
|
protected function loadRoutes(): void
|
||||||
{
|
{
|
||||||
foreach ($this->config['routes'] as $route) {
|
foreach ($this->config['routes'] as $route) {
|
||||||
if (is_array($route['method'])) {
|
if (is_array($route['method'])) {
|
||||||
$definition = $this->app->map($route['method'], $route['path'], $route['handler']);
|
$definition = $this->app->map($route['method'], $route['path'], $route['handler']);
|
||||||
} else {
|
} 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) {
|
$func = match (true) {
|
||||||
$route['method'] === '*' => 'any',
|
$method === '*' => 'any',
|
||||||
$isPossible => strtolower($route['method']),
|
$isPossible => strtolower($method),
|
||||||
default => throw new InvalidArgumentException(sprintf('Неверный HTTP метод %s', $route['method']))
|
default => throw new InvalidArgumentException(sprintf('Неверный HTTP метод %s', $method))
|
||||||
};
|
};
|
||||||
|
|
||||||
$definition = $this->app->$func($route['path'], $route['handler']);
|
$definition = $this->app->$func($route['path'], $route['handler']);
|
||||||
@@ -189,42 +160,84 @@ final class Core
|
|||||||
* @throws LoaderError
|
* @throws LoaderError
|
||||||
* @see https://www.slimframework.com/docs/v4/features/twig-view.html
|
* @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 = Twig::create(root_path('views'), $this->config['twig']);
|
||||||
$twig->addExtension(new IptvTwigExtension());
|
$twig->addExtension(new IptvTwigExtension());
|
||||||
$this->app->add(TwigMiddleware::create($this->app, $twig));
|
$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
|
* @see https://github.com/phpredis/phpredis/?tab=readme-ov-file
|
||||||
*/
|
*/
|
||||||
protected function bootRedis(): void
|
public function redis(): Redis
|
||||||
{
|
{
|
||||||
$options = [
|
if (!empty($this->cache)) {
|
||||||
'host' => $this->config['redis']['host'],
|
return $this->cache;
|
||||||
'port' => (int)$this->config['redis']['port'],
|
|
||||||
];
|
|
||||||
|
|
||||||
if (!empty($this->config['redis']['password'])) {
|
|
||||||
$options['auth'] = $this->config['redis']['password'];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->redis = new Redis($options);
|
$options = [
|
||||||
$this->redis->select((int)$this->config['redis']['db']);
|
'host' => $this->config['cache']['host'],
|
||||||
$this->redis->setOption(Redis::OPT_SERIALIZER, Redis::SERIALIZER_JSON);
|
'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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,368 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Core;
|
|
||||||
|
|
||||||
use CurlHandle;
|
|
||||||
use Exception;
|
|
||||||
use Random\RandomException;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Плейлист без редиректа
|
|
||||||
*/
|
|
||||||
class Playlist
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* @var string|null Название плейлиста
|
|
||||||
*/
|
|
||||||
public ?string $name;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @var string|null Описание плейлиста
|
|
||||||
*/
|
|
||||||
public ?string $desc;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @var string Прямой URL до файла плейлиста на третьей стороне
|
|
||||||
*/
|
|
||||||
public string $pls;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @var string|null Источник плейлиста
|
|
||||||
*/
|
|
||||||
public ?string $src;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @var string Ссылка на плейлист в рамках проекта
|
|
||||||
*/
|
|
||||||
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 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('#(?<key>[a-z-]+)="(?<value>.*)"#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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,41 +1,91 @@
|
|||||||
<?php
|
<?php
|
||||||
|
/*
|
||||||
|
* 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
|
||||||
|
*/
|
||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace App\Core;
|
namespace App\Core;
|
||||||
|
|
||||||
|
use Twig\Error\LoaderError;
|
||||||
use Twig\Extension\AbstractExtension;
|
use Twig\Extension\AbstractExtension;
|
||||||
use Twig\TwigFunction;
|
use Twig\TwigFunction;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Расширение twig
|
||||||
|
*/
|
||||||
class TwigExtention extends AbstractExtension
|
class TwigExtention extends AbstractExtension
|
||||||
{
|
{
|
||||||
|
/**
|
||||||
|
* @inheritDoc
|
||||||
|
*/
|
||||||
public function getFunctions(): array
|
public function getFunctions(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
new TwigFunction('config', [$this, 'config']),
|
new TwigFunction('config', [$this, 'config']),
|
||||||
new TwigFunction('commit', [$this, 'commit']),
|
new TwigFunction('version', [$this, 'version']),
|
||||||
new TwigFunction('is_file', [$this, 'is_file']),
|
new TwigFunction('is_file', [$this, 'isFile']),
|
||||||
new TwigFunction('base_url', [$this, 'base_url']),
|
new TwigFunction('base_url', [$this, 'baseUrl']),
|
||||||
|
new TwigFunction('to_date', [$this, 'toDate']),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Возвращает значение из конфига
|
||||||
|
*
|
||||||
|
* @param string $key Ключ в формате "config.key"
|
||||||
|
* @param mixed|null $default Значение по умолчанию
|
||||||
|
* @return mixed
|
||||||
|
* @throws LoaderError
|
||||||
|
*/
|
||||||
public function config(string $key, mixed $default = null): mixed
|
public function config(string $key, mixed $default = null): mixed
|
||||||
{
|
{
|
||||||
return config($key, $default);
|
return kernel()->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);
|
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);
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,9 @@
|
|||||||
<?php
|
<?php
|
||||||
|
/*
|
||||||
|
* 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
|
||||||
|
*/
|
||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,9 @@
|
|||||||
<?php
|
<?php
|
||||||
|
/*
|
||||||
|
* 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
|
||||||
|
*/
|
||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
@@ -8,8 +13,8 @@ use Exception;
|
|||||||
|
|
||||||
class PlaylistNotFoundException extends Exception
|
class PlaylistNotFoundException extends Exception
|
||||||
{
|
{
|
||||||
public function __construct(string $id)
|
public function __construct(string $code)
|
||||||
{
|
{
|
||||||
parent::__construct("Плейлист $id не найден!");
|
parent::__construct("Плейлист '$code' не найден");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,9 @@
|
|||||||
<?php
|
<?php
|
||||||
|
/*
|
||||||
|
* 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
|
||||||
|
*/
|
||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,13 @@
|
|||||||
<?php
|
<?php
|
||||||
|
/*
|
||||||
|
* 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
|
||||||
|
*/
|
||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace App\Core;
|
namespace App\Playlists;
|
||||||
|
|
||||||
class ChannelLogo implements \Stringable
|
class ChannelLogo implements \Stringable
|
||||||
{
|
{
|
||||||
149
app/helpers.php
149
app/helpers.php
@@ -1,9 +1,14 @@
|
|||||||
<?php
|
<?php
|
||||||
|
/*
|
||||||
|
* 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
|
||||||
|
*/
|
||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
use App\Core\Core;
|
|
||||||
use App\Core\IniFile;
|
use App\Core\IniFile;
|
||||||
|
use App\Core\Kernel;
|
||||||
use Slim\App;
|
use Slim\App;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -74,28 +79,13 @@ function env(string $key, mixed $default = null): mixed
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Renders template
|
* Returns kernel object
|
||||||
*
|
*
|
||||||
* @param mixed $template
|
* @return Kernel
|
||||||
* @param array $data
|
|
||||||
* @return void
|
|
||||||
* @throws Exception
|
|
||||||
*/
|
*/
|
||||||
function view(mixed $template, array $data = []): void
|
function kernel(): Kernel
|
||||||
{
|
{
|
||||||
$template = str_contains($template, '.twig') ? $template : "$template.twig";
|
return Kernel::instance();
|
||||||
/** @noinspection PhpVoidFunctionResultUsedInspection */
|
|
||||||
echo Flight::view()->render($template, $data);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns core object
|
|
||||||
*
|
|
||||||
* @return Core
|
|
||||||
*/
|
|
||||||
function core(): Core
|
|
||||||
{
|
|
||||||
return Core::get();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -105,25 +95,7 @@ function core(): Core
|
|||||||
*/
|
*/
|
||||||
function app(): App
|
function app(): App
|
||||||
{
|
{
|
||||||
return Core::get()->app();
|
return Kernel::instance()->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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -135,7 +107,7 @@ function bool(mixed $value): bool
|
|||||||
*/
|
*/
|
||||||
function config(string $key, mixed $default = null): mixed
|
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
|
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
|
function ini(): IniFile
|
||||||
{
|
{
|
||||||
return Core::get()->ini();
|
return Kernel::instance()->ini();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,17 +12,18 @@
|
|||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"require": {
|
"require": {
|
||||||
"php": "^8.3",
|
"php": "^8.3",
|
||||||
"ext-json": "*",
|
|
||||||
"ext-curl": "*",
|
"ext-curl": "*",
|
||||||
"ext-redis": "*",
|
|
||||||
"ext-fileinfo": "*",
|
"ext-fileinfo": "*",
|
||||||
|
"ext-json": "*",
|
||||||
"ext-mbstring": "*",
|
"ext-mbstring": "*",
|
||||||
"nesbot/carbon": "^3.8",
|
"ext-redis": "*",
|
||||||
|
"focusim/php-qrcode": "^4.3",
|
||||||
"guzzlehttp/guzzle": "^7.9",
|
"guzzlehttp/guzzle": "^7.9",
|
||||||
|
"nesbot/carbon": "^3.8",
|
||||||
"nyholm/psr7": "^1.8",
|
"nyholm/psr7": "^1.8",
|
||||||
"vlucas/phpdotenv": "^5.6",
|
|
||||||
"slim/slim": "^4.14",
|
"slim/slim": "^4.14",
|
||||||
"slim/twig-view": "^3.4"
|
"slim/twig-view": "^3.4",
|
||||||
|
"vlucas/phpdotenv": "^5.6"
|
||||||
},
|
},
|
||||||
"autoload": {
|
"autoload": {
|
||||||
"psr-4": {
|
"psr-4": {
|
||||||
@@ -41,7 +42,10 @@
|
|||||||
"config": {
|
"config": {
|
||||||
"optimize-autoloader": true,
|
"optimize-autoloader": true,
|
||||||
"preferred-install": "dist",
|
"preferred-install": "dist",
|
||||||
"sort-packages": true
|
"sort-packages": true,
|
||||||
|
"allow-plugins": {
|
||||||
|
"yiisoft/yii2-composer": false
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"minimum-stability": "dev",
|
"minimum-stability": "dev",
|
||||||
"prefer-stable": true
|
"prefer-stable": true
|
||||||
|
|||||||
275
composer.lock
generated
275
composer.lock
generated
@@ -4,7 +4,7 @@
|
|||||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||||
"This file is @generated automatically"
|
"This file is @generated automatically"
|
||||||
],
|
],
|
||||||
"content-hash": "d149d6555d367f3abeee22b27cf971b7",
|
"content-hash": "eb7c9751c009420f33c222a768c1797a",
|
||||||
"packages": [
|
"packages": [
|
||||||
{
|
{
|
||||||
"name": "carbonphp/carbon-doctrine-types",
|
"name": "carbonphp/carbon-doctrine-types",
|
||||||
@@ -75,6 +75,154 @@
|
|||||||
],
|
],
|
||||||
"time": "2024-02-09T16:56:22+00:00"
|
"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",
|
"name": "graham-campbell/result-type",
|
||||||
"version": "v1.1.3",
|
"version": "v1.1.3",
|
||||||
@@ -139,16 +287,16 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "guzzlehttp/guzzle",
|
"name": "guzzlehttp/guzzle",
|
||||||
"version": "7.9.2",
|
"version": "7.9.3",
|
||||||
"source": {
|
"source": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://github.com/guzzle/guzzle.git",
|
"url": "https://github.com/guzzle/guzzle.git",
|
||||||
"reference": "d281ed313b989f213357e3be1a179f02196ac99b"
|
"reference": "7b2f29fe81dc4da0ca0ea7d42107a0845946ea77"
|
||||||
},
|
},
|
||||||
"dist": {
|
"dist": {
|
||||||
"type": "zip",
|
"type": "zip",
|
||||||
"url": "https://api.github.com/repos/guzzle/guzzle/zipball/d281ed313b989f213357e3be1a179f02196ac99b",
|
"url": "https://api.github.com/repos/guzzle/guzzle/zipball/7b2f29fe81dc4da0ca0ea7d42107a0845946ea77",
|
||||||
"reference": "d281ed313b989f213357e3be1a179f02196ac99b",
|
"reference": "7b2f29fe81dc4da0ca0ea7d42107a0845946ea77",
|
||||||
"shasum": ""
|
"shasum": ""
|
||||||
},
|
},
|
||||||
"require": {
|
"require": {
|
||||||
@@ -245,7 +393,7 @@
|
|||||||
],
|
],
|
||||||
"support": {
|
"support": {
|
||||||
"issues": "https://github.com/guzzle/guzzle/issues",
|
"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": [
|
"funding": [
|
||||||
{
|
{
|
||||||
@@ -261,20 +409,20 @@
|
|||||||
"type": "tidelift"
|
"type": "tidelift"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"time": "2024-07-24T11:22:20+00:00"
|
"time": "2025-03-27T13:37:11+00:00"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "guzzlehttp/promises",
|
"name": "guzzlehttp/promises",
|
||||||
"version": "2.0.4",
|
"version": "2.2.0",
|
||||||
"source": {
|
"source": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://github.com/guzzle/promises.git",
|
"url": "https://github.com/guzzle/promises.git",
|
||||||
"reference": "f9c436286ab2892c7db7be8c8da4ef61ccf7b455"
|
"reference": "7c69f28996b0a6920945dd20b3857e499d9ca96c"
|
||||||
},
|
},
|
||||||
"dist": {
|
"dist": {
|
||||||
"type": "zip",
|
"type": "zip",
|
||||||
"url": "https://api.github.com/repos/guzzle/promises/zipball/f9c436286ab2892c7db7be8c8da4ef61ccf7b455",
|
"url": "https://api.github.com/repos/guzzle/promises/zipball/7c69f28996b0a6920945dd20b3857e499d9ca96c",
|
||||||
"reference": "f9c436286ab2892c7db7be8c8da4ef61ccf7b455",
|
"reference": "7c69f28996b0a6920945dd20b3857e499d9ca96c",
|
||||||
"shasum": ""
|
"shasum": ""
|
||||||
},
|
},
|
||||||
"require": {
|
"require": {
|
||||||
@@ -328,7 +476,7 @@
|
|||||||
],
|
],
|
||||||
"support": {
|
"support": {
|
||||||
"issues": "https://github.com/guzzle/promises/issues",
|
"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": [
|
"funding": [
|
||||||
{
|
{
|
||||||
@@ -344,20 +492,20 @@
|
|||||||
"type": "tidelift"
|
"type": "tidelift"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"time": "2024-10-17T10:06:22+00:00"
|
"time": "2025-03-27T13:27:01+00:00"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "guzzlehttp/psr7",
|
"name": "guzzlehttp/psr7",
|
||||||
"version": "2.7.0",
|
"version": "2.7.1",
|
||||||
"source": {
|
"source": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://github.com/guzzle/psr7.git",
|
"url": "https://github.com/guzzle/psr7.git",
|
||||||
"reference": "a70f5c95fb43bc83f07c9c948baa0dc1829bf201"
|
"reference": "c2270caaabe631b3b44c85f99e5a04bbb8060d16"
|
||||||
},
|
},
|
||||||
"dist": {
|
"dist": {
|
||||||
"type": "zip",
|
"type": "zip",
|
||||||
"url": "https://api.github.com/repos/guzzle/psr7/zipball/a70f5c95fb43bc83f07c9c948baa0dc1829bf201",
|
"url": "https://api.github.com/repos/guzzle/psr7/zipball/c2270caaabe631b3b44c85f99e5a04bbb8060d16",
|
||||||
"reference": "a70f5c95fb43bc83f07c9c948baa0dc1829bf201",
|
"reference": "c2270caaabe631b3b44c85f99e5a04bbb8060d16",
|
||||||
"shasum": ""
|
"shasum": ""
|
||||||
},
|
},
|
||||||
"require": {
|
"require": {
|
||||||
@@ -444,7 +592,7 @@
|
|||||||
],
|
],
|
||||||
"support": {
|
"support": {
|
||||||
"issues": "https://github.com/guzzle/psr7/issues",
|
"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": [
|
"funding": [
|
||||||
{
|
{
|
||||||
@@ -460,20 +608,20 @@
|
|||||||
"type": "tidelift"
|
"type": "tidelift"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"time": "2024-07-18T11:15:46+00:00"
|
"time": "2025-03-27T12:30:47+00:00"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "nesbot/carbon",
|
"name": "nesbot/carbon",
|
||||||
"version": "3.8.6",
|
"version": "3.9.1",
|
||||||
"source": {
|
"source": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://github.com/CarbonPHP/carbon.git",
|
"url": "https://github.com/CarbonPHP/carbon.git",
|
||||||
"reference": "ff2f20cf83bd4d503720632ce8a426dc747bf7fd"
|
"reference": "ced71f79398ece168e24f7f7710462f462310d4d"
|
||||||
},
|
},
|
||||||
"dist": {
|
"dist": {
|
||||||
"type": "zip",
|
"type": "zip",
|
||||||
"url": "https://api.github.com/repos/CarbonPHP/carbon/zipball/ff2f20cf83bd4d503720632ce8a426dc747bf7fd",
|
"url": "https://api.github.com/repos/CarbonPHP/carbon/zipball/ced71f79398ece168e24f7f7710462f462310d4d",
|
||||||
"reference": "ff2f20cf83bd4d503720632ce8a426dc747bf7fd",
|
"reference": "ced71f79398ece168e24f7f7710462f462310d4d",
|
||||||
"shasum": ""
|
"shasum": ""
|
||||||
},
|
},
|
||||||
"require": {
|
"require": {
|
||||||
@@ -566,7 +714,7 @@
|
|||||||
"type": "tidelift"
|
"type": "tidelift"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"time": "2025-02-20T17:33:38+00:00"
|
"time": "2025-05-01T19:51:51+00:00"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "nikic/fast-route",
|
"name": "nikic/fast-route",
|
||||||
@@ -1563,7 +1711,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "symfony/polyfill-ctype",
|
"name": "symfony/polyfill-ctype",
|
||||||
"version": "v1.31.0",
|
"version": "v1.32.0",
|
||||||
"source": {
|
"source": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://github.com/symfony/polyfill-ctype.git",
|
"url": "https://github.com/symfony/polyfill-ctype.git",
|
||||||
@@ -1622,7 +1770,7 @@
|
|||||||
"portable"
|
"portable"
|
||||||
],
|
],
|
||||||
"support": {
|
"support": {
|
||||||
"source": "https://github.com/symfony/polyfill-ctype/tree/v1.31.0"
|
"source": "https://github.com/symfony/polyfill-ctype/tree/v1.32.0"
|
||||||
},
|
},
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
@@ -1642,19 +1790,20 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "symfony/polyfill-mbstring",
|
"name": "symfony/polyfill-mbstring",
|
||||||
"version": "v1.31.0",
|
"version": "v1.32.0",
|
||||||
"source": {
|
"source": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://github.com/symfony/polyfill-mbstring.git",
|
"url": "https://github.com/symfony/polyfill-mbstring.git",
|
||||||
"reference": "85181ba99b2345b0ef10ce42ecac37612d9fd341"
|
"reference": "6d857f4d76bd4b343eac26d6b539585d2bc56493"
|
||||||
},
|
},
|
||||||
"dist": {
|
"dist": {
|
||||||
"type": "zip",
|
"type": "zip",
|
||||||
"url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/85181ba99b2345b0ef10ce42ecac37612d9fd341",
|
"url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/6d857f4d76bd4b343eac26d6b539585d2bc56493",
|
||||||
"reference": "85181ba99b2345b0ef10ce42ecac37612d9fd341",
|
"reference": "6d857f4d76bd4b343eac26d6b539585d2bc56493",
|
||||||
"shasum": ""
|
"shasum": ""
|
||||||
},
|
},
|
||||||
"require": {
|
"require": {
|
||||||
|
"ext-iconv": "*",
|
||||||
"php": ">=7.2"
|
"php": ">=7.2"
|
||||||
},
|
},
|
||||||
"provide": {
|
"provide": {
|
||||||
@@ -1702,7 +1851,7 @@
|
|||||||
"shim"
|
"shim"
|
||||||
],
|
],
|
||||||
"support": {
|
"support": {
|
||||||
"source": "https://github.com/symfony/polyfill-mbstring/tree/v1.31.0"
|
"source": "https://github.com/symfony/polyfill-mbstring/tree/v1.32.0"
|
||||||
},
|
},
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
@@ -1718,20 +1867,20 @@
|
|||||||
"type": "tidelift"
|
"type": "tidelift"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"time": "2024-09-09T11:45:10+00:00"
|
"time": "2024-12-23T08:48:59+00:00"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "symfony/polyfill-php80",
|
"name": "symfony/polyfill-php80",
|
||||||
"version": "v1.31.0",
|
"version": "v1.32.0",
|
||||||
"source": {
|
"source": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://github.com/symfony/polyfill-php80.git",
|
"url": "https://github.com/symfony/polyfill-php80.git",
|
||||||
"reference": "60328e362d4c2c802a54fcbf04f9d3fb892b4cf8"
|
"reference": "0cc9dd0f17f61d8131e7df6b84bd344899fe2608"
|
||||||
},
|
},
|
||||||
"dist": {
|
"dist": {
|
||||||
"type": "zip",
|
"type": "zip",
|
||||||
"url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/60328e362d4c2c802a54fcbf04f9d3fb892b4cf8",
|
"url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/0cc9dd0f17f61d8131e7df6b84bd344899fe2608",
|
||||||
"reference": "60328e362d4c2c802a54fcbf04f9d3fb892b4cf8",
|
"reference": "0cc9dd0f17f61d8131e7df6b84bd344899fe2608",
|
||||||
"shasum": ""
|
"shasum": ""
|
||||||
},
|
},
|
||||||
"require": {
|
"require": {
|
||||||
@@ -1782,7 +1931,7 @@
|
|||||||
"shim"
|
"shim"
|
||||||
],
|
],
|
||||||
"support": {
|
"support": {
|
||||||
"source": "https://github.com/symfony/polyfill-php80/tree/v1.31.0"
|
"source": "https://github.com/symfony/polyfill-php80/tree/v1.32.0"
|
||||||
},
|
},
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
@@ -1798,11 +1947,11 @@
|
|||||||
"type": "tidelift"
|
"type": "tidelift"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"time": "2024-09-09T11:45:10+00:00"
|
"time": "2025-01-02T08:10:11+00:00"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "symfony/polyfill-php81",
|
"name": "symfony/polyfill-php81",
|
||||||
"version": "v1.31.0",
|
"version": "v1.32.0",
|
||||||
"source": {
|
"source": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://github.com/symfony/polyfill-php81.git",
|
"url": "https://github.com/symfony/polyfill-php81.git",
|
||||||
@@ -1858,7 +2007,7 @@
|
|||||||
"shim"
|
"shim"
|
||||||
],
|
],
|
||||||
"support": {
|
"support": {
|
||||||
"source": "https://github.com/symfony/polyfill-php81/tree/v1.31.0"
|
"source": "https://github.com/symfony/polyfill-php81/tree/v1.32.0"
|
||||||
},
|
},
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
@@ -1878,7 +2027,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "symfony/polyfill-php83",
|
"name": "symfony/polyfill-php83",
|
||||||
"version": "v1.31.0",
|
"version": "v1.32.0",
|
||||||
"source": {
|
"source": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://github.com/symfony/polyfill-php83.git",
|
"url": "https://github.com/symfony/polyfill-php83.git",
|
||||||
@@ -1934,7 +2083,7 @@
|
|||||||
"shim"
|
"shim"
|
||||||
],
|
],
|
||||||
"support": {
|
"support": {
|
||||||
"source": "https://github.com/symfony/polyfill-php83/tree/v1.31.0"
|
"source": "https://github.com/symfony/polyfill-php83/tree/v1.32.0"
|
||||||
},
|
},
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
@@ -1954,16 +2103,16 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "symfony/translation",
|
"name": "symfony/translation",
|
||||||
"version": "v7.2.4",
|
"version": "v7.2.6",
|
||||||
"source": {
|
"source": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://github.com/symfony/translation.git",
|
"url": "https://github.com/symfony/translation.git",
|
||||||
"reference": "283856e6981286cc0d800b53bd5703e8e363f05a"
|
"reference": "e7fd8e2a4239b79a0fd9fb1fef3e0e7f969c6dc6"
|
||||||
},
|
},
|
||||||
"dist": {
|
"dist": {
|
||||||
"type": "zip",
|
"type": "zip",
|
||||||
"url": "https://api.github.com/repos/symfony/translation/zipball/283856e6981286cc0d800b53bd5703e8e363f05a",
|
"url": "https://api.github.com/repos/symfony/translation/zipball/e7fd8e2a4239b79a0fd9fb1fef3e0e7f969c6dc6",
|
||||||
"reference": "283856e6981286cc0d800b53bd5703e8e363f05a",
|
"reference": "e7fd8e2a4239b79a0fd9fb1fef3e0e7f969c6dc6",
|
||||||
"shasum": ""
|
"shasum": ""
|
||||||
},
|
},
|
||||||
"require": {
|
"require": {
|
||||||
@@ -2029,7 +2178,7 @@
|
|||||||
"description": "Provides tools to internationalize your application",
|
"description": "Provides tools to internationalize your application",
|
||||||
"homepage": "https://symfony.com",
|
"homepage": "https://symfony.com",
|
||||||
"support": {
|
"support": {
|
||||||
"source": "https://github.com/symfony/translation/tree/v7.2.4"
|
"source": "https://github.com/symfony/translation/tree/v7.2.6"
|
||||||
},
|
},
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
@@ -2045,7 +2194,7 @@
|
|||||||
"type": "tidelift"
|
"type": "tidelift"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"time": "2025-02-13T10:27:23+00:00"
|
"time": "2025-04-07T19:09:28+00:00"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "symfony/translation-contracts",
|
"name": "symfony/translation-contracts",
|
||||||
@@ -2127,16 +2276,16 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "twig/twig",
|
"name": "twig/twig",
|
||||||
"version": "v3.20.0",
|
"version": "v3.21.1",
|
||||||
"source": {
|
"source": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://github.com/twigphp/Twig.git",
|
"url": "https://github.com/twigphp/Twig.git",
|
||||||
"reference": "3468920399451a384bef53cf7996965f7cd40183"
|
"reference": "285123877d4dd97dd7c11842ac5fb7e86e60d81d"
|
||||||
},
|
},
|
||||||
"dist": {
|
"dist": {
|
||||||
"type": "zip",
|
"type": "zip",
|
||||||
"url": "https://api.github.com/repos/twigphp/Twig/zipball/3468920399451a384bef53cf7996965f7cd40183",
|
"url": "https://api.github.com/repos/twigphp/Twig/zipball/285123877d4dd97dd7c11842ac5fb7e86e60d81d",
|
||||||
"reference": "3468920399451a384bef53cf7996965f7cd40183",
|
"reference": "285123877d4dd97dd7c11842ac5fb7e86e60d81d",
|
||||||
"shasum": ""
|
"shasum": ""
|
||||||
},
|
},
|
||||||
"require": {
|
"require": {
|
||||||
@@ -2190,7 +2339,7 @@
|
|||||||
],
|
],
|
||||||
"support": {
|
"support": {
|
||||||
"issues": "https://github.com/twigphp/Twig/issues",
|
"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": [
|
"funding": [
|
||||||
{
|
{
|
||||||
@@ -2202,20 +2351,20 @@
|
|||||||
"type": "tidelift"
|
"type": "tidelift"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"time": "2025-02-13T08:34:43+00:00"
|
"time": "2025-05-03T07:21:55+00:00"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "vlucas/phpdotenv",
|
"name": "vlucas/phpdotenv",
|
||||||
"version": "v5.6.1",
|
"version": "v5.6.2",
|
||||||
"source": {
|
"source": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://github.com/vlucas/phpdotenv.git",
|
"url": "https://github.com/vlucas/phpdotenv.git",
|
||||||
"reference": "a59a13791077fe3d44f90e7133eb68e7d22eaff2"
|
"reference": "24ac4c74f91ee2c193fa1aaa5c249cb0822809af"
|
||||||
},
|
},
|
||||||
"dist": {
|
"dist": {
|
||||||
"type": "zip",
|
"type": "zip",
|
||||||
"url": "https://api.github.com/repos/vlucas/phpdotenv/zipball/a59a13791077fe3d44f90e7133eb68e7d22eaff2",
|
"url": "https://api.github.com/repos/vlucas/phpdotenv/zipball/24ac4c74f91ee2c193fa1aaa5c249cb0822809af",
|
||||||
"reference": "a59a13791077fe3d44f90e7133eb68e7d22eaff2",
|
"reference": "24ac4c74f91ee2c193fa1aaa5c249cb0822809af",
|
||||||
"shasum": ""
|
"shasum": ""
|
||||||
},
|
},
|
||||||
"require": {
|
"require": {
|
||||||
@@ -2274,7 +2423,7 @@
|
|||||||
],
|
],
|
||||||
"support": {
|
"support": {
|
||||||
"issues": "https://github.com/vlucas/phpdotenv/issues",
|
"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": [
|
"funding": [
|
||||||
{
|
{
|
||||||
@@ -2286,7 +2435,7 @@
|
|||||||
"type": "tidelift"
|
"type": "tidelift"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"time": "2024-07-20T21:52:34+00:00"
|
"time": "2025-04-30T23:37:27+00:00"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"packages-dev": [],
|
"packages-dev": [],
|
||||||
@@ -2297,11 +2446,11 @@
|
|||||||
"prefer-lowest": false,
|
"prefer-lowest": false,
|
||||||
"platform": {
|
"platform": {
|
||||||
"php": "^8.3",
|
"php": "^8.3",
|
||||||
"ext-json": "*",
|
|
||||||
"ext-curl": "*",
|
"ext-curl": "*",
|
||||||
"ext-redis": "*",
|
|
||||||
"ext-fileinfo": "*",
|
"ext-fileinfo": "*",
|
||||||
"ext-mbstring": "*"
|
"ext-json": "*",
|
||||||
|
"ext-mbstring": "*",
|
||||||
|
"ext-redis": "*"
|
||||||
},
|
},
|
||||||
"platform-dev": {},
|
"platform-dev": {},
|
||||||
"plugin-api-version": "2.6.0"
|
"plugin-api-version": "2.6.0"
|
||||||
|
|||||||
@@ -1,4 +1,9 @@
|
|||||||
<?php
|
<?php
|
||||||
|
/*
|
||||||
|
* 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
|
||||||
|
*/
|
||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
@@ -6,9 +11,9 @@ return [
|
|||||||
'base_url' => env('APP_URL', 'http://localhost:8080'),
|
'base_url' => env('APP_URL', 'http://localhost:8080'),
|
||||||
'debug' => bool(env('APP_DEBUG', false)),
|
'debug' => bool(env('APP_DEBUG', false)),
|
||||||
'env' => env('APP_ENV', env('IPTV_ENV', 'prod')),
|
'env' => env('APP_ENV', env('IPTV_ENV', 'prod')),
|
||||||
'title' => env('APP_TITLE', 'IPTV Плейлисты'),
|
'title' => 'IPTV Плейлисты',
|
||||||
'user_agent' => env('USER_AGENT'),
|
'timezone' => env('APP_TIMEZONE', 'UTC'),
|
||||||
'page_size' => (int)env('PAGE_SIZE', 10),
|
'page_size' => int(env('PAGE_SIZE', 10)),
|
||||||
'pls_encodings' => [
|
'pls_encodings' => [
|
||||||
'UTF-8',
|
'UTF-8',
|
||||||
'CP1251',
|
'CP1251',
|
||||||
|
|||||||
16
config/cache.php
Normal file
16
config/cache.php
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
<?php
|
||||||
|
/*
|
||||||
|
* 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
|
||||||
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'host' => 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)),
|
||||||
|
];
|
||||||
29
config/http.php
Normal file
29
config/http.php
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
<?php
|
||||||
|
/*
|
||||||
|
* 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
|
||||||
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @see https://docs.guzzlephp.org/en/stable/request-options.html
|
||||||
|
*/
|
||||||
|
return [
|
||||||
|
'base_url' => 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
|
||||||
|
]
|
||||||
|
];
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
return [
|
|
||||||
'host' => 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 недели
|
|
||||||
];
|
|
||||||
@@ -1,10 +1,21 @@
|
|||||||
<?php
|
<?php
|
||||||
|
/*
|
||||||
|
* 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
|
||||||
|
*/
|
||||||
|
|
||||||
use App\Controllers\ApiController;
|
use App\Controllers\ApiController;
|
||||||
use App\Controllers\BasicController;
|
use App\Controllers\BasicController;
|
||||||
use App\Controllers\WebController;
|
use App\Controllers\WebController;
|
||||||
|
|
||||||
return [
|
return [
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Web routes
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
*/
|
||||||
|
|
||||||
[
|
[
|
||||||
'method' => 'GET',
|
'method' => 'GET',
|
||||||
'path' => '/[page/{page:[0-9]+}]',
|
'path' => '/[page/{page:[0-9]+}]',
|
||||||
@@ -19,13 +30,7 @@ return [
|
|||||||
],
|
],
|
||||||
[
|
[
|
||||||
'method' => 'GET',
|
'method' => 'GET',
|
||||||
'path' => '/logo',
|
'path' => '/{code:[0-9a-zA-Z]+}[.m3u[8]]',
|
||||||
'handler' => [WebController::class, 'logo'],
|
|
||||||
'name' => 'logo',
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'method' => 'GET',
|
|
||||||
'path' => '/{code:[0-9a-zA-Z]+}',
|
|
||||||
'handler' => [WebController::class, 'redirect'],
|
'handler' => [WebController::class, 'redirect'],
|
||||||
'name' => 'redirect',
|
'name' => 'redirect',
|
||||||
],
|
],
|
||||||
@@ -35,18 +40,43 @@ return [
|
|||||||
'handler' => [WebController::class, 'details'],
|
'handler' => [WebController::class, 'details'],
|
||||||
'name' => 'details',
|
'name' => 'details',
|
||||||
],
|
],
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| API routes
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
*/
|
||||||
|
|
||||||
[
|
[
|
||||||
'method' => 'GET',
|
'method' => 'GET',
|
||||||
'path' => '/{code:[0-9a-zA-Z]+}/json',
|
'path' => '/{code:[0-9a-zA-Z]+}/json',
|
||||||
'handler' => [ApiController::class, 'json'],
|
'handler' => [ApiController::class, 'json'],
|
||||||
'name' => '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' => '*',
|
'method' => '*',
|
||||||
'path' => '/{path:.*}',
|
'path' => '/{path:.*}',
|
||||||
'handler' => [BasicController::class, 'notFound'],
|
'handler' => [BasicController::class, 'notFound'],
|
||||||
'name' => 'not-found',
|
'name' => 'not-found',
|
||||||
],
|
],
|
||||||
// ...
|
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,13 @@
|
|||||||
<?php
|
<?php
|
||||||
|
/*
|
||||||
|
* 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
|
||||||
|
*/
|
||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'cache' => bool(env('TWIG_USE_CACHE', true)) ? cache_path() . '/views' : false,
|
'cache' => bool(env('TWIG_USE_CACHE', true)) ? cache_path() . '/views' : false,
|
||||||
'debug' => bool(env('TWIG_DEBUG', false)),
|
'debug' => bool(env('APP_DEBUG', false)),
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -1,7 +1,14 @@
|
|||||||
<?php
|
<?php
|
||||||
|
/*
|
||||||
|
* 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
|
||||||
|
*/
|
||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Core\Kernel;
|
||||||
|
|
||||||
require '../vendor/autoload.php';
|
require '../vendor/autoload.php';
|
||||||
|
|
||||||
core()->boot()->run();
|
Kernel::instance()->app()->run();
|
||||||
|
|||||||
@@ -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" %}
|
{% extends "template.twig" %}
|
||||||
|
|
||||||
{% block title %}[{{ id }}] {{ name }} - {{ config('app.title') }}{% endblock %}
|
{% block title %}[{{ playlist.code }}] {{ playlist.name }} - {{ config('app.title') }}{% endblock %}
|
||||||
|
|
||||||
{% block head %}
|
{% block head %}
|
||||||
<style>
|
<style>
|
||||||
@@ -9,125 +15,395 @@
|
|||||||
td.chindex{width:1%}
|
td.chindex{width:1%}
|
||||||
td.chlogo{width:100px}
|
td.chlogo{width:100px}
|
||||||
div.chlist-table{max-height:550px}
|
div.chlist-table{max-height:550px}
|
||||||
|
textarea.m3u-raw{font-size:.7rem}
|
||||||
</style>
|
</style>
|
||||||
|
<script>
|
||||||
|
function setDefaultLogo(imgtag) {
|
||||||
|
imgtag.onerror = null
|
||||||
|
imgtag.src = '{{ base_url('no-tvg-logo.png') }}'
|
||||||
|
}
|
||||||
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block header %}
|
{% block header %}
|
||||||
<h2>О плейлисте: {{ name }}</h2>
|
<h2>О плейлисте: {{ playlist.name }}</h2>
|
||||||
{% if (content.encoding.alert) %}
|
{% if (playlist.channels|length > 500) %}
|
||||||
<div class="alert alert-warning small" role="alert">
|
<div class="alert alert-warning small" role="alert">
|
||||||
Кодировка исходного плейлиста отличается от UTF-8.
|
В плейлисте очень много каналов. На загрузку их списка и логотипов потребуется некоторое время.
|
||||||
Он был автоматически с конвертирован из {{ content.encoding.name }}, чтобы отобразить здесь список каналов.
|
|
||||||
Однако названия каналов могут отображаться некорректно, причём не только здесь, но и в плеере.
|
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if (status.errCode > 0) %}
|
{% if playlist.isOnline is same as(false) %}
|
||||||
<div class="alert alert-danger small" role="alert">
|
<div class="alert alert-danger small" role="alert">
|
||||||
Ошибка плейлиста: [{{ status.errCode }}] {{ status.errText }}
|
Ошибка плейлиста: {{ playlist.content }}
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block footer %}
|
|
||||||
<script src="{{ base_url('js/list.min.js') }}"></script>
|
|
||||||
<script>
|
|
||||||
var list = new List('chlist',{valueNames:['chname','chindex']});
|
|
||||||
</script>
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-lg-7">
|
<div class="col-lg-7">
|
||||||
<table class="table table-dark table-hover small mb-lg-5">
|
<ul class="nav nav-tabs">
|
||||||
<tbody>
|
<li class="nav-item">
|
||||||
<tr>
|
<a class="nav-link active"
|
||||||
<th class="w-25" scope="row">ID</th>
|
type="button"
|
||||||
<td class="text-break">
|
href="#tab-data"
|
||||||
<code>{{ id }}</code> {% if status.possibleStatus == 'online' %}
|
data-bs-toggle="tab"
|
||||||
<span class="badge small text-dark bg-success">online</span>
|
data-bs-target="#tab-data"
|
||||||
{% elseif status.possibleStatus == 'offline' %}
|
>
|
||||||
<span class="badge small text-dark bg-danger">offline</span>
|
<ion-icon name="radio-outline"></ion-icon> Основные данные
|
||||||
{% elseif status.possibleStatus == 'timeout' %}
|
</a>
|
||||||
<span class="badge small text-dark bg-warning">timeout</span>
|
</li>
|
||||||
{% elseif status.possibleStatus == 'error' %}
|
<li class="nav-item">
|
||||||
<span class="badge small text-dark bg-danger">error</span>
|
<a class="nav-link"
|
||||||
{% endif %}
|
type="button"
|
||||||
</td>
|
href="#tab-raw"
|
||||||
</tr>
|
data-bs-toggle="tab"
|
||||||
<tr>
|
data-bs-target="#tab-raw"
|
||||||
<th scope="row">Описание</th>
|
>
|
||||||
<td class="text-break"><p>{{ desc }}</p></td>
|
<ion-icon name="document-text-outline"></ion-icon> Исходный текст
|
||||||
</tr>
|
</a>
|
||||||
<tr>
|
</li>
|
||||||
<th scope="row">Ccылка для ТВ</th>
|
</ul>
|
||||||
<td><b onclick="prompt('Скопируй адрес плейлиста', '{{ url }}')"
|
<div class="tab-content small">
|
||||||
data-bs-toggle="tooltip"
|
<div class="tab-pane fade show active" id="tab-data" tabindex="0">
|
||||||
data-bs-placement="top"
|
<table class="table table-dark table-hover small mb-lg-5">
|
||||||
title="Нажми на ссылку, чтобы скопировать её в буфер обмена"
|
<tbody>
|
||||||
class="font-monospace cursor-pointer text-break">{{ url }}</b></td>
|
<tr>
|
||||||
</tr>
|
<th class="w-25" scope="row">Код</th>
|
||||||
<tr>
|
<th class="text-break">
|
||||||
<th scope="row">M3U</th>
|
{% if playlist.isOnline is same as(true) %}
|
||||||
<td class="text-break">{{ pls }}</td>
|
<span class="font-monospace text-success">{{ playlist.code }}</span>
|
||||||
</tr>
|
<span class="badge small text-dark bg-success">онлайн</span>
|
||||||
<tr>
|
{% elseif playlist.isOnline is same as(false) %}
|
||||||
<th scope="row">Источник</th>
|
<span class="font-monospace text-danger">{{ playlist.code }}</span>
|
||||||
<td class="text-break">{{ src }}</td>
|
<span class="badge small text-dark bg-danger">оффлайн</span>
|
||||||
</tr>
|
{% elseif playlist.isOnline is same as(null) %}
|
||||||
</tbody>
|
<span class="font-monospace">{{ playlist.code }}</span>
|
||||||
</table>
|
<span class="badge small text-dark bg-secondary" title="Не проверялся">unknown</span>
|
||||||
|
{% endif %}
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th scope="row">Описание</th>
|
||||||
|
<td class="text-break"><p class="mb-0">{{ playlist.description }}</p></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th scope="row">Ccылка для ТВ</th>
|
||||||
|
<td><b onclick="prompt('Скопируй адрес плейлиста', 'm3u.su/{{ playlist.code }}')"
|
||||||
|
data-bs-toggle="tooltip"
|
||||||
|
data-bs-placement="top"
|
||||||
|
title="Нажми на ссылку, чтобы скопировать её в буфер обмена"
|
||||||
|
class="font-monospace cursor-pointer text-break">m3u.su/{{ playlist.code }}</b></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th scope="row">Источник</th>
|
||||||
|
<td class="text-break">{{ playlist.source }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th scope="row">Наполнение</th>
|
||||||
|
<td class="text-break">
|
||||||
|
группы: {{ playlist.groups|length }},
|
||||||
|
каналы: {{ playlist.channels|length }}
|
||||||
|
(<span class="text-success">{{ playlist.onlineCount }}</span> + <span class="text-danger">{{ playlist.offlineCount }}</span>)
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th scope="row">Возможности</th>
|
||||||
|
<td class="text-break">
|
||||||
|
<ion-icon name="newspaper-outline"></ion-icon> Программа передач: {{ playlist.hasTvg ? 'есть' : 'нет' }}<br>
|
||||||
|
<ion-icon name="play-back"></ion-icon> Перемотка (архив): {{ playlist.hasCatchup ? 'есть' : 'нет' }}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr class="text-secondary">
|
||||||
|
<th scope="row">M3U</th>
|
||||||
|
<td class="text-break">{{ playlist.url }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr class="text-secondary">
|
||||||
|
<th class="w-25" scope="row">Проверка плейлиста</th>
|
||||||
|
<td class="text-break">
|
||||||
|
<span title="Фактическая метка времени окончания проверки плейлиста">
|
||||||
|
{{ to_date(playlist.checkedAt) }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% if playlist.isOnline is same as(false) %}
|
||||||
|
<tr class="text-secondary">
|
||||||
|
<th class="w-25" scope="row">Ошибка проверки</th>
|
||||||
|
<td class="text-break">{{ playlist.content }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endif %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
{% if (content.attributes) %}
|
{% if (playlist.content.attributes) %}
|
||||||
<h4>Дополнительные атрибуты</h4>
|
<h4>Дополнительные атрибуты</h4>
|
||||||
<table class="table table-dark table-hover small">
|
<table class="table table-dark table-hover small font-monospace">
|
||||||
<tbody>
|
<tbody>
|
||||||
{% for attribute,value in content.attributes %}
|
{% for attribute,value in playlist.attributes %}
|
||||||
<tr>
|
<tr>
|
||||||
<th class="w-25" scope="row">{{ attribute }}</th>
|
<th class="w-25" scope="row">{{ attribute }}</th>
|
||||||
<td class="text-break">{{ value }}</td>
|
<td class="text-break">{{ value }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="tab-pane fade" id="tab-raw" tabindex="1">
|
||||||
|
<button class="btn btn-sm btn-success my-3"
|
||||||
|
id="saveM3UBtn"
|
||||||
|
onclick="savePlaylist('{{ playlist.code }}')"
|
||||||
|
>
|
||||||
|
<ion-icon name="download-outline"></ion-icon> Скачать файл {{ playlist.code }}.m3u8
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-sm btn-outline-light my-3"
|
||||||
|
id="saveM3UBtn"
|
||||||
|
data-bs-toggle="modal"
|
||||||
|
data-bs-target="#qrcode-popup"
|
||||||
|
>
|
||||||
|
<ion-icon name="qr-code-outline"></ion-icon> Скачать по QR-коду
|
||||||
|
</button>
|
||||||
|
<textarea class="form-control bg-dark text-light font-monospace m3u-raw"
|
||||||
|
rows="40"
|
||||||
|
id="m3u-raw"
|
||||||
|
readonly
|
||||||
|
>{{ playlist.content }}</textarea>
|
||||||
|
<div class="modal fade" id="qrcode-popup" tabindex="-1">
|
||||||
|
<div class="modal-dialog ">
|
||||||
|
<div class="modal-content bg-dark">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h1 class="modal-title fs-5">QR-код со ссылкой на плейлист</h1>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body text-center">
|
||||||
|
<img src="{{ base_url(playlist.code ~ '/qrcode') }}" alt="">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="col-lg-5">
|
<div class="col-lg-5">
|
||||||
<h4>Список каналов ({{ content.channelCount ?? 0 }})</h4>
|
<h4>Список каналов: <span id="chcount">{{ playlist.channels|length }}</span></h4>
|
||||||
{% if (content.channelCount > 0) %}
|
{% if (playlist.channels|length > 0) %}
|
||||||
<div id="chlist">
|
{% if (playlist.groups|length > 1) %}
|
||||||
<input type="text"
|
<div class="row my-3">
|
||||||
class="form-control form-control-sm bg-dark text-light mb-2 fuzzy-search"
|
<div class="col-12">
|
||||||
placeholder="Поиск..."
|
<div class="input-group">
|
||||||
/>
|
<select id="groupSelector"
|
||||||
|
class="form-select form-select-sm border-secondary bg-dark text-light"
|
||||||
|
onchange="updateFilter()"
|
||||||
|
>
|
||||||
|
<option selected value="all">Все группы</option>
|
||||||
|
{% for group in playlist.groups %}
|
||||||
|
<option value="{{ group.id }}">{{ group.name }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<button type="button"
|
||||||
|
onclick="resetGroup()"
|
||||||
|
class="btn btn-sm btn-outline-secondary"
|
||||||
|
title="Сбросить группу"
|
||||||
|
>
|
||||||
|
<ion-icon name="close-outline"></ion-icon>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
<div class="row my-3">
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="input-group">
|
||||||
|
<input type="text"
|
||||||
|
id="search-field"
|
||||||
|
class="form-control form-control-sm border-secondary bg-dark text-light fuzzy-search"
|
||||||
|
placeholder="Поиск каналов..."
|
||||||
|
title="Начни вводить название"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<input type="radio"
|
||||||
|
class="btn-check"
|
||||||
|
name="chFilter"
|
||||||
|
id="chfAll"
|
||||||
|
autocomplete="off"
|
||||||
|
onclick="updateFilter()"
|
||||||
|
checked
|
||||||
|
>
|
||||||
|
<label class="btn btn-sm btn-outline-secondary"
|
||||||
|
for="chfAll"
|
||||||
|
title="Выбрать все каналы"
|
||||||
|
>
|
||||||
|
<ion-icon name="radio-button-on-outline"></ion-icon>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<input type="radio"
|
||||||
|
class="btn-check"
|
||||||
|
name="chFilter"
|
||||||
|
id="chfOnline"
|
||||||
|
autocomplete="off"
|
||||||
|
onclick="updateFilter()"
|
||||||
|
>
|
||||||
|
<label class="btn btn-sm btn-outline-success"
|
||||||
|
for="chfOnline"
|
||||||
|
title="Выбрать только онлайн каналы"
|
||||||
|
>
|
||||||
|
<ion-icon name="radio-button-on-outline"></ion-icon>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<input type="radio"
|
||||||
|
class="btn-check"
|
||||||
|
name="chFilter"
|
||||||
|
id="chfOffline"
|
||||||
|
autocomplete="off"
|
||||||
|
onclick="updateFilter()"
|
||||||
|
>
|
||||||
|
<label class="btn btn-sm btn-outline-danger"
|
||||||
|
for="chfOffline"
|
||||||
|
title="Выбрать только оффлайн каналы"
|
||||||
|
>
|
||||||
|
<ion-icon name="radio-button-on-outline"></ion-icon>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<button type="button"
|
||||||
|
class="btn btn-sm btn-outline-secondary"
|
||||||
|
onclick="resetSearch()"
|
||||||
|
title="Сбросить фильтрацию"
|
||||||
|
>
|
||||||
|
<ion-icon name="close-outline"></ion-icon>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="chlist-table overflow-auto">
|
<div class="chlist-table overflow-auto">
|
||||||
<table class="table table-dark table-hover small">
|
<table id="chlist" class="table table-dark table-hover small">
|
||||||
<tbody class="list">
|
<tbody class="list">
|
||||||
{% for channel in content.channels %}
|
{% for channel in playlist.channels %}
|
||||||
<tr class="chrow">
|
<tr class="chrow"
|
||||||
|
data-id="{{ channel.id }}"
|
||||||
|
data-group="{{ channel.groupId ?? 'all' }}"
|
||||||
|
data-online="{{ channel.isOnline ? 1 : 0 }}"
|
||||||
|
title="
HTTP: {{ channel.status ?: '(неизвестно)' }}
Error: {{ channel.error ?: '(нет)' }}"
|
||||||
|
>
|
||||||
<td class="chindex">{{ loop.index }}</td>
|
<td class="chindex">{{ loop.index }}</td>
|
||||||
|
|
||||||
<td class="chlogo text-center">
|
<td class="chlogo text-center">
|
||||||
<img class="tvg-logo"
|
{% if (channel.attributes['tvg-logo']) %}
|
||||||
{% if (channel.logo.base64) %}
|
<img class="tvg-logo"
|
||||||
src="{{ channel.logo.base64 }}"
|
alt="Логотип канала '{{ channel.title }}'"
|
||||||
{% elseif (channel.attributes['tvg-logo']) %}
|
title="Логотип канала '{{ channel.title }}'"
|
||||||
src="{{ base_url('logo?url=' ~ channel.attributes['tvg-logo']) }}"
|
src="{{ channel.attributes['tvg-logo'] }}"
|
||||||
loading="lazy"
|
onerror="setDefaultLogo(this)"
|
||||||
|
/>
|
||||||
{% else %}
|
{% else %}
|
||||||
src="{{ base_url('no-tvg-logo.png') }}"
|
<img class="tvg-logo"
|
||||||
|
alt="Нет логотипа для канала '{{ channel.title }}'"
|
||||||
|
title="Нет логотипа для канала '{{ channel.title }}'"
|
||||||
|
src="{{ base_url('no-tvg-logo.png') }}"
|
||||||
|
/>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
alt="Логотип канала '{{ channel.name }}'"
|
|
||||||
title="Логотип канала '{{ channel.name }}'"
|
|
||||||
/>
|
|
||||||
</td>
|
</td>
|
||||||
<td class="chname text-break">{{ channel.name }}</td>
|
|
||||||
|
<td class="text-break">
|
||||||
|
<ion-icon name="radio-button-on-outline"
|
||||||
|
{% if (channel.isOnline) %}
|
||||||
|
class="me-1 text-success"
|
||||||
|
title="Состояние: онлайн"
|
||||||
|
{% else %}
|
||||||
|
class="me-1 text-danger"
|
||||||
|
title="Состояние: оффлайн"
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
></ion-icon> <span class="chname">{{ channel.title }}</span>
|
||||||
|
<div class="text-secondary small">
|
||||||
|
{% if (channel.attributes['tvg-id']) %}
|
||||||
|
<div title="tvg-id">
|
||||||
|
<ion-icon name="star-outline" class="me-1"></ion-icon> {{ channel.attributes['tvg-id'] }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if (channel.contentType != null) %}
|
||||||
|
<div title="MIME type">
|
||||||
|
<ion-icon name="eye-outline" class="me-1"></ion-icon> {{ channel.contentType }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if channel.tags|length > 0 %}
|
||||||
|
<ion-icon name="pricetag-outline" class="me-1"></ion-icon>
|
||||||
|
{% for tag in channel.tags %}
|
||||||
|
<span class="chtag">#{{ tag }}</span>
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block footer %}
|
||||||
|
<script src="{{ base_url('js/list.min.js') }}"></script>
|
||||||
|
<script>
|
||||||
|
const options = {
|
||||||
|
valueNames: [
|
||||||
|
'chname',
|
||||||
|
'chtag',
|
||||||
|
{data: ['online', 'group']}
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const list = new List('chlist', options)
|
||||||
|
list.on('updated', (data) => document.getElementById('chcount').innerText = data.visibleItems.length)
|
||||||
|
document.getElementById('search-field').addEventListener('keyup', (e) => list.search(e.target.value))
|
||||||
|
|
||||||
|
function savePlaylist() {
|
||||||
|
const link = document.createElement("a");
|
||||||
|
const content = document.getElementById("m3u-raw").value
|
||||||
|
const file = new Blob([content], { type: 'text/plain' });
|
||||||
|
link.href = URL.createObjectURL(file);
|
||||||
|
link.download = "{{ playlist.code }}.m3u8";
|
||||||
|
link.click();
|
||||||
|
URL.revokeObjectURL(link.href);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetGroup() {
|
||||||
|
document.getElementById('groupSelector').value = 'all'
|
||||||
|
updateFilter()
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetSearch() {
|
||||||
|
list.search('')
|
||||||
|
document.getElementById('search-field').value = ''
|
||||||
|
const elementById = document.getElementById('chfAll');
|
||||||
|
elementById.checked = true
|
||||||
|
updateFilter()
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateFilter() {
|
||||||
|
const groupHash = document.getElementById('groupSelector')?.value ?? 'all';
|
||||||
|
const activeType = document.querySelector('input[name="chFilter"]:checked').id;
|
||||||
|
switch (activeType) {
|
||||||
|
case 'chfAll':
|
||||||
|
list.filter(item => item.values().group === groupHash || groupHash === 'all')
|
||||||
|
break
|
||||||
|
case 'chfOnline':
|
||||||
|
list.filter(
|
||||||
|
item => (item.values().group === groupHash || groupHash === 'all')
|
||||||
|
&& item.values().online === '1'
|
||||||
|
)
|
||||||
|
break
|
||||||
|
case 'chfOffline':
|
||||||
|
list.filter(
|
||||||
|
item => (item.values().group === groupHash || groupHash === 'all')
|
||||||
|
&& item.values().online === '0'
|
||||||
|
)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
|
|||||||
519
views/faq.twig
519
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" %}
|
{% extends "template.twig" %}
|
||||||
|
|
||||||
|
{% block head %}
|
||||||
|
<script async type="module" src="https://unpkg.com/ionicons@7.1.0/dist/ionicons/ionicons.esm.js"></script>
|
||||||
|
<script async nomodule src="https://unpkg.com/ionicons@7.1.0/dist/ionicons/ionicons.js"></script>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
{% block header %}
|
{% block header %}
|
||||||
<h2>FAQ</h2>
|
<h2>FAQ</h2>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
@@ -7,19 +18,20 @@
|
|||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-md-12">
|
<div class="col-md-12">
|
||||||
<p>
|
<p class="mb-5">
|
||||||
В этом сервисе собраны ссылки на IPTV-плейлисты, которые находятся в открытом доступе.
|
В этом сервисе собраны ссылки на IPTV-плейлисты, которые находятся в открытом доступе.
|
||||||
Они отбираются вручную и постоянно проверяются здесь автоматически.
|
Они отбираются вручную и периодически проверяются здесь автоматически.
|
||||||
</p>
|
</p>
|
||||||
<p>
|
|
||||||
|
<p class="mb-5">
|
||||||
Сервис "{{ config('app.title') }}" ({{ base_url() }}) не предназначен для хранения или трансляции
|
Сервис "{{ config('app.title') }}" ({{ base_url() }}) не предназначен для хранения или трансляции
|
||||||
видео/аудио потоков, программ телепередач, плейлистов и их поддержки. Этим занимаются администраторы
|
видео/аудио потоков, программ телепередач, плейлистов и их поддержки. Этим занимаются администраторы
|
||||||
ресурсов, указанные как источник, и те, с чьих ресурсов ведётся трансляция.
|
ресурсов, указанные как источник, и те, с чьих ресурсов ведётся трансляция.
|
||||||
</p>
|
</p>
|
||||||
<p>
|
|
||||||
За содержимое плейлистов и их качество отвечают авторы плейлистов. На стороне сервиса управляются сами
|
<p class="mb-5">
|
||||||
плейлисты.
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p class="mb-5">
|
<p class="mb-5">
|
||||||
Сервис "{{ config('app.title') }}" ({{ base_url() }}) предоставляет только информацию об активности
|
Сервис "{{ config('app.title') }}" ({{ base_url() }}) предоставляет только информацию об активности
|
||||||
плейлистов, найденных в открытом доступе, и короткие ссылки на них для удобства использования в ПО.
|
плейлистов, найденных в открытом доступе, и короткие ссылки на них для удобства использования в ПО.
|
||||||
@@ -27,270 +39,269 @@
|
|||||||
тем, кто несёт за них ответственность (см. источники плейлистов).
|
тем, кто несёт за них ответственность (см. источники плейлистов).
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div class="accordion" id="faq-accordion">
|
<p class="mb-5">
|
||||||
<div class="accordion-item bg-dark text-light">
|
Автор не занимается созданием, изменением, размещением и хранением плейлистов на сайте
|
||||||
<h2 class="accordion-header" id="h-purpose">
|
"{{ config('app.title') }}" ({{ base_url() }}). Ни бесплатно, ни за деньги, ни бартером, ни за спасибо.
|
||||||
<button class="accordion-button text-light bg-dark" type="button" data-bs-toggle="collapse" data-bs-target="#purpose" aria-expanded="false" aria-controls="purpose">
|
<b>Все плейлисты, которые отображаются на сайте "{{ config('app.title') }}" ({{ base_url() }}), созданы
|
||||||
Для чего нужен сервис?
|
и размещены третьими лицами на чужих серверах.</b>
|
||||||
</button>
|
</p>
|
||||||
</h2>
|
|
||||||
<div id="purpose" class="accordion-collapse collapse" aria-labelledby="h-purpose" data-bs-parent="#faq-accordion">
|
|
||||||
<div class="accordion-body">
|
|
||||||
<p>Изначально сервис создавался "для себя", чтобы:</p>
|
|
||||||
<ul>
|
|
||||||
<li>сократить ссылки на сторонние плейлисты и их было проще вводить с пульта;</li>
|
|
||||||
<li>собрать в одном месте наиболее годные плейлисты.</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
<p class="mb-5">
|
||||||
|
Проект "{{ config('app.title') }}" ({{ base_url() }}) является бесплатным проектом с открытым исходным
|
||||||
|
кодом, он публичен и открыт для всех. Весь его исходный код размещён в публичных репозиториях под
|
||||||
|
лицензией MIT.
|
||||||
|
</p>
|
||||||
|
|
||||||
<div class="accordion-item bg-dark text-light">
|
<p class="mb-5">
|
||||||
<h2 class="accordion-header" id="h-howtouse">
|
Автор не взимает плату за размещение ссылок на сторонние плейлисты на сайте "{{ config('app.title') }}"
|
||||||
<button class="accordion-button text-light bg-dark" type="button" data-bs-toggle="collapse" data-bs-target="#howtouse" aria-expanded="false" aria-controls="howtouse">
|
({{ base_url() }}). За содержимое плейлистов и их качество отвечают авторы плейлистов.
|
||||||
Как пользоваться сервисом?
|
</p>
|
||||||
</button>
|
|
||||||
</h2>
|
|
||||||
<div id="howtouse" class="accordion-collapse collapse" aria-labelledby="h-howtouse" data-bs-parent="#faq-accordion">
|
|
||||||
<p class="accordion-body">
|
|
||||||
На главной странице отображается список доступных в плейлистов, их идентификаторы, статусы,
|
|
||||||
количество каналов и короткие ссылки.
|
|
||||||
Для просмотра списка каналов следует нажать на ссылку <b>"Подробнее..."</b> под интересующим плейлистом.
|
|
||||||
Для добавления плейлиста в свой медиаплеер удобно использовать <b>"Ссылку для ТВ"</b>.
|
|
||||||
Это делается для удобства ввода, например, на телевизоре с пульта.
|
|
||||||
На странице детальной информации также есть прямая ссылка на сам плейлист от источника.
|
|
||||||
Можно использовать и её.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="accordion-item bg-dark text-light">
|
<p class="mb-5">
|
||||||
<h2 class="accordion-header bg-dark" id="h-howtoconnect">
|
Автор не зарабатывает на проекте "{{ config('app.title') }}" ({{ base_url() }}) и не собирается.
|
||||||
<button class="accordion-button text-light bg-dark" type="button" data-bs-toggle="collapse" data-bs-target="#howtoconnect" aria-expanded="false" aria-controls="howtoconnect">
|
Всё, что ты видишь по этому адресу, сделано бесплатно и на энтузиазме.
|
||||||
Как подключить плейлист?
|
Но ты можешь сделать добровольное пожертвование, которое поможет мне компенсировать затраты на
|
||||||
</button>
|
поддержку и техническое развитие проекта. Ссылки в шапке сайта.
|
||||||
</h2>
|
</p>
|
||||||
<div id="howtoconnect" class="accordion-collapse collapse" aria-labelledby="h-howtoconnect" data-bs-parent="#faq-accordion">
|
|
||||||
<p class="accordion-body">
|
|
||||||
<a href="https://www.google.com/search?q=%D0%BA%D0%B0%D0%BA%20%D0%BF%D0%BE%D0%B4%D0%BA%D0%BB%D1%8E%D1%87%D0%B8%D1%82%D1%8C%20iptv%20%D0%BF%D0%BB%D0%B5%D0%B9%D0%BB%D0%B8%D1%81%D1%82%20%D0%BF%D0%BE%20%D1%81%D1%81%D1%8B%D0%BB%D0%BA%D0%B5">
|
|
||||||
Добавь в свой медиаплеер</a> "Ссылку для ТВ".
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="accordion-item bg-dark text-light">
|
<!-- Для чего нужен сервис? -->
|
||||||
<h2 class="accordion-header bg-dark" id="h-isitfree">
|
<div class="alert my-5 bg-dark text-light border-secondary" id="why">
|
||||||
<button class="accordion-button text-light bg-dark" type="button" data-bs-toggle="collapse" data-bs-target="#isitfree" aria-expanded="false" aria-controls="isitfree">
|
<h2>Для чего нужен сервис?</h2>
|
||||||
Эти плейлисты и каналы в них -- бесплатны?
|
<p>Изначально сервис создавался "для себя", чтобы:</p>
|
||||||
</button>
|
<ul>
|
||||||
</h2>
|
<li>сократить ссылки на сторонние плейлисты и их было проще вводить с пульта;</li>
|
||||||
<div id="isitfree" class="accordion-collapse collapse" aria-labelledby="h-isitfree" data-bs-parent="#faq-accordion">
|
<li>собрать в одном месте наиболее годные плейлисты.</li>
|
||||||
<p class="accordion-body">
|
</ul>
|
||||||
Возможно. По крайней мере, так утверждают источники. Но гарантий никаких никто не даёт.
|
<p>
|
||||||
</p>
|
Сейчас я сам им не пользуюсь, но им пользуются сотни людей ежедневно, чтобы найти
|
||||||
</div>
|
плейлист себе по душе или по необходимости. Например, чтобы смотреть заблокированные российские
|
||||||
</div>
|
телеканалы в свободной демократической европе.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="accordion-item bg-dark text-light">
|
<!-- Как пользоваться сервисом? -->
|
||||||
<h2 class="accordion-header" id="h-logos">
|
<div class="alert my-5 bg-dark text-light border-secondary" id="how">
|
||||||
<button class="accordion-button text-light bg-dark" type="button" data-bs-toggle="collapse" data-bs-target="#logos" aria-expanded="false" aria-controls="logos">
|
<h2>Как пользоваться сервисом?</h2>
|
||||||
Откуда берутся логотипы каналов и программы передач?
|
<p>
|
||||||
</button>
|
На главной странице отображается список доступных в плейлистов, их идентификаторы, статусы,
|
||||||
</h2>
|
количество каналов и короткие ссылки.
|
||||||
<div id="logos" class="accordion-collapse collapse" aria-labelledby="h-logos" data-bs-parent="#faq-accordion">
|
Для просмотра списка каналов следует нажать на ссылку <b>"Подробнее..."</b> под интересующим
|
||||||
<p class="accordion-body">
|
плейлистом.
|
||||||
Всё это (не) указывается внутри плейлиста его авторами.
|
Для добавления плейлиста в свой медиаплеер удобно использовать <b>"Ссылку для ТВ"</b>.
|
||||||
Но в некоторых плеерах можно вручную указывать программу передач (см. ниже).
|
На странице детальной информации также есть прямая ссылка на сам плейлист от источника.
|
||||||
</p>
|
Можно использовать и её.
|
||||||
</div>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="accordion-item bg-dark text-light">
|
<!-- Какие плейлисты попадают сюда? -->
|
||||||
<h2 class="accordion-header bg-dark" id="h-which">
|
<div class="alert my-5 bg-dark text-light border-secondary" id="which">
|
||||||
<button class="accordion-button text-light bg-dark" type="button" data-bs-toggle="collapse" data-bs-target="#which" aria-expanded="false" aria-controls="which">
|
<h2>Какие плейлисты попадают сюда?</h2>
|
||||||
Какие плейлисты попадают сюда?
|
<p>Есть некоторые критерии, по которым плейлисты отбираются в этот список:</p>
|
||||||
</button>
|
<ul>
|
||||||
</h2>
|
<li>Прежде всего -- каналы РФ и бывшего СНГ, но не только</li>
|
||||||
<div id="which" class="accordion-collapse collapse" aria-labelledby="h-which" data-bs-parent="#faq-accordion">
|
<li>Открытый источник</li>
|
||||||
<div class="accordion-body">
|
<li>Прямая ссылка на плейлист</li>
|
||||||
<p>Есть некоторые критерии, по которым плейлисты отбираются в этот список:</p>
|
<li>Автообновление плейлиста</li>
|
||||||
<ul>
|
</ul>
|
||||||
<li>Прежде всего -- каналы РФ и бывшего СНГ, но не только</li>
|
<p>
|
||||||
<li>Открытый источник</li>
|
В основном, в плейлистах именно трансляции телеканалов, но могут быть просто список каких-то
|
||||||
<li>Прямая ссылка на плейлист</li>
|
(мульт)фильмов и передач, находящихся на чужих дисках (как если бы вы сами составили плейлист с музыкой,
|
||||||
<li>Автообновление плейлиста</li>
|
например).
|
||||||
</ul>
|
</p>
|
||||||
<p>
|
</div>
|
||||||
В основном, в плейлистах именно трансляции телеканалов, но могут быть просто список каких-то
|
|
||||||
(мульт)фильмов и передач, находящихся на чужих дисках (как если бы вы сами составили плейлист с музыкой,
|
|
||||||
например).
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="accordion-item bg-dark text-light">
|
<!-- Что означают статусы? -->
|
||||||
<h2 class="accordion-header bg-dark" id="h-statuses">
|
<div class="alert my-5 bg-dark text-light border-secondary" id="statuses">
|
||||||
<button class="accordion-button text-light bg-dark" type="button" data-bs-toggle="collapse" data-bs-target="#statuses" aria-expanded="false" aria-controls="statuses">
|
<h2>Что означают статусы?</h2>
|
||||||
Что означают статусы плейлистов?
|
<p>Плейлист может быть в одном из трёх статусов:</p>
|
||||||
</button>
|
<ul>
|
||||||
</h2>
|
<li>
|
||||||
<div id="statuses" class="accordion-collapse collapse" aria-labelledby="h-statuses" data-bs-parent="#faq-accordion">
|
<span class="badge small text-dark bg-secondary">unknown</span>
|
||||||
<div class="accordion-body">
|
Плейлист ещё не проверялся, можно зайти позже.
|
||||||
<ul>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<span class="badge small text-dark bg-secondary">loading</span>
|
<span class="badge small text-dark bg-success">online</span>
|
||||||
Загрузка данных, нужно немного подождать.
|
Плейлист активен. Это не значит, что он работает. В нём может быть 0 каналов.
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<span class="badge small text-dark bg-success">online</span>
|
<span class="badge small text-dark bg-danger">offline</span>
|
||||||
Плейлист, возможно, активен. <i>Если каналов 0, значит, вероятно, источник поставил
|
Плейлист недоступен, вообще никак. Главный кандидат на удаление с сайта.
|
||||||
редирект с плейлиста на куда ему вздумалось. То есть плейлист, наверное, отсутствует
|
</li>
|
||||||
и, возможно, больше никогда не появится по текущему адресу.</i>
|
</ul>
|
||||||
</li>
|
<p>Каждый канал в плейлисте может быть в одном из трёх статусов:</p>
|
||||||
<li>
|
<ul>
|
||||||
<span class="badge small text-dark bg-warning">timeout</span>
|
<li>
|
||||||
Не удалось вовремя проверить плейлист, сервер с плейлистом слишком долго запрягает.
|
<span class="text-success"><ion-icon name="radio-button-on-outline"></ion-icon></span>
|
||||||
</li>
|
Канал активен. Это не значит, что он работает. Там может транслироваться какая-нибудь заглушка (например, от Wink).
|
||||||
<li>
|
</li>
|
||||||
<span class="badge small text-dark bg-danger">offline</span>
|
<li>
|
||||||
Плейлист недоступен, вообще.
|
<span class="text-danger"><ion-icon name="radio-button-on-outline"></ion-icon></span>
|
||||||
</li>
|
Канал не работает.
|
||||||
<li>
|
</li>
|
||||||
<span class="badge small text-dark bg-danger">error</span>
|
</ul>
|
||||||
Ошибка при проверке плейлиста. Пора удалять плейлист отсюда.
|
<p>
|
||||||
</li>
|
Я не гарантирую корректность и актуальность информации, которую ты увидишь здесь.
|
||||||
</ul>
|
Хотя я и стараюсь улучшать качество проверок, но всё же рекомендую проверять желаемые
|
||||||
</div>
|
плейлисты самостоятельно вручную, ибо нет никаких гарантий:
|
||||||
</div>
|
</p>
|
||||||
</div>
|
<ul>
|
||||||
|
<li>что плейлисты по разным ссылкам не дублируют друг друга и отличаются каналами хотя бы на четверть;</li>
|
||||||
|
<li>что плейлист работоспособен (каналы работают, корректно названы, имеют аудио, etc.);</li>
|
||||||
|
<li>что подгрузится корректное количество каналов и их список.</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="accordion-item bg-dark text-light">
|
<!-- Как часто обновляется список плейлистов? -->
|
||||||
<h2 class="accordion-header bg-dark" id="h-donttrust">
|
<div class="alert my-5 bg-dark text-light border-secondary" id="how-often-list">
|
||||||
<button class="accordion-button text-light bg-dark" type="button" data-bs-toggle="collapse" data-bs-target="#donttrust" aria-expanded="false" aria-controls="donttrust">
|
<h2>Как часто обновляется список плейлистов?</h2>
|
||||||
Почему нельзя доверять результатам проверки?
|
<p>
|
||||||
</button>
|
Время от времени.
|
||||||
</h2>
|
Иногда я захожу сюда и проверяю всё ли на месте, иногда занимаюсь какими-то доработками.
|
||||||
<div id="donttrust" class="accordion-collapse collapse" aria-labelledby="h-donttrust" data-bs-parent="#faq-accordion">
|
Если есть кандидаты на добавление, то читай ниже.
|
||||||
<div class="accordion-body">
|
</p>
|
||||||
<p>
|
</div>
|
||||||
Я не гарантирую корректность и актуальность информации, которую ты увидишь здесь.
|
|
||||||
Хотя я и стараюсь улучшать качество проверок, но всё же рекомендую проверять желаемые
|
|
||||||
плейлисты самостоятельно вручную, ибо нет никаких гарантий:
|
|
||||||
</p>
|
|
||||||
<ul>
|
|
||||||
<li>что это вообще плейлисты, а не чьи-то архивы с мокрыми кисками;</li>
|
|
||||||
<li>что плейлисты по разным ссылкам не дублируют друг друга и отличаются каналами хотя бы на четверть;</li>
|
|
||||||
<li>что плейлист работоспособен (каналы работают, корректно названы, имеют аудио, etc.);</li>
|
|
||||||
<li>что подгрузится корректное количество каналов и их список (хотя на это я ещё могу влиять и стараюсь как-то улучшить).</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="accordion-item bg-dark text-light">
|
<!-- Как часто обновляется содержимое плейлистов? -->
|
||||||
<h2 class="accordion-header bg-dark" id="h-guarantee">
|
<div class="alert my-5 bg-dark text-light border-secondary" id="how-often-chan">
|
||||||
<button class="accordion-button text-light bg-dark" type="button" data-bs-toggle="collapse" data-bs-target="#guarantee" aria-expanded="false" aria-controls="guarantee">
|
<h2>Как часто обновляется содержимое плейлистов?</h2>
|
||||||
Какова гарантия, что я добавлю себе плейлист отсюда и он работать хоть сколько-нибудь долго?
|
<p>Зависит от источника. Я этим не занимаюсь.</p>
|
||||||
</button>
|
</div>
|
||||||
</h2>
|
|
||||||
<div id="guarantee" class="accordion-collapse collapse" aria-labelledby="h-guarantee" data-bs-parent="#faq-accordion">
|
|
||||||
<p class="accordion-body">
|
|
||||||
Никакова.
|
|
||||||
Мёртвые плейлисты я периодически вычищаю, реже -- добавляю новые.
|
|
||||||
ID плейлистов могут меняться, поэтому вполне может произойти внезапная подмена одного другим, однако
|
|
||||||
намеренно я так не делаю.
|
|
||||||
Если один плейлист переезжает на новый адрес, то я ставлю временное перенаправление со старого ID на
|
|
||||||
новый.
|
|
||||||
Плюс читай выше про доверие результатам проверки (проблема может быть не стороне сервиса).
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="accordion-item bg-dark text-light">
|
<!-- Есть приложение? -->
|
||||||
<h2 class="accordion-header" id="h-panic">
|
<div class="alert my-5 bg-dark text-light border-secondary" id="app">
|
||||||
<button class="accordion-button text-warning bg-dark" type="button" data-bs-toggle="collapse" data-bs-target="#panic" aria-expanded="false" aria-controls="panic">
|
<h2>Есть приложение?</h2>
|
||||||
У меня перестал работать/исчез любимый канал/плейлист! Нет лого канала/программы передач!
|
<p>Нет, и не планируется. Ищи плеер и добавляй плейлист туда по ссылке.</p>
|
||||||
</button>
|
</div>
|
||||||
</h2>
|
|
||||||
<div id="panic" class="accordion-collapse collapse" aria-labelledby="h-panic" data-bs-parent="#faq-accordion">
|
|
||||||
<p class="accordion-body">Ну штош ¯\_(ツ)_/¯</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="accordion-item bg-dark text-light">
|
<!-- Эти плейлисты и каналы в них -- бесплатны? -->
|
||||||
<h2 class="accordion-header bg-dark" id="h-epg">
|
<div class="alert my-5 bg-dark text-light border-secondary" id="is-pls-free">
|
||||||
<button class="accordion-button text-light bg-dark" type="button" data-bs-toggle="collapse" data-bs-target="#epg" aria-expanded="false" aria-controls="epg">
|
<h2>Эти плейлисты и каналы в них -- бесплатны?</h2>
|
||||||
Где взять программу передач (EPG)?
|
<p>
|
||||||
</button>
|
Возможно. По крайней мере, так утверждают источники, которые их распространяют.
|
||||||
</h2>
|
Но гарантий никаких никто не даёт. Любой плейлист и любой канал в любом плейлисте может сдохнуть
|
||||||
<div id="epg" class="accordion-collapse collapse" aria-labelledby="h-epg" data-bs-parent="#faq-accordion">
|
навсегда в любой момент. Или показывать заглушку.
|
||||||
<div class="accordion-body">
|
</p>
|
||||||
<ul>
|
</div>
|
||||||
<li><b>https://iptvx.one/viewtopic.php?f=12&t=4</b></li>
|
|
||||||
<li>https://iptvmaster.ru/epg-for-iptv</li>
|
|
||||||
<li>https://google.com</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="accordion-item bg-dark text-light">
|
<!-- Заглушка 1 -->
|
||||||
<h2 class="accordion-header bg-dark" id="h-howoftenlist">
|
<div class="alert my-5 bg-dark text-light border-secondary" id="paywall1">
|
||||||
<button class="accordion-button text-light bg-dark" type="button" data-bs-toggle="collapse" data-bs-target="#howoftenlist" aria-expanded="false" aria-controls="howoftenlist">
|
<h2 class="text-warning">
|
||||||
Как часто обновляется список плейлистов?
|
На канале отображается заглушка:<br /><br />
|
||||||
</button>
|
<span class="fst-italic">"Уважаемый клиент! Для возобновления просмотра Вам необходимо использовать не более 2 устройств"</span><br /><br />
|
||||||
</h2>
|
или<br /><br />
|
||||||
<div id="howoftenlist" class="accordion-collapse collapse" aria-labelledby="h-howoftenlist" data-bs-parent="#faq-accordion">
|
<span class="fst-italic">"Ваша подписка не активна"</span>
|
||||||
<p class="accordion-body">
|
</h2>
|
||||||
Время от времени.
|
<p>Кто-то воткнул платный канал в плейлист и распространил его как бесплатный.</p>
|
||||||
Иногда я захожу сюда и проверяю всё ли на месте, иногда занимаюсь какими-то доработками.
|
<p>Забудь про этот плейлист. Ищи другой. Без вариантов. Такова цена халявы.</p>
|
||||||
Если есть кандидаты на добавление, то читай ниже.
|
<p>Нет, я не буду это исправлять.</p>
|
||||||
</p>
|
</div>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="accordion-item bg-dark text-light">
|
<!-- Заглушка 2 -->
|
||||||
<h2 class="accordion-header bg-dark" id="h-howoftench">
|
<div class="alert my-5 bg-dark text-light border-secondary" id="paywall2">
|
||||||
<button class="accordion-button text-light bg-dark" type="button" data-bs-toggle="collapse" data-bs-target="#howoftench" aria-expanded="false" aria-controls="howoftench">
|
<h2 class="text-warning">
|
||||||
Как часто обновляется содержимое плейлистов?
|
На канале отображается заглушка:<br /><br />
|
||||||
</button>
|
<span class="fst-italic">"Просмотр ТВ-каналов, фильмов и сериалов доступен только в официальных приложения Wink и на территории России"</span>
|
||||||
</h2>
|
</h2>
|
||||||
<div id="howoftench" class="accordion-collapse collapse" aria-labelledby="h-howoftench" data-bs-parent="#faq-accordion">
|
<p>Кто-то воткнул платный канал в плейлист и распространил его как бесплатный.</p>
|
||||||
<p class="accordion-body">
|
<p>Попробуй использовать плеер, который позволяет указать User-Agent, и указать User-Agent:</p>
|
||||||
Зависит от источника. Я этим не занимаюсь.
|
<pre class="fw-bold">Mozilla/5.0 WINK/1.31.1 (AndroidTV/9) HlsWinkPlayer</pre>
|
||||||
</p>
|
<p>Или подключи Wink. Или забудь про этот плейлист и ищи другой.</p>
|
||||||
</div>
|
<p>Нет, я не буду это исправлять.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="accordion-item bg-dark text-light">
|
<!-- Добавь канал! -->
|
||||||
<h2 class="accordion-header bg-dark" id="h-api">
|
<div class="alert my-5 bg-dark text-light border-secondary" id="add-chan">
|
||||||
<button class="accordion-button text-light bg-dark" type="button" data-bs-toggle="collapse" data-bs-target="#api" aria-expanded="false" aria-controls="api">
|
<h2 class="text-danger">Добавь канал!</h2>
|
||||||
Есть ли API? Как им пользоваться?
|
<p class="h1 my-5">Нет.</p>
|
||||||
</button>
|
</div>
|
||||||
</h2>
|
|
||||||
<div id="api" class="accordion-collapse collapse" aria-labelledby="h-api" data-bs-parent="#faq-accordion">
|
|
||||||
<p class="accordion-body">
|
|
||||||
Есть, подробности <a href="https://github.com/anthonyaxenov/iptv2#api">здесь</a>.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="accordion-item bg-dark text-light">
|
<!-- Добавь плейлист! -->
|
||||||
<h2 class="accordion-header bg-dark" id="h-howtoadd">
|
<div class="alert my-5 bg-dark text-light border-secondary" id="create-list">
|
||||||
<button class="accordion-button text-light bg-dark" type="button" data-bs-toggle="collapse" data-bs-target="#howtoadd" aria-expanded="false" aria-controls="howtoadd">
|
<h2 class="text-danger">Сделай плейлист!</h2>
|
||||||
Как пополнить этот список?
|
<p class="h1 my-5">Нет.</p>
|
||||||
</button>
|
</div>
|
||||||
</h2>
|
|
||||||
<div id="howtoadd" class="accordion-collapse collapse" aria-labelledby="h-howtoadd" data-bs-parent="#faq-accordion">
|
|
||||||
<p class="accordion-body">
|
|
||||||
Сделать pull-request в <a href="https://github.com/anthonyaxenov/iptv">репозиторий</a>.
|
|
||||||
Я проверю плейлист и добавлю его в общий список, если всё ок.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
<!-- Откуда берутся логотипы каналов и программы передач? -->
|
||||||
|
<div class="alert my-5 bg-dark text-light border-secondary" id="logos">
|
||||||
|
<h2>Откуда берутся логотипы каналов и программы передач?</h2>
|
||||||
|
<p>
|
||||||
|
Всё это (не) указывается внутри плейлиста его авторами.
|
||||||
|
Но в некоторых плеерах можно вручную указывать программу передач (см. ниже).
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Нет лого канала! -->
|
||||||
|
<div class="alert my-5 bg-dark text-light border-secondary" id="channel-no-logo">
|
||||||
|
<h2>Нет лого канала!</h2>
|
||||||
|
<p>Грустно ¯\_(ツ)_/¯</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Где спортивные каналы? Почему они не работают? -->
|
||||||
|
<div class="alert my-5 bg-dark text-light border-secondary" id="sport">
|
||||||
|
<h2 class="text-primary">Где спортивные каналы? Почему они не работают?</h2>
|
||||||
|
<p>
|
||||||
|
Спортивные телеканалы очень пристально следят за тем, куда текут их трансляции. Они зарабатывают
|
||||||
|
на спорте и активно защищают свои права на трансляцию каких-то уникальных спортивных состязаний и
|
||||||
|
событий. Они активно рубят все левые источники, приходят к авторам плейлистов и любезно
|
||||||
|
просят удалить любые упоминания, ссылки и трансляции их каналов из паблика. Поэтому некоторые
|
||||||
|
авторы сразу предупреждают, что в плейлистах таких каналов нет. Судиться потом, вот это всё...
|
||||||
|
нафиг надо.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Нет, я не буду добавлять каналы в плейлисты.
|
||||||
|
Если будет спортивный рабочий плейлист -- добавлю на сайт.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Какова гарантия, что я добавлю себе плейлист отсюда и он будет работать? -->
|
||||||
|
<div class="alert my-5 bg-dark text-light border-secondary" id="guarantee">
|
||||||
|
<h2>Какова гарантия, что я добавлю себе плейлист отсюда и он будет работать?</h2>
|
||||||
|
<p>Никакова.</p>
|
||||||
|
<p>Мёртвые плейлисты я периодически вычищаю, реже -- добавляю новые.
|
||||||
|
ID плейлистов могут меняться, поэтому вполне может произойти внезапная подмена одного другим, однако
|
||||||
|
это происходит редко.</p>
|
||||||
|
<p>Если один плейлист переезжает на новый адрес, то я ставлю временное перенаправление со старого ID на
|
||||||
|
новый.</p>
|
||||||
|
<p>Плюс читай выше про доверие результатам проверки (проблема может быть не стороне сервиса).</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- У меня перестал работать/исчез любимый канал/плейлист! -->
|
||||||
|
<div class="alert my-5 bg-dark text-light border-secondary" id="down">
|
||||||
|
<h2 class="text-danger">У меня перестал работать/исчез любимый канал/плейлист!</h2>
|
||||||
|
<p>Ну штош ¯\_(ツ)_/¯</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Где взять программу передач (EPG)? -->
|
||||||
|
<div class="alert my-5 bg-dark text-light border-secondary" id="epg">
|
||||||
|
<h2>Где взять программу передач (EPG)?</h2>
|
||||||
|
<ul>
|
||||||
|
<li><b>https://iptvx.one/viewtopic.php?f=12&t=4</b></li>
|
||||||
|
<li>https://iptvmaster.ru/epg-for-iptv</li>
|
||||||
|
<li>https://google.com</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- В плейлистах одна порнуха! -->
|
||||||
|
<div class="alert my-5 bg-dark text-light border-secondary" id="adult">
|
||||||
|
<h2 class="text-danger">В плейлистах одна порнуха!</h2>
|
||||||
|
<p>Ну, бывает, да. Смотри сколько хочешь. Или не смотри. Или не хоти.</p>
|
||||||
|
<h2 class="text-danger">Но у меня же дети! Яжмать! Яжотец!</h2>
|
||||||
|
<p>Я вот детям порнуху не показываю. Ты тоже не показывай.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Есть ли API? Как им пользоваться? -->
|
||||||
|
{# <div class="alert my-5 bg-dark text-light border-secondary">#}
|
||||||
|
{# <h2 id="api">Есть ли API? Как им пользоваться?</h2>#}
|
||||||
|
{# <p>Есть, подробности <a href="https://github.com/anthonyaxenov/iptv2#api">здесь</a>.</p>#}
|
||||||
|
{# </div>#}
|
||||||
|
|
||||||
|
<!-- Как добавить плейлист в список? -->
|
||||||
|
<div class="alert my-5 bg-dark text-light border-secondary" id="pr">
|
||||||
|
<h2>Как добавить плейлист в список?</h2>
|
||||||
|
<p>
|
||||||
|
Сделать pull-request в <a href="https://git.axenov.dev/IPTV/playlists">репозиторий</a>.
|
||||||
|
Я проверю плейлист и добавлю его в общий список, если всё ок.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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" %}
|
{% extends "template.twig" %}
|
||||||
|
|
||||||
{% block header %}
|
{% block header %}
|
||||||
<p class="text-muted small">
|
<div class="row text-muted small">
|
||||||
Обновлено: {{ updated_at }} МСК<br/>
|
<div class="col-md">
|
||||||
Плейлистов в списке: <strong>{{ count }}</strong>
|
Состояние проверки:<br />
|
||||||
</p>
|
<span class="me-1">
|
||||||
|
<span class="badge me-1 bg-success text-dark">online</span>{{ onlineCount }}
|
||||||
|
</span>
|
||||||
|
<span class="me-1">
|
||||||
|
<span class="badge me-1 bg-danger text-dark">offline</span>{{ offlineCount }}
|
||||||
|
</span>
|
||||||
|
<span class="me-1">
|
||||||
|
<span class="badge me-1 bg-secondary text-dark">unknown</span>{{ uncheckedCount }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="col-md">
|
||||||
|
Обновлено: {{ updatedAt }} МСК<br/>
|
||||||
|
Плейлистов в списке: <strong>{{ count }}</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<hr/>
|
<hr/>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
@@ -15,34 +35,58 @@
|
|||||||
<tr>
|
<tr>
|
||||||
<th class="text-center">ID</th>
|
<th class="text-center">ID</th>
|
||||||
<th>Информация о плейлисте</th>
|
<th>Информация о плейлисте</th>
|
||||||
|
<th class="text-center">Каналов</th>
|
||||||
<th class="d-none d-sm-table-cell">Ссылка для ТВ</th>
|
<th class="d-none d-sm-table-cell">Ссылка для ТВ</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{% for id, playlist in playlists %}
|
{% for code, playlist in playlists %}
|
||||||
<tr class="pls" data-playlist-id="{{ id }}">
|
<tr class="pls" data-playlist-code="{{ code }}">
|
||||||
<td class="text-center font-monospace id">{{ id }}</td>
|
<td class="text-center font-monospace code">{{ code }}</td>
|
||||||
<td class="info">
|
<td class="info">
|
||||||
<a href="{{ base_url(id ~ '/details') }}" class="text-light fw-bold text-decoration-none">{{ playlist.name }}</a>
|
{% if playlist.isOnline is same as(true) %}
|
||||||
|
<span class="badge small bg-success text-dark">online</span>
|
||||||
|
{% elseif playlist.isOnline is same as(false) %}
|
||||||
|
<span class="badge small bg-danger text-dark">offline</span>
|
||||||
|
{% elseif playlist.isOnline is same as(null) %}
|
||||||
|
<span class="badge small bg-secondary text-dark" title="Не проверялся">unknown</span>
|
||||||
|
{% endif %}
|
||||||
|
<a href="{{ base_url(code ~ '/details') }}" class="text-light fw-bold text-decoration-none">{{ playlist.name }}</a>
|
||||||
<div class="small mt-2">
|
<div class="small mt-2">
|
||||||
{% if playlist.desc|length > 0 %}
|
{% if playlist.description|length > 0 %}
|
||||||
<p class="my-1 d-none d-lg-block">{{ playlist.desc }}</p>
|
<p class="my-1 d-none d-lg-block">{{ playlist.description }}</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<a href="{{ base_url(id ~ '/details') }}" class="text-light">Подробнее...</a>
|
{% if playlist.tags|length > 0 %}
|
||||||
|
<p class="my-1 d-none d-lg-block text-muted" title="Теги, присвоенные каналам при проверке">
|
||||||
|
<ion-icon name="pricetag-outline" class="me-1"></ion-icon>
|
||||||
|
{% for tag in playlist.tags %}
|
||||||
|
<span class="chtag">#{{ tag }}</span>
|
||||||
|
{% endfor %}
|
||||||
|
</p>
|
||||||
|
{% endif %}
|
||||||
|
<a href="{{ base_url(code ~ '/details') }}" class="text-light">Подробнее...</a>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
|
<td class="text-center">
|
||||||
|
{% if (playlist.isOnline is not same as(null)) %}
|
||||||
|
{{ playlist.channels|length }}
|
||||||
|
{% else %}
|
||||||
|
?
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
<td class="col-3 d-none d-sm-table-cell">
|
<td class="col-3 d-none d-sm-table-cell">
|
||||||
<span onclick="prompt('Скопируй адрес плейлиста', '{{ playlist.url }}')"
|
<span onclick="prompt('Скопируй адрес плейлиста', 'm3u.su/{{ playlist.code }}')"
|
||||||
title="Нажми на ссылку, чтобы скопировать её в буфер обмена"
|
title="Нажми на ссылку, чтобы скопировать её в буфер обмена"
|
||||||
class="font-monospace cursor-pointer">
|
class="font-monospace cursor-pointer"
|
||||||
{{ playlist.url }}
|
>
|
||||||
|
m3u.su/{{ playlist.code }}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
{% if pageCount > 0 %}
|
{% if pageCount > 1 %}
|
||||||
<div aria-label="pages">
|
<div aria-label="pages">
|
||||||
<ul class="pagination justify-content-center">
|
<ul class="pagination justify-content-center">
|
||||||
{% for page in range(1, pageCount) %}
|
{% for page in range(1, pageCount) %}
|
||||||
|
|||||||
@@ -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" %}
|
{% extends "template.twig" %}
|
||||||
|
|
||||||
{% block header %}
|
{% block header %}
|
||||||
|
|||||||
@@ -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
|
||||||
|
###########################################################################}
|
||||||
|
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="ru">
|
<html lang="ru">
|
||||||
<head>
|
<head>
|
||||||
@@ -6,7 +12,12 @@
|
|||||||
<meta name="description" content="Самообновляемые бесплатные IPTV-плейлисты для домашнего просмотра по коротким ссылкам, списки каналов, проверка доступности">
|
<meta name="description" content="Самообновляемые бесплатные IPTV-плейлисты для домашнего просмотра по коротким ссылкам, списки каналов, проверка доступности">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<meta http-equiv="x-ua-compatible" content="ie=edge">
|
<meta http-equiv="x-ua-compatible" content="ie=edge">
|
||||||
<style>.cursor-pointer{cursor:pointer}</style>
|
<style>
|
||||||
|
.cursor-pointer{cursor:pointer}
|
||||||
|
.boosty{vertical-align:baseline;float:left;display:inline;width:20px}
|
||||||
|
</style>
|
||||||
|
<script async type="module" src="https://unpkg.com/ionicons@7.1.0/dist/ionicons/ionicons.esm.js"></script>
|
||||||
|
<script async nomodule src="https://unpkg.com/ionicons@7.1.0/dist/ionicons/ionicons.js"></script>
|
||||||
<link href="{{ base_url('css/bootstrap.min.css') }}" rel="stylesheet">
|
<link href="{{ base_url('css/bootstrap.min.css') }}" rel="stylesheet">
|
||||||
<link rel="apple-touch-icon" sizes="180x180" href="{{ base_url('/favicon/apple-touch-icon.png') }}">
|
<link rel="apple-touch-icon" sizes="180x180" href="{{ base_url('/favicon/apple-touch-icon.png') }}">
|
||||||
<link rel="icon" type="image/png" sizes="32x32" href="{{ base_url('/favicon/favicon-32x32.png') }}">
|
<link rel="icon" type="image/png" sizes="32x32" href="{{ base_url('/favicon/favicon-32x32.png') }}">
|
||||||
@@ -16,14 +27,13 @@
|
|||||||
<meta name="msapplication-TileColor" content="#00aba9">
|
<meta name="msapplication-TileColor" content="#00aba9">
|
||||||
<meta name="msapplication-TileImage" content="{{ base_url('/favicon/mstile-144x144.png') }}">
|
<meta name="msapplication-TileImage" content="{{ base_url('/favicon/mstile-144x144.png') }}">
|
||||||
<meta name="theme-color" content="#212529">
|
<meta name="theme-color" content="#212529">
|
||||||
<style>.boosty{vertical-align:baseline;float:left;display:inline;width:20px}</style>
|
|
||||||
{% block head %}{% endblock %}
|
{% block head %}{% endblock %}
|
||||||
</head>
|
</head>
|
||||||
<body class="bg-dark text-light">
|
<body class="bg-dark text-light">
|
||||||
<div class="container col-lg-8 mx-auto">
|
<div class="container col-lg-10 mx-auto">
|
||||||
<header>
|
<header>
|
||||||
<nav class="navbar navbar-expand-lg navbar-dark">
|
<nav class="navbar navbar-expand-lg navbar-dark">
|
||||||
<img src="{{ base_url('/favicon/favicon-32x32.png') }}" class="d-inline-block align-text-top px-lg-1" alt=""/>
|
<img src="{{ base_url('/favicon/favicon-32x32.png') }}" class="d-inline-block px-lg-1" alt="Логотип проекта - emoji телевизора"/>
|
||||||
<a class="navbar-brand" href="{{ base_url() }}" title="На главную">
|
<a class="navbar-brand" href="{{ base_url() }}" title="На главную">
|
||||||
{{ config('app.title') }}
|
{{ config('app.title') }}
|
||||||
</a>
|
</a>
|
||||||
@@ -39,10 +49,7 @@
|
|||||||
<a class="nav-link" href="{{ base_url('faq') }}">FAQ</a>
|
<a class="nav-link" href="{{ base_url('faq') }}">FAQ</a>
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<a class="nav-link" href="https://git.axenov.dev/anthony/iptv">Gitea</a>
|
<a class="nav-link" href="https://git.axenov.dev/IPTV">Исходники</a>
|
||||||
</li>
|
|
||||||
<li class="nav-item">
|
|
||||||
<a class="nav-link" href="https://github.com/anthonyaxenov/iptv">GitHub</a>
|
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<a class="nav-link" href="https://axenov.dev">axenov.dev</a>
|
<a class="nav-link" href="https://axenov.dev">axenov.dev</a>
|
||||||
@@ -56,6 +63,12 @@
|
|||||||
Boosty
|
Boosty
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link" href="https://yoomoney.ru/to/41001685237530">
|
||||||
|
<ion-icon name="card-outline"></ion-icon>
|
||||||
|
Yoomoney
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
@@ -70,12 +83,16 @@
|
|||||||
<script src="{{ base_url('js/bootstrap.bundle.min.js') }}"></script>
|
<script src="{{ base_url('js/bootstrap.bundle.min.js') }}"></script>
|
||||||
{% block footer %}{% endblock %}
|
{% block footer %}{% endblock %}
|
||||||
<a href="{{ base_url('faq') }}">FAQ</a> | <a
|
<a href="{{ base_url('faq') }}">FAQ</a> | <a
|
||||||
href="https://github.com/anthonyaxenov/iptv">GitHub</a> | <a
|
href="https://git.axenov.dev/IPTV">Исходники</a> | <a
|
||||||
href="https://git.axenov.dev/anthony/iptv">Gitea</a> | <a
|
|
||||||
href="https://axenov.dev">axenov.dev</a> | <a
|
href="https://axenov.dev">axenov.dev</a> | <a
|
||||||
href="https://t.me/iptv_aggregator">Telegram</a><br>
|
href="https://t.me/iptv_aggregator">Telegram</a><br>
|
||||||
|
<a class="small text-secondary"
|
||||||
|
href="https://git.axenov.dev/IPTV/web/releases/tag/v{{ version() }}"
|
||||||
|
target="_blank"
|
||||||
|
>v{{ version() }}</a>
|
||||||
</footer>
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% include("custom.twig") ignore missing %}
|
{% include("custom.twig") ignore missing %}
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
Reference in New Issue
Block a user