Переработка под iptvc
This commit is contained in:
@@ -1,36 +1,117 @@
|
||||
<?php
|
||||
/*
|
||||
* Copyright (c) 2025, Антон Аксенов
|
||||
* This file is part of iptv.axenov.dev web interface
|
||||
* MIT License: https://git.axenov.dev/IPTV/web/src/branch/master/LICENSE
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controllers;
|
||||
|
||||
use App\Errors\PlaylistNotFoundException;
|
||||
use App\Playlists\ChannelLogo;
|
||||
use chillerlan\QRCode\QRCode;
|
||||
use chillerlan\QRCode\QROptions;
|
||||
use Psr\Http\Message\ResponseInterface;
|
||||
use Psr\Http\Message\ServerRequestInterface;
|
||||
use Random\RandomException;
|
||||
use Twig\Error\LoaderError;
|
||||
|
||||
/**
|
||||
*
|
||||
* Контроллер методов API
|
||||
*/
|
||||
class ApiController extends BasicController
|
||||
{
|
||||
/**
|
||||
* Возвращает информацию о каналов плейлиста
|
||||
*
|
||||
* @param ServerRequestInterface $request
|
||||
* @param ResponseInterface $response
|
||||
* @return ResponseInterface
|
||||
* @throws RandomException
|
||||
* @throws LoaderError
|
||||
*/
|
||||
public function json(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
|
||||
public function makeQrCode(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
|
||||
{
|
||||
$code = $request->getAttribute('code');
|
||||
$codes = array_keys(ini()->getPlaylists());
|
||||
if (!in_array($code, $codes, true)) {
|
||||
return $response->withStatus(404);
|
||||
}
|
||||
|
||||
$filePath = cache_path("qr-codes/$code.jpg");
|
||||
if (file_exists($filePath)) {
|
||||
$raw = file_get_contents($filePath);
|
||||
} else {
|
||||
$options = new QROptions([
|
||||
'version' => 5,
|
||||
'outputType' => QRCode::OUTPUT_IMAGE_JPG,
|
||||
'eccLevel' => QRCode::ECC_L,
|
||||
]);
|
||||
$data = base_url("$code");
|
||||
$raw = (new QRCode($options))->render($data, $filePath);
|
||||
$raw = base64_decode(str_replace('data:image/jpg;base64,', '', $raw));
|
||||
}
|
||||
|
||||
$mime = mime_content_type($filePath);
|
||||
$response->getBody()->write($raw);
|
||||
return $response->withStatus(200)
|
||||
->withHeader('Content-Type', $mime);
|
||||
}
|
||||
|
||||
/**
|
||||
* Возвращает информацию о плейлисте
|
||||
*
|
||||
* @param ServerRequestInterface $request
|
||||
* @param ResponseInterface $response
|
||||
* @return ResponseInterface
|
||||
* @throws LoaderError
|
||||
*/
|
||||
public function getPlaylist(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);
|
||||
try {
|
||||
$playlist = ini()->getPlaylist($code);
|
||||
return $this->responseJson($response, 200, $playlist);
|
||||
} catch (PlaylistNotFoundException $e) {
|
||||
return $this->responseJsonError($response, 404, $e);
|
||||
}
|
||||
}
|
||||
|
||||
return $response
|
||||
->withHeader('Content-Type', 'application/json')
|
||||
->withHeader('Content-Length', strlen($json));
|
||||
/**
|
||||
* Возвращает логотип канала
|
||||
*
|
||||
* @param ServerRequestInterface $request
|
||||
* @param ResponseInterface $response
|
||||
* @return ResponseInterface
|
||||
* @throws LoaderError
|
||||
* @throws PlaylistNotFoundException
|
||||
* @todo логотипы каналов
|
||||
*/
|
||||
public function logo(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
|
||||
{
|
||||
$code = $request->getAttributes()['code'];
|
||||
$playlist = ini()->getPlaylist($code);
|
||||
$channelHash = $request->getAttributes()['hash'];
|
||||
$channel = $playlist['channels'][$channelHash];
|
||||
$url = $channel['attributes']['tvg-logo'] ?? '';
|
||||
|
||||
$logo = new ChannelLogo($url);
|
||||
if (!$logo->readFile()) {
|
||||
$logo->fetch();
|
||||
if ($logo->size() === 0) {
|
||||
$logo->setDefault();
|
||||
} else {
|
||||
$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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,25 +1,29 @@
|
||||
<?php
|
||||
/*
|
||||
* Copyright (c) 2025, Антон Аксенов
|
||||
* This file is part of iptv.axenov.dev web interface
|
||||
* MIT License: https://git.axenov.dev/IPTV/web/src/branch/master/LICENSE
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controllers;
|
||||
|
||||
use App\Core\Playlist;
|
||||
use App\Errors\PlaylistNotFoundException;
|
||||
use Psr\Http\Message\ResponseInterface;
|
||||
use Psr\Http\Message\ServerRequestInterface;
|
||||
use Slim\Views\Twig;
|
||||
use Throwable;
|
||||
use Twig\Error\LoaderError;
|
||||
use Twig\Error\RuntimeError;
|
||||
use Twig\Error\SyntaxError;
|
||||
|
||||
/**
|
||||
*
|
||||
* Базовый класс контроллера
|
||||
*/
|
||||
class BasicController
|
||||
{
|
||||
/**
|
||||
* Отправляет сообщение о том, что метод не найден с кодом страницы 404
|
||||
* Отображает страницу 404
|
||||
*
|
||||
* @param ServerRequestInterface $request
|
||||
* @param ResponseInterface $response
|
||||
@@ -37,6 +41,46 @@ class BasicController
|
||||
}
|
||||
|
||||
/**
|
||||
* Возвращает ответ в формате json
|
||||
*
|
||||
* @param ResponseInterface $response
|
||||
* @param int $status
|
||||
* @param array $data
|
||||
* @return ResponseInterface
|
||||
*/
|
||||
protected function responseJson(ResponseInterface $response, int $status, array $data): ResponseInterface
|
||||
{
|
||||
$data = array_merge(['timestamp' => time()], $data);
|
||||
$json = json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
|
||||
$response->getBody()->write($json);
|
||||
return $response->withStatus($status)
|
||||
->withHeader('Content-Type', 'application/json');
|
||||
}
|
||||
|
||||
/**
|
||||
* Возвращает ответ с ошибкой в формате json
|
||||
*
|
||||
* @param ResponseInterface $response
|
||||
* @param int $status
|
||||
* @param Throwable $t
|
||||
* @return ResponseInterface
|
||||
*/
|
||||
protected function responseJsonError(ResponseInterface $response, int $status, Throwable $t): ResponseInterface
|
||||
{
|
||||
$data = [
|
||||
'error' => [
|
||||
'code' => array_last(explode('\\', $t::class)),
|
||||
'message' => $t->getMessage(),
|
||||
],
|
||||
];
|
||||
|
||||
return $this->responseJson($response, $status, $data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Возвращает ответ в формате html
|
||||
*
|
||||
* @param ServerRequestInterface $request
|
||||
* @param ResponseInterface $response
|
||||
* @param string $template
|
||||
|
||||
@@ -1,24 +1,31 @@
|
||||
<?php
|
||||
/*
|
||||
* Copyright (c) 2025, Антон Аксенов
|
||||
* This file is part of iptv.axenov.dev web interface
|
||||
* MIT License: https://git.axenov.dev/IPTV/web/src/branch/master/LICENSE
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controllers;
|
||||
|
||||
use App\Core\ChannelLogo;
|
||||
use App\Errors\PlaylistNotFoundException;
|
||||
use Exception;
|
||||
use Psr\Http\Message\ResponseInterface;
|
||||
use Psr\Http\Message\ServerRequestInterface;
|
||||
use Throwable;
|
||||
use Twig\Error\LoaderError;
|
||||
use Twig\Error\RuntimeError;
|
||||
use Twig\Error\SyntaxError;
|
||||
|
||||
/**
|
||||
*
|
||||
* Контроллер маршрутов web
|
||||
*/
|
||||
class WebController extends BasicController
|
||||
{
|
||||
/**
|
||||
* Возвращает главную страницу со списком плейлистов
|
||||
*
|
||||
* @param ServerRequestInterface $request
|
||||
* @param ResponseInterface $response
|
||||
* @return ResponseInterface
|
||||
@@ -29,26 +36,36 @@ class WebController extends BasicController
|
||||
*/
|
||||
public function home(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
|
||||
{
|
||||
ini()->load();
|
||||
$playlists = ini()->getPlaylists();
|
||||
|
||||
$playlists = ini()->playlists(false);
|
||||
$count = count($playlists);
|
||||
$page = (int)($request->getAttributes()['page'] ?? $request->getQueryParams()['page'] ?? 1);
|
||||
$onlineCount = count(array_filter($playlists, static fn (array $playlist) => $playlist['isOnline'] === true));
|
||||
$uncheckedCount = count(array_filter($playlists, static fn (array $playlist) => $playlist['isOnline'] === null));
|
||||
$offlineCount = $count - $onlineCount - $uncheckedCount;
|
||||
|
||||
$pageSize = config('app.page_size');
|
||||
$pageCount = ceil($count / $pageSize);
|
||||
$offset = max(0, ($page - 1) * $pageSize);
|
||||
$list = array_slice($playlists, $offset, $pageSize, true);
|
||||
if ($pageSize > 0) {
|
||||
$pageCurrent = (int)($request->getAttributes()['page'] ?? $request->getQueryParams()['page'] ?? 1);
|
||||
$pageCount = ceil($count / $pageSize);
|
||||
$offset = max(0, ($pageCurrent - 1) * $pageSize);
|
||||
$playlists = array_slice($playlists, $offset, $pageSize, true);
|
||||
}
|
||||
|
||||
return $this->view($request, $response, 'list.twig', [
|
||||
'updated_at' => ini()->updatedAt(),
|
||||
'playlists' => $list,
|
||||
'updatedAt' => ini()->updatedAt(),
|
||||
'playlists' => $playlists,
|
||||
'count' => $count,
|
||||
'pageCount' => $pageCount,
|
||||
'pageCurrent' => $page,
|
||||
'onlineCount' => $onlineCount,
|
||||
'uncheckedCount' => $uncheckedCount,
|
||||
'offlineCount' => $offlineCount,
|
||||
'pageCount' => $pageCount ?? 1,
|
||||
'pageCurrent' => $pageCurrent ?? 1,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Возвращает страницу FAQ
|
||||
*
|
||||
* @param ServerRequestInterface $request
|
||||
* @param ResponseInterface $response
|
||||
* @return ResponseInterface
|
||||
@@ -62,67 +79,46 @@ class WebController extends BasicController
|
||||
}
|
||||
|
||||
/**
|
||||
* Переадресует запрос на прямую ссылку плейлиста
|
||||
*
|
||||
* @param ServerRequestInterface $request
|
||||
* @param ResponseInterface $response
|
||||
* @return ResponseInterface
|
||||
* @throws LoaderError
|
||||
* @throws RuntimeError
|
||||
* @throws SyntaxError
|
||||
*/
|
||||
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 $response->withHeader('Location', $playlist['url']);
|
||||
} catch (Throwable) {
|
||||
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);
|
||||
return $this->view($request, $response, 'details.twig', ['playlist' => $playlist]);
|
||||
} 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
<?php
|
||||
/*
|
||||
* Copyright (c) 2025, Антон Аксенов
|
||||
* This file is part of iptv.axenov.dev web interface
|
||||
* MIT License: https://git.axenov.dev/IPTV/web/src/branch/master/LICENSE
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
@@ -8,7 +13,7 @@ use App\Errors\PlaylistNotFoundException;
|
||||
use Exception;
|
||||
|
||||
/**
|
||||
* Класс для работы с ini-файлом плейлистов
|
||||
* Класс для работы со списком плейлистов
|
||||
*/
|
||||
class IniFile
|
||||
{
|
||||
@@ -18,65 +23,76 @@ class IniFile
|
||||
protected array $ini;
|
||||
|
||||
/**
|
||||
* @var Playlist[] Коллекция подгруженных плейлистов
|
||||
* @var array[] Коллекция подгруженных плейлистов
|
||||
*/
|
||||
protected array $playlists = [];
|
||||
|
||||
/**
|
||||
* @var string[] Карта переадресаций плейлистов
|
||||
*/
|
||||
protected array $redirections = [];
|
||||
protected array $playlists;
|
||||
|
||||
/**
|
||||
* @var string Дата последнего обновления списка
|
||||
*/
|
||||
protected string $updated_at;
|
||||
protected string $updatedAt;
|
||||
|
||||
/**
|
||||
* Считывает ini-файл и инициализирует объекты плейлистов
|
||||
* Считывает ini-файл и инициализирует плейлисты
|
||||
*
|
||||
* @return void
|
||||
* @return array
|
||||
* @throws Exception
|
||||
*/
|
||||
public function load(): void
|
||||
public function load(): array
|
||||
{
|
||||
$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);
|
||||
$filepath = config_path('playlists.ini');
|
||||
$ini = parse_ini_file($filepath, true);
|
||||
$this->updatedAt = date('d.m.Y h:i', filemtime($filepath));
|
||||
|
||||
// сохраняем порядок
|
||||
foreach (array_keys($ini) as $code) {
|
||||
$data = redis()->get($code);
|
||||
if ($data === false) {
|
||||
$raw = $ini[$code];
|
||||
$data = [
|
||||
'code' => $code,
|
||||
'name' => $raw['name'],
|
||||
'description' => $raw['desc'],
|
||||
'url' => $raw['pls'],
|
||||
'source' => $raw['src'],
|
||||
'content' => null,
|
||||
'isOnline' => null,
|
||||
'attributes' => [],
|
||||
'groups' => [],
|
||||
'channels' => [],
|
||||
'onlineCount' => 0,
|
||||
'offlineCount' => 0,
|
||||
'checkedAt' => null,
|
||||
];
|
||||
} else if (!isset($data['attributes'])) {
|
||||
$data['attributes'] = [];
|
||||
}
|
||||
|
||||
$data['hasTvg'] = !empty($data['asttributes']['url-tvg']);
|
||||
$data['hasCatchup'] = str_contains($data['content'] ?? '', 'catchup');
|
||||
|
||||
$data['tags'] = [];
|
||||
foreach ($data['channels'] ?? [] as $channel) {
|
||||
$data['tags'] = array_merge($data['tags'], $channel['tags']);
|
||||
}
|
||||
$data['tags'] = array_values(array_unique($data['tags']));
|
||||
sort($data['tags']);
|
||||
|
||||
$this->playlists[$code] = $data;
|
||||
}
|
||||
|
||||
$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();
|
||||
return $this->playlists;
|
||||
}
|
||||
|
||||
/**
|
||||
* Возвращает объекты плейлистов
|
||||
* Возвращает плейлисты
|
||||
*
|
||||
* @param bool $all true - получить все, false - получить только НЕпереадресованные
|
||||
* @return Playlist[]
|
||||
* @return array[]
|
||||
* @throws Exception
|
||||
*/
|
||||
public function playlists(bool $all = true): array
|
||||
public function getPlaylists(): array
|
||||
{
|
||||
return $all
|
||||
? $this->playlists
|
||||
: array_filter($this->playlists, static fn ($playlist) => is_null($playlist->redirectId));
|
||||
return $this->playlists ??= $this->load();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -86,50 +102,23 @@ class IniFile
|
||||
*/
|
||||
public function updatedAt(): string
|
||||
{
|
||||
return $this->updated_at;
|
||||
return $this->updatedAt;
|
||||
}
|
||||
|
||||
/**
|
||||
* Возвращает 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
|
||||
* @param string $code Код плейлиста
|
||||
* @return array|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
|
||||
public function getPlaylist(string $code): ?array
|
||||
{
|
||||
$id = (string)$id;
|
||||
if (isset($params['redirect'])) {
|
||||
$this->redirections[$id] = $redirectId = (string)$params['redirect'];
|
||||
$params = $this->ini[$redirectId];
|
||||
return $this->makePlaylist($id, $params, $redirectId);
|
||||
if (empty($this->playlists)) {
|
||||
$this->load();
|
||||
}
|
||||
|
||||
return new Playlist($id, $params, $redirectId);
|
||||
return $this->playlists[$code] ?? throw new PlaylistNotFoundException($code);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
<?php
|
||||
/*
|
||||
* Copyright (c) 2025, Антон Аксенов
|
||||
* This file is part of iptv.axenov.dev web interface
|
||||
* MIT License: https://git.axenov.dev/IPTV/web/src/branch/master/LICENSE
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
@@ -6,6 +11,7 @@ namespace App\Core;
|
||||
|
||||
use App\Core\TwigExtention as IptvTwigExtension;
|
||||
use Dotenv\Dotenv;
|
||||
use GuzzleHttp\Client;
|
||||
use InvalidArgumentException;
|
||||
use Redis;
|
||||
use Slim\App;
|
||||
@@ -13,110 +19,73 @@ use Slim\Factory\AppFactory;
|
||||
use Slim\Views\Twig;
|
||||
use Slim\Views\TwigMiddleware;
|
||||
use Twig\Error\LoaderError;
|
||||
use Twig\Extension\DebugExtension;
|
||||
|
||||
/**
|
||||
* Загрузчик приложения
|
||||
*/
|
||||
final class Core
|
||||
final class Kernel
|
||||
{
|
||||
/**
|
||||
* @var Core
|
||||
* Версия приложения
|
||||
*/
|
||||
private static Core $instance;
|
||||
public const string VERSION = '1.0.0';
|
||||
|
||||
/**
|
||||
* @var Kernel
|
||||
*/
|
||||
private static Kernel $instance;
|
||||
|
||||
/**
|
||||
* @var App
|
||||
*/
|
||||
protected App $app;
|
||||
|
||||
/**
|
||||
* @var array Конфигурация приложения
|
||||
*/
|
||||
protected array $config = [];
|
||||
|
||||
/**
|
||||
* @var Redis
|
||||
*/
|
||||
protected Redis $redis;
|
||||
|
||||
/**
|
||||
* @var IniFile
|
||||
*/
|
||||
protected IniFile $iniFile;
|
||||
|
||||
/**
|
||||
* @var array Конфигурация приложения
|
||||
*/
|
||||
protected array $config = [];
|
||||
|
||||
/**
|
||||
* @var Redis|null
|
||||
*/
|
||||
protected ?Redis $cache = null;
|
||||
|
||||
/**
|
||||
* @var Client|null
|
||||
*/
|
||||
protected ?Client $httpClient = null;
|
||||
|
||||
/**
|
||||
* Закрытый конструктор
|
||||
*
|
||||
* @throws LoaderError
|
||||
*/
|
||||
private function __construct()
|
||||
{
|
||||
$this->app = AppFactory::create();
|
||||
$this->loadSettings();
|
||||
$this->loadRoutes();
|
||||
$this->loadTwig();
|
||||
|
||||
return $this->app;
|
||||
}
|
||||
|
||||
/**
|
||||
* Возвращает объект приложения
|
||||
*
|
||||
* @return Core
|
||||
* @return Kernel
|
||||
*/
|
||||
public static function get(): Core
|
||||
public static function instance(): Kernel
|
||||
{
|
||||
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
|
||||
*
|
||||
@@ -139,10 +108,9 @@ final class Core
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
protected function bootSettings(): void
|
||||
protected function loadSettings(): void
|
||||
{
|
||||
$env = $this->loadDotEnvFile();
|
||||
|
||||
if (!empty($env['APP_ENV'])) {
|
||||
$this->loadDotEnvFile($env['APP_ENV']);
|
||||
}
|
||||
@@ -151,6 +119,8 @@ final class Core
|
||||
$key = basename($file, '.php');
|
||||
$this->config += [$key => require_once $file];
|
||||
}
|
||||
|
||||
date_default_timezone_set($this->config['app']['timezone'] ?? 'GMT');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -159,18 +129,19 @@ final class Core
|
||||
* @return void
|
||||
* @see https://www.slimframework.com/docs/v4/objects/routing.html
|
||||
*/
|
||||
protected function bootRoutes(): void
|
||||
protected function loadRoutes(): 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']);
|
||||
$method = trim($route['method']);
|
||||
$isPossible = in_array($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']))
|
||||
$method === '*' => 'any',
|
||||
$isPossible => strtolower($method),
|
||||
default => throw new InvalidArgumentException(sprintf('Неверный HTTP метод %s', $method))
|
||||
};
|
||||
|
||||
$definition = $this->app->$func($route['path'], $route['handler']);
|
||||
@@ -189,42 +160,84 @@ final class Core
|
||||
* @throws LoaderError
|
||||
* @see https://www.slimframework.com/docs/v4/features/twig-view.html
|
||||
*/
|
||||
protected function bootTwig(): void
|
||||
protected function loadTwig(): void
|
||||
{
|
||||
$twig = Twig::create(root_path('views'), $this->config['twig']);
|
||||
$twig->addExtension(new IptvTwigExtension());
|
||||
$this->app->add(TwigMiddleware::create($this->app, $twig));
|
||||
if ($this->config['twig']['debug']) {
|
||||
$twig->addExtension(new DebugExtension());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Инициализирует подключение к Redis
|
||||
* Возвращает объект подключения к Redis
|
||||
*
|
||||
* @return void
|
||||
* @return Redis
|
||||
* @see https://github.com/phpredis/phpredis/?tab=readme-ov-file
|
||||
*/
|
||||
protected function bootRedis(): void
|
||||
public function redis(): Redis
|
||||
{
|
||||
$options = [
|
||||
'host' => $this->config['redis']['host'],
|
||||
'port' => (int)$this->config['redis']['port'],
|
||||
];
|
||||
|
||||
if (!empty($this->config['redis']['password'])) {
|
||||
$options['auth'] = $this->config['redis']['password'];
|
||||
if (!empty($this->cache)) {
|
||||
return $this->cache;
|
||||
}
|
||||
|
||||
$this->redis = new Redis($options);
|
||||
$this->redis->select((int)$this->config['redis']['db']);
|
||||
$this->redis->setOption(Redis::OPT_SERIALIZER, Redis::SERIALIZER_JSON);
|
||||
$options = [
|
||||
'host' => $this->config['cache']['host'],
|
||||
'port' => (int)$this->config['cache']['port'],
|
||||
];
|
||||
|
||||
if (!empty($this->config['cache']['password'])) {
|
||||
$options['auth'] = $this->config['cache']['password'];
|
||||
}
|
||||
|
||||
$this->cache = new Redis($options);
|
||||
$this->cache->select((int)$this->config['cache']['db']);
|
||||
$this->cache->setOption(Redis::OPT_SERIALIZER, Redis::SERIALIZER_JSON);
|
||||
|
||||
return $this->cache;
|
||||
}
|
||||
|
||||
/**
|
||||
* Инициализирует объект ini-файла
|
||||
* Возвращает объект http-клиента
|
||||
*
|
||||
* @return void
|
||||
* @return Client
|
||||
*/
|
||||
protected function bootIni(): void
|
||||
public function guzzle(): Client
|
||||
{
|
||||
$this->iniFile = new IniFile();
|
||||
return $this->httpClient ??= new Client($this->config['http']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Возвращает значение из конфига
|
||||
*
|
||||
* @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 App
|
||||
*/
|
||||
public function app(): App
|
||||
{
|
||||
return $this->app;
|
||||
}
|
||||
|
||||
/**
|
||||
* Возвращает объект ini-файла
|
||||
*
|
||||
* @return IniFile
|
||||
*/
|
||||
public function ini(): IniFile
|
||||
{
|
||||
return $this->iniFile ??= new IniFile();
|
||||
}
|
||||
}
|
||||
@@ -1,368 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Core;
|
||||
|
||||
use CurlHandle;
|
||||
use Exception;
|
||||
use Random\RandomException;
|
||||
|
||||
/**
|
||||
* Плейлист без редиректа
|
||||
*/
|
||||
class Playlist
|
||||
{
|
||||
/**
|
||||
* @var string|null Название плейлиста
|
||||
*/
|
||||
public ?string $name;
|
||||
|
||||
/**
|
||||
* @var string|null Описание плейлиста
|
||||
*/
|
||||
public ?string $desc;
|
||||
|
||||
/**
|
||||
* @var string Прямой URL до файла плейлиста на третьей стороне
|
||||
*/
|
||||
public string $pls;
|
||||
|
||||
/**
|
||||
* @var string|null Источник плейлиста
|
||||
*/
|
||||
public ?string $src;
|
||||
|
||||
/**
|
||||
* @var string Ссылка на плейлист в рамках проекта
|
||||
*/
|
||||
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 ID плейлиста
|
||||
* @param array $params Описание плейлиста
|
||||
* @param string|null $redirectId ID для переадресации
|
||||
* @throws Exception
|
||||
*/
|
||||
public function __construct(
|
||||
public readonly string $id,
|
||||
array $params,
|
||||
public readonly ?string $redirectId = null
|
||||
) {
|
||||
empty($params['pls']) && throw new Exception(
|
||||
"Плейлист с ID=$id обязан иметь параметр pls или redirect"
|
||||
);
|
||||
|
||||
$this->url = base_url($id);
|
||||
$this->name = empty($params['name']) ? "Плейлист #$id" : $params['name'];
|
||||
$this->desc = empty($params['desc']) ? null : $params['desc'];
|
||||
$this->pls = $params['pls'];
|
||||
$this->src = empty($params['src']) ? null : $params['src'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Получает содержимое плейлиста с третьей стороны
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function fetchContent(): void
|
||||
{
|
||||
$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);
|
||||
$this->downloadStatus['errCode'] = curl_errno($curl);
|
||||
$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')]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Возвращает статус проверки плейлиста по коду ошибки 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;
|
||||
$logoUrl = $channel['attributes']['tvg-logo'] ?? null;
|
||||
if (is_string($logoUrl)) {
|
||||
$logo = new ChannelLogo($logoUrl);
|
||||
$logo->readFile();
|
||||
$channel['logo'] = [
|
||||
'base64' => $logo->asBase64(),
|
||||
'size' => $logo->size(),
|
||||
'mime-type' => $logo->mimeType(),
|
||||
];
|
||||
}
|
||||
$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;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Парсит атрибуты строки и возвращает ассоциативный массив
|
||||
*
|
||||
* @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
|
||||
{
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'url' => $this->url,
|
||||
'name' => $this->name,
|
||||
'desc' => $this->desc,
|
||||
'pls' => $this->pls,
|
||||
'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;
|
||||
}
|
||||
}
|
||||
@@ -1,41 +1,91 @@
|
||||
<?php
|
||||
/*
|
||||
* Copyright (c) 2025, Антон Аксенов
|
||||
* This file is part of iptv.axenov.dev web interface
|
||||
* MIT License: https://git.axenov.dev/IPTV/web/src/branch/master/LICENSE
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Core;
|
||||
|
||||
use Twig\Error\LoaderError;
|
||||
use Twig\Extension\AbstractExtension;
|
||||
use Twig\TwigFunction;
|
||||
|
||||
/**
|
||||
* Расширение twig
|
||||
*/
|
||||
class TwigExtention extends AbstractExtension
|
||||
{
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function getFunctions(): array
|
||||
{
|
||||
return [
|
||||
new TwigFunction('config', [$this, 'config']),
|
||||
new TwigFunction('commit', [$this, 'commit']),
|
||||
new TwigFunction('is_file', [$this, 'is_file']),
|
||||
new TwigFunction('base_url', [$this, 'base_url']),
|
||||
new TwigFunction('version', [$this, 'version']),
|
||||
new TwigFunction('is_file', [$this, 'isFile']),
|
||||
new TwigFunction('base_url', [$this, 'baseUrl']),
|
||||
new TwigFunction('to_date', [$this, 'toDate']),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Возвращает значение из конфига
|
||||
*
|
||||
* @param string $key Ключ в формате "config.key"
|
||||
* @param mixed|null $default Значение по умолчанию
|
||||
* @return mixed
|
||||
* @throws LoaderError
|
||||
*/
|
||||
public function config(string $key, mixed $default = null): mixed
|
||||
{
|
||||
return config($key, $default);
|
||||
return kernel()->config($key, $default);
|
||||
}
|
||||
|
||||
public function commit(): string
|
||||
/**
|
||||
* Возвращает версию приложения
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public function version(): string
|
||||
{
|
||||
return file_get_contents(root_path('commit'));
|
||||
return Kernel::VERSION;
|
||||
}
|
||||
|
||||
public function base_url(string $path = ''): string
|
||||
/**
|
||||
* Возвращает базовый URL приложения
|
||||
*
|
||||
* @param string $path
|
||||
* @return string
|
||||
*/
|
||||
public function baseUrl(string $path = ''): string
|
||||
{
|
||||
return base_url($path);
|
||||
}
|
||||
|
||||
public function is_file(string $path): bool
|
||||
/**
|
||||
* Проверячет существование файла
|
||||
*
|
||||
* @param string $path Полный путь к файлу
|
||||
* @return bool
|
||||
*/
|
||||
public function isFile(string $path): bool
|
||||
{
|
||||
return is_file($path);
|
||||
}
|
||||
|
||||
/**
|
||||
* Конвертирует unix timestamp в дату и время
|
||||
*
|
||||
* @param float|null $timestamp
|
||||
* @param string $format
|
||||
* @return string
|
||||
*/
|
||||
public function toDate(?float $timestamp, string $format = 'd.m.Y H:i:s'): string
|
||||
{
|
||||
return $timestamp === null ? '(неизвестно)' : date($format, (int)$timestamp);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
<?php
|
||||
/*
|
||||
* Copyright (c) 2025, Антон Аксенов
|
||||
* This file is part of iptv.axenov.dev web interface
|
||||
* MIT License: https://git.axenov.dev/IPTV/web/src/branch/master/LICENSE
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
<?php
|
||||
/*
|
||||
* Copyright (c) 2025, Антон Аксенов
|
||||
* This file is part of iptv.axenov.dev web interface
|
||||
* MIT License: https://git.axenov.dev/IPTV/web/src/branch/master/LICENSE
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
@@ -8,8 +13,8 @@ use Exception;
|
||||
|
||||
class PlaylistNotFoundException extends Exception
|
||||
{
|
||||
public function __construct(string $id)
|
||||
public function __construct(string $code)
|
||||
{
|
||||
parent::__construct("Плейлист $id не найден!");
|
||||
parent::__construct("Плейлист '$code' не найден");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
<?php
|
||||
/*
|
||||
* Copyright (c) 2025, Антон Аксенов
|
||||
* This file is part of iptv.axenov.dev web interface
|
||||
* MIT License: https://git.axenov.dev/IPTV/web/src/branch/master/LICENSE
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
<?php
|
||||
/*
|
||||
* Copyright (c) 2025, Антон Аксенов
|
||||
* This file is part of iptv.axenov.dev web interface
|
||||
* MIT License: https://git.axenov.dev/IPTV/web/src/branch/master/LICENSE
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Core;
|
||||
namespace App\Playlists;
|
||||
|
||||
class ChannelLogo implements \Stringable
|
||||
{
|
||||
149
app/helpers.php
149
app/helpers.php
@@ -1,9 +1,14 @@
|
||||
<?php
|
||||
/*
|
||||
* Copyright (c) 2025, Антон Аксенов
|
||||
* This file is part of iptv.axenov.dev web interface
|
||||
* MIT License: https://git.axenov.dev/IPTV/web/src/branch/master/LICENSE
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Core\Core;
|
||||
use App\Core\IniFile;
|
||||
use App\Core\Kernel;
|
||||
use Slim\App;
|
||||
|
||||
/**
|
||||
@@ -74,28 +79,13 @@ function env(string $key, mixed $default = null): mixed
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders template
|
||||
* Returns kernel object
|
||||
*
|
||||
* @param mixed $template
|
||||
* @param array $data
|
||||
* @return void
|
||||
* @throws Exception
|
||||
* @return Kernel
|
||||
*/
|
||||
function view(mixed $template, array $data = []): void
|
||||
function kernel(): Kernel
|
||||
{
|
||||
$template = str_contains($template, '.twig') ? $template : "$template.twig";
|
||||
/** @noinspection PhpVoidFunctionResultUsedInspection */
|
||||
echo Flight::view()->render($template, $data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns core object
|
||||
*
|
||||
* @return Core
|
||||
*/
|
||||
function core(): Core
|
||||
{
|
||||
return Core::get();
|
||||
return Kernel::instance();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -105,25 +95,7 @@ function core(): Core
|
||||
*/
|
||||
function app(): App
|
||||
{
|
||||
return Core::get()->app();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns any value as boolean
|
||||
*
|
||||
* @param mixed $value
|
||||
* @return bool
|
||||
*/
|
||||
function bool(mixed $value): bool
|
||||
{
|
||||
is_string($value) && $value = strtolower(trim($value));
|
||||
if (in_array($value, [true, 1, '1', '+', 'y', 'yes', 'on', 'true', 'enable', 'enabled'], true)) {
|
||||
return true;
|
||||
}
|
||||
if (in_array($value, [false, 0, '0', '-', 'n', 'no', 'off', 'false', 'disable', 'disabled'], true)) {
|
||||
return false;
|
||||
}
|
||||
return (bool)$value;
|
||||
return Kernel::instance()->app();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -135,7 +107,7 @@ function bool(mixed $value): bool
|
||||
*/
|
||||
function config(string $key, mixed $default = null): mixed
|
||||
{
|
||||
return Core::get()->config($key, $default);
|
||||
return Kernel::instance()->config($key, $default);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -145,7 +117,100 @@ function config(string $key, mixed $default = null): mixed
|
||||
*/
|
||||
function redis(): Redis
|
||||
{
|
||||
return Core::get()->redis();
|
||||
return Kernel::instance()->redis();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns any value as boolean
|
||||
*
|
||||
* @param mixed $value
|
||||
* @return bool
|
||||
*/
|
||||
function bool(mixed $value): bool
|
||||
{
|
||||
is_string($value) && $value = strtolower(trim($value));
|
||||
|
||||
$positives = [true, 1, '1', '+', 'yes', 'on', 'true', 'enable', 'enabled'];
|
||||
if (in_array($value, $positives, true)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$negatives = [false, 0, '0', '-', 'no', 'off', 'false', 'disable', 'disabled'];
|
||||
if (in_array($value, $negatives, true)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (bool)$value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Проверяет значениен на пустоту
|
||||
*
|
||||
* @param $value
|
||||
* @return bool
|
||||
*/
|
||||
function is_blank($value): bool
|
||||
{
|
||||
if (is_null($value)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (is_string($value)) {
|
||||
return trim($value) === '';
|
||||
}
|
||||
|
||||
if (is_numeric($value) || is_bool($value)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($value instanceof Countable) {
|
||||
return count($value) === 0;
|
||||
}
|
||||
|
||||
return empty($value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Возвращает натуральное представление значения переменной или null
|
||||
*
|
||||
* @param mixed $value
|
||||
* @return int|null
|
||||
*/
|
||||
function int(mixed $value): ?int
|
||||
{
|
||||
if (is_blank($value)) {
|
||||
return null;
|
||||
}
|
||||
$filtered = filter_var($value, FILTER_VALIDATE_INT);
|
||||
return $filtered === false ? (int)$value : $filtered;
|
||||
}
|
||||
|
||||
/**
|
||||
* Возвращает первый элемент массива без перемотки указателя
|
||||
*
|
||||
* @param array $array Входной массив
|
||||
* @param callable|null $callback Замыкание для предварительной фильтрации вх. массива
|
||||
* @return mixed
|
||||
*/
|
||||
function array_first(array $array, ?callable $callback = null): mixed
|
||||
{
|
||||
is_null($callback) || $array = array_filter($array, $callback);
|
||||
|
||||
return $array[array_key_first($array)] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Возвращает последний элемент массива без перемотки указателя
|
||||
*
|
||||
* @param array $array Входной массив
|
||||
* @param callable|null $callback Замыкание для предварительной фильтрации вх. массива
|
||||
* @return mixed
|
||||
*/
|
||||
function array_last(array $array, ?callable $callback = null): mixed
|
||||
{
|
||||
is_null($callback) || $array = array_filter($array, $callback);
|
||||
|
||||
return $array[array_key_last($array)] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -155,5 +220,5 @@ function redis(): Redis
|
||||
*/
|
||||
function ini(): IniFile
|
||||
{
|
||||
return Core::get()->ini();
|
||||
return Kernel::instance()->ini();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user