Compare commits

...

3 Commits

Author SHA1 Message Date
a36b3e1e9a Косметика
- пересмотрено меню в шапке и подвале
- ссылка для тв + qr-код теперь по умолчанию зеркальные, с протоколом и подсказкой про .m3u
- предупреждение о большом кол-ве каналов теперь справа и скрывается после загрузки списка
- увеличен шрифт в табилце основных данных листа
2025-06-10 09:37:43 +08:00
7f10930b7b Команды /help, /stats и /links + конфиг для зеркала 2025-06-10 09:03:46 +08:00
687ebc3fdc Первичная реализация, команды /list и /info 2025-06-09 22:19:47 +08:00
18 changed files with 709 additions and 76 deletions

View File

@@ -4,12 +4,17 @@
# config/app.php
APP_URL="http://localhost:8080"
APP_URL_MIRROR="https://m3u.su/"
APP_DEBUG=false
APP_ENV="prod"
APP_TITLE="IPTV Плейлисты"
APP_TIMEZONE=Europe/Moscow
PAGE_SIZE=10
# config/bot.php
TG_BOT_TOKEN=
TG_BOT_SECRET=
# config/cache.php
CACHE_HOST="keydb"
CACHE_PORT=6379

View File

@@ -64,6 +64,7 @@
* [Bootstrap v5.2](https://getbootstrap.com/docs/5.2/getting-started/introduction/)
* [Ionicons](https://ionic.io/ionicons)
* [List.js](https://listjs.com)
* [telegram-bot/api](https://packagist.org/packages/telegram-bot/api)
## Лицензия

View File

@@ -46,8 +46,8 @@ class ApiController extends BasicController
'outputType' => QRCode::OUTPUT_IMAGE_JPG,
'eccLevel' => QRCode::ECC_L,
]);
$data = base_url("$code");
$raw = (new QRCode($options))->render($data, $filePath);
$data = config('app.mirror_url') ? mirror_url("$code.m3u") : base_url("$code.m3u");
$raw = new QRCode($options)->render($data, $filePath);
$raw = base64_decode(str_replace('data:image/jpg;base64,', '', $raw));
}

View File

@@ -0,0 +1,38 @@
<?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);
namespace App\Controllers;
use App\Core\Bot;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use TelegramBot\Api\Exception;
use TelegramBot\Api\InvalidArgumentException;
/**
* Контроллер методов ТГ бота
*/
class BotController extends BasicController
{
/**
* @throws Exception
* @throws InvalidArgumentException
* @throws \Exception
* @see https://github.com/TelegramBot/Api
* @see https://core.telegram.org/bots/api
* @see https://core.telegram.org/bots/api#markdownv2-style
*/
public function webhook(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
{
$bot = new Bot($request);
$bot->process();
return $response;
}
}

448
app/Core/Bot.php Normal file
View File

@@ -0,0 +1,448 @@
<?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);
namespace App\Core;
use App\Errors\InvalidTelegramSecretException;
use App\Errors\PlaylistNotFoundException;
use DateTimeImmutable;
use Exception;
use JsonException;
use Psr\Http\Message\ServerRequestInterface;
use TelegramBot\Api\BotApi;
use TelegramBot\Api\InvalidArgumentException;
use TelegramBot\Api\Types\ForceReply;
use TelegramBot\Api\Types\Inline\InlineKeyboardMarkup;
use TelegramBot\Api\Types\MessageEntity;
use TelegramBot\Api\Types\ReplyKeyboardMarkup;
use TelegramBot\Api\Types\ReplyKeyboardRemove;
use TelegramBot\Api\Types\Update;
use Throwable;
/**
* Обработчик команд бота
*/
class Bot
{
/**
* @var BotApi Объект Telegram Bot API
*/
protected BotApi $bot;
/**
* @var Update Объект обновления бота
*/
protected Update $update;
/**
* Конструктор
*
* @param ServerRequestInterface $request
* @throws InvalidTelegramSecretException
* @throws JsonException
* @throws InvalidArgumentException
* @throws Exception
*/
public function __construct(ServerRequestInterface $request)
{
$this->checkSecret($request);
$body = json_decode((string)$request->getBody(), true);
if (json_last_error() !== JSON_ERROR_NONE) {
throw new JsonException(json_last_error_msg());
}
$this->bot = new BotApi(config('bot.token'));
$this->update = Update::fromResponse($body);
}
/**
* Запсукает обработку команды
*
* @return bool
* @throws InvalidArgumentException
* @throws \TelegramBot\Api\Exception
* @throws Exception
*/
public function process(): bool
{
$this->bot->sendChatAction($this->update->getMessage()->getChat()->getId(), 'typing');
$commandText = $this->getBotCommandText();
return match (true) {
str_starts_with($commandText, '/list') => $this->processListCommand(),
str_starts_with($commandText, '/info') => $this->processInfoCommand(),
str_starts_with($commandText, '/help') => $this->processHelpCommand(),
str_starts_with($commandText, '/links') => $this->processLinksCommand(),
str_starts_with($commandText, '/stats') => $this->processStatsCommand(),
default => true,
};
}
/**
* Обрабатывает команду /list
*
* @return bool
* @throws InvalidArgumentException
*/
protected function processListCommand(): bool
{
$playlists = ini()->getPlaylists();
if (empty($playlists)) {
$replyText = 'Плейлистов нет';
} else {
$replyText = [];
foreach ($playlists as $code => $pls) {
$statusEmoji = match ($pls['isOnline']) {
true => '🟢',
false => '🔴',
default => '⚪',
};
in_array('adult', $pls['tags'] ?? []) === true && $statusEmoji .= "🔞";
$replyText[] = "$statusEmoji \[$code\]";
}
$replyText = "Полный список плейлистов:\n\n**>" . implode("\n>", $replyText) . "||\n";
}
return $this->reply($replyText, InlineKeyboardMarkup::fromResponse([
'inline_keyboard' => [
[
[
'text' => 'Список на сайте',
'url' => base_url(),
],
[
'text' => 'FAQ',
'url' => base_url('faq'),
]
]
]
]));
}
/**
* Обрабатывает команду /info
*
* @return bool
* @throws InvalidArgumentException
*/
protected function processInfoCommand(): bool
{
$message = $this->update->getMessage();
$text = $message->getText();
$command = $this->getBotCommand();
$code = mb_substr($text, $command->getLength() + 1 , mb_strlen($text));
if (empty($code)) {
return $this->reply('Укажите код плейлиста с сайта, чтобы получить результаты его проверки, ' .
'например, `/info ru` или `/info@iptv_aggregator_bot ru`');
}
try {
$pls = ini()->getPlaylist($code);
} catch (PlaylistNotFoundException) {
return $this->reply("Плейлист `$code` не найден");
}
$statusEmoji = match ($pls['isOnline']) {
true => '🟢',
false => '🔴',
default => '⚪',
};
in_array('adult', $pls['tags']) && $statusEmoji .= "🔞";
$replyText[] = $statusEmoji . ' [' . $this->escape($pls['name']) . '](' . base_url("$code/details") . ")\n";
empty($pls['description']) || $replyText[] = $this->escape($pls['description']) . "\n";
if ($pls['isOnline'] === null) {
$replyText[] = "⏲️ *Проверка:* в очереди";
} else {
$now = DateTimeImmutable::createFromTimestamp(time());
$checkedAt = DateTimeImmutable::createFromTimestamp($pls['checkedAt']);
$minutes = $checkedAt->diff($now)->i;
$replyText[] = "⏲️ *Проверка:* " . ($minutes > 1 ? "$minutes мин\. назад" : "только что");
if ($pls['isOnline'] === true) {
$replyText[] = "📺 *Каналов:* " . count($pls['channels'] ?? []) .
' \(онлайн ' . $pls['onlineCount'] . ', оффлайн ' . $pls['offlineCount'] . "\)";
$replyText[] = "⏪ *Перемотка:* " . ($pls['hasCatchup'] === true ? '*есть*' : 'нет');
$replyText[] = "🗞️ *Телепрограмма:* " . ($pls['hasTvg'] === true ? '*есть*' : 'нет');
if (count($pls['groups'] ?? []) > 0) {
$groups = array_map(fn (array $group) => $this->escape($group['name']), $pls['groups']);
$replyText[] = "\n🗂️ *Группы* \(" . count($pls['groups'] ?? []) . "\):\n**>\- " .
implode("\n>\- ", $groups) . '||';
} else {
$replyText[] = "🗂️ *Группы:* нет";
}
if (count($pls['tags'] ?? []) > 0) {
$replyText[] = "\n🏷️ *Теги* \(" . count($pls['tags']) . "\):\n**>" .
$this->escape(trim(implode(' ', $pls['tags']))) . "||\n";
} else {
$replyText[] = "🏷️ *Теги:* нет";
}
} else {
$replyText[] = "❌ *Ошибка проверки:*\n**>" . $this->escape($pls['content']) . "||\n";
}
}
$replyText[] = "🔗 *Ссылка для ТВ:* \(скопируй подходящую\)";
if (config('app.mirror_url')) {
$replyText[] = '\- `' . mirror_url("$code") . '`';
$replyText[] = '\- `' . mirror_url("$code.m3u") . '`';
}
$replyText[] = '\- `' . base_url("$code") . '`';
$replyText[] = '\- `' . base_url("$code.m3u") . "`\n";
return $this->reply(
implode("\n", $replyText),
InlineKeyboardMarkup::fromResponse([
'inline_keyboard' => [
[
[
'text' => is_null($pls['isOnline']) ? 'Подробности' : 'Список каналов',
'url' => base_url("$code/details"),
],
[
'text' => 'FAQ',
'url' => base_url('faq'),
]
]
]
]),
);
}
/**
* Обрабатывает команду /help
*
* @return bool
*/
protected function processHelpCommand(): bool
{
$replyText[] = 'Бот предоставляет короткую сводку о плейлистах, которые видны на сайте ' .
$this->escape(base_url()) . '\.';
$replyText[] = 'Плейлисты проверяются сервером автоматически\.';
$replyText[] = '';
$replyText[] = 'Команды бота:';
$replyText[] = '`/list` \- список кодов всех плейлистов;';
$replyText[] = '`/info <код>` \- информация о плейлисте с указанным кодом;';
$replyText[] = '`/help` \- данная справка\.';
$replyText[] = '';
$replyText[] = 'Статусы плейлистов:';
$replyText[] = '🟢 \- онлайн';
$replyText[] = '🔴 \- оффлайн';
$replyText[] = '⚪ \- не проверялось';
$replyText[] = '🔞 \- есть каналы для взрослых';
return $this->reply(implode("\n", $replyText));
}
/**
* Обрабатывает команду /links
*
* @return bool
*/
protected function processLinksCommand(): bool
{
$replyText[] = '*Ресурсы и страницы*';
$replyText[] = '';
$replyText[] = '🌏 Сайт: ' . $this->escape(base_url());
config('app.mirror_url') && $replyText[] = '🪞 Зеркало: ' . $this->escape(mirror_url());
$replyText[] = '👩‍💻 Исходный код: ' . $this->escape('https://git.axenov.dev/IPTV');
$replyText[] = '✈️ Telegram\-канал: @iptv\_aggregator';
$replyText[] = '✈️ Обсуждение: @iptv\_aggregator\_chat';
$replyText[] = '📚 Доп\. сведения:';
$replyText[] = '\- ' . $this->escape('https://git.axenov.dev/IPTV/.profile');
$replyText[] = '\- ' . $this->escape(base_url('faq'));
return $this->reply(implode("\n", $replyText));
}
/**
* Обрабатывает команду /stats
*
* @return bool
* @throws Exception
*/
protected function processStatsCommand(): bool
{
$allChannels = [];
foreach (ini()->getPlaylists() as $pls) {
$allChannels = array_merge($allChannels, $pls['channels'] ?? []);
}
$onlinePls = array_filter(
ini()->getPlaylists(),
static fn (array $pls) => $pls['isOnline'] === true,
);
$offlinePls = array_filter(
ini()->getPlaylists(),
static fn (array $pls) => $pls['isOnline'] === false,
);
$unknownPls = array_filter(
ini()->getPlaylists(),
static fn (array $pls) => $pls['isOnline'] === null,
);
$adultPls = array_filter(
$onlinePls,
static fn (array $pls) => in_array('adult', $pls['tags']),
);
$catchupPls = array_filter(
$onlinePls,
static fn (array $pls) => $pls['hasCatchup'] === true,
);
$tvgPls = array_filter(
$onlinePls,
static fn (array $pls) => $pls['hasTvg'] === true,
);
$grouppedPls = array_filter(
$onlinePls,
static fn (array $pls) => count($pls['groups'] ?? []) > 0
);
$onlineCh = $offlineCh = $adultCh = [];
foreach ($onlinePls as $pls) {
$tmpOnline = array_filter(
$pls['channels'] ?? [],
static fn (array $ch) => $ch['isOnline'] === true,
);
$tmpOffline = array_filter(
$pls['channels'] ?? [],
static fn (array $ch) => $ch['isOnline'] === false,
);
$tmpAdult = array_filter(
$pls['channels'] ?? [],
static fn (array $ch) => in_array('adult', $ch['tags']),
);
$onlineCh = array_merge($onlineCh, $tmpOnline);
$offlineCh = array_merge($offlineCh, $tmpOffline);
$adultCh = array_merge($adultCh, $tmpAdult);
}
$replyText[] = '📊 *Статистика*';
$replyText[] = '';
$replyText[] = '*Список изменён:* ' . $this->escape(ini()->updatedAt());
$replyText[] = '';
$replyText[] = '*Плейлистов:* ' . count(ini()->getPlaylists());
$replyText[] = '🟢 Онлайн \- ' . count($onlinePls);
$replyText[] = '🔴 Оффлайн \- ' . count($offlinePls);
$replyText[] = '⚪ В очереди \- ' . count($unknownPls);
$replyText[] = '🔞 Для взрослых \- ' . count($adultPls);
$replyText[] = '⏪ С перемоткой \- ' . count($catchupPls);
$replyText[] = '🗞️ С телепрограммой \- ' . count($tvgPls);
$replyText[] = '🗂️ С группировкой каналов \- ' . count($grouppedPls);
$replyText[] = '';
$replyText[] = '*Каналов:* ' . count($allChannels);
$replyText[] = '🟢 Онлайн \- ' . count($onlineCh);
$replyText[] = '🔴 Оффлайн \- ' . count($offlineCh);
$replyText[] = '🔞 Для взрослых \- ' . count($adultCh);
$replyText[] = '';
$replyText[] = '';
return $this->reply(implode("\n", $replyText));
}
/**
* Сверяет секретный заголовок с заданным в конфиге
*
* @param ServerRequestInterface $request
* @return void
* @throws InvalidTelegramSecretException
*/
protected function checkSecret(ServerRequestInterface $request): void
{
$secret = config('bot.secret');
if (empty($secret)) {
return;
}
$header = $request->getHeaderLine('X-Telegram-Bot-Api-Secret-Token');
if (empty($header) || $header !== $secret) {
throw new InvalidTelegramSecretException();
}
}
/**
* Возвращает объект, описывающий команду бота
*
* @return MessageEntity|null
*/
protected function getBotCommand(): ?MessageEntity
{
return array_filter(
$this->update->getMessage()->getEntities(),
static fn (MessageEntity $entity) => $entity->getType() === 'bot_command',
)[0] ?? null;
}
/**
* Возвращает текст команды бота
*
* @return string|null
*/
protected function getBotCommandText(): ?string
{
$text = $this->update->getMessage()->getText();
$command = $this->getBotCommand();
return $command ? mb_substr($text, 0, $command->getLength()) : null;
}
/**
* Отправляет текстовое сообщение в ответ на команду, отправленную пользователем
*
* @param string $text
* @param InlineKeyboardMarkup|ReplyKeyboardMarkup|ReplyKeyboardRemove|ForceReply|null $keyboard
* @return bool
*/
protected function reply(
string $text,
InlineKeyboardMarkup|ReplyKeyboardMarkup|ReplyKeyboardRemove|ForceReply|null $keyboard = null,
): bool {
try {
$this->bot->sendMessage(
chatId: $this->update->getMessage()->getChat()->getId(),
text: $text,
parseMode: 'MarkdownV2',
replyToMessageId: $this->update->getMessage()->getMessageId(),
replyMarkup: $keyboard,
);
} catch (Throwable $e) {
error_log($e->getMessage());
return false;
}
return true;
}
/**
* Экранирует служебные символы в строке
*
* @param string $string
* @return string
*/
protected function escape(string $string): string
{
return str_replace(
['_', '*', '[', ']', '(', ')', '~', '`', '>', '#', '+', '-', '=', '|', '{', '}', '.', '!'],
['\_', '\*', '\[', '\]', '\(', '\)', '\~', '\`', '\>', '\#', '\+', '\-', '\=', '\|', '\{', '\}', '\.', '\!'],
$string,
);
}
}

View File

@@ -59,7 +59,7 @@ class IniFile
'offlineCount' => 0,
'checkedAt' => null,
];
} else if (!isset($data['attributes'])) {
} elseif (!isset($data['attributes'])) {
$data['attributes'] = [];
}

View File

@@ -28,6 +28,7 @@ class TwigExtention extends AbstractExtension
new TwigFunction('version', [$this, 'version']),
new TwigFunction('is_file', [$this, 'isFile']),
new TwigFunction('base_url', [$this, 'baseUrl']),
new TwigFunction('mirror_url', [$this, 'mirrorUrl']),
new TwigFunction('to_date', [$this, 'toDate']),
];
}
@@ -65,6 +66,17 @@ class TwigExtention extends AbstractExtension
return base_url($path);
}
/**
* Возвращает зеркальный URL приложения
*
* @param string $path
* @return string
*/
public function mirrorUrl(string $path = ''): string
{
return mirror_url($path);
}
/**
* Проверячет существование файла
*

View File

@@ -0,0 +1,20 @@
<?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);
namespace App\Errors;
use Exception;
class InvalidTelegramSecretException extends Exception
{
public function __construct()
{
parent::__construct("Ошибка валидации запроса от Telegram Bot API");
}
}

View File

@@ -52,7 +52,18 @@ function cache_path(string $path = ''): string
*/
function base_url(string $route = ''): string
{
return rtrim(sprintf('%s/%s', env('APP_URL'), $route), '/');
return rtrim(sprintf('%s/%s', config('app.base_url'), $route), '/');
}
/**
* Returns mirror URL
*
* @param string $route
* @return string
*/
function mirror_url(string $route = ''): string
{
return rtrim(sprintf('%s/%s', config('app.mirror_url'), $route), '/');
}
/**

View File

@@ -11,7 +11,7 @@
],
"license": "MIT",
"require": {
"php": "^8.3",
"php": "^8.4",
"ext-curl": "*",
"ext-fileinfo": "*",
"ext-json": "*",
@@ -23,6 +23,7 @@
"nyholm/psr7": "^1.8",
"slim/slim": "^4.14",
"slim/twig-view": "^3.4",
"telegram-bot/api": "^2.5",
"vlucas/phpdotenv": "^5.6"
},
"autoload": {

112
composer.lock generated
View File

@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "eb7c9751c009420f33c222a768c1797a",
"content-hash": "af7bdd24bd2061cc3e8277488f404fb0",
"packages": [
{
"name": "carbonphp/carbon-doctrine-types",
@@ -1570,7 +1570,7 @@
},
{
"name": "symfony/clock",
"version": "v7.2.0",
"version": "v7.3.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/clock.git",
@@ -1624,7 +1624,7 @@
"time"
],
"support": {
"source": "https://github.com/symfony/clock/tree/v7.2.0"
"source": "https://github.com/symfony/clock/tree/v7.3.0"
},
"funding": [
{
@@ -1644,16 +1644,16 @@
},
{
"name": "symfony/deprecation-contracts",
"version": "v3.5.1",
"version": "v3.6.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/deprecation-contracts.git",
"reference": "74c71c939a79f7d5bf3c1ce9f5ea37ba0114c6f6"
"reference": "63afe740e99a13ba87ec199bb07bbdee937a5b62"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/74c71c939a79f7d5bf3c1ce9f5ea37ba0114c6f6",
"reference": "74c71c939a79f7d5bf3c1ce9f5ea37ba0114c6f6",
"url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/63afe740e99a13ba87ec199bb07bbdee937a5b62",
"reference": "63afe740e99a13ba87ec199bb07bbdee937a5b62",
"shasum": ""
},
"require": {
@@ -1666,7 +1666,7 @@
"name": "symfony/contracts"
},
"branch-alias": {
"dev-main": "3.5-dev"
"dev-main": "3.6-dev"
}
},
"autoload": {
@@ -1691,7 +1691,7 @@
"description": "A generic function and convention to trigger deprecation notices",
"homepage": "https://symfony.com",
"support": {
"source": "https://github.com/symfony/deprecation-contracts/tree/v3.5.1"
"source": "https://github.com/symfony/deprecation-contracts/tree/v3.6.0"
},
"funding": [
{
@@ -1707,7 +1707,7 @@
"type": "tidelift"
}
],
"time": "2024-09-25T14:20:29+00:00"
"time": "2024-09-25T14:21:43+00:00"
},
{
"name": "symfony/polyfill-ctype",
@@ -2103,16 +2103,16 @@
},
{
"name": "symfony/translation",
"version": "v7.2.6",
"version": "v7.3.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/translation.git",
"reference": "e7fd8e2a4239b79a0fd9fb1fef3e0e7f969c6dc6"
"reference": "4aba29076a29a3aa667e09b791e5f868973a8667"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/translation/zipball/e7fd8e2a4239b79a0fd9fb1fef3e0e7f969c6dc6",
"reference": "e7fd8e2a4239b79a0fd9fb1fef3e0e7f969c6dc6",
"url": "https://api.github.com/repos/symfony/translation/zipball/4aba29076a29a3aa667e09b791e5f868973a8667",
"reference": "4aba29076a29a3aa667e09b791e5f868973a8667",
"shasum": ""
},
"require": {
@@ -2122,6 +2122,7 @@
"symfony/translation-contracts": "^2.5|^3.0"
},
"conflict": {
"nikic/php-parser": "<5.0",
"symfony/config": "<6.4",
"symfony/console": "<6.4",
"symfony/dependency-injection": "<6.4",
@@ -2135,7 +2136,7 @@
"symfony/translation-implementation": "2.3|3.0"
},
"require-dev": {
"nikic/php-parser": "^4.18|^5.0",
"nikic/php-parser": "^5.0",
"psr/log": "^1|^2|^3",
"symfony/config": "^6.4|^7.0",
"symfony/console": "^6.4|^7.0",
@@ -2178,7 +2179,7 @@
"description": "Provides tools to internationalize your application",
"homepage": "https://symfony.com",
"support": {
"source": "https://github.com/symfony/translation/tree/v7.2.6"
"source": "https://github.com/symfony/translation/tree/v7.3.0"
},
"funding": [
{
@@ -2194,20 +2195,20 @@
"type": "tidelift"
}
],
"time": "2025-04-07T19:09:28+00:00"
"time": "2025-05-29T07:19:49+00:00"
},
{
"name": "symfony/translation-contracts",
"version": "v3.5.1",
"version": "v3.6.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/translation-contracts.git",
"reference": "4667ff3bd513750603a09c8dedbea942487fb07c"
"reference": "df210c7a2573f1913b2d17cc95f90f53a73d8f7d"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/translation-contracts/zipball/4667ff3bd513750603a09c8dedbea942487fb07c",
"reference": "4667ff3bd513750603a09c8dedbea942487fb07c",
"url": "https://api.github.com/repos/symfony/translation-contracts/zipball/df210c7a2573f1913b2d17cc95f90f53a73d8f7d",
"reference": "df210c7a2573f1913b2d17cc95f90f53a73d8f7d",
"shasum": ""
},
"require": {
@@ -2220,7 +2221,7 @@
"name": "symfony/contracts"
},
"branch-alias": {
"dev-main": "3.5-dev"
"dev-main": "3.6-dev"
}
},
"autoload": {
@@ -2256,7 +2257,7 @@
"standards"
],
"support": {
"source": "https://github.com/symfony/translation-contracts/tree/v3.5.1"
"source": "https://github.com/symfony/translation-contracts/tree/v3.6.0"
},
"funding": [
{
@@ -2272,7 +2273,68 @@
"type": "tidelift"
}
],
"time": "2024-09-25T14:20:29+00:00"
"time": "2024-09-27T08:32:26+00:00"
},
{
"name": "telegram-bot/api",
"version": "v2.5.0",
"source": {
"type": "git",
"url": "https://github.com/TelegramBot/Api.git",
"reference": "eaae3526223db49a1bad76a2dfa501dc287979cf"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/TelegramBot/Api/zipball/eaae3526223db49a1bad76a2dfa501dc287979cf",
"reference": "eaae3526223db49a1bad76a2dfa501dc287979cf",
"shasum": ""
},
"require": {
"ext-curl": "*",
"ext-json": "*",
"php": ">=5.5.0"
},
"require-dev": {
"friendsofphp/php-cs-fixer": "^3.16",
"symfony/phpunit-bridge": "*",
"vimeo/psalm": "^5.9"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "2.5-dev"
}
},
"autoload": {
"psr-4": {
"TelegramBot\\Api\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Ilya Gusev",
"email": "mail@igusev.ru",
"homepage": "https://php-cat.com",
"role": "Developer"
}
],
"description": "PHP Wrapper for Telegram Bot API",
"homepage": "https://github.com/TelegramBot/Api",
"keywords": [
"bot",
"bot api",
"php",
"telegram"
],
"support": {
"issues": "https://github.com/TelegramBot/Api/issues",
"source": "https://github.com/TelegramBot/Api/tree/v2.5.0"
},
"time": "2023-08-09T13:53:12+00:00"
},
{
"name": "twig/twig",
@@ -2445,7 +2507,7 @@
"prefer-stable": true,
"prefer-lowest": false,
"platform": {
"php": "^8.3",
"php": "^8.4",
"ext-curl": "*",
"ext-fileinfo": "*",
"ext-json": "*",

View File

@@ -8,7 +8,8 @@
declare(strict_types=1);
return [
'base_url' => env('APP_URL', 'http://localhost:8080'),
'base_url' => rtrim(trim(env('APP_URL', 'http://localhost:8080')), '/'),
'mirror_url' => rtrim(trim(env('APP_URL_MIRROR') ?? '', '/')),
'debug' => bool(env('APP_DEBUG', false)),
'env' => env('APP_ENV', env('IPTV_ENV', 'prod')),
'title' => 'IPTV Плейлисты',

13
config/bot.php Normal file
View File

@@ -0,0 +1,13 @@
<?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 [
'token' => env('TG_BOT_TOKEN'),
'secret' => env('TG_BOT_SECRET'),
];

View File

@@ -6,16 +6,29 @@
*/
use App\Controllers\ApiController;
use App\Controllers\BasicController;
use App\Controllers\BotController;
use App\Controllers\WebController;
return [
/*
|--------------------------------------------------------------------------
| Web routes
|--------------------------------------------------------------------------
*/
[
'method' => ['GET', 'POST'],
'path' => '/bot/webhook',
'handler' => [BotController::class, 'webhook'],
'name' => 'bot::webhook',
],
[
'method' => ['GET', 'POST'],
'path' => '/bot/update',
'handler' => [BotController::class, 'update'],
'name' => 'bot::update',
],
[
'method' => 'GET',
'path' => '/[page/{page:[0-9]+}]',
@@ -59,12 +72,6 @@ return [
'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',
// ],
/*
|--------------------------------------------------------------------------
@@ -74,7 +81,7 @@ return [
[
'method' => '*',
'path' => '/{path:.*}',
'path' => '/{path:.+}',
'handler' => [BasicController::class, 'notFound'],
'name' => 'not-found',
],

View File

@@ -27,11 +27,6 @@
{% block header %}
<h2>О плейлисте: {{ playlist.name }}</h2>
{% if (playlist.channels|length > 500) %}
<div class="alert alert-warning small" role="alert">
В плейлисте очень много каналов. На загрузку их списка и логотипов потребуется некоторое время.
</div>
{% endif %}
{% if playlist.isOnline is same as(false) %}
<div class="alert alert-danger small" role="alert">
Ошибка плейлиста: {{ playlist.content }}
@@ -64,7 +59,7 @@
</a>
</li>
</ul>
<div class="tab-content small">
<div class="tab-content">
<div class="tab-pane fade show active" id="tab-data" tabindex="0">
<table class="table table-dark table-hover small mb-lg-5">
<tbody>
@@ -92,11 +87,13 @@
</tr>
<tr>
<th scope="row">Ccылка для ТВ</th>
<td><b onclick="prompt('Скопируй адрес плейлиста', 'm3u.su/{{ playlist.code }}')"
<td>
<b onclick="prompt('Скопируй адрес плейлиста. Если не работает, добавь \'.m3u\' в конец.', '{{ mirror_url(playlist.code) }}')"
data-bs-toggle="tooltip"
data-bs-placement="top"
title="Нажми на ссылку, чтобы скопировать её в буфер обмена"
class="font-monospace cursor-pointer text-break">m3u.su/{{ playlist.code }}</b></td>
class="font-monospace cursor-pointer text-break">{{ mirror_url(playlist.code) }}</b>
</td>
</tr>
<tr>
<th scope="row">Источник</th>
@@ -166,11 +163,6 @@
>
<ion-icon name="qr-code-outline"></ion-icon>&nbsp;QR-код
</button>
<textarea class="form-control bg-dark text-light font-monospace mb-3 mb-md-0 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">
@@ -184,6 +176,11 @@
</div>
</div>
</div>
<textarea class="form-control bg-dark text-light font-monospace mb-3 mb-md-0 m3u-raw"
rows="40"
id="m3u-raw"
readonly
>{{ playlist.content }}</textarea>
</div>
</div>
</div>
@@ -194,6 +191,14 @@
{% if (playlist.groups|length > 1) %}
<div class="row my-3">
<div class="col-12">
{% if (playlist.channels|length >= 500) %}
<div class="alert alert-warning small" role="alert" id="toomuchalert">
<div class="spinner-border text-success spinner-border-sm" role="status">
<span class="visually-hidden">Загрузка...</span>
</div>
В плейлисте очень много каналов. На загрузку их списка и логотипов потребуется некоторое время.
</div>
{% endif %}
<div class="input-group">
<select id="groupSelector"
class="form-select form-select-sm border-secondary bg-dark text-light"
@@ -382,6 +387,11 @@
list.on('updated', (data) => document.getElementById('chcount').innerText = data.visibleItems.length)
document.getElementById('search-field').addEventListener('keyup', (e) => list.search(e.target.value))
document.addEventListener("DOMContentLoaded", () => {
const alert = document.getElementById("toomuchalert");
!!alert && alert.remove()
});
function savePlaylist() {
const link = document.createElement("a");
const content = document.getElementById("m3u-raw").value

View File

@@ -193,7 +193,7 @@
</h2>
<p>Кто-то воткнул платный канал в плейлист и распространил его как бесплатный.</p>
<p>Забудь про этот плейлист. Ищи другой. Без вариантов. Такова цена халявы.</p>
<p>Нет, я не буду это исправлять.</p>
<p>Я могу это исправить только удалением плейлиста с сайта. Это единственный вариант, который устроит всех.</p>
</div>
<!-- Заглушка 2 -->
@@ -206,7 +206,7 @@
<p>Попробуй использовать плеер, который позволяет указать User-Agent, и указать User-Agent:</p>
<pre class="fw-bold">Mozilla/5.0 WINK/1.31.1 (AndroidTV/9) HlsWinkPlayer</pre>
<p>Или подключи Wink. Или забудь про этот плейлист и ищи другой.</p>
<p>Нет, я не буду это исправлять.</p>
<p>Я могу это исправить только удалением плейлиста с сайта. Это единственный вариант, который устроит всех.</p>
</div>
<!-- Добавь канал! -->

View File

@@ -33,7 +33,7 @@
<table class="table table-responsive table-dark table-hover small">
<thead>
<tr>
<th class="col-1 text-center">ID</th>
<th class="col-1 text-center">Код</th>
<th class="col-8">Информация о плейлисте</th>
<th class="col-1 text-center">Каналов</th>
<th class="col-2 d-none d-sm-table-cell">Ссылка для ТВ</th>
@@ -87,11 +87,14 @@
{% endif %}
</td>
<td class="d-none d-sm-table-cell">
<span onclick="prompt('Скопируй адрес плейлиста', 'm3u.su/{{ playlist.code }}')"
<span onclick="prompt('Скопируй адрес плейлиста. Если не работает, добавь \'.m3u\' в конец.', '{{ mirror_url(playlist.code) }}')"
title="Нажми на ссылку, чтобы скопировать её в буфер обмена"
class="font-monospace cursor-pointer"
data-bs-toggle="tooltip"
data-bs-placement="top"
title="Нажми на ссылку, чтобы скопировать её в буфер обмена"
>
m3u.su/{{ playlist.code }}
{{ mirror_url(playlist.code) }}
</span>
</td>
</tr>

View File

@@ -46,31 +46,28 @@
<div class="collapse navbar-collapse" id="navbarNav">
<ul class="navbar-nav">
<li class="nav-item">
<a class="nav-link" href="{{ base_url() }}">Главная</a>
<a class="nav-link" href="/">Список</a>
</li>
<li class="nav-item">
<a class="nav-link" href="/faq">FAQ</a>
</li>
<li class="nav-item">
<a class="nav-link" href="https://git.axenov.dev/IPTV">Исходники</a>
<a class="nav-link" href="https://git.axenov.dev/IPTV/.profile/src/branch/master/README.md#помощь-проекту">Поддержка</a>
</li>
<li class="nav-item">
<a class="nav-link" href="https://axenov.dev">axenov.dev</a>
</li>
<li class="nav-item">
<a class="nav-link" href="https://t.me/iptv_aggregator">Telegram</a>
</li>
<li class="nav-item">
<a class="nav-link" href="https://boosty.to/anthonyaxenov">
<img class="boosty" src="/boosty.svg" alt="Boosty">
Boosty
</a>
</li>
<li class="nav-item">
<a class="nav-link" href="https://yoomoney.ru/to/41001685237530">
<ion-icon name="card-outline"></ion-icon>
Yoomoney
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle"
href="#"
role="button"
data-bs-toggle="dropdown"
aria-expanded="false"
>
Telegram
</a>
<ul class="dropdown-menu dropdown-menu-dark">
<li><a class="dropdown-item" href="https://t.me/iptv_aggregator">Канал @iptv_aggregator</a></li>
<li><a class="dropdown-item" href="https://t.me/iptv_aggregator_chat">Чат @iptv_aggregator_chat</a></li>
<li><a class="dropdown-item" href="https://t.me/iptv_aggregator_bot">Бот @iptv_aggregator_bot</a></li>
</ul>
</li>
</ul>
</div>
@@ -88,7 +85,11 @@
<a href="/faq">FAQ</a>&nbsp;|&nbsp;<a
href="https://git.axenov.dev/IPTV">Исходники</a>&nbsp;|&nbsp;<a
href="https://axenov.dev">axenov.dev</a>&nbsp;|&nbsp;<a
href="https://t.me/iptv_aggregator">Telegram</a><br>
href="https://t.me/iptv_aggregator">Канал</a>&nbsp;|&nbsp;<a
href="https://t.me/iptv_aggregator_chat">Чат</a>&nbsp;|&nbsp;<a
href="https://t.me/iptv_aggregator_bot">Бот</a>
<br>
<a class="small text-secondary"
href="https://git.axenov.dev/IPTV/web/releases/tag/v{{ version() }}"
target="_blank"