Compare commits
21 Commits
97cea17f3c
...
restyle
| Author | SHA1 | Date | |
|---|---|---|---|
|
5c4554b9f9
|
|||
|
36c3570e05
|
|||
|
9083a96564
|
|||
|
80586d1489
|
|||
|
48d8a00b13
|
|||
|
1e188be6f3
|
|||
|
0b41503131
|
|||
|
58d9445d88
|
|||
|
66cbd73a02
|
|||
|
832be45a7a
|
|||
|
e42d7d75fe
|
|||
|
c1ce931677
|
|||
|
b30bd2fc18
|
|||
|
a36b3e1e9a
|
|||
|
7f10930b7b
|
|||
|
687ebc3fdc
|
|||
|
6cebf7356c
|
|||
|
1dc6389cb9
|
|||
|
5e3cc1eb63
|
|||
|
4c5ab796d3
|
|||
|
4e7cf9104c
|
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
## Лицензия
|
||||
|
||||
|
||||
@@ -21,6 +21,30 @@ use Psr\Http\Message\ServerRequestInterface;
|
||||
*/
|
||||
class ApiController extends BasicController
|
||||
{
|
||||
/**
|
||||
* Возвращает информацию о плейлисте
|
||||
*
|
||||
* @param ServerRequestInterface $request
|
||||
* @param ResponseInterface $response
|
||||
* @return ResponseInterface
|
||||
* @throws Exception
|
||||
*/
|
||||
public function getOne(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
|
||||
{
|
||||
try {
|
||||
$code = $request->getAttributes()['code'] ?? null;
|
||||
empty($code) && throw new PlaylistNotFoundException('');
|
||||
|
||||
$playlist = ini()->getPlaylist($code);
|
||||
if ($playlist['isOnline'] === true) {
|
||||
unset($playlist['content']);
|
||||
}
|
||||
return $this->responseJson($response, 200, $playlist);
|
||||
} catch (PlaylistNotFoundException $e) {
|
||||
return $this->responseJsonError($response, 404, $e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Возвращает информацию о каналов плейлиста
|
||||
*
|
||||
@@ -46,8 +70,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));
|
||||
}
|
||||
|
||||
@@ -56,24 +80,4 @@ class ApiController extends BasicController
|
||||
return $response->withStatus(200)
|
||||
->withHeader('Content-Type', $mime);
|
||||
}
|
||||
|
||||
/**
|
||||
* Возвращает информацию о плейлисте
|
||||
*
|
||||
* @param ServerRequestInterface $request
|
||||
* @param ResponseInterface $response
|
||||
* @return ResponseInterface
|
||||
* @throws Exception
|
||||
*/
|
||||
public function json(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
|
||||
{
|
||||
$code = $request->getAttributes()['code'];
|
||||
|
||||
try {
|
||||
$playlist = ini()->getPlaylist($code);
|
||||
return $this->responseJson($response, 200, $playlist);
|
||||
} catch (PlaylistNotFoundException $e) {
|
||||
return $this->responseJsonError($response, 404, $e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,8 +34,9 @@ class BasicController
|
||||
*/
|
||||
public function notFound(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
|
||||
{
|
||||
$code = $request->getAttributes()['code'] ?? '';
|
||||
$response->withStatus(404);
|
||||
$this->view($request, $response, 'notfound.twig');
|
||||
$this->view($request, $response, 'notfound.twig', ['code' => $code]);
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
38
app/Controllers/BotController.php
Normal file
38
app/Controllers/BotController.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -63,21 +63,6 @@ class WebController extends BasicController
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Возвращает страницу FAQ
|
||||
*
|
||||
* @param ServerRequestInterface $request
|
||||
* @param ResponseInterface $response
|
||||
* @return ResponseInterface
|
||||
* @throws LoaderError
|
||||
* @throws RuntimeError
|
||||
* @throws SyntaxError
|
||||
*/
|
||||
public function faq(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
|
||||
{
|
||||
return $this->view($request, $response, 'faq.twig');
|
||||
}
|
||||
|
||||
/**
|
||||
* Переадресует запрос на прямую ссылку плейлиста
|
||||
*
|
||||
|
||||
465
app/Core/Bot.php
Normal file
465
app/Core/Bot.php
Normal file
@@ -0,0 +1,465 @@
|
||||
<?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
|
||||
{
|
||||
$commandText = $this->getBotCommandText();
|
||||
return match (true) {
|
||||
str_starts_with($commandText, '/start') => $this->processHelpCommand(),
|
||||
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
|
||||
* @throws \TelegramBot\Api\Exception
|
||||
* @throws Exception
|
||||
*/
|
||||
protected function processListCommand(): bool
|
||||
{
|
||||
$this->bot->sendChatAction($this->update->getMessage()->getChat()->getId(), 'typing');
|
||||
|
||||
$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
|
||||
* @throws \TelegramBot\Api\Exception
|
||||
*/
|
||||
protected function processInfoCommand(): bool
|
||||
{
|
||||
$this->bot->sendChatAction($this->update->getMessage()->getChat()->getId(), 'typing');
|
||||
|
||||
$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
|
||||
* @throws \TelegramBot\Api\Exception
|
||||
*/
|
||||
protected function processHelpCommand(): bool
|
||||
{
|
||||
$this->bot->sendChatAction($this->update->getMessage()->getChat()->getId(), 'typing');
|
||||
|
||||
$replyText[] = 'Бот предоставляет короткую сводку о плейлистах, которые видны на сайте ' .
|
||||
$this->escape(base_url()) . '\.';
|
||||
$replyText[] = 'Плейлисты проверяются сервером автоматически\.';
|
||||
$replyText[] = '';
|
||||
$replyText[] = 'Команды бота:';
|
||||
$replyText[] = '`/list` \- список кодов всех плейлистов;';
|
||||
$replyText[] = '`/info <код>` \- информация о плейлисте с указанным кодом;';
|
||||
$replyText[] = '`/help` \- данная справка\;';
|
||||
$replyText[] = '`/links` \- ссылки на все страницы проекта\;';
|
||||
$replyText[] = '`/stats` \- статистика по плейлистам и каналам\.';
|
||||
$replyText[] = '';
|
||||
$replyText[] = 'Статусы плейлистов:';
|
||||
$replyText[] = '🟢 \- онлайн';
|
||||
$replyText[] = '🔴 \- оффлайн';
|
||||
$replyText[] = '⚪ \- не проверялось';
|
||||
$replyText[] = '🔞 \- есть каналы для взрослых';
|
||||
|
||||
return $this->reply(implode("\n", $replyText));
|
||||
}
|
||||
|
||||
/**
|
||||
* Обрабатывает команду /links
|
||||
*
|
||||
* @return bool
|
||||
* @throws \TelegramBot\Api\Exception
|
||||
*/
|
||||
protected function processLinksCommand(): bool
|
||||
{
|
||||
$this->bot->sendChatAction($this->update->getMessage()->getChat()->getId(), 'typing');
|
||||
|
||||
$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
|
||||
{
|
||||
$this->bot->sendChatAction($this->update->getMessage()->getChat()->getId(), 'typing');
|
||||
|
||||
$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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -41,15 +41,19 @@ class IniFile
|
||||
|
||||
// сохраняем порядок
|
||||
foreach (array_keys($ini) as $code) {
|
||||
$data = redis()->get($code);
|
||||
try {
|
||||
$data = @redis()->get($code);
|
||||
} catch (Throwable) {
|
||||
$data = false;
|
||||
}
|
||||
if ($data === false) {
|
||||
$raw = $ini[$code];
|
||||
$data = [
|
||||
'code' => $code,
|
||||
'name' => $raw['name'],
|
||||
'description' => $raw['desc'],
|
||||
'name' => $raw['name'] ?? "Playlist #$code",
|
||||
'description' => $raw['desc'] ?? null,
|
||||
'url' => $raw['pls'],
|
||||
'source' => $raw['src'],
|
||||
'source' => $raw['src'] ?? null,
|
||||
'content' => null,
|
||||
'isOnline' => null,
|
||||
'attributes' => [],
|
||||
@@ -59,7 +63,7 @@ class IniFile
|
||||
'offlineCount' => 0,
|
||||
'checkedAt' => null,
|
||||
];
|
||||
} else if (!isset($data['attributes'])) {
|
||||
} elseif (!isset($data['attributes'])) {
|
||||
$data['attributes'] = [];
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
* Проверячет существование файла
|
||||
*
|
||||
|
||||
20
app/Errors/InvalidTelegramSecretException.php
Normal file
20
app/Errors/InvalidTelegramSecretException.php
Normal 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");
|
||||
}
|
||||
}
|
||||
@@ -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), '/');
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
112
composer.lock
generated
@@ -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": "*",
|
||||
|
||||
@@ -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
13
config/bot.php
Normal 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'),
|
||||
];
|
||||
@@ -7,27 +7,35 @@
|
||||
|
||||
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]+}]',
|
||||
'handler' => [WebController::class, 'home'],
|
||||
'name' => 'home',
|
||||
],
|
||||
[
|
||||
'method' => 'GET',
|
||||
'path' => '/faq',
|
||||
'handler' => [WebController::class, 'faq'],
|
||||
'name' => 'faq',
|
||||
],
|
||||
[
|
||||
'method' => 'GET',
|
||||
'path' => '/{code:[0-9a-zA-Z]+}[.m3u[8]]',
|
||||
@@ -49,22 +57,16 @@ return [
|
||||
|
||||
[
|
||||
'method' => 'GET',
|
||||
'path' => '/{code:[0-9a-zA-Z]+}/json',
|
||||
'handler' => [ApiController::class, 'json'],
|
||||
'name' => 'json',
|
||||
'path' => '/api/playlists/{code:[0-9a-zA-Z]+}',
|
||||
'handler' => [ApiController::class, 'getOne'],
|
||||
'name' => 'api::getOne',
|
||||
],
|
||||
[
|
||||
'method' => 'GET',
|
||||
'path' => '/{code:[0-9a-zA-Z]+}/qrcode',
|
||||
'path' => '/api/playlists/{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',
|
||||
// ],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
@@ -74,7 +76,7 @@ return [
|
||||
|
||||
[
|
||||
'method' => '*',
|
||||
'path' => '/{path:.*}',
|
||||
'path' => '/{path:.+}',
|
||||
'handler' => [BasicController::class, 'notFound'],
|
||||
'name' => 'not-found',
|
||||
],
|
||||
|
||||
@@ -8,6 +8,10 @@
|
||||
|
||||
{% block title %}[{{ playlist.code }}] {{ playlist.name }} - {{ config('app.title') }}{% endblock %}
|
||||
|
||||
{% block metadescription %}Смотреть бесплатный самообновляемый плейлист {{ playlist.name }}, посмотреть статус плейлиста {{ playlist.description }}{% endblock %}
|
||||
|
||||
{% block metakeywords %}самообновляемый,бесплатный,iptv-плейлист,iptv,плейлист{% if (playlist.groups|length > 1) %}{% for group in playlist.groups %},{{ group.name|lower }}{% endfor %}{% endif %},{{ playlist.tags|join(',') }}{% endblock %}
|
||||
|
||||
{% block head %}
|
||||
<style>
|
||||
img.tvg-logo{max-width:80px;max-height:80px;padding:2px;border-radius:5px}
|
||||
@@ -27,11 +31,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 +63,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 +91,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>
|
||||
@@ -105,8 +106,8 @@
|
||||
<tr>
|
||||
<th scope="row">Наполнение</th>
|
||||
<td class="text-break">
|
||||
группы: {{ playlist.groups|length }},
|
||||
каналы: {{ playlist.channels|length }}
|
||||
<ion-icon name="folder-open-outline"></ion-icon> группы: {{ playlist.groups|length }},
|
||||
<ion-icon name="videocam-outline"></ion-icon> каналы: {{ playlist.channels|length }}
|
||||
(<span class="text-success">{{ playlist.onlineCount }}</span> + <span class="text-danger">{{ playlist.offlineCount }}</span>)
|
||||
</td>
|
||||
</tr>
|
||||
@@ -166,11 +167,6 @@
|
||||
>
|
||||
<ion-icon name="qr-code-outline"></ion-icon> 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">
|
||||
@@ -179,11 +175,16 @@
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body text-center">
|
||||
<img src="/{{ playlist.code }}/qrcode" alt="">
|
||||
<img src="/api/playlists/{{ playlist.code }}/qrcode" alt="">
|
||||
</div>
|
||||
</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 +195,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"
|
||||
@@ -336,7 +345,9 @@
|
||||
title="Состояние: оффлайн"
|
||||
{% endif %}
|
||||
|
||||
></ion-icon> <span class="chname">{{ channel.title }}</span>
|
||||
></ion-icon>{% if "adult" in channel.tags %}
|
||||
<span class="badge small bg-warning text-dark" title="Канал для взрослых!">18+</span>
|
||||
{% endif %}<span class="chname">{{ channel.title }}</span>
|
||||
<div class="text-secondary small">
|
||||
{% if (channel.attributes['tvg-id']) %}
|
||||
<div title="tvg-id">
|
||||
@@ -380,6 +391,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
|
||||
|
||||
308
views/faq.twig
308
views/faq.twig
@@ -1,308 +0,0 @@
|
||||
{###########################################################################
|
||||
# Copyright (c) 2025, Антон Аксенов
|
||||
# This file is part of iptv.axenov.dev web interface
|
||||
# MIT License: https://git.axenov.dev/IPTV/web/src/branch/master/LICENSE
|
||||
###########################################################################}
|
||||
|
||||
{% extends "template.twig" %}
|
||||
|
||||
{% block head %}
|
||||
<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 %}
|
||||
<h2>FAQ</h2>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row">
|
||||
<div class="col-md-12">
|
||||
<p class="mb-5">
|
||||
В этом сервисе собраны ссылки на IPTV-плейлисты, которые находятся в открытом доступе.
|
||||
Они отбираются вручную и периодически проверяются здесь автоматически.
|
||||
</p>
|
||||
|
||||
<p class="mb-5">
|
||||
Сервис "{{ config('app.title') }}" ({{ base_url() }}) не предназначен для хранения или трансляции
|
||||
видео/аудио потоков, программ телепередач, плейлистов и их поддержки. Этим занимаются администраторы
|
||||
ресурсов, указанные как источник, и те, с чьих ресурсов ведётся трансляция.
|
||||
</p>
|
||||
|
||||
<p class="mb-5">
|
||||
</p>
|
||||
|
||||
<p class="mb-5">
|
||||
Сервис "{{ config('app.title') }}" ({{ base_url() }}) предоставляет только информацию об активности
|
||||
плейлистов, найденных в открытом доступе, и короткие ссылки на них для удобства использования в ПО.
|
||||
Вопросы по содержанию и работоспособности плейлистов, а также вопросы юридического характера, адресуйте
|
||||
тем, кто несёт за них ответственность (см. источники плейлистов).
|
||||
</p>
|
||||
|
||||
<p class="mb-5">
|
||||
Автор не занимается созданием, изменением, размещением и хранением плейлистов на сайте
|
||||
"{{ config('app.title') }}" ({{ base_url() }}). Ни бесплатно, ни за деньги, ни бартером, ни за спасибо.
|
||||
<b>Все плейлисты, которые отображаются на сайте "{{ config('app.title') }}" ({{ base_url() }}), созданы
|
||||
и размещены третьими лицами на чужих серверах.</b>
|
||||
</p>
|
||||
|
||||
<p class="mb-5">
|
||||
Проект "{{ config('app.title') }}" ({{ base_url() }}) является бесплатным проектом с открытым исходным
|
||||
кодом, он публичен и открыт для всех. Весь его исходный код размещён в публичных репозиториях под
|
||||
лицензией MIT.
|
||||
</p>
|
||||
|
||||
<p class="mb-5">
|
||||
Автор не взимает плату за размещение ссылок на сторонние плейлисты на сайте "{{ config('app.title') }}"
|
||||
({{ base_url() }}). За содержимое плейлистов и их качество отвечают авторы плейлистов.
|
||||
</p>
|
||||
|
||||
<p class="mb-5">
|
||||
Автор не зарабатывает на проекте "{{ config('app.title') }}" ({{ base_url() }}) и не собирается.
|
||||
Всё, что ты видишь по этому адресу, сделано бесплатно и на энтузиазме.
|
||||
Но ты можешь сделать добровольное пожертвование, которое поможет мне компенсировать затраты на
|
||||
поддержку и техническое развитие проекта. Ссылки в шапке сайта.
|
||||
</p>
|
||||
|
||||
<!-- Для чего нужен сервис? -->
|
||||
<div class="alert my-5 bg-dark text-light border-secondary" id="why">
|
||||
<h2>Для чего нужен сервис?</h2>
|
||||
<p>Изначально сервис создавался "для себя", чтобы:</p>
|
||||
<ul>
|
||||
<li>сократить ссылки на сторонние плейлисты и их было проще вводить с пульта;</li>
|
||||
<li>собрать в одном месте наиболее годные плейлисты.</li>
|
||||
</ul>
|
||||
<p>
|
||||
Сейчас я сам им не пользуюсь, но им пользуются сотни людей ежедневно, чтобы найти
|
||||
плейлист себе по душе или по необходимости. Например, чтобы смотреть заблокированные российские
|
||||
телеканалы в свободной демократической европе.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Как пользоваться сервисом? -->
|
||||
<div class="alert my-5 bg-dark text-light border-secondary" id="how">
|
||||
<h2>Как пользоваться сервисом?</h2>
|
||||
<p>
|
||||
На главной странице отображается список доступных в плейлистов, их идентификаторы, статусы,
|
||||
количество каналов и короткие ссылки.
|
||||
Для просмотра списка каналов следует нажать на ссылку <b>"Подробнее..."</b> под интересующим
|
||||
плейлистом.
|
||||
Для добавления плейлиста в свой медиаплеер удобно использовать <b>"Ссылку для ТВ"</b>.
|
||||
На странице детальной информации также есть прямая ссылка на сам плейлист от источника.
|
||||
Можно использовать и её.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Какие плейлисты попадают сюда? -->
|
||||
<div class="alert my-5 bg-dark text-light border-secondary" id="which">
|
||||
<h2>Какие плейлисты попадают сюда?</h2>
|
||||
<p>Есть некоторые критерии, по которым плейлисты отбираются в этот список:</p>
|
||||
<ul>
|
||||
<li>Прежде всего -- каналы РФ и бывшего СНГ, но не только</li>
|
||||
<li>Открытый источник</li>
|
||||
<li>Прямая ссылка на плейлист</li>
|
||||
<li>Автообновление плейлиста</li>
|
||||
</ul>
|
||||
<p>
|
||||
В основном, в плейлистах именно трансляции телеканалов, но могут быть просто список каких-то
|
||||
(мульт)фильмов и передач, находящихся на чужих дисках (как если бы вы сами составили плейлист с музыкой,
|
||||
например).
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Что означают статусы? -->
|
||||
<div class="alert my-5 bg-dark text-light border-secondary" id="statuses">
|
||||
<h2>Что означают статусы?</h2>
|
||||
<p>Плейлист может быть в одном из трёх статусов:</p>
|
||||
<ul>
|
||||
<li>
|
||||
<span class="badge small text-dark bg-secondary">unknown</span>
|
||||
Плейлист ещё не проверялся, можно зайти позже.
|
||||
</li>
|
||||
<li>
|
||||
<span class="badge small text-dark bg-success">online</span>
|
||||
Плейлист активен. Это не значит, что он работает. В нём может быть 0 каналов.
|
||||
</li>
|
||||
<li>
|
||||
<span class="badge small text-dark bg-danger">offline</span>
|
||||
Плейлист недоступен, вообще никак. Главный кандидат на удаление с сайта.
|
||||
</li>
|
||||
</ul>
|
||||
<p>Каждый канал в плейлисте может быть в одном из трёх статусов:</p>
|
||||
<ul>
|
||||
<li>
|
||||
<span class="text-success"><ion-icon name="radio-button-on-outline"></ion-icon></span>
|
||||
Канал активен. Это не значит, что он работает. Там может транслироваться какая-нибудь заглушка (например, от Wink).
|
||||
</li>
|
||||
<li>
|
||||
<span class="text-danger"><ion-icon name="radio-button-on-outline"></ion-icon></span>
|
||||
Канал не работает.
|
||||
</li>
|
||||
</ul>
|
||||
<p>
|
||||
Я не гарантирую корректность и актуальность информации, которую ты увидишь здесь.
|
||||
Хотя я и стараюсь улучшать качество проверок, но всё же рекомендую проверять желаемые
|
||||
плейлисты самостоятельно вручную, ибо нет никаких гарантий:
|
||||
</p>
|
||||
<ul>
|
||||
<li>что плейлисты по разным ссылкам не дублируют друг друга и отличаются каналами хотя бы на четверть;</li>
|
||||
<li>что плейлист работоспособен (каналы работают, корректно названы, имеют аудио, etc.);</li>
|
||||
<li>что подгрузится корректное количество каналов и их список.</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- Как часто обновляется список плейлистов? -->
|
||||
<div class="alert my-5 bg-dark text-light border-secondary" id="how-often-list">
|
||||
<h2>Как часто обновляется список плейлистов?</h2>
|
||||
<p>
|
||||
Время от времени.
|
||||
Иногда я захожу сюда и проверяю всё ли на месте, иногда занимаюсь какими-то доработками.
|
||||
Если есть кандидаты на добавление, то читай ниже.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Как часто обновляется содержимое плейлистов? -->
|
||||
<div class="alert my-5 bg-dark text-light border-secondary" id="how-often-chan">
|
||||
<h2>Как часто обновляется содержимое плейлистов?</h2>
|
||||
<p>Зависит от источника. Я этим не занимаюсь.</p>
|
||||
</div>
|
||||
|
||||
<!-- Есть приложение? -->
|
||||
<div class="alert my-5 bg-dark text-light border-secondary" id="app">
|
||||
<h2>Есть приложение?</h2>
|
||||
<p>Нет, и не планируется. Ищи плеер и добавляй плейлист туда по ссылке.</p>
|
||||
</div>
|
||||
|
||||
<!-- Эти плейлисты и каналы в них -- бесплатны? -->
|
||||
<div class="alert my-5 bg-dark text-light border-secondary" id="is-pls-free">
|
||||
<h2>Эти плейлисты и каналы в них -- бесплатны?</h2>
|
||||
<p>
|
||||
Возможно. По крайней мере, так утверждают источники, которые их распространяют.
|
||||
Но гарантий никаких никто не даёт. Любой плейлист и любой канал в любом плейлисте может сдохнуть
|
||||
навсегда в любой момент. Или показывать заглушку.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Заглушка 1 -->
|
||||
<div class="alert my-5 bg-dark text-light border-secondary" id="paywall1">
|
||||
<h2 class="text-warning">
|
||||
На канале отображается заглушка:<br /><br />
|
||||
<span class="fst-italic">"Уважаемый клиент! Для возобновления просмотра Вам необходимо использовать не более 2 устройств"</span><br /><br />
|
||||
или<br /><br />
|
||||
<span class="fst-italic">"Ваша подписка не активна"</span>
|
||||
</h2>
|
||||
<p>Кто-то воткнул платный канал в плейлист и распространил его как бесплатный.</p>
|
||||
<p>Забудь про этот плейлист. Ищи другой. Без вариантов. Такова цена халявы.</p>
|
||||
<p>Нет, я не буду это исправлять.</p>
|
||||
</div>
|
||||
|
||||
<!-- Заглушка 2 -->
|
||||
<div class="alert my-5 bg-dark text-light border-secondary" id="paywall2">
|
||||
<h2 class="text-warning">
|
||||
На канале отображается заглушка:<br /><br />
|
||||
<span class="fst-italic">"Просмотр ТВ-каналов, фильмов и сериалов доступен только в официальных приложения Wink и на территории России"</span>
|
||||
</h2>
|
||||
<p>Кто-то воткнул платный канал в плейлист и распространил его как бесплатный.</p>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<!-- Добавь канал! -->
|
||||
<div class="alert my-5 bg-dark text-light border-secondary" id="add-chan">
|
||||
<h2 class="text-danger">Добавь канал!</h2>
|
||||
<p class="h1 my-5">Нет.</p>
|
||||
</div>
|
||||
|
||||
<!-- Добавь плейлист! -->
|
||||
<div class="alert my-5 bg-dark text-light border-secondary" id="create-list">
|
||||
<h2 class="text-danger">Сделай плейлист!</h2>
|
||||
<p class="h1 my-5">Нет.</p>
|
||||
</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>
|
||||
{% endblock %}
|
||||
199
views/list.twig
199
views/list.twig
@@ -6,107 +6,132 @@
|
||||
|
||||
{% extends "template.twig" %}
|
||||
|
||||
{% block metadescription %}Самообновляемые бесплатные IPTV-плейлисты для домашнего просмотра по коротким ссылкам, списки каналов, проверка доступности{% endblock %}
|
||||
|
||||
{% block metakeywords %}самообновляемые,бесплатные,iptv-плейлисты,iptv,плейлисты{% endblock %}
|
||||
|
||||
{% block head %}
|
||||
<style>
|
||||
.card {transition: box-shadow .2s, transform .2s}
|
||||
.card.hover-success:hover {transform: translateY(-7px); box-shadow: rgba(var(--bs-success-rgb), 1) 0 5px 20px -5px}
|
||||
.card.hover-danger:hover {transform: translateY(-7px); box-shadow: rgba(var(--bs-danger-rgb), 1) 0 5px 20px -5px}
|
||||
.card.hover-secondary:hover {transform: translateY(-7px); box-shadow: rgba(var(--bs-secondary-rgb), 1) 0 5px 20px -5px}
|
||||
</style>
|
||||
<script>
|
||||
function setDefaultLogo(imgtag) {
|
||||
imgtag.onerror = null
|
||||
imgtag.src = '/no-tvg-logo.png'
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
{% block header %}
|
||||
<div class="row text-muted small">
|
||||
<div class="col-md">
|
||||
Список изменён: {{ updatedAt }} МСК<br/>
|
||||
Плейлистов в списке: <strong>{{ count }}</strong>
|
||||
<div class="d-flex flex-wrap justify-content-between align-items-center mb-4">
|
||||
<div class="mb-2">
|
||||
<h2 class="mb-0">Список плейлистов ({{ count }})</h2>
|
||||
<div class="text-muted small">Изменён {{ updatedAt }} МСК</div>
|
||||
</div>
|
||||
<div class="col-md">
|
||||
Состояние проверки:<br />
|
||||
<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 class="d-flex flex-wrap gap-2 mb-2">
|
||||
<span class="badge bg-success">online: {{ onlineCount }}</span>
|
||||
<span class="badge bg-danger">offline: {{ offlineCount }}</span>
|
||||
<span class="badge bg-secondary">unknown: {{ uncheckedCount }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<hr/>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="table-responsive">
|
||||
<table class="table table-responsive table-dark table-hover small">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="col-1 text-center">ID</th>
|
||||
<th class="col-8">Информация о плейлисте</th>
|
||||
<th class="col-1 text-center">Каналов</th>
|
||||
<th class="col-2 d-none d-sm-table-cell">Ссылка для ТВ</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<div class="row g-4">
|
||||
{% for code, playlist in playlists %}
|
||||
<tr class="pls" data-playlist-code="{{ code }}">
|
||||
<td class="text-center font-monospace code">{{ code }}</td>
|
||||
<td class="info">
|
||||
{% 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 %}
|
||||
{% if "adult" in playlist.tags %}
|
||||
<span class="badge small bg-warning text-dark" title="Есть каналы для взрослых!">18+</span>
|
||||
{% endif %}
|
||||
<a href="/{{ code }}/details" class="text-light fw-bold text-decoration-none">{{ playlist.name }}</a>
|
||||
<div class="small mt-2">
|
||||
{% if playlist.description|length > 0 %}
|
||||
<p class="my-1 d-none d-lg-block">{{ playlist.description }}</p>
|
||||
{% set statusClass = 'secondary' %}
|
||||
{% if playlist.isOnline is same as(true) %}
|
||||
{% set statusClass = 'success' %}
|
||||
{% elseif playlist.isOnline is same as(false) %}
|
||||
{% set statusClass = 'danger' %}
|
||||
{% endif %}
|
||||
|
||||
<div class="col-md-6 col-lg-4">
|
||||
<div class="card bg-dark text-light h-100 border border-{{ statusClass }} hover-{{ statusClass }} position-relative">
|
||||
<a href="/{{ code }}/details" class="text-decoration-none">
|
||||
<div class="card-header d-flex align-items-center gap-2">
|
||||
<span class="font-monospace text-{{ statusClass }}">{{ code }}</span>
|
||||
<span class="badge bg-{{ statusClass }} ms-auto">
|
||||
{% if playlist.isOnline is same as(true) %}online
|
||||
{% elseif playlist.isOnline is same as(false) %}offline
|
||||
{% elseif playlist.isOnline is same as(null) %}unknown
|
||||
{% endif %}
|
||||
</span>
|
||||
{% if "adult" in playlist.tags %}
|
||||
<span class="badge bg-warning text-dark" title="Есть каналы для взрослых!">18+</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<div class="card-body position-relative z-2">
|
||||
<a href="/{{ code }}/details" class="text-decoration-none">
|
||||
<h5 class="card-title text-light">{{ playlist.name }}</h5>
|
||||
</a>
|
||||
{% if playlist.description is not same as(null) %}
|
||||
<p class="card-text small text-secondary d-none d-md-block">{{ playlist.description }}</p>
|
||||
{% endif %}
|
||||
{% 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="/{{ code }}/details" class="text-light">Подробнее...</a>
|
||||
<div class="d-flex flex-wrap gap-2 mb-1">
|
||||
{% if playlist.isOnline is not same as(null) %}
|
||||
<span class="badge border border-secondary">
|
||||
<ion-icon name="videocam-outline" class="me-1"></ion-icon> {{ playlist.channels|length }}<span class="d-none d-xl-inline-block"> каналов</span>
|
||||
</span>
|
||||
{% endif %}
|
||||
{% if playlist.groups|length > 0 %}
|
||||
<span class="badge border border-secondary">
|
||||
<ion-icon name="folder-open-outline" class="me-1"></ion-icon> {{ playlist.groups|length }}<span class="d-none d-xl-inline-block"> групп</span>
|
||||
</span>
|
||||
{% endif %}
|
||||
{% if playlist.hasTvg %}
|
||||
<span class="badge border border-secondary">
|
||||
<ion-icon name="newspaper-outline" class="me-1"></ion-icon><span class="d-none d-xl-inline-block"> ТВ-программа</span>
|
||||
</span>
|
||||
{% endif %}
|
||||
{% if playlist.hasCatchup %}
|
||||
<span class="badge border border-secondary">
|
||||
<ion-icon name="play-back-outline" class="me-1"></ion-icon><span class="d-none d-xl-inline-block"> Архив</span>
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td class="text-center">
|
||||
{% if (playlist.isOnline is not same as(null)) %}
|
||||
{{ playlist.channels|length }}
|
||||
{% else %}
|
||||
?
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="d-none d-sm-table-cell">
|
||||
<span onclick="prompt('Скопируй адрес плейлиста', 'm3u.su/{{ playlist.code }}')"
|
||||
title="Нажми на ссылку, чтобы скопировать её в буфер обмена"
|
||||
class="font-monospace cursor-pointer"
|
||||
|
||||
<div class="card-footer cursor-pointer"
|
||||
onclick="prompt('Скопируй адрес плейлиста. Если не работает, добавь \'.m3u\' в конец.', '{{ mirror_url(playlist.code) }}')"
|
||||
title="Нажми чтобы скопировать"
|
||||
>
|
||||
m3u.su/{{ playlist.code }}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
<div class="d-flex justify-content-between align-items-center small">
|
||||
<span class="font-monospace text-truncate">
|
||||
{{ mirror_url(playlist.code) }}
|
||||
</span>
|
||||
<ion-icon name="copy-outline"></ion-icon>
|
||||
</div>
|
||||
</div>
|
||||
<a href="/{{ code }}/details" class="text-decoration-none">
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% if pageCount > 1 %}
|
||||
<div aria-label="pages">
|
||||
<ul class="pagination justify-content-center">
|
||||
{% for page in range(1, pageCount) %}
|
||||
{% if page == pageCurrent %}
|
||||
<li class="page-item active" aria-current="page">
|
||||
<span class="page-link">{{ page }}</span>
|
||||
</li>
|
||||
{% else %}
|
||||
<li class="page-item">
|
||||
<a class="page-link bg-dark border-secondary text-light" href="page/{{ page }}">{{ page }}</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{% if pageCount > 1 %}
|
||||
<nav class="mt-4">
|
||||
<ul class="pagination justify-content-center">
|
||||
{% for page in range(1, pageCount) %}
|
||||
{% if page == pageCurrent %}
|
||||
<li class="page-item active" aria-current="page">
|
||||
<span class="page-link">{{ page }}</span>
|
||||
</li>
|
||||
{% else %}
|
||||
<li class="page-item">
|
||||
<a class="page-link bg-dark text-light" href="page/{{ page }}">{{ page }}</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</nav>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block footer %}
|
||||
|
||||
@@ -6,19 +6,26 @@
|
||||
|
||||
{% extends "template.twig" %}
|
||||
|
||||
{% block header %}
|
||||
<h2>Плейлист не найден</h2>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row">
|
||||
<div class="col-md-12">
|
||||
<p>
|
||||
Плейлист {{ id }} не найден
|
||||
</p>
|
||||
<a class="btn btn-outline-light" href="{{ base_url() }}" title="На главную">
|
||||
Перейти к списку
|
||||
</a>
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-md-8 col-lg-6 text-center">
|
||||
<div class="card bg-dark border-secondary">
|
||||
<div class="card-body">
|
||||
<ion-icon name="warning-outline" class="display-1 text-warning mb-3"></ion-icon>
|
||||
<h2 class="card-title">Плейлист <code>{{ code }}</code> не найден</h2>
|
||||
<p class="card-text">
|
||||
Возможно, его здесь никогда не было, либо он уже был удалён.
|
||||
</p>
|
||||
<p class="text-muted small">
|
||||
Если хочешь, чтобы здесь был плейлист, предложи его к добавлению.
|
||||
<br />
|
||||
<a href="https://iptv.axenov.dev/docs/support.html#participate">Как это сделать?</a>
|
||||
</p>
|
||||
<a class="btn btn-outline-light" href="/" title="На главную">
|
||||
<ion-icon name="list-outline" class="me-1"></ion-icon>Перейти к списку плейлистов
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
@@ -5,17 +5,15 @@
|
||||
###########################################################################}
|
||||
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<html lang="ru" class="h-100">
|
||||
<head>
|
||||
<title>{% block title %}{{ config('app.title') }}{% endblock %}</title>
|
||||
<meta charset="utf-8">
|
||||
<meta name="description" content="Самообновляемые бесплатные IPTV-плейлисты для домашнего просмотра по коротким ссылкам, списки каналов, проверка доступности">
|
||||
<meta name="description" content="{% block metadescription %}{% endblock %}">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="keywords" content="{% block metakeywords %}{% endblock %}" />
|
||||
<meta http-equiv="x-ua-compatible" content="ie=edge">
|
||||
<style>
|
||||
.cursor-pointer{cursor:pointer}
|
||||
.boosty{vertical-align:baseline;float:left;display:inline;width:20px}
|
||||
</style>
|
||||
<style>.cursor-pointer{cursor:pointer}</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="/css/bootstrap.min.css" rel="stylesheet">
|
||||
@@ -32,69 +30,107 @@
|
||||
<meta name="theme-color" content="#212529">
|
||||
{% block head %}{% endblock %}
|
||||
</head>
|
||||
<body class="bg-dark text-light">
|
||||
<div class="container col-lg-10 mx-auto">
|
||||
<header>
|
||||
<nav class="navbar navbar-expand-lg navbar-dark">
|
||||
<a class="navbar-brand" href="/" title="На главную">
|
||||
<img src="/favicon/favicon-32x32.png" class="d-inline-block px-lg-1" alt="Логотип проекта - emoji телевизора"/>
|
||||
{{ config('app.title') }}
|
||||
</a>
|
||||
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav" aria-controls="navbarNav" aria-expanded="false" aria-label="Toggle navigation">
|
||||
<span class="navbar-toggler-icon"></span>
|
||||
</button>
|
||||
<div class="collapse navbar-collapse" id="navbarNav">
|
||||
<ul class="navbar-nav">
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{{ base_url() }}">Главная</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>
|
||||
</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
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</nav>
|
||||
</header>
|
||||
<body class="d-flex flex-column h-100 bg-dark text-light">
|
||||
<header class="sticky-top bg-dark border-bottom border-secondary">
|
||||
<nav class="navbar navbar-expand-lg navbar-dark container px-2">
|
||||
<a class="navbar-brand d-flex align-items-center gap-2" href="/" title="На главную">
|
||||
<img src="/favicon/favicon-32x32.png" alt="Логотип проекта" class="d-inline-block">
|
||||
<span>{{ config('app.title') }}</span>
|
||||
</a>
|
||||
<button class="navbar-toggler"
|
||||
type="button"
|
||||
data-bs-toggle="collapse"
|
||||
data-bs-target="#navbarNav"
|
||||
aria-controls="navbarNav"
|
||||
>
|
||||
<span class="navbar-toggler-icon"></span>
|
||||
</button>
|
||||
<div class="collapse navbar-collapse" id="navbarNav">
|
||||
<ul class="navbar-nav ms-auto">
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" target="_blank" href="/docs">
|
||||
<ion-icon name="document-text-outline" class="me-1"></ion-icon> Документация
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" target="_blank" href="/docs/support.html">
|
||||
<ion-icon name="heart-outline" class="me-1"></ion-icon> Помочь проекту
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item dropdown">
|
||||
<a class="nav-link dropdown-toggle d-flex align-items-center"
|
||||
href="#"
|
||||
role="button"
|
||||
data-bs-toggle="dropdown"
|
||||
aria-expanded="false"
|
||||
>
|
||||
<ion-icon name="paper-plane-outline" class="me-1"></ion-icon> Telegram
|
||||
</a>
|
||||
<ul class="dropdown-menu dropdown-menu-dark">
|
||||
<li>
|
||||
<a class="dropdown-item d-flex align-items-center gap-2" target="_blank" href="https://t.me/iptv_aggregator">
|
||||
<ion-icon name="megaphone-outline"></ion-icon> Канал @iptv_aggregator
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a class="dropdown-item d-flex align-items-center gap-2" target="_blank" href="https://t.me/iptv_aggregator_chat">
|
||||
<ion-icon name="chatbubbles-outline"></ion-icon> Чат @iptv_aggregator_chat
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a class="dropdown-item d-flex align-items-center gap-2" target="_blank" href="https://t.me/iptv_aggregator_bot">
|
||||
<ion-icon name="chatbox-ellipses-outline"></ion-icon> Бот @iptv_aggregator_bot
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<section class="container h-100 pt-lg-3 px-0 pb-0">
|
||||
{% block header %}{% endblock %}
|
||||
<main class="flex-grow-1 container py-4">
|
||||
{% block header %}{% endblock %}
|
||||
<div class="content-wrapper">
|
||||
{% block content %}{% endblock %}
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<footer class="py-4 text-center">
|
||||
<script src="/js/bootstrap.bundle.min.js"></script>
|
||||
{% block footer %}{% endblock %}
|
||||
<a href="/faq">FAQ</a> | <a
|
||||
href="https://git.axenov.dev/IPTV">Исходники</a> | <a
|
||||
href="https://axenov.dev">axenov.dev</a> | <a
|
||||
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>
|
||||
</div>
|
||||
<footer class="bg-dark border-top border-secondary py-4">
|
||||
<div class="container text-center">
|
||||
<div class="d-flex flex-wrap justify-content-center gap-3 mb-3">
|
||||
<a target="_blank" href="/docs" class="text-light text-decoration-none d-flex align-items-center gap-1">
|
||||
<ion-icon name="document-text-outline"></ion-icon>Документация
|
||||
</a>
|
||||
<a target="_blank" href="https://git.axenov.dev/IPTV" class="text-light text-decoration-none d-flex align-items-center gap-1">
|
||||
<ion-icon name="code-slash-outline"></ion-icon>Исходники
|
||||
</a>
|
||||
<a target="_blank" href="https://axenov.dev" class="text-light text-decoration-none d-flex align-items-center gap-1">
|
||||
<ion-icon name="person-outline"></ion-icon>axenov.dev
|
||||
</a>
|
||||
<a target="_blank" href="https://t.me/iptv_aggregator" class="text-light text-decoration-none d-flex align-items-center gap-1">
|
||||
<ion-icon name="megaphone-outline"></ion-icon>Канал
|
||||
</a>
|
||||
<a target="_blank" href="https://t.me/iptv_aggregator_chat" class="text-light text-decoration-none d-flex align-items-center gap-1">
|
||||
<ion-icon name="chatbubbles-outline"></ion-icon>Чат
|
||||
</a>
|
||||
<a target="_blank" href="https://t.me/iptv_aggregator_bot" class="text-light text-decoration-none d-flex align-items-center gap-1">
|
||||
<ion-icon name="chatbox-ellipses-outline"></ion-icon>Бот
|
||||
</a>
|
||||
</div>
|
||||
<div>
|
||||
<a class="small text-secondary d-inline-flex align-items-center gap-1"
|
||||
href="https://git.axenov.dev/IPTV/web/releases/tag/v{{ version() }}"
|
||||
target="_blank"
|
||||
>
|
||||
<ion-icon name="pricetag-outline"></ion-icon>v{{ version() }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<script src="/js/bootstrap.bundle.min.js"></script>
|
||||
{% block footer %}{% endblock %}
|
||||
|
||||
{% include("custom.twig") ignore missing %}
|
||||
</body>
|
||||
|
||||
Reference in New Issue
Block a user