0
0
mirror of https://github.com/anthonyaxenov/iptv.git synced 2024-11-01 01:26:00 +00:00

Большое обновление и рефакторинг

- улучшен и нарощен парсинг плейлистов
- упрощена конфигурация
- название плейлиста в заголовке страницы подробностей
- fuzzy-поиск каналов на странице подробностей
- эскизы логотипов на странице подробностей
- бейдж статуса плейлиста на главной теперь перед названием
- удалены laravel-завивимости
- какие-нибудь мелочи, которые забыл упомянуть
This commit is contained in:
Anthony Axenov 2024-09-23 13:05:01 +08:00
parent aff93aaf78
commit 24636837cc
Signed by: anthony
GPG Key ID: EA9EC32FF7CCD4EC
22 changed files with 557 additions and 739 deletions

View File

@ -1,6 +1,8 @@
[PHP] [PHP]
error_reporting = E_ALL error_reporting = E_ALL
file_uploads = Off file_uploads = Off
memory_limit=-1
max_execution_time=-1
; upload_max_filesize=10M ; upload_max_filesize=10M
; post_max_size=10M ; post_max_size=10M

View File

@ -4,8 +4,12 @@ declare(strict_types=1);
namespace App\Controllers; namespace App\Controllers;
use App\Core\IniFile;
use App\Core\Playlist;
use App\Exceptions\PlaylistNotFoundException;
use Exception; use Exception;
use Flight; use Flight;
use Random\RandomException;
/** /**
* Абстрактный контроллер для расширения * Абстрактный контроллер для расширения
@ -13,15 +17,69 @@ use Flight;
abstract class Controller abstract class Controller
{ {
/** /**
* Перебрасывает на страницу 404 при ненайденном плейлисте * @var IniFile Класс для работы с ini-файлом плейлистов
*/
protected IniFile $ini;
/**
* Конструктор
*/
public function __construct()
{
$this->ini = Flight::get('ini');
}
/**
* Возвращает плейлист по его ID для обработки
* *
* @param string $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 * @return void
* @throws Exception * @throws Exception
*/ */
public function notFound(string $id): void public function notFound(string $id, bool $asJson = false): void
{ {
Flight::response()->status(404)->sendHeaders(); Flight::response()->status(404)->sendHeaders();
view('notfound', ['id' => $id]); $asJson || view('notfound', ['id' => $id]);
} }
} }

View File

@ -4,8 +4,6 @@ declare(strict_types=1);
namespace App\Controllers; namespace App\Controllers;
use App\Core\PlaylistProcessor;
use App\Core\RedirectedPlaylist;
use Exception; use Exception;
use Flight; use Flight;
@ -15,22 +13,9 @@ use Flight;
class HomeController extends Controller class HomeController extends Controller
{ {
/** /**
* @var PlaylistProcessor Обработчик ini-списка * Отображает главную страницу с учётом пагинации списка плейлистов
*/
protected PlaylistProcessor $ini;
/**
* Конструктор
*/
public function __construct()
{
$this->ini = new PlaylistProcessor();
}
/**
* Отображает главную страницу на указанной странице списка плейлистов
* *
* @param int $page * @param int $page Текущая страница списка
* @return void * @return void
* @throws Exception * @throws Exception
*/ */
@ -44,19 +29,21 @@ class HomeController extends Controller
} }
// иначе формируем и сортируем список при необходимости, рисуем страницу // иначе формируем и сортируем список при необходимости, рисуем страницу
$per_page = 10; $perPage = 10;
$list = $this->ini->playlists $playlists = $this->ini->playlists(false);
->filter(static fn ($playlist) => !($playlist instanceof RedirectedPlaylist)) $count = count($playlists);
->forPage($page, $per_page); $pageCount = ceil($count / $perPage);
$offset = max(0, ($page - 1) * $perPage);
$list = array_slice($playlists, $offset, $perPage, true);
view('list', [ view('list', [
'updated_at' => $this->ini->updatedAt(), 'updated_at' => $this->ini->updatedAt(),
'count' => $this->ini->playlists->count(), 'count' => $count,
'pages' => [ 'pages' => [
'count' => ceil($this->ini->playlists->count() / $per_page), 'count' => $pageCount,
'current' => $page, 'current' => $page,
], ],
'playlists' => $list->toArray(), 'playlists' => $list,
]); ]);
} }

View File

@ -4,9 +4,6 @@ declare(strict_types=1);
namespace App\Controllers; namespace App\Controllers;
use App\Core\{
PlaylistProcessor,
RedirectedPlaylist};
use App\Exceptions\PlaylistNotFoundException; use App\Exceptions\PlaylistNotFoundException;
use Exception; use Exception;
use Flight; use Flight;
@ -16,34 +13,17 @@ use Flight;
*/ */
class PlaylistController extends Controller class PlaylistController extends Controller
{ {
/**
* @var PlaylistProcessor Обработчик ini-списка
*/
protected PlaylistProcessor $ini;
/**
* Конструктор
*/
public function __construct()
{
$this->ini = new PlaylistProcessor();
}
/** /**
* Отправляет запрос с клиента по прямой ссылке плейлиста * Отправляет запрос с клиента по прямой ссылке плейлиста
* *
* @param $id * @param string $id ID плейлиста
* @return void * @return void
* @throws Exception * @throws Exception
*/ */
public function download($id): void public function download(string $id): void
{ {
try { try {
$playlist = $this->ini->playlist($id); $playlist = $this->ini->getPlaylist($id);
if ($playlist instanceof RedirectedPlaylist) {
Flight::redirect(base_url($playlist->redirect_id));
die;
}
Flight::redirect($playlist->pls); Flight::redirect($playlist->pls);
} catch (PlaylistNotFoundException) { } catch (PlaylistNotFoundException) {
$this->notFound($id); $this->notFound($id);
@ -54,49 +34,27 @@ class PlaylistController extends Controller
/** /**
* Отображает страницу описания плейлиста * Отображает страницу описания плейлиста
* *
* @param string $id * @param string $id ID плейлиста
* @return void * @return void
* @throws Exception * @throws Exception
*/ */
public function details(string $id): void public function details(string $id): void
{ {
try { $result = $this->getPlaylistResponse($id);
$playlist = $this->ini->playlist($id);
if ($playlist instanceof RedirectedPlaylist) { view('details', $result);
Flight::redirect(base_url($playlist->redirect_id . '/details'));
die;
}
view('details', [
...$playlist->toArray(),
...$this->ini->parse($id),
]);
} catch (PlaylistNotFoundException) {
$this->notFound($id);
}
} }
/** /**
* Возвращает JSON с описанием плейлиста * Возвращает JSON с описанием плейлиста
* *
* @param string $id * @param string $id ID плейлиста
* @return void * @return void
* @throws Exception * @throws Exception
*/ */
public function json(string $id): void public function json(string $id): void
{ {
try { $result = $this->getPlaylistResponse($id, true);
$playlist = $this->ini->playlist($id); Flight::json($result);
if ($playlist instanceof RedirectedPlaylist) {
Flight::redirect(base_url($playlist->redirect_id . '/json'));
die;
}
Flight::json([
...$playlist->toArray(),
...$this->ini->parse($id),
]);
} catch (PlaylistNotFoundException) {
Flight::response()->status(404)->sendHeaders();
Flight::json(['error' => ['message' => 'Playlist not found']]);
}
} }
} }

View File

@ -1,28 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Core;
use Illuminate\Contracts\Support\Arrayable;
/**
* Базовый класс плейлиста
*/
abstract class BasicPlaylist implements Arrayable
{
/**
* @var string ID плейлиста
*/
public string $id;
/**
* Возвращает ссылку на плейлист в рамках проекта
*
* @return string
*/
public function url(): string
{
return sprintf('%s/%s', base_url(), $this->id);
}
}

View File

@ -4,10 +4,8 @@ declare(strict_types=1);
namespace App\Core; namespace App\Core;
use App\Controllers\AjaxController;
use App\Extensions\TwigFunctions; use App\Extensions\TwigFunctions;
use Flight; use Flight;
use Illuminate\Support\Arr;
use Twig\Environment; use Twig\Environment;
use Twig\Extension\DebugExtension; use Twig\Extension\DebugExtension;
use Twig\Loader\FilesystemLoader; use Twig\Loader\FilesystemLoader;
@ -24,11 +22,19 @@ final class Bootstrapper
*/ */
public static function bootSettings(): void public static function bootSettings(): void
{ {
$settings = Arr::dot(require_once config_path('app.php')); $config = require_once config_path('app.php');
Arr::map($settings, function ($value, $key) { foreach ($config as $key => $value) {
Flight::set("flight.$key", $value); Flight::set($key, $value);
}); }
Flight::set('config', $settings); Flight::set('config', $config);
}
public static function bootIni(): void
{
$loader = new IniFile();
$loader->load();
Flight::set('ini', $loader);
} }
/** /**
@ -38,18 +44,20 @@ final class Bootstrapper
*/ */
public static function bootTwig(): void public static function bootTwig(): void
{ {
$filesystemLoader = new FilesystemLoader(config('views.path')); $twigCfg = [
Flight::register( 'cache' => config('twig.cache'),
'view', 'debug' => config('twig.debug'),
Environment::class, ];
[$filesystemLoader, config('twig')],
function ($twig) { $closure = static function ($twig) {
/** @var Environment $twig */ /** @var Environment $twig */
Flight::set('twig', $twig); Flight::set('twig', $twig);
$twig->addExtension(new TwigFunctions()); $twig->addExtension(new TwigFunctions());
$twig->addExtension(new DebugExtension()); $twig->addExtension(new DebugExtension());
} };
);
$loader = new FilesystemLoader(config('flight.views.path'));
Flight::register('view', Environment::class, [$loader, $twigCfg], $closure);
} }
/** /**

120
src/app/Core/IniFile.php Normal file
View File

@ -0,0 +1,120 @@
<?php
declare(strict_types=1);
namespace App\Core;
use App\Exceptions\PlaylistNotFoundException;
use Exception;
/**
* Класс для работы с ini-файлом плейлистов
*/
class IniFile
{
/**
* @var array Считанное из файла содержимое ini-файла
*/
protected array $rawIni;
/**
* @var Playlist[] Коллекция подгруженных плейлистов
*/
protected array $playlists = [];
/**
* @var string[] Карта переадресаций плейлистов
*/
protected array $redirections = [];
/**
* @var string Дата последнего обновления списка
*/
protected string $updated_at;
/**
* Считывает ini-файл и инициализирует объекты плейлистов
*
* @return void
* @throws Exception
*/
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);
}
}
/**
* Возвращает объекты плейлистов
*
* @param bool $all true - получить все, false - получить только НЕпереадресованные
* @return Playlist[]
*/
public function playlists(bool $all = true): array
{
if ($all) {
return $this->playlists;
}
return array_filter($this->playlists, static fn ($playlist) => is_null($playlist->redirectId));
}
/**
* Возвращает дату обновления ini-файла
*
* @return string
*/
public function updatedAt(): string
{
return $this->updated_at;
}
/**
* Возвращает ID плейлиста, на который нужно переадресовать указанный
*
* @param string $id ID плейлиста
* @return string|null
*/
public function getRedirection(string $id): ?string
{
return $this->redirections[$id] ?? null;
}
/**
* Возвращает объект плейлиста
*
* @param string $id ID плейлиста
* @return Playlist|null
* @throws PlaylistNotFoundException
*/
public function getPlaylist(string $id): ?Playlist
{
return $this->playlists[$id] ?? throw new PlaylistNotFoundException($id);
}
/**
* Создаёт объекты плейлистов, рекурсивно определяя переадресации
*
* @param int|string $id ID плейлиста
* @param array $params Описание плейлиста
* @param string|null $redirectId ID для переадресации
* @return Playlist
* @throws Exception
*/
protected function makePlaylist(int|string $id, array $params, ?string $redirectId = null): Playlist
{
$id = (string)$id;
if (isset($params['redirect'])) {
$this->redirections[$id] = $redirectId = (string)$params['redirect'];
$params = $this->rawIni[$redirectId];
return $this->makePlaylist($id, $params, $redirectId);
}
return new Playlist($id, $params, $redirectId);
}
}

View File

@ -5,11 +5,12 @@ declare(strict_types=1);
namespace App\Core; namespace App\Core;
use Exception; use Exception;
use Random\RandomException;
/** /**
* Плейлист без редиректа * Плейлист без редиректа
*/ */
class Playlist extends BasicPlaylist class Playlist
{ {
/** /**
* @var string|null Название плейлиста * @var string|null Название плейлиста
@ -36,18 +37,43 @@ class Playlist extends BasicPlaylist
*/ */
public string $url; public string $url;
/**
* @var string|null Сырое содержимое плейлиста
*/
protected ?string $rawContent = null;
/**
* @var array Обработанное содержимое плейлиста
*/
protected array $parsedContent = [];
/**
* @var array Статус скачивания плейлиста
*/
protected array $downloadStatus = [
'httpCode' => 'unknown',
'errCode' => 'unknown',
'errText' => 'unknown',
'possibleStatus' => 'unknown',
];
/** /**
* Конструктор * Конструктор
* *
* @param string $id * @param string $id ID плейлиста
* @param array $params * @param array $params Описание плейлиста
* @param string|null $redirectId ID для переадресации
* @throws Exception * @throws Exception
*/ */
public function __construct(public string $id, array $params) public function __construct(
{ public readonly string $id,
array $params,
public readonly ?string $redirectId = null
) {
empty($params['pls']) && throw new Exception( empty($params['pls']) && throw new Exception(
"Плейлист с ID=$id обязан иметь параметр pls или redirect" "Плейлист с ID=$id обязан иметь параметр pls или redirect"
); );
$this->url = base_url($id); $this->url = base_url($id);
$this->name = empty($params['name']) ? "Плейлист #$id" : $params['name']; $this->name = empty($params['name']) ? "Плейлист #$id" : $params['name'];
$this->desc = empty($params['desc']) ? null : $params['desc']; $this->desc = empty($params['desc']) ? null : $params['desc'];
@ -56,7 +82,172 @@ class Playlist extends BasicPlaylist
} }
/** /**
* @inheritDoc * Получает содержимое плейлиста с третьей стороны
*
* @return void
*/
public function download(): void
{
$curl = curl_init();
curl_setopt_array($curl, [
CURLOPT_URL => $this->pls,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 30,
CURLOPT_HEADER => false,
CURLOPT_FAILONERROR => true,
]);
$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);
}
/**
* Возвращает статус проверки плейлиста по коду ошибки curl
*
* @param int $curlErrCode
* @return string
*/
protected function guessStatus(int $curlErrCode): string
{
return match ($curlErrCode) {
0 => 'online',
28 => 'timeout',
5, 6, 7, 22, 35 => 'offline',
default => 'error',
};
}
/**
* Парсит полученный от третьей стороны плейлист
*
* @return array Информация о составе плейлиста
* @throws RandomException
*/
public function parse(): array
{
if (!empty($this->parsed())) {
return $this->parsed();
}
$result = [
'attributes' => [],
'channels' => [],
'groups' => [],
'encoding' => [
'name' => 'unknown',
'alert' => false,
],
];
if (is_null($this->rawContent)) {
return $this->parsedContent = $result;
}
$enc = mb_detect_encoding($this->rawContent, config('app.pls_encodings'));
$result['encoding']['name'] = $enc;
if ($enc !== 'UTF-8') {
$result['encoding']['alert'] = true;
$this->rawContent = mb_convert_encoding($this->rawContent, 'UTF-8', $enc);
}
$lines = explode("\n", $this->rawContent);
$isHeader = $isGroup = $isChannel = false;
foreach ($lines as $line) {
if (empty($line = trim($line))) {
continue;
}
if (str_starts_with($line, '#EXTM3U ')) {
$isHeader = true;
$isGroup = $isChannel = false;
$result['attributes'] = $this->parseAttributes($line);
continue;
}
if (str_starts_with($line, '#EXTINF:')) {
$isChannel = true;
$isHeader = $isGroup = false;
$combined = trim(substr($line, strpos($line, ',') + 1));
$exploded = explode(',', $line);
$attrs = $this->parseAttributes($exploded[0]);
$tvgid = empty($attrs['tvg-id']) ? ' неизвестен' : "='{$attrs['tvg-id']}'";
$name = trim($exploded[1] ?? "(канал без названия, tvg-id$tvgid)");
$channel = [
'_id' => md5($name . random_int(1, 99999)),
'name' => trim($name),
'url' => null,
'group' => $attrs['group-title'] ?? null,
'attributes' => $attrs,
];
unset($name, $attrs, $combined, $exploded);
continue;
}
if (str_starts_with($line, '#EXTGRP:')) {
$isGroup = true;
$isHeader = false;
if ($isChannel) {
$exploded = explode(':', $line);
$channel['group'] = $exploded[1];
}
continue;
}
if ($isChannel) {
$channel['url'] = str_starts_with($line, 'http') ? $line : null;
$result['channels'][] = $channel;
$isChannel = false;
unset($channel);
}
}
$groups = [];
foreach ($result['channels'] as $channel) {
$name = $channel['group'] ?? '(без группы)';
$id = md5($name);
if (empty($groups[$id])) {
$groups[$id] = [
'_id' => $id,
'name' => $name,
'channels' => [],
];
}
$groups[$id]['channels'][] = $channel['_id'];
}
$result['groups'] = array_values($groups);
return $this->parsedContent = $result;
}
/**
* Парсит атрибуты строки и возвращает ассоциативный массив
*
* @param string $line
* @return array
*/
protected function parseAttributes(string $line): array
{
if (str_starts_with($line, '#')) {
$line = trim(substr($line, strpos($line, ' ') + 1));
}
preg_match_all('#(?<key>[a-z-]+)="(?<value>.*)"#U', $line, $matches);
return array_combine($matches['key'], $matches['value']);
}
/**
* Возвращает содержимое объекта в виде массива
*
* @return array
*/ */
public function toArray(): array public function toArray(): array
{ {
@ -67,6 +258,41 @@ class Playlist extends BasicPlaylist
'desc' => $this->desc, 'desc' => $this->desc,
'pls' => $this->pls, 'pls' => $this->pls,
'src' => $this->src, 'src' => $this->src,
'status' => $this->status(),
'content' => [
...$this->parsed(),
'channelCount' => count($this->parsed()['channels'])
],
]; ];
} }
/**
* Возвращает ссылку на плейлист в рамках проекта
*
* @return string
*/
public function url(): string
{
return sprintf('%s/%s', base_url(), $this->id);
}
/**
* Возвращает статус скачивания плейлиста
*
* @return array|string[]
*/
public function status(): array
{
return $this->downloadStatus;
}
/**
* Возвращает обработанное содержимое плейлиста
*
* @return array
*/
public function parsed(): array
{
return $this->parsedContent;
}
} }

View File

@ -1,175 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Core;
use App\Exceptions\PlaylistNotFoundException;
use Illuminate\Support\Collection;
/**
* Обработчик списка плейлистов
*/
final class PlaylistProcessor
{
/**
* @var Collection Коллекция подгруженных плейлистов
*/
public Collection $playlists;
/**
* @var string Дата последнего обновления списка
*/
protected string $updated_at;
/**
* Конструктор
*/
public function __construct()
{
$filepath = config_path('playlists.ini');
$this->updated_at = date('d.m.Y h:i', filemtime($filepath));
$this->playlists = collect(parse_ini_file($filepath, true))
->transform(static fn ($playlist, $id) => empty($playlist['redirect'])
? new Playlist((string)$id, $playlist)
: new RedirectedPlaylist((string)$id, $playlist['redirect'])
);
}
/**
* Проверяет есть ли в списке плейлист по его id
*
* @param string $id
* @return bool
*/
public function hasId(string $id): bool
{
return $this->playlists->keys()->contains($id);
}
/**
* Возвращает из коллекции указанный плейлист, если он существует
*
* @param string $id
* @return Playlist|RedirectedPlaylist
* @throws PlaylistNotFoundException
*/
public function playlist(string $id): Playlist|RedirectedPlaylist
{
!$this->hasId($id) && throw new PlaylistNotFoundException($id);
return $this->playlists[$id];
}
/**
* Проверяет доступность плейлиста на третьей стороне
*
* @param string $id
* @return bool
* @throws PlaylistNotFoundException
*/
public function check(string $id): bool
{
$curl = curl_init();
curl_setopt_array($curl, [
CURLOPT_URL => $this->playlist($id)->pls,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 5,
CURLOPT_HEADER => false,
CURLOPT_NOBODY => true,
]);
curl_exec($curl);
$code = curl_getinfo($curl, CURLINFO_RESPONSE_CODE);
curl_close($curl);
return $code < 400;
}
/**
* Получает содержимое плейлиста с третьей стороны
*
* @param string $id
* @return array
* @throws PlaylistNotFoundException
*/
protected function fetch(string $id): array
{
$curl = curl_init();
curl_setopt_array($curl, [
CURLOPT_URL => $this->playlist($id)->pls,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 30,
CURLOPT_HEADER => false,
CURLOPT_FAILONERROR => true,
]);
$content = curl_exec($curl);
$http_code = curl_getinfo($curl, CURLINFO_RESPONSE_CODE);
$err_code = curl_errno($curl);
$err_text = curl_error($curl);
curl_close($curl);
return [
'content' => $content,
'http_code' => $http_code,
'err_code' => $err_code,
'err_text' => $err_text,
];
}
/**
* Возвращает статус проверки плейлиста по коду ошибки curl
*
* @param int $curl_err_code
* @return string
*/
protected function guessStatus(int $curl_err_code): string
{
return match ($curl_err_code) {
0 => 'online',
28 => 'timeout',
5, 6, 7, 22, 35 => 'offline',
default => 'error',
};
}
/**
* Парсит полученный от третьей стороны плейлист
*
* @param string $id
* @return array Информация о составе плейлиста
* @throws PlaylistNotFoundException
*/
public function parse(string $id): array
{
$fetched = $this->fetch($id);
if ($fetched['err_code'] > 0) {
return [
'status' => $this->guessStatus($fetched['err_code']),
'error' => [
'code' => $fetched['err_code'],
'message' => $fetched['err_text'],
],
];
}
$result['status'] = $this->guessStatus($fetched['err_code']);
$result['encoding']['name'] = 'UTF-8';
$result['encoding']['alert'] = false;
if (($enc = mb_detect_encoding($fetched['content'], config('app.pls_encodings'))) !== 'UTF-8') {
$fetched['content'] = mb_convert_encoding($fetched['content'], 'UTF-8', $enc);
$result['encoding']['name'] = $enc;
$result['encoding']['alert'] = true;
}
$matches = [];
preg_match_all("/^#EXTINF:-?\d.*,\s*(.*)/m", $fetched['content'], $matches);
$result['channels'] = array_map('trim', $matches[1]);
$result['count'] = $fetched['http_code'] < 400 ? count($result['channels']) : 0;
return $result;
}
/**
* Возвращает дату последнего обновления списка плейлистов
*
* @return string
*/
public function updatedAt(): string
{
return $this->updated_at;
}
}

View File

@ -1,34 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Core;
/**
* Плейлист с редиректом
*/
class RedirectedPlaylist extends BasicPlaylist
{
/**
* Конструктор
*
* @param string $id
* @param string $redirect_id
*/
public function __construct(
public string $id,
public string $redirect_id,
) {
}
/**
* @inheritDoc
*/
public function toArray(): array
{
return [
'id' => $this->id,
'redirect_id' => $this->redirect_id,
];
}
}

View File

@ -8,8 +8,8 @@ use Exception;
class PlaylistNotFoundException extends Exception class PlaylistNotFoundException extends Exception
{ {
public function __construct(string $pls_code) public function __construct(string $id)
{ {
parent::__construct("Плейлист $pls_code не найден!"); parent::__construct("Плейлист $id не найден!");
} }
} }

View File

@ -84,6 +84,7 @@ function env(string $key, mixed $default = null): mixed
function view(mixed $template, array $data = []): void function view(mixed $template, array $data = []): void
{ {
$template = str_contains($template, '.twig') ? $template : "$template.twig"; $template = str_contains($template, '.twig') ? $template : "$template.twig";
/** @noinspection PhpVoidFunctionResultUsedInspection */
echo Flight::view()->render($template, $data); echo Flight::view()->render($template, $data);
} }
@ -134,16 +135,5 @@ function bool(mixed $value): bool
*/ */
function config(string $key, mixed $default = null): mixed function config(string $key, mixed $default = null): mixed
{ {
$config = Flight::get('config'); return Flight::get('config')[$key] ?? $default;
if (isset($config["flight.$key"])) {
return $config["flight.$key"];
}
if (isset($config[$key])) {
return $config[$key];
}
$config = Arr::undot($config);
if (Arr::has($config, $key)) {
return Arr::get($config, $key);
}
return $default;
} }

View File

@ -2,7 +2,7 @@
"require": { "require": {
"php": "^8.2", "php": "^8.2",
"ext-json": "*", "ext-json": "*",
"illuminate/collections": "^11.23", "ext-curl": "*",
"mikecao/flight": "^3.12", "mikecao/flight": "^3.12",
"symfony/dotenv": "^7.1", "symfony/dotenv": "^7.1",
"twig/twig": "^3.14" "twig/twig": "^3.14"

316
src/composer.lock generated
View File

@ -4,203 +4,8 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically" "This file is @generated automatically"
], ],
"content-hash": "8e2b70162003ee57e4e033d98f833457", "content-hash": "3cbd8253b2f0790d682e38f308df6e7f",
"packages": [ "packages": [
{
"name": "illuminate/collections",
"version": "v11.23.5",
"source": {
"type": "git",
"url": "https://github.com/illuminate/collections.git",
"reference": "cbea9d7a82984bbc1a9376498533cc77513f9a09"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/illuminate/collections/zipball/cbea9d7a82984bbc1a9376498533cc77513f9a09",
"reference": "cbea9d7a82984bbc1a9376498533cc77513f9a09",
"shasum": ""
},
"require": {
"illuminate/conditionable": "^11.0",
"illuminate/contracts": "^11.0",
"illuminate/macroable": "^11.0",
"php": "^8.2"
},
"suggest": {
"symfony/var-dumper": "Required to use the dump method (^7.0)."
},
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "11.x-dev"
}
},
"autoload": {
"files": [
"helpers.php"
],
"psr-4": {
"Illuminate\\Support\\": ""
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Taylor Otwell",
"email": "taylor@laravel.com"
}
],
"description": "The Illuminate Collections package.",
"homepage": "https://laravel.com",
"support": {
"issues": "https://github.com/laravel/framework/issues",
"source": "https://github.com/laravel/framework"
},
"time": "2024-09-12T14:50:04+00:00"
},
{
"name": "illuminate/conditionable",
"version": "v11.23.5",
"source": {
"type": "git",
"url": "https://github.com/illuminate/conditionable.git",
"reference": "362dd761b9920367bca1427a902158225e9e3a23"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/illuminate/conditionable/zipball/362dd761b9920367bca1427a902158225e9e3a23",
"reference": "362dd761b9920367bca1427a902158225e9e3a23",
"shasum": ""
},
"require": {
"php": "^8.0.2"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "11.x-dev"
}
},
"autoload": {
"psr-4": {
"Illuminate\\Support\\": ""
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Taylor Otwell",
"email": "taylor@laravel.com"
}
],
"description": "The Illuminate Conditionable package.",
"homepage": "https://laravel.com",
"support": {
"issues": "https://github.com/laravel/framework/issues",
"source": "https://github.com/laravel/framework"
},
"time": "2024-06-28T20:10:30+00:00"
},
{
"name": "illuminate/contracts",
"version": "v11.23.5",
"source": {
"type": "git",
"url": "https://github.com/illuminate/contracts.git",
"reference": "5a4c6dcf633c1f69e1b70bbea1ef1b7d2186d3da"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/illuminate/contracts/zipball/5a4c6dcf633c1f69e1b70bbea1ef1b7d2186d3da",
"reference": "5a4c6dcf633c1f69e1b70bbea1ef1b7d2186d3da",
"shasum": ""
},
"require": {
"php": "^8.2",
"psr/container": "^1.1.1|^2.0.1",
"psr/simple-cache": "^1.0|^2.0|^3.0"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "11.x-dev"
}
},
"autoload": {
"psr-4": {
"Illuminate\\Contracts\\": ""
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Taylor Otwell",
"email": "taylor@laravel.com"
}
],
"description": "The Illuminate Contracts package.",
"homepage": "https://laravel.com",
"support": {
"issues": "https://github.com/laravel/framework/issues",
"source": "https://github.com/laravel/framework"
},
"time": "2024-09-12T15:25:08+00:00"
},
{
"name": "illuminate/macroable",
"version": "v11.23.5",
"source": {
"type": "git",
"url": "https://github.com/illuminate/macroable.git",
"reference": "e1cb9e51b9ed5d3c9bc1ab431d0a52fe42a990ed"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/illuminate/macroable/zipball/e1cb9e51b9ed5d3c9bc1ab431d0a52fe42a990ed",
"reference": "e1cb9e51b9ed5d3c9bc1ab431d0a52fe42a990ed",
"shasum": ""
},
"require": {
"php": "^8.2"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "11.x-dev"
}
},
"autoload": {
"psr-4": {
"Illuminate\\Support\\": ""
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Taylor Otwell",
"email": "taylor@laravel.com"
}
],
"description": "The Illuminate Macroable package.",
"homepage": "https://laravel.com",
"support": {
"issues": "https://github.com/laravel/framework/issues",
"source": "https://github.com/laravel/framework"
},
"time": "2024-06-28T20:10:30+00:00"
},
{ {
"name": "mikecao/flight", "name": "mikecao/flight",
"version": "v3.12.0", "version": "v3.12.0",
@ -272,110 +77,6 @@
}, },
"time": "2024-08-22T17:05:34+00:00" "time": "2024-08-22T17:05:34+00:00"
}, },
{
"name": "psr/container",
"version": "2.0.2",
"source": {
"type": "git",
"url": "https://github.com/php-fig/container.git",
"reference": "c71ecc56dfe541dbd90c5360474fbc405f8d5963"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/php-fig/container/zipball/c71ecc56dfe541dbd90c5360474fbc405f8d5963",
"reference": "c71ecc56dfe541dbd90c5360474fbc405f8d5963",
"shasum": ""
},
"require": {
"php": ">=7.4.0"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "2.0.x-dev"
}
},
"autoload": {
"psr-4": {
"Psr\\Container\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "PHP-FIG",
"homepage": "https://www.php-fig.org/"
}
],
"description": "Common Container Interface (PHP FIG PSR-11)",
"homepage": "https://github.com/php-fig/container",
"keywords": [
"PSR-11",
"container",
"container-interface",
"container-interop",
"psr"
],
"support": {
"issues": "https://github.com/php-fig/container/issues",
"source": "https://github.com/php-fig/container/tree/2.0.2"
},
"time": "2021-11-05T16:47:00+00:00"
},
{
"name": "psr/simple-cache",
"version": "3.0.0",
"source": {
"type": "git",
"url": "https://github.com/php-fig/simple-cache.git",
"reference": "764e0b3939f5ca87cb904f570ef9be2d78a07865"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/php-fig/simple-cache/zipball/764e0b3939f5ca87cb904f570ef9be2d78a07865",
"reference": "764e0b3939f5ca87cb904f570ef9be2d78a07865",
"shasum": ""
},
"require": {
"php": ">=8.0.0"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "3.0.x-dev"
}
},
"autoload": {
"psr-4": {
"Psr\\SimpleCache\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "PHP-FIG",
"homepage": "https://www.php-fig.org/"
}
],
"description": "Common interfaces for simple caching",
"keywords": [
"cache",
"caching",
"psr",
"psr-16",
"simple-cache"
],
"support": {
"source": "https://github.com/php-fig/simple-cache/tree/3.0.0"
},
"time": "2021-10-29T13:26:27+00:00"
},
{ {
"name": "symfony/deprecation-contracts", "name": "symfony/deprecation-contracts",
"version": "v3.5.0", "version": "v3.5.0",
@ -445,16 +146,16 @@
}, },
{ {
"name": "symfony/dotenv", "name": "symfony/dotenv",
"version": "v7.1.3", "version": "v7.1.5",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/symfony/dotenv.git", "url": "https://github.com/symfony/dotenv.git",
"reference": "a26be30fd61678dab694a18a85084cea7673bbf3" "reference": "6d966200b399fa59759286f3fc7c919f0677c449"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/symfony/dotenv/zipball/a26be30fd61678dab694a18a85084cea7673bbf3", "url": "https://api.github.com/repos/symfony/dotenv/zipball/6d966200b399fa59759286f3fc7c919f0677c449",
"reference": "a26be30fd61678dab694a18a85084cea7673bbf3", "reference": "6d966200b399fa59759286f3fc7c919f0677c449",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@ -499,7 +200,7 @@
"environment" "environment"
], ],
"support": { "support": {
"source": "https://github.com/symfony/dotenv/tree/v7.1.3" "source": "https://github.com/symfony/dotenv/tree/v7.1.5"
}, },
"funding": [ "funding": [
{ {
@ -515,7 +216,7 @@
"type": "tidelift" "type": "tidelift"
} }
], ],
"time": "2024-07-09T19:36:07+00:00" "time": "2024-09-17T09:16:35+00:00"
}, },
{ {
"name": "symfony/polyfill-ctype", "name": "symfony/polyfill-ctype",
@ -840,7 +541,8 @@
"prefer-lowest": false, "prefer-lowest": false,
"platform": { "platform": {
"php": "^8.2", "php": "^8.2",
"ext-json": "*" "ext-json": "*",
"ext-curl": "*"
}, },
"platform-dev": [], "platform-dev": [],
"plugin-api-version": "2.6.0" "plugin-api-version": "2.6.0"

View File

@ -3,28 +3,20 @@
declare(strict_types=1); declare(strict_types=1);
return [ return [
'flight' => [ // https://flightphp.com/learn#configuration
// https://flightphp.com/learn#configuration 'flight.base_url' => env('APP_URL', 'http://localhost:8080'),
'base_url' => env('APP_URL', 'http://localhost:8080'), 'flight.case_sensitive' => bool(env('FLIGHT_CASE_SENSITIVE', false)),
'case_sensitive' => bool(env('FLIGHT_CASE_SENSITIVE', false)), 'flight.handle_errors' => bool(env('FLIGHT_HANDLE_ERRORS', true)),
'handle_errors' => bool(env('FLIGHT_HANDLE_ERRORS', true)), 'flight.log_errors' => bool(env('FLIGHT_LOG_ERRORS', true)),
'log_errors' => bool(env('FLIGHT_LOG_ERRORS', true)), 'flight.views.path' => views_path(),
'views' => [ 'flight.views.extension' => '.twig',
'path' => views_path(), 'twig.cache' => bool(env('TWIG_CACHE', true)) ? cache_path() . '/views' : false,
'extension' => '.twig', 'twig.debug' => bool(env('TWIG_DEBUG', false)),
], 'app.title' => env('APP_TITLE', 'IPTV Playlists'),
], 'app.pls_encodings' => [
'twig' => [ 'UTF-8',
'cache' => bool(env('TWIG_CACHE', true)) ? cache_path() . '/views' : false, 'CP1251',
'debug' => bool(env('TWIG_DEBUG', false)), // 'CP866',
], // 'ISO-8859-5',
'app' => [
'title' => env('APP_TITLE', 'IPTV Playlists'),
'pls_encodings' => [
'UTF-8',
'CP1251',
// 'CP866',
// 'ISO-8859-5',
],
], ],
]; ];

View File

@ -6,11 +6,11 @@ use App\Controllers\HomeController;
use App\Controllers\PlaylistController; use App\Controllers\PlaylistController;
return [ return [
'GET /' => (new HomeController())->index(...), 'GET /' => [HomeController::class, 'index'],
'GET /page/@page:[0-9]+' => (new HomeController())->index(...), 'GET /page/@page:[0-9]+' => [HomeController::class, 'index'],
'GET /faq' => (new HomeController())->faq(...), 'GET /faq' => [HomeController::class, 'faq'],
'GET /@id:[a-zA-Z0-9_-]+' => (new PlaylistController())->download(...), 'GET /@id:[a-zA-Z0-9_-]+' => [PlaylistController::class, 'download'],
'GET /?[a-zA-Z0-9_-]+' => (new PlaylistController())->download(...), 'GET /?[a-zA-Z0-9_-]+' => [PlaylistController::class, 'download'],
'GET /@id:[a-zA-Z0-9_-]+/details' => (new PlaylistController())->details(...), 'GET /@id:[a-zA-Z0-9_-]+/details' => [PlaylistController::class, 'details'],
'GET /@id:[a-zA-Z0-9_-]+/json' => (new PlaylistController())->json(...), 'GET /@id:[a-zA-Z0-9_-]+/json' => [PlaylistController::class, 'json'],
]; ];

View File

@ -11,20 +11,10 @@ use Symfony\Component\Dotenv\Dotenv;
|-------------------------------------------------------------------------- |--------------------------------------------------------------------------
*/ */
// autoload composer packages
require '../vendor/autoload.php'; require '../vendor/autoload.php';
// load .env parameters
(new Dotenv())->loadEnv(root_path() . '/.env'); (new Dotenv())->loadEnv(root_path() . '/.env');
// set up framework according to its config
Bootstrapper::bootSettings(); Bootstrapper::bootSettings();
// set up Twig template engine
Bootstrapper::bootTwig(); Bootstrapper::bootTwig();
Bootstrapper::bootIni();
// set up routes defined in config file
Bootstrapper::bootRoutes(); Bootstrapper::bootRoutes();
// start application
Flight::start(); Flight::start();

2
src/public/js/list.min.js vendored Normal file

File diff suppressed because one or more lines are too long

View File

@ -1,39 +1,49 @@
{% extends "template.twig" %} {% extends "template.twig" %}
{% block title %}{{ title }}{% endblock %} {% block title %}{{ name }} - {{ config('app.title') }}{% endblock %}
{% block head %}
<style>.tvg-logo-background{max-width:100px;max-height:100px;background:white;padding:2px;border-radius:5px}</style>
{% endblock %}
{% block header %} {% block header %}
<h2>{{ name }}</h2> <h2>О плейлисте {{ name }}</h2>
{% if (encoding.alert) %} {% if (content.encoding.alert) %}
<div class="alert alert-warning small" role="alert"> <div class="alert alert-warning small" role="alert">
Кодировка исходного плейлиста отличается от UTF-8. Кодировка исходного плейлиста отличается от UTF-8.
Он был автоматически с конвертирован из {{ encoding.name }}, чтобы отобразить здесь список каналов. Он был автоматически с конвертирован из {{ content.encoding.name }}, чтобы отобразить здесь список каналов.
Однако названия каналов могут отображаться некорректно, причём не только здесь, но и в плеере. Однако названия каналов могут отображаться некорректно, причём не только здесь, но и в плеере.
</div> </div>
{% endif %} {% endif %}
{% if (error) %} {% if (status.errCode > 0) %}
<div class="alert alert-danger small" role="alert"> <div class="alert alert-danger small" role="alert">
Ошибка плейлиста: [{{ error.code }}] {{ error.message }} Ошибка плейлиста: [{{ status.errCode }}] {{ status.errText }}
</div> </div>
{% endif %} {% endif %}
{% endblock %} {% endblock %}
{% block footer %}
<script src="{{ base_url('js/list.min.js') }}"></script>
<script>
var list = new List('chlist',{valueNames:['chname','chindex']});
</script>
{% endblock %}
{% block content %} {% block content %}
<div class="row"> <div class="row">
<div class="col-md-8"> <div class="col-md-6">
<h4>О плейлисте</h4>
<table class="table table-dark table-hover small"> <table class="table table-dark table-hover small">
<tbody> <tbody>
<tr> <tr>
<td class="w-25">ID</td> <td class="w-25">ID</td>
<td> <td>
<code>{{ id }}</code>&nbsp;{% if status == 'online' %} <code>{{ id }}</code>&nbsp;{% if status.possibleStatus == 'online' %}
<span class="badge small text-dark bg-success">online</span> <span class="badge small text-dark bg-success">online</span>
{% elseif status == 'offline' %} {% elseif status.possibleStatus == 'offline' %}
<span class="badge small text-dark bg-danger">offline</span> <span class="badge small text-dark bg-danger">offline</span>
{% elseif status == 'timeout' %} {% elseif status.possibleStatus == 'timeout' %}
<span class="badge small text-dark bg-warning">timeout</span> <span class="badge small text-dark bg-warning">timeout</span>
{% elseif status == 'error' %} {% elseif status.possibleStatus == 'error' %}
<span class="badge small text-dark bg-danger">error</span> <span class="badge small text-dark bg-danger">error</span>
{% endif %} {% endif %}
</td> </td>
@ -61,20 +71,31 @@
</tbody> </tbody>
</table> </table>
</div> </div>
<div class="col-md-4">
<h4>Список каналов ({{ count ?? 0 }})</h4> <div class="col-md-6">
<div class="overflow-auto" style="max-height: 350px;"> <h4>Список каналов ({{ content.channelCount ?? 0 }})</h4>
<table class="table table-dark table-hover small"> {% if (content.channelCount > 0) %}
<tbody> <div id="chlist">
{% for channel in channels %} <input type="text" class="form-control form-control-sm bg-dark text-light mb-2 fuzzy-search" placeholder="Поиск...">
<tr> <div class="overflow-auto" style="max-height:550px">
<td>{{ loop.index }}</td> <table class="table table-dark table-hover small">
<td>{{ channel }}</td> <tbody class="list">
</tr> {% for channel in content.channels %}
{% endfor %} <tr class="chrow">
</tbody> <td class="p-1" class="chindex">{{ loop.index }}</td>
</table> <td class="p-1">
{% if (channel.attributes['tvg-logo']) %}
<img class="tvg-logo-background" src="{{ channel.attributes['tvg-logo'] }}" />
{% endif %}
</td>
<td class="p-1 chname">{{ channel.name }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div> </div>
{% endif %}
</div> </div>
</div> </div>
{% endblock %} {% endblock %}

View File

@ -26,14 +26,13 @@
<strong>{{ id }}</strong> <strong>{{ id }}</strong>
</td> </td>
<td class="info"> <td class="info">
<strong>{{ playlist.name }}</strong>
<span class="badge small bg-secondary text-dark status">loading</span> <span class="badge small bg-secondary text-dark status">loading</span>
<strong>{{ playlist.name }}</strong>
<div class="small mt-2"> <div class="small mt-2">
{% if playlist.desc|length > 0 %} {% if playlist.desc|length > 0 %}
<p class="my-1 d-none d-lg-block">{{ playlist.desc }}</p> <p class="my-1 d-none d-lg-block">{{ playlist.desc }}</p>
{% endif %} {% endif %}
<a href="{{ base_url(id ~ '/details') }}">Подробнее...</a> <a href="{{ base_url(id ~ '/details') }}" class="text-light">Подробнее...</a>
{# <a class="btn btn-sm btn-outline-light" href="{{ base_url(id ~ '/details') }}">Подробнее...</a>#}
</div> </div>
</td> </td>
<td class="text-center count"> <td class="text-center count">
@ -87,9 +86,9 @@
if (xhr.readyState === XMLHttpRequest.DONE) { if (xhr.readyState === XMLHttpRequest.DONE) {
console.log('[' + id + '] DONE', xhr.response) console.log('[' + id + '] DONE', xhr.response)
el_status.classList.remove('bg-secondary') el_status.classList.remove('bg-secondary')
el_status.innerText = xhr.response.status el_status.innerText = xhr.response.status.possibleStatus
el_count.innerText = xhr.response?.count ?? 0 el_count.innerText = xhr.response?.content.channelCount ?? 0
switch (xhr.response.status) { switch (xhr.response.status.possibleStatus) {
case 'online': case 'online':
el_status.classList.add('bg-success') el_status.classList.add('bg-success')
break break

View File

@ -10,7 +10,7 @@
<p> <p>
Плейлист {{ id }} не найден Плейлист {{ id }} не найден
</p> </p>
<a class="navbar-brand" href="{{ base_url() }}" title="На главную"> <a class="btn btn-outline-light" href="{{ base_url() }}" title="На главную">
Перейти к списку Перейти к списку
</a> </a>
</div> </div>

View File

@ -1,7 +1,7 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="ru"> <html lang="ru">
<head> <head>
<title>{{ config('app.title') }}</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="Самообновляемые бесплатные IPTV-плейлисты для домашнего просмотра по коротким ссылкам, списки каналов, проверка доступности">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
@ -55,10 +55,10 @@
</nav> </nav>
</header> </header>
<div class="pt-lg-3 px-0 pb-0"> <section class="container-fluid h-100 pt-lg-3 px-0 pb-0">
{% block header %}{% endblock %} {% block header %}{% endblock %}
{% block content %}{% endblock %} {% block content %}{% endblock %}
</div> </section>
<footer class="py-4 text-center"> <footer class="py-4 text-center">
<script src="{{ base_url('js/bootstrap.bundle.min.js') }}"></script> <script src="{{ base_url('js/bootstrap.bundle.min.js') }}"></script>