Compare commits

...

13 Commits

11 changed files with 287 additions and 530 deletions

View File

@@ -21,6 +21,30 @@ use Psr\Http\Message\ServerRequestInterface;
*/ */
class ApiController extends BasicController 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);
}
}
/** /**
* Возвращает информацию о каналов плейлиста * Возвращает информацию о каналов плейлиста
* *
@@ -56,24 +80,4 @@ class ApiController extends BasicController
return $response->withStatus(200) return $response->withStatus(200)
->withHeader('Content-Type', $mime); ->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);
}
}
} }

View File

@@ -34,8 +34,9 @@ class BasicController
*/ */
public function notFound(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface public function notFound(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
{ {
$code = $request->getAttributes()['code'] ?? '';
$response->withStatus(404); $response->withStatus(404);
$this->view($request, $response, 'notfound.twig'); $this->view($request, $response, 'notfound.twig', ['code' => $code]);
return $response; return $response;
} }

View File

@@ -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');
}
/** /**
* Переадресует запрос на прямую ссылку плейлиста * Переадресует запрос на прямую ссылку плейлиста
* *

View File

@@ -72,9 +72,9 @@ class Bot
*/ */
public function process(): bool public function process(): bool
{ {
$this->bot->sendChatAction($this->update->getMessage()->getChat()->getId(), 'typing');
$commandText = $this->getBotCommandText(); $commandText = $this->getBotCommandText();
return match (true) { return match (true) {
str_starts_with($commandText, '/start') => $this->processHelpCommand(),
str_starts_with($commandText, '/list') => $this->processListCommand(), str_starts_with($commandText, '/list') => $this->processListCommand(),
str_starts_with($commandText, '/info') => $this->processInfoCommand(), str_starts_with($commandText, '/info') => $this->processInfoCommand(),
str_starts_with($commandText, '/help') => $this->processHelpCommand(), str_starts_with($commandText, '/help') => $this->processHelpCommand(),
@@ -89,9 +89,13 @@ class Bot
* *
* @return bool * @return bool
* @throws InvalidArgumentException * @throws InvalidArgumentException
* @throws \TelegramBot\Api\Exception
* @throws Exception
*/ */
protected function processListCommand(): bool protected function processListCommand(): bool
{ {
$this->bot->sendChatAction($this->update->getMessage()->getChat()->getId(), 'typing');
$playlists = ini()->getPlaylists(); $playlists = ini()->getPlaylists();
if (empty($playlists)) { if (empty($playlists)) {
$replyText = 'Плейлистов нет'; $replyText = 'Плейлистов нет';
@@ -131,9 +135,12 @@ class Bot
* *
* @return bool * @return bool
* @throws InvalidArgumentException * @throws InvalidArgumentException
* @throws \TelegramBot\Api\Exception
*/ */
protected function processInfoCommand(): bool protected function processInfoCommand(): bool
{ {
$this->bot->sendChatAction($this->update->getMessage()->getChat()->getId(), 'typing');
$message = $this->update->getMessage(); $message = $this->update->getMessage();
$text = $message->getText(); $text = $message->getText();
$command = $this->getBotCommand(); $command = $this->getBotCommand();
@@ -224,9 +231,12 @@ class Bot
* Обрабатывает команду /help * Обрабатывает команду /help
* *
* @return bool * @return bool
* @throws \TelegramBot\Api\Exception
*/ */
protected function processHelpCommand(): bool protected function processHelpCommand(): bool
{ {
$this->bot->sendChatAction($this->update->getMessage()->getChat()->getId(), 'typing');
$replyText[] = 'Бот предоставляет короткую сводку о плейлистах, которые видны на сайте ' . $replyText[] = 'Бот предоставляет короткую сводку о плейлистах, которые видны на сайте ' .
$this->escape(base_url()) . '\.'; $this->escape(base_url()) . '\.';
$replyText[] = 'Плейлисты проверяются сервером автоматически\.'; $replyText[] = 'Плейлисты проверяются сервером автоматически\.';
@@ -234,7 +244,9 @@ class Bot
$replyText[] = 'Команды бота:'; $replyText[] = 'Команды бота:';
$replyText[] = '`/list` \- список кодов всех плейлистов;'; $replyText[] = '`/list` \- список кодов всех плейлистов;';
$replyText[] = '`/info <код>` \- информация о плейлисте с указанным кодом;'; $replyText[] = '`/info <код>` \- информация о плейлисте с указанным кодом;';
$replyText[] = '`/help` \- данная справка\.'; $replyText[] = '`/help` \- данная справка\;';
$replyText[] = '`/links` \- ссылки на все страницы проекта\;';
$replyText[] = '`/stats` \- статистика по плейлистам и каналам\.';
$replyText[] = ''; $replyText[] = '';
$replyText[] = 'Статусы плейлистов:'; $replyText[] = 'Статусы плейлистов:';
$replyText[] = '🟢 \- онлайн'; $replyText[] = '🟢 \- онлайн';
@@ -249,9 +261,12 @@ class Bot
* Обрабатывает команду /links * Обрабатывает команду /links
* *
* @return bool * @return bool
* @throws \TelegramBot\Api\Exception
*/ */
protected function processLinksCommand(): bool protected function processLinksCommand(): bool
{ {
$this->bot->sendChatAction($this->update->getMessage()->getChat()->getId(), 'typing');
$replyText[] = '*Ресурсы и страницы*'; $replyText[] = '*Ресурсы и страницы*';
$replyText[] = ''; $replyText[] = '';
$replyText[] = '🌏 Сайт: ' . $this->escape(base_url()); $replyText[] = '🌏 Сайт: ' . $this->escape(base_url());
@@ -274,6 +289,8 @@ class Bot
*/ */
protected function processStatsCommand(): bool protected function processStatsCommand(): bool
{ {
$this->bot->sendChatAction($this->update->getMessage()->getChat()->getId(), 'typing');
$allChannels = []; $allChannels = [];
foreach (ini()->getPlaylists() as $pls) { foreach (ini()->getPlaylists() as $pls) {
$allChannels = array_merge($allChannels, $pls['channels'] ?? []); $allChannels = array_merge($allChannels, $pls['channels'] ?? []);

View File

@@ -41,7 +41,11 @@ class IniFile
// сохраняем порядок // сохраняем порядок
foreach (array_keys($ini) as $code) { foreach (array_keys($ini) as $code) {
$data = redis()->get($code); try {
$data = @redis()->get($code);
} catch (Throwable) {
$data = false;
}
if ($data === false) { if ($data === false) {
$raw = $ini[$code]; $raw = $ini[$code];
$data = [ $data = [

View File

@@ -6,6 +6,7 @@
*/ */
use App\Controllers\ApiController; use App\Controllers\ApiController;
use App\Controllers\BasicController;
use App\Controllers\BotController; use App\Controllers\BotController;
use App\Controllers\WebController; use App\Controllers\WebController;
@@ -35,12 +36,6 @@ return [
'handler' => [WebController::class, 'home'], 'handler' => [WebController::class, 'home'],
'name' => 'home', 'name' => 'home',
], ],
[
'method' => 'GET',
'path' => '/faq',
'handler' => [WebController::class, 'faq'],
'name' => 'faq',
],
[ [
'method' => 'GET', 'method' => 'GET',
'path' => '/{code:[0-9a-zA-Z]+}[.m3u[8]]', 'path' => '/{code:[0-9a-zA-Z]+}[.m3u[8]]',
@@ -62,13 +57,13 @@ return [
[ [
'method' => 'GET', 'method' => 'GET',
'path' => '/{code:[0-9a-zA-Z]+}/json', 'path' => '/api/playlists/{code:[0-9a-zA-Z]+}',
'handler' => [ApiController::class, 'json'], 'handler' => [ApiController::class, 'getOne'],
'name' => 'json', 'name' => 'api::getOne',
], ],
[ [
'method' => 'GET', 'method' => 'GET',
'path' => '/{code:[0-9a-zA-Z]+}/qrcode', 'path' => '/api/playlists/{code:[0-9a-zA-Z]+}/qrcode',
'handler' => [ApiController::class, 'makeQrCode'], 'handler' => [ApiController::class, 'makeQrCode'],
'name' => 'api::makeQrCode', 'name' => 'api::makeQrCode',
], ],

View File

@@ -8,6 +8,10 @@
{% block title %}[{{ playlist.code }}] {{ playlist.name }} - {{ config('app.title') }}{% endblock %} {% 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 %} {% block head %}
<style> <style>
img.tvg-logo{max-width:80px;max-height:80px;padding:2px;border-radius:5px} img.tvg-logo{max-width:80px;max-height:80px;padding:2px;border-radius:5px}
@@ -171,7 +175,7 @@
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button> <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div> </div>
<div class="modal-body text-center"> <div class="modal-body text-center">
<img src="/{{ playlist.code }}/qrcode" alt=""> <img src="/api/playlists/{{ playlist.code }}/qrcode" alt="">
</div> </div>
</div> </div>
</div> </div>

View File

@@ -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 %}

View File

@@ -6,119 +6,132 @@
{% extends "template.twig" %} {% 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 %} {% block header %}
<div class="row text-muted small"> <div class="d-flex flex-wrap justify-content-between align-items-center mb-4">
<div class="col-md"> <div class="mb-2">
Список изменён:&nbsp;{{ updatedAt }}&nbsp;МСК<br/> <h2 class="mb-0">Список плейлистов ({{ count }})</h2>
Плейлистов в списке:&nbsp;<strong>{{ count }}</strong> <div class="text-muted small">Изменён {{ updatedAt }} МСК</div>
</div> </div>
<div class="col-md"> <div class="d-flex flex-wrap gap-2 mb-2">
Состояние проверки:<br /> <span class="badge bg-success">online: {{ onlineCount }}</span>
<span class="me-1"> <span class="badge bg-danger">offline: {{ offlineCount }}</span>
<span class="badge me-1 bg-success text-dark">online</span>{{ onlineCount }} <span class="badge bg-secondary">unknown: {{ uncheckedCount }}</span>
</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" title="В очереди на проверку">unknown</span>{{ uncheckedCount }}
</span>
</div> </div>
</div> </div>
<hr/>
{% endblock %} {% endblock %}
{% block content %} {% block content %}
<div class="table-responsive"> <div class="row g-4">
<table class="table table-responsive table-dark table-hover small">
<thead>
<tr>
<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>
</tr>
</thead>
<tbody>
{% for code, playlist in playlists %} {% for code, playlist in playlists %}
<tr class="pls" data-playlist-code="{{ code }}"> {% set statusClass = 'secondary' %}
<td class="text-center font-monospace code">{{ code }}</td> {% if playlist.isOnline is same as(true) %}
<td class="info"> {% set statusClass = 'success' %}
{% if playlist.isOnline is same as(true) %} {% elseif playlist.isOnline is same as(false) %}
<span class="badge small bg-success text-dark">online</span> {% set statusClass = 'danger' %}
{% elseif playlist.isOnline is same as(false) %} {% endif %}
<span class="badge small bg-danger text-dark">offline</span>
{% elseif playlist.isOnline is same as(null) %} <div class="col-md-6 col-lg-4">
<span class="badge small bg-secondary text-dark" title="В очереди на проверку">unknown</span> <div class="card bg-dark text-light h-100 border border-{{ statusClass }} hover-{{ statusClass }} position-relative">
{% endif %} <a href="/{{ code }}/details" class="text-decoration-none">
{% if "adult" in playlist.tags %} <div class="card-header d-flex align-items-center gap-2">
<span class="badge small bg-warning text-dark" title="Есть каналы для взрослых!">18+</span> <span class="font-monospace text-{{ statusClass }}">{{ code }}</span>
{% endif %} <span class="badge bg-{{ statusClass }} ms-auto">
<a href="/{{ code }}/details" class="text-light fw-bold text-decoration-none">{{ playlist.name }}</a> {% if playlist.isOnline is same as(true) %}online
<div class="small mt-2"> {% elseif playlist.isOnline is same as(false) %}offline
<p class="my-1 d-none d-lg-block"> {% 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 %}
<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>&nbsp;{{ playlist.channels|length }}<span class="d-none d-xl-inline-block">&nbsp;каналов</span>
</span>
{% endif %}
{% if playlist.groups|length > 0 %} {% if playlist.groups|length > 0 %}
<ion-icon name="folder-open-outline" title="Каналы разбиты на группы"></ion-icon> <span class="badge border border-secondary">
<ion-icon name="folder-open-outline" class="me-1"></ion-icon>&nbsp;{{ playlist.groups|length }}<span class="d-none d-xl-inline-block">&nbsp;групп</span>
</span>
{% endif %} {% endif %}
{% if playlist.hasTvg %} {% if playlist.hasTvg %}
<ion-icon name="newspaper-outline" title="Есть программа передач"></ion-icon> <span class="badge border border-secondary">
<ion-icon name="newspaper-outline" class="me-1"></ion-icon><span class="d-none d-xl-inline-block">&nbsp;ТВ-программа</span>
</span>
{% endif %} {% endif %}
{% if playlist.hasCatchup %} {% if playlist.hasCatchup %}
<ion-icon name="play-back-outline" title="Есть перемотка (архив)"></ion-icon> <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">&nbsp;Архив</span>
</span>
{% endif %} {% endif %}
{{ playlist.description }} </div>
</p>
{% 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> </div>
</td>
<td class="text-center"> <div class="card-footer cursor-pointer"
{% if (playlist.isOnline is not same as(null)) %} onclick="prompt('Скопируй адрес плейлиста. Если не работает, добавь \'.m3u\' в конец.', '{{ mirror_url(playlist.code) }}')"
{{ playlist.channels|length }} title="Нажми чтобы скопировать"
{% else %}
?
{% endif %}
</td>
<td class="d-none d-sm-table-cell">
<span onclick="prompt('Скопируй адрес плейлиста. Если не работает, добавь \'.m3u\' в конец.', '{{ mirror_url(playlist.code) }}')"
title="Нажми на ссылку, чтобы скопировать её в буфер обмена"
class="font-monospace cursor-pointer"
data-bs-toggle="tooltip"
data-bs-placement="top"
title="Нажми на ссылку, чтобы скопировать её в буфер обмена"
> >
{{ mirror_url(playlist.code) }} <div class="d-flex justify-content-between align-items-center small">
</span> <span class="font-monospace text-truncate">
</td> {{ mirror_url(playlist.code) }}
</tr> </span>
<ion-icon name="copy-outline"></ion-icon>
</div>
</div>
<a href="/{{ code }}/details" class="text-decoration-none">
</a>
</div>
</div>
{% endfor %} {% 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> </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 %} {% endif %}
</div>
{% endblock %} {% endblock %}
{% block footer %} {% block footer %}

View File

@@ -6,19 +6,26 @@
{% extends "template.twig" %} {% extends "template.twig" %}
{% block header %}
<h2>Плейлист не найден</h2>
{% endblock %}
{% block content %} {% block content %}
<div class="row"> <div class="row justify-content-center">
<div class="col-md-12"> <div class="col-md-8 col-lg-6 text-center">
<p> <div class="card bg-dark border-secondary">
Плейлист {{ id }} не найден <div class="card-body">
</p> <ion-icon name="warning-outline" class="display-1 text-warning mb-3"></ion-icon>
<a class="btn btn-outline-light" href="{{ base_url() }}" title="На главную"> <h2 class="card-title">Плейлист <code>{{ code }}</code> не найден</h2>
Перейти к списку <p class="card-text">
</a> Возможно, его здесь никогда не было, либо он уже был удалён.
</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>
</div> </div>
{% endblock %} {% endblock %}

View File

@@ -5,17 +5,15 @@
###########################################################################} ###########################################################################}
<!DOCTYPE html> <!DOCTYPE html>
<html lang="ru"> <html lang="ru" class="h-100">
<head> <head>
<title>{% block title %}{{ config('app.title') }}{% endblock %}</title> <title>{% block title %}{{ config('app.title') }}{% endblock %}</title>
<meta charset="utf-8"> <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="viewport" content="width=device-width, initial-scale=1">
<meta name="keywords" content="{% block metakeywords %}{% endblock %}" />
<meta http-equiv="x-ua-compatible" content="ie=edge"> <meta http-equiv="x-ua-compatible" content="ie=edge">
<style> <style>.cursor-pointer{cursor:pointer}</style>
.cursor-pointer{cursor:pointer}
.boosty{vertical-align:baseline;float:left;display:inline;width:20px}
</style>
<script async type="module" src="https://unpkg.com/ionicons@7.1.0/dist/ionicons/ionicons.esm.js"></script> <script async 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> <script async nomodule src="https://unpkg.com/ionicons@7.1.0/dist/ionicons/ionicons.js"></script>
<link href="/css/bootstrap.min.css" rel="stylesheet"> <link href="/css/bootstrap.min.css" rel="stylesheet">
@@ -32,70 +30,107 @@
<meta name="theme-color" content="#212529"> <meta name="theme-color" content="#212529">
{% block head %}{% endblock %} {% block head %}{% endblock %}
</head> </head>
<body class="bg-dark text-light"> <body class="d-flex flex-column h-100 bg-dark text-light">
<div class="container col-lg-10 mx-auto"> <header class="sticky-top bg-dark border-bottom border-secondary">
<header> <nav class="navbar navbar-expand-lg navbar-dark container px-2">
<nav class="navbar navbar-expand-lg navbar-dark"> <a class="navbar-brand d-flex align-items-center gap-2" href="/" title="На главную">
<a class="navbar-brand" href="/" title="На главную"> <img src="/favicon/favicon-32x32.png" alt="Логотип проекта" class="d-inline-block">
<img src="/favicon/favicon-32x32.png" class="d-inline-block px-lg-1" alt="Логотип проекта - emoji телевизора"/> <span>{{ config('app.title') }}</span>
{{ config('app.title') }} </a>
</a> <button class="navbar-toggler"
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav" aria-controls="navbarNav" aria-expanded="false" aria-label="Toggle navigation"> type="button"
<span class="navbar-toggler-icon"></span> data-bs-toggle="collapse"
</button> data-bs-target="#navbarNav"
<div class="collapse navbar-collapse" id="navbarNav"> aria-controls="navbarNav"
<ul class="navbar-nav"> >
<li class="nav-item"> <span class="navbar-toggler-icon"></span>
<a class="nav-link" href="/">Список</a> </button>
</li> <div class="collapse navbar-collapse" id="navbarNav">
<li class="nav-item"> <ul class="navbar-nav ms-auto">
<a class="nav-link" href="/faq">FAQ</a> <li class="nav-item">
</li> <a class="nav-link" target="_blank" href="/docs">
<li class="nav-item"> <ion-icon name="document-text-outline" class="me-1"></ion-icon>&nbsp;Документация
<a class="nav-link" href="https://git.axenov.dev/IPTV/.profile/src/branch/master/README.md#помощь-проекту">Поддержка</a> </a>
</li> </li>
<li class="nav-item dropdown"> <li class="nav-item">
<a class="nav-link dropdown-toggle" <a class="nav-link" target="_blank" href="/docs/support.html">
href="#" <ion-icon name="heart-outline" class="me-1"></ion-icon>&nbsp;Помочь проекту
role="button" </a>
data-bs-toggle="dropdown" </li>
aria-expanded="false" <li class="nav-item dropdown">
> <a class="nav-link dropdown-toggle d-flex align-items-center"
Telegram href="#"
</a> role="button"
<ul class="dropdown-menu dropdown-menu-dark"> data-bs-toggle="dropdown"
<li><a class="dropdown-item" href="https://t.me/iptv_aggregator">Канал @iptv_aggregator</a></li> aria-expanded="false"
<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> <ion-icon name="paper-plane-outline" class="me-1"></ion-icon>&nbsp;Telegram
</ul> </a>
</li> <ul class="dropdown-menu dropdown-menu-dark">
</ul> <li>
</div> <a class="dropdown-item d-flex align-items-center gap-2" target="_blank" href="https://t.me/iptv_aggregator">
</nav> <ion-icon name="megaphone-outline"></ion-icon>&nbsp;Канал @iptv_aggregator
</header> </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>&nbsp;Чат @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>&nbsp;Бот @iptv_aggregator_bot
</a>
</li>
</ul>
</li>
</ul>
</div>
</nav>
</header>
<section class="container h-100 pt-lg-3 px-0 pb-0"> <main class="flex-grow-1 container py-4">
{% block header %}{% endblock %} {% block header %}{% endblock %}
<div class="content-wrapper">
{% block content %}{% endblock %} {% block content %}{% endblock %}
</section> </div>
</main>
<footer class="py-4 text-center"> <footer class="bg-dark border-top border-secondary py-4">
<script src="/js/bootstrap.bundle.min.js"></script> <div class="container text-center">
{% block footer %}{% endblock %} <div class="d-flex flex-wrap justify-content-center gap-3 mb-3">
<a href="/faq">FAQ</a>&nbsp;|&nbsp;<a <a target="_blank" href="/docs" class="text-light text-decoration-none d-flex align-items-center gap-1">
href="https://git.axenov.dev/IPTV">Исходники</a>&nbsp;|&nbsp;<a <ion-icon name="document-text-outline"></ion-icon>Документация
href="https://axenov.dev">axenov.dev</a>&nbsp;|&nbsp;<a </a>
href="https://t.me/iptv_aggregator">Канал</a>&nbsp;|&nbsp;<a <a target="_blank" href="https://git.axenov.dev/IPTV" class="text-light text-decoration-none d-flex align-items-center gap-1">
href="https://t.me/iptv_aggregator_chat">Чат</a>&nbsp;|&nbsp;<a <ion-icon name="code-slash-outline"></ion-icon>Исходники
href="https://t.me/iptv_aggregator_bot">Бот</a> </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>
<br> <script src="/js/bootstrap.bundle.min.js"></script>
<a class="small text-secondary" {% block footer %}{% endblock %}
href="https://git.axenov.dev/IPTV/web/releases/tag/v{{ version() }}"
target="_blank"
>v{{ version() }}</a>
</footer>
</div>
{% include("custom.twig") ignore missing %} {% include("custom.twig") ignore missing %}
</body> </body>