Compare commits

25 Commits
wip2 ... master

Author SHA1 Message Date
d52a2de888 Fixed [ru] 2025-05-03 11:16:48 +08:00
62b54977c5 Добавлены плейлисты [ss] [sm] [rub] [runtv] [rurt] [rusm] [rutp] [ruz]
Добавлены плейлисты [ss] [sm] [rub] [runtv] [rurt] [rusm] [rutp] [ruz]
2025-04-28 10:48:30 +08:00
e18facb4c4 Обновлены листы smolnp
Добавлены ss + sm + so2 вместо sx
2025-04-25 00:28:54 +08:00
303c0f10b4 Удалены все листы tviptv, добавлен am 2025-04-17 11:15:13 +08:00
b88b307360 Вернул sh 2025-04-17 01:28:29 +08:00
eeb96bb438 Удалены мёртвые плейлисты 2025-04-13 11:47:05 +08:00
2350b8b042 Удалены мёртвые плейлисты 2025-03-07 19:49:44 +08:00
017b8d3598 Удалены мёртвые плейлисты 2025-03-05 12:42:42 +08:00
8004d43a4a Удалена ссылка на коммит из футера 2025-03-03 14:05:36 +08:00
1bce9ecfe5 Исправлена логика редиректов 2025-03-03 14:03:39 +08:00
50a5161374 Временно удалена ajax-проверка плейлистов 2025-03-03 13:41:40 +08:00
00d612c0e9 Большая переработка
- миграция с Flight на Slim v4
- кэширование ini-файла
- кэширование скачанных плейлистов
- прочее
2025-03-03 13:04:05 +08:00
de84bc8ae9 Удалены git-хуки 2025-03-03 13:01:28 +08:00
8ef148db73 Удалены мёртвые moto и de 2025-03-03 12:50:30 +08:00
56bc180de5 Добавлен keydb 2025-03-03 02:03:49 +08:00
cb38d26abe По 10 листов на страницу в списке 2025-03-01 16:54:24 +08:00
2dcd20b125 Ссылка на Boosty в меню 2025-03-01 16:47:49 +08:00
c8a7cc94a4 +72 плейлиста и реорганизация playlists.ini 2025-03-01 13:25:50 +08:00
5a4bfdbe9f В списке каналов название плейлиста теперь кликабельно 2025-03-01 13:00:04 +08:00
388ca90d48 Поддержка подгрузки плейлистов с редиректами + юзерагент на всякий случай 2025-02-26 01:36:30 +08:00
2ab7cd7378 Переход на php8.3 + обновление зависимостей 2025-02-26 01:35:19 +08:00
d337925713 Merge pull request #4 from anthonyaxenov/dependabot/composer/src/twig/twig-3.14.1
Bump twig/twig from 3.14.0 to 3.14.1 in /src
2025-02-24 15:26:18 +08:00
dependabot[bot]
53596d5959 Bump twig/twig from 3.14.0 to 3.14.1 in /src
Bumps [twig/twig](https://github.com/twigphp/Twig) from 3.14.0 to 3.14.1.
- [Changelog](https://github.com/twigphp/Twig/blob/3.x/CHANGELOG)
- [Commits](https://github.com/twigphp/Twig/compare/v3.14.0...v3.14.1)

---
updated-dependencies:
- dependency-name: twig/twig
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-02-24 07:25:56 +00:00
7c0d2f97f0 Пересортировка 2025-02-19 18:07:31 +08:00
b41c7e6e42 Removed dead playlists 2025-02-19 17:58:00 +08:00
43 changed files with 5255 additions and 1797 deletions

View File

@@ -1 +1,2 @@
IPTV_ENV=dev
REDIS_PORT=6379

3
.gitignore vendored
View File

@@ -4,11 +4,14 @@ downloaded/
/src/commit
/src/cache/*
/src/vendor
/src/config/playlists.ini
/src/views/custom.twig
/tmp
*.log
.env
*.m3u
*.m3u.*
*.m3u8
*.m3u8.*
*.rdb
!/**/.gitkeep

View File

@@ -4,6 +4,22 @@ networks:
services:
keydb:
container_name: iptv-keydb
image: eqalpha/keydb:latest
restart: unless-stopped
volumes:
- /etc/localtime:/etc/localtime:ro
- ./docker/keydb/keydb.conf:/etc/keydb/keydb.conf
- ./docker/keydb/data/:/data:rw
- ./log/keydb:/var/log/keydb/:rw
env_file:
- .env
ports:
- "${REDIS_PORT:-6379}:6379"
networks:
- iptv
php:
container_name: iptv-php
env_file:
@@ -13,6 +29,8 @@ services:
build:
dockerfile: docker/php/${IPTV_ENV}.dockerfile
restart: unless-stopped
extra_hosts:
- host.docker.internal:host-gateway
networks:
- iptv
volumes:
@@ -22,11 +40,15 @@ services:
- ./log/php:/var/log/php:rw
- ./src:/var/www:rw
- ./playlists.ini:/var/www/config/playlists.ini:ro
depends_on:
- keydb
nginx:
container_name: iptv-nginx
image: nginx:latest
restart: unless-stopped
extra_hosts:
- host.docker.internal:host-gateway
networks:
- iptv
volumes:

View File

1835
docker/keydb/keydb.conf Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,13 +1,17 @@
FROM php:8.2-fpm
FROM php:8.3-fpm
RUN apt update && \
apt upgrade -y && \
apt install -y git unzip 7zip
# https://pecl.php.net/package/xdebug
# https://pecl.php.net/package/redis
RUN pecl channel-update pecl.php.net && \
pecl install xdebug-3.3.2 unzip && \
mkdir -p /var/log/php
pecl install xdebug-3.4.1 redis && \
docker-php-ext-enable redis && \
mkdir -p /var/run/php && \
mkdir -p /var/log/php && \
chmod -R 777 /var/log/php
COPY --from=composer /usr/bin/composer /usr/local/bin/composer

View File

@@ -2,10 +2,7 @@
error_reporting = E_ALL
expose_php = Off
file_uploads = Off
memory_limit=-1
max_execution_time=-1
; upload_max_filesize=10M
; post_max_size=10M
[opcache]
opcache.enable = 1
@@ -22,7 +19,7 @@ zend_extension = xdebug.so
xdebug.mode = debug
xdebug.start_with_request = yes
xdebug.trigger_value = go
xdebug.client_host = 172.17.0.1
xdebug.client_host = host.docker.internal
xdebug.REQUEST = *
xdebug.SESSION = *
xdebug.SERVER = *

View File

@@ -1,11 +1,19 @@
FROM php:8.2-fpm
FROM php:8.3-fpm
RUN apt update && \
apt upgrade -y && \
apt install -y git
# https://pecl.php.net/package/redis
RUN pecl channel-update pecl.php.net && \
pecl install redis && \
docker-php-ext-enable redis && \
mkdir -p /var/log/php && \
chmod -R 777 /var/log/php
COPY --from=composer /usr/bin/composer /usr/local/bin/composer
USER www-data
EXPOSE 9000
WORKDIR /var/www
CMD composer install

View File

@@ -16,6 +16,6 @@ access.log = /var/log/php/$pool.access.log
; chroot = /var/www
; chdir = /var/www
php_flag[display_errors] = on
php_admin_value[error_log] = /var/log/php/www.error.log
php_admin_value[error_log] = /var/log/php/$pool.error.log
php_admin_flag[log_errors] = on
php_admin_value[memory_limit] = 32M
php_admin_value[memory_limit] = 512M

View File

@@ -1,4 +0,0 @@
#!/bin/bash
# хук пробрасывает хэш свежего коммита в контейнер
# для его отображения в подвале страницы
git rev-parse HEAD > src/commit

View File

@@ -1,7 +0,0 @@
#!/bin/bash
# хук пробрасывает хэш свежего коммита в контейнер
# для его отображения в подвале страницы и очищает
# кеш шаблонов twig после слияния веток
# главным образом необходимо при git pull
git rev-parse HEAD > src/commit
docker exec -ti iptv-php rm -rf cache/views

4
iptv
View File

@@ -15,10 +15,9 @@ open_browser() {
}
case "$1" in
'' | 'help' ) echo -e "Provide one of operations: \t init, start, stop, up, down, restart, rebuild, open, hooks";
'' | 'help' ) echo -e "Provide one of operations: \t init, start, stop, up, down, restart, rebuild, open";
echo "Otherwise all args will passed to 'docker exec -ti $CONTAINER ...'" ;;
'init' ) cp src/.env.example src/.env && \
./iptv hooks && \
./iptv up && \
./iptv composer i && \
echo "Project started successfully! $APP_URL" ;;
@@ -29,6 +28,5 @@ case "$1" in
'restart' ) $CMD stop && $CMD start ;; # restart containers
'rebuild' ) $CMD down --remove-orphans && $CMD up -d --build ;; # rebuild containers
'open' ) open_browser $APP_URL && echo -e "\nYou're welcome!\n\t$APP_URL" ;;
'hooks' ) ./hooks/post-commit && cp hooks/* .git/hooks ;;
* ) docker exec -ti $CONTAINER $* ;; # exec anything else in container
esac

0
log/redis/.gitkeep Normal file
View File

View File

@@ -1 +0,0 @@
src/config/playlists.ini

1061
playlists.ini Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,18 @@
APP_TITLE='Плейлисты IPTV'
# config/app.php
APP_DEBUG=false
APP_ENV=prod
APP_URL=http://localhost:8080
TWIG_CACHE=1
TWIG_DEBUG=0
FLIGHT_CASE_SENSITIVE=0
FLIGHT_HANDLE_ERRORS=1
FLIGHT_LOG_ERRORS=1
APP_TITLE='IPTV Плейлисты'
USER_AGENT='Mozilla/5.0 (Windows NT 10.0; Win64; x99) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/133.0.0.0 Safari/537.36'
PAGE_SIZE=10
# config/redis.php
REDIS_HOST='keydb'
REDIS_PORT=6379
REDIS_PASSWORD=
REDIS_DB=0
REDIS_TTL_DAYS=14
# config/redis.php
TWIG_USE_CACHE=true
TWIG_DEBUG=false

View File

@@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace App\Controllers;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Random\RandomException;
/**
*
*/
class ApiController extends BasicController
{
/**
* @param ServerRequestInterface $request
* @param ResponseInterface $response
* @return ResponseInterface
* @throws RandomException
*/
public function json(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
{
$code = $request->getAttributes()['code'];
$playlist = $this->getPlaylist($code, true);
$playlist->fetchContent();
$playlist->parse();
$json = json_encode($playlist->toArray(), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
$response->getBody()->write($json);
return $response
->withHeader('Content-Type', 'application/json')
->withHeader('Content-Length', strlen($json));
}
}

View File

@@ -0,0 +1,58 @@
<?php
declare(strict_types=1);
namespace App\Controllers;
use App\Core\Playlist;
use App\Errors\PlaylistNotFoundException;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Slim\Views\Twig;
use Twig\Error\LoaderError;
use Twig\Error\RuntimeError;
use Twig\Error\SyntaxError;
/**
*
*/
class BasicController
{
/**
* Отправляет сообщение о том, что метод не найден с кодом страницы 404
*
* @param ServerRequestInterface $request
* @param ResponseInterface $response
* @return ResponseInterface
* @throws LoaderError
* @throws RuntimeError
* @throws SyntaxError
*/
public function notFound(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
{
$response->withStatus(404);
$this->view($request, $response, 'notfound.twig');
return $response;
}
/**
* @param ServerRequestInterface $request
* @param ResponseInterface $response
* @param string $template
* @param array $data
* @return ResponseInterface
* @throws LoaderError
* @throws RuntimeError
* @throws SyntaxError
*/
protected function view(
ServerRequestInterface $request,
ResponseInterface $response,
string $template,
array $data = [],
): ResponseInterface {
$view = Twig::fromRequest($request);
return $view->render($response, $template, $data);
}
}

View File

@@ -1,85 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Controllers;
use App\Core\IniFile;
use App\Core\Playlist;
use App\Exceptions\PlaylistNotFoundException;
use Exception;
use Flight;
use Random\RandomException;
/**
* Абстрактный контроллер для расширения
*/
abstract class Controller
{
/**
* @var IniFile Класс для работы с ini-файлом плейлистов
*/
protected IniFile $ini;
/**
* Конструктор
*/
public function __construct()
{
$this->ini = Flight::get('ini');
}
/**
* Возвращает плейлист по его ID для обработки
*
* @param string $id
* @param bool $asJson
* @return Playlist
* @throws Exception
*/
protected function getPlaylist(string $id, bool $asJson = false): Playlist
{
if ($this->ini->getRedirection($id)) {
Flight::redirect(base_url($this->ini->getRedirection($id) . ($asJson ? '/json' : '/details')));
die;
}
try {
return $this->ini->getPlaylist($id);
} catch (PlaylistNotFoundException) {
$this->notFound($id, $asJson);
die;
}
}
/**
* Возвращает обработанный плейлист для ответа
*
* @param string $id ID плейлиста
* @param bool $asJson Обрабатывать как json
* @return array
* @throws RandomException
* @throws Exception
*/
protected function getPlaylistResponse(string $id, bool $asJson = false): array
{
$playlist = $this->getPlaylist($id, $asJson);
$playlist->download();
$playlist->parse();
return $playlist->toArray();
}
/**
* Перебрасывает на страницу 404 при ненайденном плейлисте
*
* @param string $id ID плейлиста
* @param bool $asJson Обрабатывать как json
* @return void
* @throws Exception
*/
public function notFound(string $id, bool $asJson = false): void
{
Flight::response()->status(404)->sendHeaders();
$asJson || view('notfound', ['id' => $id]);
}
}

View File

@@ -1,60 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Controllers;
use Exception;
use Flight;
/**
* Контроллер домашней страницы (списка плейлистов)
*/
class HomeController extends Controller
{
/**
* Отображает главную страницу с учётом пагинации списка плейлистов
*
* @param int $page Текущая страница списка
* @return void
* @throws Exception
*/
public function index(int $page = 1): void
{
// если пришёл любой get-параметр, то считаем его как id плейлиста и перебрасываем на страницу о нём
if (Flight::request()->query->count() > 0) {
$id = Flight::request()->query->keys()[0];
Flight::redirect(base_url($id));
die;
}
// иначе формируем и сортируем список при необходимости, рисуем страницу
$perPage = 20;
$playlists = $this->ini->playlists(false);
$count = count($playlists);
$pageCount = ceil($count / $perPage);
$offset = max(0, ($page - 1) * $perPage);
$list = array_slice($playlists, $offset, $perPage, true);
view('list', [
'updated_at' => $this->ini->updatedAt(),
'count' => $count,
'pages' => [
'count' => $pageCount,
'current' => $page,
],
'playlists' => $list,
]);
}
/**
* Отображает страницу FAQ
*
* @return void
* @throws Exception
*/
public function faq(): void
{
view('faq');
}
}

View File

@@ -1,89 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Controllers;
use App\Core\ChannelLogo;
use App\Exceptions\PlaylistNotFoundException;
use Exception;
use Flight;
/**
* Контроллер методов получения описания плейлистов
*/
class PlaylistController extends Controller
{
/**
* Отправляет запрос с клиента по прямой ссылке плейлиста
*
* @param string $id ID плейлиста
* @return void
* @throws Exception
*/
public function download(string $id): void
{
try {
$playlist = $this->ini->getPlaylist($id);
Flight::redirect($playlist->pls);
} catch (PlaylistNotFoundException) {
$this->notFound($id);
}
die;
}
/**
* Отображает страницу описания плейлиста
*
* @param string $id ID плейлиста
* @return void
* @throws Exception
*/
public function details(string $id): void
{
$result = $this->getPlaylistResponse($id);
view('details', $result);
}
/**
* Возвращает JSON с описанием плейлиста
*
* @param string $id ID плейлиста
* @return void
* @throws Exception
*/
public function json(string $id): void
{
$result = $this->getPlaylistResponse($id, true);
Flight::json($result);
}
/**
* Возвращает логотип канала, кэшируя при необходимости
*
* @return void
*/
public function logo(): void
{
$input = Flight::request()->query['url'] ?? null;
$logo = new ChannelLogo($input);
if (!$logo->readFile()) {
$logo->fetch();
}
if ($logo->size() === 0) {
$logo->setDefault();
}
$logo->store();
$body = $logo->raw();
$size = $logo->size();
$mime = $logo->mimeType();
Flight::response()
->write($body)
->header('Content-Type', $mime)
->header('Content-Length', (string)$size);
}
}

View File

@@ -0,0 +1,128 @@
<?php
declare(strict_types=1);
namespace App\Controllers;
use App\Core\ChannelLogo;
use App\Errors\PlaylistNotFoundException;
use Exception;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Twig\Error\LoaderError;
use Twig\Error\RuntimeError;
use Twig\Error\SyntaxError;
/**
*
*/
class WebController extends BasicController
{
/**
* @param ServerRequestInterface $request
* @param ResponseInterface $response
* @return ResponseInterface
* @throws LoaderError
* @throws RuntimeError
* @throws SyntaxError
* @throws Exception
*/
public function home(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
{
ini()->load();
$playlists = ini()->playlists(false);
$count = count($playlists);
$page = (int)($request->getAttributes()['page'] ?? $request->getQueryParams()['page'] ?? 1);
$pageSize = config('app.page_size');
$pageCount = ceil($count / $pageSize);
$offset = max(0, ($page - 1) * $pageSize);
$list = array_slice($playlists, $offset, $pageSize, true);
return $this->view($request, $response, 'list.twig', [
'updated_at' => ini()->updatedAt(),
'playlists' => $list,
'count' => $count,
'pageCount' => $pageCount,
'pageCurrent' => $page,
]);
}
/**
* @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');
}
/**
* @param ServerRequestInterface $request
* @param ResponseInterface $response
* @return ResponseInterface
*/
public function redirect(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
{
ini()->load();
$code = $request->getAttributes()['code'];
try {
$playlist = ini()->getPlaylist($code);
return $response->withHeader('Location', $playlist->pls);
} catch (PlaylistNotFoundException) {
return $this->notFound($request, $response);
}
}
/**
* @param ServerRequestInterface $request
* @param ResponseInterface $response
* @return ResponseInterface
* @throws \Random\RandomException
* @throws LoaderError
* @throws RuntimeError
* @throws SyntaxError
*/
public function details(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
{
ini()->load();
$code = $request->getAttributes()['code'];
try {
$playlist = ini()->getPlaylist($code);
$response->withHeader('Location', $playlist->pls);
} catch (PlaylistNotFoundException) {
return $this->notFound($request, $response);
}
$playlist->fetchContent();
$playlist->parse();
return $this->view($request, $response, 'details.twig', $playlist->toArray());
}
/**
* @param ServerRequestInterface $request
* @param ResponseInterface $response
* @return ResponseInterface
*/
public function logo(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
{
$input = $request->getQueryParams()['url'] ?? null;
$logo = new ChannelLogo($input);
$logo->readFile() || $logo->fetch();
$logo->size() === 0 && $logo->setDefault();
$logo->store();
$body = $logo->raw();
$size = $logo->size();
$mime = $logo->mimeType();
$response->getBody()->write($body);
return $response->withHeader('Content-Type', $mime)
->withHeader('Content-Length', $size);
}
}

View File

@@ -1,75 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Core;
use App\Extensions\TwigFunctions;
use Flight;
use Twig\Environment;
use Twig\Extension\DebugExtension;
use Twig\Loader\FilesystemLoader;
/**
* Сборщик приложения
*/
final class Bootstrapper
{
/**
* Загружает конфигурацию приложения в контейнер
*
* @return void
*/
public static function bootSettings(): void
{
$config = require_once config_path('app.php');
foreach ($config as $key => $value) {
Flight::set($key, $value);
}
Flight::set('config', $config);
}
public static function bootCore(): void
{
$loader = new IniFile();
$loader->load();
Flight::set('ini', $loader);
}
/**
* Загружает шаблонизатор и его расширения
*
* @return void
*/
public static function bootTwig(): void
{
$twigCfg = [
'cache' => config('twig.cache'),
'debug' => config('twig.debug'),
];
$closure = static function ($twig) {
/** @var Environment $twig */
Flight::set('twig', $twig);
$twig->addExtension(new TwigFunctions());
$twig->addExtension(new DebugExtension());
};
$loader = new FilesystemLoader(config('flight.views.path'));
Flight::register('view', Environment::class, [$loader, $twigCfg], $closure);
}
/**
* Загружает маршруты
*
* @return void
*/
public static function bootRoutes(): void
{
$routes = require_once config_path('routes.php');
foreach ($routes as $route => $handler) {
Flight::route($route, $handler);
}
}
}

View File

@@ -41,7 +41,7 @@ class ChannelLogo implements \Stringable
*/
public function __construct(string $url)
{
$url = $this->prepareUrl($url);
$url = empty($url) ? base_url('public/no-tvg-logo.png') : $this->prepareUrl($url);
if (is_string($url)) {
$this->url = $url;
$this->hash = md5($url);
@@ -57,17 +57,15 @@ class ChannelLogo implements \Stringable
*/
protected function prepareUrl(string $url): false|string
{
$url = filter_var(trim($url), FILTER_VALIDATE_URL);
if ($url === false) {
$parts = parse_url(trim($url));
if (!is_array($parts) || count($parts) < 2) {
return false;
}
$parts = parse_url($url);
if (!is_array($parts)) {
return false;
}
$result = $parts['scheme'] . '://' . $parts['host'];
$result .= (empty($parts['port']) ? '' : ':' . $parts['port']);
return $parts['scheme'] . '://' . $parts['host'] . $parts['path'];
return $result . $parts['path'];
}
/**
@@ -122,7 +120,7 @@ class ChannelLogo implements \Stringable
public function setDefault(): bool
{
$this->path = root_path('public/no-tvg-logo.png');
return$this->readFile();
return $this->readFile();
}
/**

230
src/app/Core/Core.php Normal file
View File

@@ -0,0 +1,230 @@
<?php
declare(strict_types=1);
namespace App\Core;
use App\Core\TwigExtention as IptvTwigExtension;
use Dotenv\Dotenv;
use InvalidArgumentException;
use Redis;
use Slim\App;
use Slim\Factory\AppFactory;
use Slim\Views\Twig;
use Slim\Views\TwigMiddleware;
use Twig\Error\LoaderError;
/**
* Загрузчик приложения
*/
final class Core
{
/**
* @var Core
*/
private static Core $instance;
/**
* @var App
*/
protected App $app;
/**
* @var array Конфигурация приложения
*/
protected array $config = [];
/**
* @var Redis
*/
protected Redis $redis;
/**
* @var IniFile
*/
protected IniFile $iniFile;
/**
* Закрытый конструктор
*/
private function __construct()
{
}
/**
* Возвращает объект приложения
*
* @return Core
*/
public static function get(): Core
{
return self::$instance ??= new self();
}
/**
* Загружает приложение
*
* @return App
* @throws LoaderError
*/
public function boot(): App
{
$this->app = AppFactory::create();
$this->bootSettings();
$this->bootRoutes();
$this->bootTwig();
$this->bootRedis();
$this->bootIni();
return $this->app;
}
/**
* Возвращает значение из конфига
*
* @param string $key Ключ в формате "config.key"
* @param mixed|null $default Значение по умолчанию
* @return mixed
*/
public function config(string $key, mixed $default = null): mixed
{
$parts = explode('.', $key);
return $this->config[$parts[0]][$parts[1]] ?? $default;
}
/**
* @return Redis
*/
public function redis(): Redis
{
return $this->redis;
}
/**
* @return IniFile
*/
public function ini(): IniFile
{
return $this->iniFile;
}
/**
* @return App
*/
public function app(): App
{
return $this->app;
}
/**
* Загружает файл .env или .env.$env
*
* @param string $env
* @return array
*/
protected function loadDotEnvFile(string $env = ''): array
{
$filename = empty($env) ? '.env' : ".env.$env";
if (!file_exists(root_path($filename))) {
return [];
}
$dotenv = Dotenv::createMutable(root_path(), $filename);
return $dotenv->safeLoad();
}
/**
* Загружает конфигурационные файлы
*
* @return void
*/
protected function bootSettings(): void
{
$env = $this->loadDotEnvFile();
if (!empty($env['APP_ENV'])) {
$this->loadDotEnvFile($env['APP_ENV']);
}
foreach (glob(config_path() . '/*.php') as $file) {
$key = basename($file, '.php');
$this->config += [$key => require_once $file];
}
}
/**
* Загружает маршруты
*
* @return void
* @see https://www.slimframework.com/docs/v4/objects/routing.html
*/
protected function bootRoutes(): void
{
foreach ($this->config['routes'] as $route) {
if (is_array($route['method'])) {
$definition = $this->app->map($route['method'], $route['path'], $route['handler']);
} else {
$isPossible = in_array($route['method'], ['GET', 'POST', 'OPTIONS', 'PUT', 'PATCH', 'DELETE']);
$func = match (true) {
$route['method'] === '*' => 'any',
$isPossible => strtolower($route['method']),
default => throw new InvalidArgumentException(sprintf('Неверный HTTP метод %s', $route['method']))
};
$definition = $this->app->$func($route['path'], $route['handler']);
}
if (!empty($route['name'])) {
$definition->setName($route['name']);
}
}
}
/**
* Загружает шаблонизатор и его расширения
*
* @return void
* @throws LoaderError
* @see https://www.slimframework.com/docs/v4/features/twig-view.html
*/
protected function bootTwig(): void
{
$twig = Twig::create(root_path('views'), $this->config['twig']);
$twig->addExtension(new IptvTwigExtension());
$this->app->add(TwigMiddleware::create($this->app, $twig));
}
/**
* Инициализирует подключение к Redis
*
* @return void
* @see https://github.com/phpredis/phpredis/?tab=readme-ov-file
*/
protected function bootRedis(): void
{
$options = [
'host' => $this->config['redis']['host'],
'port' => (int)$this->config['redis']['port'],
];
if (!empty($this->config['redis']['password'])) {
$options['auth'] = $this->config['redis']['password'];
}
$this->redis = new Redis($options);
$this->redis->select((int)$this->config['redis']['db']);
$this->redis->setOption(Redis::OPT_SERIALIZER, Redis::SERIALIZER_JSON);
}
/**
* Инициализирует объект ini-файла
*
* @return void
*/
protected function bootIni(): void
{
$this->iniFile = new IniFile();
}
}

View File

@@ -4,7 +4,7 @@ declare(strict_types=1);
namespace App\Core;
use App\Exceptions\PlaylistNotFoundException;
use App\Errors\PlaylistNotFoundException;
use Exception;
/**
@@ -15,7 +15,7 @@ class IniFile
/**
* @var array Считанное из файла содержимое ini-файла
*/
protected array $rawIni;
protected array $ini;
/**
* @var Playlist[] Коллекция подгруженных плейлистов
@@ -40,13 +40,30 @@ class IniFile
*/
public function load(): void
{
$filepath = config_path('playlists.ini');
$this->updated_at = date('d.m.Y h:i', filemtime($filepath));
$this->rawIni = parse_ini_file($filepath, true);
foreach ($this->rawIni as $id => $data) {
$this->playlists[(string)$id] = $this->makePlaylist($id, $data);
$ini = redis()->hGetAll('_playlists_');
if (empty($ini)) {
$filepath = config_path('playlists.ini');
$ini = parse_ini_file($filepath, true);
$this->updated_at = date('d.m.Y h:i', filemtime($filepath));
$order = array_keys($ini);
}
$order ??= redis()->get('_order_');
$this->ini ??= $ini;
$this->updated_at ??= redis()->get('_updated_at_');
$transaction = redis()->multi();
foreach ($order as $id) {
$data = $this->ini[$id];
$this->playlists[(string)$id] = $pls = $this->makePlaylist($id, $data);
$transaction->hSet('_playlists_', $id, $pls);
}
$expireAfter = config('redis.ttl_days');
$transaction
->expire('_playlists_', $expireAfter)
->set('_order_', $order, ['EX' => $expireAfter])
->set('_updated_at_', $this->updated_at, ['EX' => $expireAfter])
->exec();
}
/**
@@ -57,11 +74,9 @@ class IniFile
*/
public function playlists(bool $all = true): array
{
if ($all) {
return $this->playlists;
}
return array_filter($this->playlists, static fn ($playlist) => is_null($playlist->redirectId));
return $all
? $this->playlists
: array_filter($this->playlists, static fn ($playlist) => is_null($playlist->redirectId));
}
/**
@@ -111,7 +126,7 @@ class IniFile
$id = (string)$id;
if (isset($params['redirect'])) {
$this->redirections[$id] = $redirectId = (string)$params['redirect'];
$params = $this->rawIni[$redirectId];
$params = $this->ini[$redirectId];
return $this->makePlaylist($id, $params, $redirectId);
}

View File

@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace App\Core;
use CurlHandle;
use Exception;
use Random\RandomException;
@@ -86,17 +87,19 @@ class Playlist
*
* @return void
*/
public function download(): void
public function fetchContent(): void
{
$curl = curl_init();
curl_setopt_array($curl, [
CURLOPT_URL => $this->pls,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 30,
CURLOPT_HEADER => false,
CURLOPT_FAILONERROR => true,
]);
$cached = redis()->get($this->id);
if (is_array($cached)) {
$this->downloadStatus['httpCode'] = $cached['httpCode'];
$this->downloadStatus['errCode'] = $cached['errCode'];
$this->downloadStatus['errText'] = $cached['errText'];
$this->downloadStatus['possibleStatus'] = $cached['possibleStatus'];
$this->rawContent = $cached['content'];
return;
}
$curl = $this->makeCurl();
$content = curl_exec($curl);
$this->rawContent = $content === false ? null : $content;
$this->downloadStatus['httpCode'] = curl_getinfo($curl, CURLINFO_RESPONSE_CODE);
@@ -104,6 +107,16 @@ class Playlist
$this->downloadStatus['errText'] = curl_error($curl);
$this->downloadStatus['possibleStatus'] = $this->guessStatus($this->downloadStatus['errCode']);
curl_close($curl);
if ($cached === false) {
redis()->set($this->id, [
'httpCode' => $this->downloadStatus['httpCode'],
'errCode' => $this->downloadStatus['errCode'],
'errText' => $this->downloadStatus['errText'],
'possibleStatus' => $this->downloadStatus['possibleStatus'],
'content' => $this->rawContent,
], ['EX' => config('redis.ttl_days')]);
}
}
/**
@@ -238,6 +251,53 @@ class Playlist
return $this->parsedContent = $result;
}
public function check(): bool
{
$curl = $this->makeCurl([
CURLOPT_RETURNTRANSFER => true,
CURLOPT_NOBODY => true,
CURLOPT_HEADER => true,
CURLOPT_CUSTOMREQUEST => 'HEAD',
]);
$content = curl_exec($curl);
$this->rawContent = $content === false ? null : $content;
$this->downloadStatus['httpCode'] = curl_getinfo($curl, CURLINFO_RESPONSE_CODE);
$this->downloadStatus['errCode'] = curl_errno($curl);
$this->downloadStatus['errText'] = curl_error($curl);
$this->downloadStatus['possibleStatus'] = $this->guessStatus($this->downloadStatus['errCode']);
curl_close($curl);
return $this->downloadStatus['httpCode'] < 400;
}
protected function makeCurl(array $customOptions = []): CurlHandle
{
$options = [
CURLOPT_URL => $this->pls,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 30,
CURLOPT_HEADER => false,
CURLOPT_FAILONERROR => true,
CURLOPT_FOLLOWLOCATION => true,
CURLOPT_MAXREDIRS => 5,
CURLOPT_USERAGENT => config('app.user_agent'),
];
$curl = curl_init();
foreach ($options as $option => $value) {
curl_setopt($curl, $option, $value);
}
// array_merge($options, $customOptions) loses keys
foreach ($customOptions as $option => $value) {
curl_setopt($curl, $option, $value);
}
return $curl;
}
/**
* Парсит атрибуты строки и возвращает ассоциативный массив
*

View File

@@ -2,12 +2,12 @@
declare(strict_types=1);
namespace App\Extensions;
namespace App\Core;
use Twig\Extension\AbstractExtension;
use Twig\TwigFunction;
class TwigFunctions extends AbstractExtension
class TwigExtention extends AbstractExtension
{
public function getFunctions(): array
{

View File

@@ -0,0 +1,92 @@
<?php
declare(strict_types=1);
namespace App\Errors;
use Psr\Http\Message\{
ResponseInterface,
ServerRequestInterface};
use Psr\Log\LoggerInterface;
use Slim\Handlers\ErrorHandler as SlimErrorHandler;
use Throwable;
/**
* Обработчик ошибок
*/
class ErrorHandler extends SlimErrorHandler
{
/**
* Логирует ошибку и отдаёт JSON-ответ с необходимым содержимым
*
* @param ServerRequestInterface $request
* @param Throwable $exception
* @param bool $displayErrorDetails
* @param bool $logErrors
* @param bool $logErrorDetails
* @param LoggerInterface|null $logger
* @return ResponseInterface
*/
public function __invoke(
ServerRequestInterface $request,
Throwable $exception,
bool $displayErrorDetails,
bool $logErrors,
bool $logErrorDetails,
?LoggerInterface $logger = null
): ResponseInterface {
$payload = $this->payload($exception, $displayErrorDetails);
$response = app()->getResponseFactory()->createResponse();
$response->getBody()->write(json_encode($payload, JSON_UNESCAPED_UNICODE));
return $response;
}
/**
* Возвращает структуру исключения для контекста
*
* @param Throwable $e Исключение
* @param bool $logErrorDetails Признак дополнения деталями
* @return array
*/
protected function context(Throwable $e, bool $logErrorDetails): array
{
$result = ['code' => $e->getCode()];
$logErrorDetails && $result += [
'class' => $e::class,
'file' => $e->getFile(),
'line' => $e->getLine(),
'trace' => $e->getTrace()
];
return $result;
}
/**
* Возвращает структуру исключения для передачи в ответе
*
* @param Throwable $e Исключение
* @param bool $displayErrorDetails Признак дополнения деталями
* @return array
*/
protected function payload(Throwable $e, bool $displayErrorDetails): array
{
$result = [
'error' => [
'code' => $e->getCode(),
'message' => $e->getMessage(),
],
];
$displayErrorDetails && $result['error'] += [
'class' => $e::class,
'file' => $e->getFile(),
'line' => $e->getLine(),
'trace' => $e->getTrace(),
];
return $result;
}
}

View File

@@ -2,7 +2,7 @@
declare(strict_types=1);
namespace App\Exceptions;
namespace App\Errors;
use Exception;

View File

@@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
namespace App\Middleware;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\RequestHandlerInterface;
/**
* Middleware для добавления запросу заголовка X-Request-ID
*/
class RequestId
{
/**
* Добавляет запросу заголовок X-Request-ID
*
* @param ServerRequestInterface $request
* @param RequestHandlerInterface $handler
* @return ResponseInterface
*/
public function __invoke(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
{
$request = $request->withHeader('X-Request-ID', uniqid());
return $handler->handle($request);
}
}

View File

@@ -2,9 +2,9 @@
declare(strict_types=1);
use flight\Engine;
use flight\net\Response;
use Illuminate\Support\Arr;
use App\Core\Core;
use App\Core\IniFile;
use Slim\App;
/**
* Returns path to root application directory
@@ -58,7 +58,7 @@ function views_path(string $path = ''): string
*/
function base_url(string $route = ''): string
{
return rtrim(sprintf('%s/%s', config('flight.base_url'), $route), '/');
return rtrim(sprintf('%s/%s', env('APP_URL'), $route), '/');
}
/**
@@ -70,7 +70,7 @@ function base_url(string $route = ''): string
*/
function env(string $key, mixed $default = null): mixed
{
return $_ENV[$key] ?? $default;
return $_ENV[$key] ?? $_SERVER[$key] ?? $default;
}
/**
@@ -89,23 +89,23 @@ function view(mixed $template, array $data = []): void
}
/**
* Returns response object
* Returns core object
*
* @return Response
* @return Core
*/
function response(): Response
function core(): Core
{
return Flight::response();
return Core::get();
}
/**
* Returns app object
*
* @return Engine
* @return App
*/
function app(): Engine
function app(): App
{
return Flight::app();
return Core::get()->app();
}
/**
@@ -135,5 +135,25 @@ function bool(mixed $value): bool
*/
function config(string $key, mixed $default = null): mixed
{
return Flight::get('config')[$key] ?? $default;
return Core::get()->config($key, $default);
}
/**
* Get Redis instance
*
* @return Redis
*/
function redis(): Redis
{
return Core::get()->redis();
}
/**
* Get ini-file instance
*
* @return IniFile
*/
function ini(): IniFile
{
return Core::get()->ini();
}

0
src/cache/.gitkeep vendored Normal file → Executable file
View File

View File

@@ -1,12 +1,26 @@
{
"name": "axenov/iptv",
"type": "project",
"description": "Сервис для сбора IPTV-плейлистов и сокращения ссылок",
"authors": [
{
"name": "Anthony Axenov",
"homepage": "https://axenov.dev/",
"role": "author"
}
],
"license": "MIT",
"require": {
"php": "^8.2",
"php": "^8.3",
"ext-json": "*",
"ext-curl": "*",
"ext-redis": "*",
"ext-fileinfo": "*",
"mikecao/flight": "^3.12",
"symfony/dotenv": "^7.1",
"twig/twig": "^3.14"
"guzzlehttp/guzzle": "^7.8",
"nyholm/psr7": "^1.6",
"vlucas/phpdotenv": "*",
"slim/slim": "^4.11",
"slim/twig-view": "^3.4"
},
"autoload": {
"psr-4": {
@@ -24,6 +38,9 @@
},
"config": {
"optimize-autoloader": true,
"preferred-install": "dist",
"sort-packages": true
}
},
"minimum-stability": "dev",
"prefer-stable": true
}

1609
src/composer.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -3,17 +3,13 @@
declare(strict_types=1);
return [
// https://flightphp.com/learn#configuration
'flight.base_url' => env('APP_URL', 'http://localhost:8080'),
'flight.case_sensitive' => bool(env('FLIGHT_CASE_SENSITIVE', false)),
'flight.handle_errors' => bool(env('FLIGHT_HANDLE_ERRORS', true)),
'flight.log_errors' => bool(env('FLIGHT_LOG_ERRORS', true)),
'flight.views.path' => views_path(),
'flight.views.extension' => '.twig',
'twig.cache' => bool(env('TWIG_CACHE', true)) ? cache_path() . '/views' : false,
'twig.debug' => bool(env('TWIG_DEBUG', false)),
'app.title' => env('APP_TITLE', 'IPTV Playlists'),
'app.pls_encodings' => [
'base_url' => env('APP_URL', 'http://localhost:8080'),
'debug' => bool(env('APP_DEBUG', false)),
'env' => env('APP_ENV', env('IPTV_ENV', 'prod')),
'title' => env('APP_TITLE', 'IPTV Плейлисты'),
'user_agent' => env('USER_AGENT'),
'page_size' => (int)env('PAGE_SIZE', 10),
'pls_encodings' => [
'UTF-8',
'CP1251',
// 'CP866',

File diff suppressed because it is too large Load Diff

11
src/config/redis.php Normal file
View File

@@ -0,0 +1,11 @@
<?php
declare(strict_types=1);
return [
'host' => env('REDIS_HOST', 'keydb'),
'port' => (int)env('REDIS_PORT', 6379),
'password' => env('REDIS_PASSWORD'),
'db' => (int)env('REDIS_DB', 0),
'ttl_days' => (int)env('REDIS_TTL_DAYS', 14) * 60 * 60 * 24, // 2 недели
];

View File

@@ -1,17 +1,52 @@
<?php
declare(strict_types=1);
use App\Controllers\HomeController;
use App\Controllers\PlaylistController;
use App\Controllers\ApiController;
use App\Controllers\BasicController;
use App\Controllers\WebController;
return [
'GET /' => [HomeController::class, 'index'],
'GET /page/@page:[0-9]+' => [HomeController::class, 'index'],
'GET /faq' => [HomeController::class, 'faq'],
'GET /logo' => [PlaylistController::class, 'logo'],
'GET /@id:[a-zA-Z0-9_-]+' => [PlaylistController::class, 'download'],
'GET /?[a-zA-Z0-9_-]+' => [PlaylistController::class, 'download'],
'GET /@id:[a-zA-Z0-9_-]+/details' => [PlaylistController::class, 'details'],
'GET /@id:[a-zA-Z0-9_-]+/json' => [PlaylistController::class, 'json'],
[
'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' => '/logo',
'handler' => [WebController::class, 'logo'],
'name' => 'logo',
],
[
'method' => 'GET',
'path' => '/{code:[0-9a-zA-Z]+}',
'handler' => [WebController::class, 'redirect'],
'name' => 'redirect',
],
[
'method' => 'GET',
'path' => '/{code:[0-9a-zA-Z]+}/details',
'handler' => [WebController::class, 'details'],
'name' => 'details',
],
[
'method' => 'GET',
'path' => '/{code:[0-9a-zA-Z]+}/json',
'handler' => [ApiController::class, 'json'],
'name' => 'json',
],
[
'method' => '*',
'path' => '/{path:.*}',
'handler' => [BasicController::class, 'notFound'],
'name' => 'not-found',
],
// ...
];

8
src/config/twig.php Normal file
View File

@@ -0,0 +1,8 @@
<?php
declare(strict_types=1);
return [
'cache' => bool(env('TWIG_USE_CACHE', true)) ? cache_path() . '/views' : false,
'debug' => bool(env('TWIG_DEBUG', false)),
];

13
src/public/boosty.svg Normal file
View File

@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 24.2.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Слой_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 235.6 292.2" style="enable-background:new 0 0 235.6 292.2;" xml:space="preserve">
<style type="text/css">
.st0{fill:#FFFFFF;}
</style>
<g id="b_1_">
<path class="st0" d="M44.3,164.5L76.9,51.6H127l-10.1,35c-0.1,0.2-0.2,0.4-0.3,0.6L90,179.6h24.8c-10.4,25.9-18.5,46.2-24.3,60.9
c-45.8-0.5-58.6-33.3-47.4-72.1 M90.7,240.6l60.4-86.9h-25.6l22.3-55.7c38.2,4,56.2,34.1,45.6,70.5
c-11.3,39.1-57.1,72.1-101.7,72.1C91.3,240.6,91,240.6,90.7,240.6z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 735 B

View File

@@ -2,19 +2,6 @@
declare(strict_types=1);
use App\Core\Bootstrapper;
use Symfony\Component\Dotenv\Dotenv;
/*
|--------------------------------------------------------------------------
| Bootstrap all classes, settings, etc.
|--------------------------------------------------------------------------
*/
require '../vendor/autoload.php';
(new Dotenv())->loadEnv(root_path() . '/.env');
Bootstrapper::bootSettings();
Bootstrapper::bootTwig();
Bootstrapper::bootCore();
Bootstrapper::bootRoutes();
Flight::start();
core()->boot()->run();

View File

@@ -13,9 +13,8 @@
<table class="table table-responsive table-dark table-hover small">
<thead>
<tr>
<th>ID</th>
<th class="text-center">ID</th>
<th>Информация о плейлисте</th>
<th>Каналов</th>
<th class="d-none d-sm-table-cell">Ссылка для ТВ</th>
</tr>
</thead>
@@ -24,8 +23,7 @@
<tr class="pls" data-playlist-id="{{ id }}">
<td class="text-center font-monospace id">{{ id }}</td>
<td class="info">
<span class="badge small bg-secondary text-dark status">loading</span>
<strong>{{ playlist.name }}</strong>
<a href="{{ base_url(id ~ '/details') }}" class="text-light fw-bold text-decoration-none">{{ playlist.name }}</a>
<div class="small mt-2">
{% if playlist.desc|length > 0 %}
<p class="my-1 d-none d-lg-block">{{ playlist.desc }}</p>
@@ -33,11 +31,6 @@
<a href="{{ base_url(id ~ '/details') }}" class="text-light">Подробнее...</a>
</div>
</td>
<td class="text-center count">
<div class="spinner-border text-success" role="status">
<span class="visually-hidden">загрузка...</span>
</div>
</td>
<td class="col-3 d-none d-sm-table-cell">
<span onclick="prompt('Скопируй адрес плейлиста', '{{ playlist.url }}')"
title="Нажми на ссылку, чтобы скопировать её в буфер обмена"
@@ -49,11 +42,11 @@
{% endfor %}
</tbody>
</table>
{% if pages.count > 0 %}
{% if pageCount > 0 %}
<div aria-label="pages">
<ul class="pagination justify-content-center">
{% for page in range(1, pages.count) %}
{% if page == pages.current %}
{% for page in range(1, pageCount) %}
{% if page == pageCurrent %}
<li class="page-item active" aria-current="page">
<span class="page-link">{{ page }}</span>
</li>
@@ -70,51 +63,4 @@
{% endblock %}
{% block footer %}
<script>
document.querySelectorAll('tr.pls').forEach((tr) => {
const id = tr.attributes['data-playlist-id'].value
const xhr = new XMLHttpRequest()
xhr.responseType = 'json'
xhr.timeout = 60000 // ms = 1 min
let el_status = tr.querySelector('span.status')
let el_count = tr.querySelector('td.count')
xhr.onreadystatechange = () => {
if (xhr.readyState === XMLHttpRequest.DONE) {
el_status.classList.remove('bg-secondary')
el_status.innerText = xhr.response?.status.possibleStatus ?? 'error'
el_count.innerText = xhr.response?.content.channelCount ?? 0
switch (el_status.innerText) {
case 'online':
el_status.classList.add('bg-success')
break
case 'timeout':
el_status.classList.add('bg-warning')
break
default:
el_status.classList.add('bg-danger')
break
}
if (xhr.response?.error) {
el_status.title = '[' + xhr.response.error.code + '] ' + xhr.response.error.message
}
}
}
xhr.onerror = () => {
el_status.classList.add('bg-danger')
el_status.innerText = 'error'
el_count.innerText = 0
}
xhr.onabort = () => {
el_status.classList.add('bg-secondary')
el_count.innerText = 0
}
xhr.ontimeout = () => {
el_status.classList.add('bg-secondary')
el_status.innerText = 'timeout'
el_count.innerText = 0
}
xhr.open('GET', '/' + id + '/json')
xhr.send()
})
</script>
{% endblock %}

View File

@@ -16,6 +16,7 @@
<meta name="msapplication-TileColor" content="#00aba9">
<meta name="msapplication-TileImage" content="{{ base_url('/favicon/mstile-144x144.png') }}">
<meta name="theme-color" content="#212529">
<style>.boosty{vertical-align:baseline;float:left;display:inline;width:20px}</style>
{% block head %}{% endblock %}
</head>
<body class="bg-dark text-light">
@@ -49,6 +50,12 @@
<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="{{ base_url('boosty.svg') }}" alt="Boosty">
Boosty
</a>
</li>
</ul>
</div>
</nav>
@@ -67,11 +74,6 @@
href="https://git.axenov.dev/anthony/iptv">Gitea</a>&nbsp;|&nbsp;<a
href="https://axenov.dev">axenov.dev</a>&nbsp;|&nbsp;<a
href="https://t.me/iptv_aggregator">Telegram</a><br>
<span class="small text-muted">
commit&nbsp;<a class="text-muted" target="_blank"
href="https://github.com/anthonyaxenov/iptv/commit/{{ commit() }}"
>{{ commit()[:8] }}</a>
</span>
</footer>
</div>
{% include("custom.twig") ignore missing %}