Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
aabad9d744
|
|||
|
ddc4374dd6
|
9
.dockerignore
Normal file
9
.dockerignore
Normal file
@@ -0,0 +1,9 @@
|
||||
/docker
|
||||
/.git
|
||||
/.idea
|
||||
/.vscode
|
||||
.phpunit.result.cache
|
||||
.php-cs-fixer.cache
|
||||
|
||||
*.cache
|
||||
*.log
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -8,7 +8,7 @@
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
config/playlists.ini
|
||||
playlists.ini
|
||||
channels.json
|
||||
.phpunit.result.cache
|
||||
.php-cs-fixer.cache
|
||||
|
||||
@@ -725,7 +725,7 @@ $finder = (new Finder())
|
||||
->notPath($excludeNames); // исключаем файлы
|
||||
|
||||
return (new Config())
|
||||
// спорная фигня, пока не
|
||||
// спорная фигня, пока не активирую
|
||||
// ->setParallelConfig(PhpCsFixer\Runner\Parallel\ParallelConfigFactory::detect());
|
||||
->setFinder($finder)
|
||||
->setRules($rules) // ставим правила
|
||||
|
||||
48
Dockerfile
Normal file
48
Dockerfile
Normal file
@@ -0,0 +1,48 @@
|
||||
FROM php:8.4-fpm AS iptv-php-base
|
||||
LABEL org.opencontainers.image.authors="Anthony Axenov <anthonyaxenov@gmail.com>"
|
||||
|
||||
RUN apt update && \
|
||||
apt upgrade -y && \
|
||||
apt install -y \
|
||||
git \
|
||||
unzip \
|
||||
7zip \
|
||||
cron \
|
||||
zlib1g-dev \
|
||||
imagemagick \
|
||||
libpng-dev \
|
||||
libjpeg-dev
|
||||
|
||||
# https://pecl.php.net/package/redis
|
||||
RUN pecl channel-update pecl.php.net && \
|
||||
pecl install redis-6.1.0
|
||||
|
||||
RUN docker-php-ext-enable redis && \
|
||||
docker-php-ext-configure gd --with-jpeg && \
|
||||
docker-php-ext-install gd
|
||||
|
||||
RUN mkdir -p /var/run/php && \
|
||||
mkdir -p /var/log/php && \
|
||||
chmod -R 777 /var/log/php
|
||||
|
||||
COPY ./ /var/www
|
||||
COPY --from=composer /usr/bin/composer /usr/local/bin/composer
|
||||
|
||||
RUN git config --global --add safe.directory /var/www
|
||||
|
||||
EXPOSE 9000
|
||||
WORKDIR /var/www
|
||||
ENTRYPOINT [ "php-fpm", "--nodaemonize" ]
|
||||
|
||||
FROM iptv-php-base AS iptv-web-dev
|
||||
LABEL org.opencontainers.image.authors="Anthony Axenov <anthonyaxenov@gmail.com>"
|
||||
|
||||
# https://pecl.php.net/package/xdebug
|
||||
RUN pecl install xdebug-3.4.1
|
||||
|
||||
RUN composer install
|
||||
|
||||
FROM iptv-php-base AS iptv-web-prod
|
||||
LABEL org.opencontainers.image.authors="Anthony Axenov <anthonyaxenov@gmail.com>"
|
||||
|
||||
RUN composer install --no-dev --no-interaction --prefer-dist --optimize-autoloader
|
||||
@@ -1,4 +1,5 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* Copyright (c) 2025, Антон Аксенов
|
||||
* This file is part of m3u.su project
|
||||
@@ -12,7 +13,7 @@ namespace App\Controllers;
|
||||
use App\Core\Bot;
|
||||
use App\Core\Kernel;
|
||||
use App\Core\StatisticsService;
|
||||
use App\Exceptions\PlaylistNotFoundException;
|
||||
use App\Errors\PlaylistNotFoundException;
|
||||
use chillerlan\QRCode\QRCode;
|
||||
use chillerlan\QRCode\QROptions;
|
||||
use Exception;
|
||||
@@ -38,10 +39,11 @@ class ApiController extends BasicController
|
||||
$code = $request->getAttributes()['code'] ?? null;
|
||||
empty($code) && throw new PlaylistNotFoundException('');
|
||||
|
||||
$playlist = ini()->playlist($code);
|
||||
$playlist = ini()->getPlaylist($code);
|
||||
if ($playlist['isOnline'] === true) {
|
||||
unset($playlist['content']);
|
||||
}
|
||||
|
||||
return $this->responseJson($response, 200, $playlist);
|
||||
} catch (PlaylistNotFoundException $e) {
|
||||
return $this->responseJsonError($response, 404, $e);
|
||||
@@ -65,7 +67,7 @@ class ApiController extends BasicController
|
||||
return $response->withStatus(404);
|
||||
}
|
||||
|
||||
$filePath = cache_path("qr-codes/$code.jpg");
|
||||
$filePath = cache_path("qr-codes/{$code}.jpg");
|
||||
if (file_exists($filePath)) {
|
||||
$raw = file_get_contents($filePath);
|
||||
} else {
|
||||
@@ -74,13 +76,14 @@ class ApiController extends BasicController
|
||||
'outputType' => QRCode::OUTPUT_IMAGE_JPG,
|
||||
'eccLevel' => QRCode::ECC_L,
|
||||
]);
|
||||
$data = config('app.mirror_url') ? mirror_url("$code.m3u") : base_url("$code.m3u");
|
||||
$data = config('app.mirror_url') ? mirror_url("{$code}.m3u") : base_url("{$code}.m3u");
|
||||
$raw = new QRCode($options)->render($data, $filePath);
|
||||
$raw = base64_decode(str_replace('data:image/jpg;base64,', '', $raw));
|
||||
}
|
||||
|
||||
$mime = mime_content_type($filePath);
|
||||
$response->getBody()->write($raw);
|
||||
|
||||
return $response->withStatus(200)
|
||||
->withHeader('Content-Type', $mime);
|
||||
}
|
||||
@@ -116,10 +119,11 @@ class ApiController extends BasicController
|
||||
function getSize(string $directory): int
|
||||
{
|
||||
$size = 0;
|
||||
foreach (glob($directory . '/*') as $path){
|
||||
foreach (glob($directory . '/*') as $path) {
|
||||
is_file($path) && $size += filesize($path);
|
||||
is_dir($path) && $size += getSize($path);
|
||||
}
|
||||
|
||||
return $size;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* Copyright (c) 2025, Антон Аксенов
|
||||
* This file is part of m3u.su project
|
||||
@@ -56,6 +57,7 @@ class BasicController
|
||||
$json = json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
|
||||
$response->getBody()->write($json);
|
||||
|
||||
return $response->withStatus($status)
|
||||
->withHeader('Content-Type', 'application/json');
|
||||
}
|
||||
@@ -99,6 +101,7 @@ class BasicController
|
||||
array $data = [],
|
||||
): ResponseInterface {
|
||||
$view = Twig::fromRequest($request);
|
||||
|
||||
return $view->render($response, $template, $data);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* Copyright (c) 2025, Антон Аксенов
|
||||
* This file is part of m3u.su project
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* Copyright (c) 2025, Антон Аксенов
|
||||
* This file is part of m3u.su project
|
||||
@@ -9,7 +10,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Controllers;
|
||||
|
||||
use App\Exceptions\PlaylistNotFoundException;
|
||||
use App\Errors\PlaylistNotFoundException;
|
||||
use Exception;
|
||||
use Psr\Http\Message\ResponseInterface;
|
||||
use Psr\Http\Message\ServerRequestInterface;
|
||||
@@ -36,20 +37,20 @@ class WebController extends BasicController
|
||||
*/
|
||||
public function home(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
|
||||
{
|
||||
$ini = ini();
|
||||
$ini = ini()->load();
|
||||
$keys = [];
|
||||
$count = count($ini);
|
||||
$pageSize = config('app.page_size');
|
||||
|
||||
if ($pageSize > 0) {
|
||||
$pageCurrent = (int)($request->getAttributes()['page'] ?? $request->getQueryParams()['page'] ?? 1);
|
||||
$pageCurrent = (int) ($request->getAttributes()['page'] ?? $request->getQueryParams()['page'] ?? 1);
|
||||
$pageCount = ceil($count / $pageSize);
|
||||
$offset = max(0, ($pageCurrent - 1) * $pageSize);
|
||||
$ini = array_slice($ini->get, $offset, $pageSize, true);
|
||||
$ini = array_slice($ini, $offset, $pageSize, true);
|
||||
$keys = array_keys($ini);
|
||||
}
|
||||
|
||||
$playlists = ini()->playlists($keys);
|
||||
$playlists = ini()->getPlaylists($keys);
|
||||
|
||||
return $this->view($request, $response, 'list.twig', [
|
||||
'updatedAt' => ini()->updatedAt(),
|
||||
@@ -75,7 +76,8 @@ class WebController extends BasicController
|
||||
$code = $request->getAttributes()['code'];
|
||||
|
||||
try {
|
||||
$playlist = ini()->playlist($code);
|
||||
$playlist = ini()->getPlaylist($code);
|
||||
|
||||
return $response->withHeader('Location', $playlist['url']);
|
||||
} catch (Throwable) {
|
||||
return $this->notFound($request, $response);
|
||||
@@ -98,7 +100,8 @@ class WebController extends BasicController
|
||||
$code = $request->getAttributes()['code'];
|
||||
|
||||
try {
|
||||
$playlist = ini()->playlist($code);
|
||||
$playlist = ini()->getPlaylist($code);
|
||||
|
||||
return $this->view($request, $response, 'details.twig', ['playlist' => $playlist]);
|
||||
} catch (PlaylistNotFoundException) {
|
||||
return $this->notFound($request, $response);
|
||||
|
||||
@@ -9,8 +9,8 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Core;
|
||||
|
||||
use App\Exceptions\InvalidTelegramSecretException;
|
||||
use App\Exceptions\PlaylistNotFoundException;
|
||||
use App\Errors\InvalidTelegramSecretException;
|
||||
use App\Errors\PlaylistNotFoundException;
|
||||
use DateTimeImmutable;
|
||||
use Exception;
|
||||
use JsonException;
|
||||
@@ -168,7 +168,7 @@ class Bot
|
||||
}
|
||||
|
||||
try {
|
||||
$pls = ini()->playlist($code);
|
||||
$pls = ini()->getPlaylist($code);
|
||||
} catch (PlaylistNotFoundException) {
|
||||
return $this->reply("Плейлист `$code` не найден");
|
||||
}
|
||||
|
||||
@@ -10,71 +10,71 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Core;
|
||||
|
||||
use App\Exceptions\FileReadException;
|
||||
use App\Exceptions\IniParsingException;
|
||||
use App\Exceptions\PlaylistNotFoundException;
|
||||
use ArrayAccess;
|
||||
use Override;
|
||||
use Throwable;
|
||||
use App\Errors\PlaylistNotFoundException;
|
||||
use Exception;
|
||||
|
||||
/**
|
||||
* Класс для работы со списком плейлистов
|
||||
*
|
||||
* @phpstan-type TIniFile array{}|TPlaylistDefinition[]
|
||||
* @phpstan-type TPlaylistDefinition array{name?: string, desc?: string, url: string, src?: string}
|
||||
* @template TKey as non-falsy-string
|
||||
* @template TValue as TPlaylistDefinition
|
||||
* @implements ArrayAccess<TKey, TValue>
|
||||
*/
|
||||
class IniFile implements ArrayAccess
|
||||
class IniFile
|
||||
{
|
||||
/**
|
||||
* @var array{}|array<TKey, TValue> Коллекция подгруженных плейлистов
|
||||
* @var array[] Коллекция подгруженных плейлистов
|
||||
*/
|
||||
protected array $playlists;
|
||||
|
||||
/**
|
||||
* @var positive-int Дата последнего обновления списка
|
||||
* @var string Дата последнего обновления списка
|
||||
*/
|
||||
protected int $updatedAt;
|
||||
protected string $updatedAt;
|
||||
|
||||
/**
|
||||
* Загружает ini-файл и инициализирует плейлисты
|
||||
* Считывает ini-файл и инициализирует плейлисты
|
||||
*
|
||||
* @param string $filepath
|
||||
* @throws FileReadException
|
||||
* @throws IniParsingException
|
||||
* @return array
|
||||
*/
|
||||
public function __construct(
|
||||
protected string $filepath,
|
||||
) {
|
||||
try {
|
||||
$content = file_get_contents($this->filepath);
|
||||
} catch (Throwable) {
|
||||
$content = false;
|
||||
}
|
||||
public function load(): array
|
||||
{
|
||||
$filepath = config_path('playlists.ini');
|
||||
$this->playlists = parse_ini_file($filepath, true);
|
||||
$this->updatedAt = date('d.m.Y h:i', filemtime($filepath));
|
||||
|
||||
$content === false && throw new FileReadException($this->filepath);
|
||||
|
||||
$parsed = parse_ini_string($content, true);
|
||||
$parsed === false && throw new IniParsingException($this->filepath);
|
||||
$this->playlists = $parsed;
|
||||
|
||||
/** @var positive-int $timestamp */
|
||||
$timestamp = is_readable($this->filepath) ? filemtime($this->filepath) : time();
|
||||
$this->updatedAt = $timestamp;
|
||||
return $this->playlists;
|
||||
}
|
||||
|
||||
/**
|
||||
* Возвращает определение плейлиста по его коду
|
||||
* Возвращает плейлисты
|
||||
*
|
||||
* @param TKey $code
|
||||
* @return TValue
|
||||
* @throws PlaylistNotFoundException
|
||||
* @return array[]
|
||||
* @throws Exception
|
||||
*/
|
||||
public function playlist(string $code): array
|
||||
public function getPlaylists(array $plsCodes = []): array
|
||||
{
|
||||
return $this->playlists[$code] ?? throw new PlaylistNotFoundException($code);
|
||||
$playlists = [];
|
||||
empty($this->playlists) && $this->load();
|
||||
empty($plsCodes) && $plsCodes = array_keys($this->playlists);
|
||||
$cached = array_combine($plsCodes, redis()->mget($plsCodes));
|
||||
foreach ($cached as $code => $data) {
|
||||
$playlists[$code] = $this->initPlaylist($code, $data);
|
||||
}
|
||||
|
||||
return $playlists;
|
||||
}
|
||||
|
||||
/**
|
||||
* Возвращает плейлист по его коду
|
||||
*
|
||||
* @param string $code Код плейлиста
|
||||
* @return array|null
|
||||
* @throws PlaylistNotFoundException
|
||||
* @throws Exception
|
||||
*/
|
||||
public function getPlaylist(string $code): ?array
|
||||
{
|
||||
empty($this->playlists) && $this->load();
|
||||
$data = redis()->get($code);
|
||||
|
||||
return $this->initPlaylist($code, $data);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -84,50 +84,107 @@ class IniFile implements ArrayAccess
|
||||
*/
|
||||
public function updatedAt(): string
|
||||
{
|
||||
return date('d.m.Y h:i', $this->updatedAt);
|
||||
return $this->updatedAt;
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
* @param non-falsy-string $offset
|
||||
* @return bool
|
||||
*/
|
||||
#[Override]
|
||||
public function offsetExists(mixed $offset): bool
|
||||
{
|
||||
return isset($this->playlists[$offset]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
* @param TKey $offset
|
||||
* @return TPlaylistDefinition
|
||||
* Подготавливает данные о плейлисте в расширенном формате
|
||||
*
|
||||
* @param string $code
|
||||
* @param array|false $data
|
||||
* @return array
|
||||
* @throws PlaylistNotFoundException
|
||||
*/
|
||||
#[Override]
|
||||
public function offsetGet(mixed $offset): array
|
||||
protected function initPlaylist(string $code, array|false $data): array
|
||||
{
|
||||
return $this->playlist($offset);
|
||||
if ($data === false) {
|
||||
$raw = $this->playlists[$code]
|
||||
?? throw new PlaylistNotFoundException($code);
|
||||
$data = [
|
||||
'code' => $code,
|
||||
'name' => $raw['name'] ?? "Плейлист #{$code}",
|
||||
'description' => $raw['desc'] ?? null,
|
||||
'url' => $raw['pls'],
|
||||
'source' => $raw['src'] ?? null,
|
||||
'content' => null,
|
||||
'isOnline' => null,
|
||||
'attributes' => [],
|
||||
'groups' => [],
|
||||
'channels' => [],
|
||||
'checkedAt' => null,
|
||||
];
|
||||
}
|
||||
|
||||
// приколы golang
|
||||
$data['attributes'] === null && $data['attributes'] = [];
|
||||
$data['groups'] === null && $data['groups'] = [];
|
||||
$data['channels'] === null && $data['channels'] = [];
|
||||
|
||||
$data['onlinePercent'] = 0;
|
||||
$data['offlinePercent'] = 0;
|
||||
if ($data['isOnline'] === true && count($data['channels']) > 0) {
|
||||
$data['onlinePercent'] = round($data['onlineCount'] / count($data['channels']) * 100);
|
||||
$data['offlinePercent'] = round($data['offlineCount'] / count($data['channels']) * 100);
|
||||
}
|
||||
|
||||
$data['hasCatchup'] = str_contains($data['content'] ?? '', 'catchup');
|
||||
$data['hasTvg'] = !empty($data['attributes']['url-tvg']) || !empty($data['attributes']['x-tvg-url']);
|
||||
$data['hasTokens'] = $this->hasTokens($data);
|
||||
|
||||
$data['tags'] = [];
|
||||
foreach ($data['channels'] as &$channel) {
|
||||
$data['tags'] = array_merge($data['tags'], $channel['tags']);
|
||||
$channel['hasToken'] = $this->hasTokens($channel);
|
||||
}
|
||||
$data['tags'] = array_values(array_unique($data['tags']));
|
||||
sort($data['tags']);
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
* @param TKey $offset
|
||||
* @param TValue $value
|
||||
* @return void
|
||||
* Проверяет наличие токенов в плейлисте
|
||||
*
|
||||
* Сделано именно так, а не через тег unstable, чтобы разделить логику: есть заведомо нестабильные каналы,
|
||||
* которые могут не транслироваться круглосуточно, а есть платные круглосуточные, которые могут оборваться
|
||||
* в любой момент.
|
||||
*
|
||||
* @param array $data
|
||||
* @return bool
|
||||
*/
|
||||
#[Override]
|
||||
public function offsetSet(mixed $offset, mixed $value): void
|
||||
protected function hasTokens(array $data): bool
|
||||
{
|
||||
}
|
||||
$string = ($data['url'] ?? '') . ($data['content'] ?? '');
|
||||
if (empty($string)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
* @param non-falsy-string $offset
|
||||
* @return void
|
||||
*/
|
||||
#[Override]
|
||||
public function offsetUnset(mixed $offset): void
|
||||
{
|
||||
$badAttributes = [
|
||||
// токены и ключи
|
||||
'[?&]token=',
|
||||
'[?&]drmreq=',
|
||||
// логины
|
||||
'[?&]u=',
|
||||
'[?&]user=',
|
||||
'[?&]username=',
|
||||
// пароли
|
||||
'[?&]p=',
|
||||
'[?&]pwd=',
|
||||
'[?&]password=',
|
||||
// неизвестные
|
||||
// 'free=true',
|
||||
// 'uid=',
|
||||
// 'c_uniq_tag=',
|
||||
// 'rlkey=',
|
||||
// '?s=',
|
||||
// '&s=',
|
||||
// '?q=',
|
||||
// '&q=',
|
||||
];
|
||||
|
||||
return array_any(
|
||||
$badAttributes,
|
||||
static fn (string $badAttribute) => preg_match_all("/{$badAttribute}/", $string) >= 1,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* Copyright (c) 2025, Антон Аксенов
|
||||
* This file is part of m3u.su project
|
||||
@@ -30,11 +31,6 @@ final class Kernel
|
||||
*/
|
||||
public const string VERSION = '1.0.0';
|
||||
|
||||
/**
|
||||
* @var Kernel
|
||||
*/
|
||||
private static Kernel $instance;
|
||||
|
||||
/**
|
||||
* @var App
|
||||
*/
|
||||
@@ -55,6 +51,11 @@ final class Kernel
|
||||
*/
|
||||
protected ?Redis $cache = null;
|
||||
|
||||
/**
|
||||
* @var Kernel
|
||||
*/
|
||||
private static Kernel $instance;
|
||||
|
||||
/**
|
||||
* Закрытый конструктор
|
||||
*
|
||||
@@ -75,11 +76,73 @@ final class Kernel
|
||||
*
|
||||
* @return Kernel
|
||||
*/
|
||||
public static function instance(): Kernel
|
||||
public static function instance(): self
|
||||
{
|
||||
return self::$instance ??= new self();
|
||||
}
|
||||
|
||||
/**
|
||||
* Возвращает объект подключения к Redis
|
||||
*
|
||||
* @return Redis
|
||||
* @see https://github.com/phpredis/phpredis/?tab=readme-ov-file
|
||||
*/
|
||||
public function redis(): Redis
|
||||
{
|
||||
if (!empty($this->cache)) {
|
||||
return $this->cache;
|
||||
}
|
||||
|
||||
$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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Возвращает значение из конфига
|
||||
*
|
||||
* @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();
|
||||
}
|
||||
|
||||
/**
|
||||
* Загружает файл .env или .env.$env
|
||||
*
|
||||
@@ -88,12 +151,13 @@ final class Kernel
|
||||
*/
|
||||
protected function loadDotEnvFile(string $env = ''): array
|
||||
{
|
||||
$filename = empty($env) ? '.env' : ".env.$env";
|
||||
$filename = empty($env) ? '.env' : ".env.{$env}";
|
||||
if (!file_exists(root_path($filename))) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$dotenv = Dotenv::createMutable(root_path(), $filename);
|
||||
|
||||
return $dotenv->safeLoad();
|
||||
}
|
||||
|
||||
@@ -138,7 +202,7 @@ final class Kernel
|
||||
default => throw new InvalidArgumentException(sprintf('Неверный HTTP метод %s', $method))
|
||||
};
|
||||
|
||||
$definition = $this->app->$func($route['path'], $route['handler']);
|
||||
$definition = $this->app->{$func}($route['path'], $route['handler']);
|
||||
}
|
||||
|
||||
if (!empty($route['name'])) {
|
||||
@@ -163,65 +227,4 @@ final class Kernel
|
||||
$twig->addExtension(new DebugExtension());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Возвращает объект подключения к Redis
|
||||
*
|
||||
* @return Redis
|
||||
* @see https://github.com/phpredis/phpredis/?tab=readme-ov-file
|
||||
*/
|
||||
public function redis(): Redis
|
||||
{
|
||||
if (!empty($this->cache)) {
|
||||
return $this->cache;
|
||||
}
|
||||
|
||||
$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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Возвращает значение из конфига
|
||||
*
|
||||
* @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(config_path('playlists.ini'));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,216 +0,0 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* Copyright (c) 2025, Антон Аксенов
|
||||
* This file is part of m3u.su project
|
||||
* MIT License: https://git.axenov.dev/IPTV/web/src/branch/master/LICENSE
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Core;
|
||||
|
||||
use App\Exceptions\PlaylistNotFoundException;
|
||||
use App\Exceptions\PlaylistWithoutUrlException;
|
||||
|
||||
/**
|
||||
* @phpstan-import-type TPlaylistDefinition from IniFile
|
||||
*/
|
||||
class Playlist
|
||||
{
|
||||
/**
|
||||
* @var non-falsy-string
|
||||
*/
|
||||
public readonly string $code;
|
||||
|
||||
/**
|
||||
* @var non-falsy-string|null
|
||||
*/
|
||||
public readonly ?string $desc;
|
||||
|
||||
/**
|
||||
* @var non-falsy-string|null
|
||||
*/
|
||||
public readonly ?string $name;
|
||||
|
||||
/**
|
||||
* @var non-falsy-string
|
||||
*/
|
||||
public readonly string $url;
|
||||
|
||||
/**
|
||||
* @var non-falsy-string|null
|
||||
*/
|
||||
public readonly ?string $src;
|
||||
|
||||
protected string $text;
|
||||
|
||||
/**
|
||||
* @param non-falsy-string $code
|
||||
* @param TPlaylistDefinition $params
|
||||
*/
|
||||
public function __construct(string $code, array $params)
|
||||
{
|
||||
$this->code = $code;
|
||||
$this->name = isset($params['name']) ? trim($params['name']) : null;
|
||||
$this->desc = isset($params['desc']) ? trim($params['desc']) : null;
|
||||
$this->url = isset($params['url']) ? trim($params['url']) : throw new PlaylistWithoutUrlException($code);
|
||||
$this->src = isset($params['src']) ? trim($params['src']) : null;
|
||||
}
|
||||
|
||||
public function getCheckResult(\Redis $redis)
|
||||
{
|
||||
$this->text = $redis->get($this->code);
|
||||
|
||||
|
||||
$stop = 1;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
private array $definition;
|
||||
|
||||
private array $cached;
|
||||
|
||||
/**
|
||||
* Возвращает плейлисты
|
||||
*
|
||||
* @return array[]
|
||||
* @throws Exception
|
||||
*/
|
||||
// public function getCachedPlaylists(array $plsCodes = []): array
|
||||
// {
|
||||
// $playlists = [];
|
||||
// empty($plsCodes) && $plsCodes = array_keys($this->playlists);
|
||||
// $cached = array_combine($plsCodes, redis()->mget($plsCodes));
|
||||
// foreach ($cached as $code => $data) {
|
||||
// $playlists[$code] = $this->initPlaylist($code, $data);
|
||||
// }
|
||||
//
|
||||
// return $playlists;
|
||||
// }
|
||||
|
||||
/**
|
||||
* Возвращает плейлист по его коду
|
||||
*
|
||||
* @param string $code Код плейлиста
|
||||
* @return array|null
|
||||
* @throws PlaylistNotFoundException
|
||||
* @throws Exception
|
||||
*/
|
||||
// public function getCachedPlaylist(string $code): ?array
|
||||
// {
|
||||
// $data = redis()->get($code);
|
||||
//
|
||||
// return $this->initPlaylist($code, $data);
|
||||
// }
|
||||
|
||||
/**
|
||||
* Подготавливает данные о плейлисте в расширенном формате
|
||||
*
|
||||
* @param string $code
|
||||
* @param array|false $data
|
||||
* @return array
|
||||
* @throws PlaylistNotFoundException
|
||||
*/
|
||||
protected function initPlaylist(string $code, array|false $data): array
|
||||
{
|
||||
if ($data === false) {
|
||||
$raw = $this->playlists[$code]
|
||||
?? throw new PlaylistNotFoundException($code);
|
||||
$data = [
|
||||
'code' => $code,
|
||||
'name' => $raw['name'] ?? "Плейлист #{$code}",
|
||||
'description' => $raw['desc'] ?? null,
|
||||
'url' => $raw['url'],
|
||||
'source' => $raw['src'] ?? null,
|
||||
'content' => null,
|
||||
'isOnline' => null,
|
||||
'attributes' => [],
|
||||
'groups' => [],
|
||||
'channels' => [],
|
||||
'checkedAt' => null,
|
||||
];
|
||||
}
|
||||
|
||||
// приколы golang
|
||||
$data['attributes'] === null && $data['attributes'] = [];
|
||||
$data['groups'] === null && $data['groups'] = [];
|
||||
$data['channels'] === null && $data['channels'] = [];
|
||||
|
||||
$data['onlinePercent'] = 0;
|
||||
$data['offlinePercent'] = 0;
|
||||
if ($data['isOnline'] === true && count($data['channels']) > 0) {
|
||||
$data['onlinePercent'] = round($data['onlineCount'] / count($data['channels']) * 100);
|
||||
$data['offlinePercent'] = round($data['offlineCount'] / count($data['channels']) * 100);
|
||||
}
|
||||
|
||||
$data['hasCatchup'] = str_contains($data['content'] ?? '', 'catchup');
|
||||
$data['hasTvg'] = !empty($data['attributes']['url-tvg']) || !empty($data['attributes']['x-tvg-url']);
|
||||
$data['hasTokens'] = $this->hasTokens($data);
|
||||
|
||||
$data['tags'] = [];
|
||||
foreach ($data['channels'] as &$channel) {
|
||||
$data['tags'] = array_merge($data['tags'], $channel['tags']);
|
||||
$channel['hasToken'] = $this->hasTokens($channel);
|
||||
}
|
||||
$data['tags'] = array_values(array_unique($data['tags']));
|
||||
sort($data['tags']);
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Проверяет наличие токенов в плейлисте
|
||||
*
|
||||
* Сделано именно так, а не через тег unstable, чтобы разделить логику: есть заведомо нестабильные каналы,
|
||||
* которые могут не транслироваться круглосуточно, а есть платные круглосуточные, которые могут оборваться
|
||||
* в любой момент.
|
||||
*
|
||||
* @param array $data
|
||||
* @return bool
|
||||
*/
|
||||
protected function hasTokens(array $data): bool
|
||||
{
|
||||
$string = ($data['url'] ?? '') . ($data['content'] ?? '');
|
||||
if (empty($string)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$badAttributes = [
|
||||
// токены и ключи
|
||||
'[?&]token=',
|
||||
'[?&]drmreq=',
|
||||
// логины
|
||||
'[?&]u=',
|
||||
'[?&]user=',
|
||||
'[?&]username=',
|
||||
// пароли
|
||||
'[?&]p=',
|
||||
'[?&]pwd=',
|
||||
'[?&]password=',
|
||||
// неизвестные
|
||||
// 'free=true',
|
||||
// 'uid=',
|
||||
// 'c_uniq_tag=',
|
||||
// 'rlkey=',
|
||||
// '?s=',
|
||||
// '&s=',
|
||||
// '?q=',
|
||||
// '&q=',
|
||||
];
|
||||
|
||||
return array_any(
|
||||
$badAttributes,
|
||||
static fn (string $badAttribute) => preg_match_all("/{$badAttribute}/", $string) >= 1,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* Copyright (c) 2025, Антон Аксенов
|
||||
* This file is part of m3u.su project
|
||||
@@ -29,7 +30,36 @@ class StatisticsService
|
||||
$this->channels = $this->getAllChannels();
|
||||
}
|
||||
|
||||
protected function getPlaylistsByField(string $field, int|string|bool|null $value): array
|
||||
/**
|
||||
* Обрабатывает команду /stats
|
||||
*
|
||||
* @return bool
|
||||
* @throws Exception
|
||||
*/
|
||||
public function get(): array
|
||||
{
|
||||
return [
|
||||
'playlists' => [
|
||||
'all' => count($this->playlists),
|
||||
'online' => count($this->getPlaylistsByField('isOnline', true)),
|
||||
'offline' => count($this->getPlaylistsByField('isOnline', false)),
|
||||
'unknown' => count($this->getPlaylistsByField('isOnline', null)),
|
||||
'adult' => count($this->getPlaylistsByTag('adult')),
|
||||
'hasCatchup' => count($this->getPlaylistsByField('hasCatchup', true)),
|
||||
'hasTvg' => count($this->getPlaylistsByField('hasTvg', true)),
|
||||
'groupped' => count($this->getPlaylistsWithGroups()),
|
||||
'latest' => $this->getLatestPlaylist(),
|
||||
],
|
||||
'channels' => [
|
||||
'all' => $this->getAllChannelsCount(),
|
||||
'online' => count($this->getChannelsByField('isOnline', true)),
|
||||
'offline' => count($this->getChannelsByField('isOnline', false)),
|
||||
'adult' => count($this->getChannelsByTag('adult')),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
protected function getPlaylistsByField(string $field, bool|int|string|null $value): array
|
||||
{
|
||||
return array_filter(
|
||||
$this->playlists,
|
||||
@@ -85,7 +115,7 @@ class StatisticsService
|
||||
return count($this->channels);
|
||||
}
|
||||
|
||||
protected function getChannelsByField(string $field, int|string|bool|null $value): array
|
||||
protected function getChannelsByField(string $field, bool|int|string|null $value): array
|
||||
{
|
||||
return array_filter(
|
||||
$this->channels,
|
||||
@@ -100,33 +130,4 @@ class StatisticsService
|
||||
static fn (array $channel) => in_array($tag, $channel['tags']),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Обрабатывает команду /stats
|
||||
*
|
||||
* @return bool
|
||||
* @throws Exception
|
||||
*/
|
||||
public function get(): array
|
||||
{
|
||||
return [
|
||||
'playlists' => [
|
||||
'all' => count($this->playlists),
|
||||
'online' => count($this->getPlaylistsByField('isOnline', true)),
|
||||
'offline' => count($this->getPlaylistsByField('isOnline', false)),
|
||||
'unknown' => count($this->getPlaylistsByField('isOnline', null)),
|
||||
'adult' => count($this->getPlaylistsByTag('adult')),
|
||||
'hasCatchup' => count($this->getPlaylistsByField('hasCatchup', true)),
|
||||
'hasTvg' => count($this->getPlaylistsByField('hasTvg', true)),
|
||||
'groupped' => count($this->getPlaylistsWithGroups()),
|
||||
'latest' => $this->getLatestPlaylist(),
|
||||
],
|
||||
'channels' => [
|
||||
'all' => $this->getAllChannelsCount(),
|
||||
'online' => count($this->getChannelsByField('isOnline', true)),
|
||||
'offline' => count($this->getChannelsByField('isOnline', false)),
|
||||
'adult' => count($this->getChannelsByTag('adult')),
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* Copyright (c) 2025, Антон Аксенов
|
||||
* This file is part of m3u.su project
|
||||
@@ -98,7 +99,7 @@ class TwigExtention extends AbstractExtension
|
||||
*/
|
||||
public function toDate(?float $timestamp, string $format = 'd.m.Y H:i:s'): string
|
||||
{
|
||||
return $timestamp === null ? '' : date($format, (int)$timestamp);
|
||||
return $timestamp === null ? '' : date($format, (int) $timestamp);
|
||||
}
|
||||
|
||||
public function arrayValues($value, ...$args)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* Copyright (c) 2025, Антон Аксенов
|
||||
* This file is part of m3u.su project
|
||||
@@ -7,11 +8,10 @@
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Exceptions;
|
||||
namespace App\Errors;
|
||||
|
||||
use Psr\Http\Message\{
|
||||
ResponseInterface,
|
||||
ServerRequestInterface};
|
||||
use Psr\Http\Message\ResponseInterface;
|
||||
use Psr\Http\Message\ServerRequestInterface;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Slim\Handlers\ErrorHandler as SlimErrorHandler;
|
||||
use Throwable;
|
||||
@@ -19,35 +19,8 @@ use Throwable;
|
||||
/**
|
||||
* Обработчик ошибок
|
||||
*/
|
||||
class ExceptionHandler extends SlimErrorHandler
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Возвращает структуру исключения для контекста
|
||||
*
|
||||
@@ -63,7 +36,7 @@ class ExceptionHandler extends SlimErrorHandler
|
||||
'class' => $e::class,
|
||||
'file' => $e->getFile(),
|
||||
'line' => $e->getLine(),
|
||||
'trace' => $e->getTrace()
|
||||
'trace' => $e->getTrace(),
|
||||
];
|
||||
|
||||
return $result;
|
||||
@@ -94,4 +67,31 @@ class ExceptionHandler extends SlimErrorHandler
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Логирует ошибку и отдаёт 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;
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* Copyright (c) 2025, Антон Аксенов
|
||||
* This file is part of m3u.su project
|
||||
@@ -7,7 +8,7 @@
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Exceptions;
|
||||
namespace App\Errors;
|
||||
|
||||
use Exception;
|
||||
|
||||
@@ -15,6 +16,6 @@ class InvalidTelegramSecretException extends Exception
|
||||
{
|
||||
public function __construct()
|
||||
{
|
||||
parent::__construct("Ошибка валидации запроса от Telegram Bot API");
|
||||
parent::__construct('Ошибка валидации запроса от Telegram Bot API');
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,7 @@
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Exceptions;
|
||||
namespace App\Errors;
|
||||
|
||||
use Exception;
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
<?php
|
||||
/*
|
||||
* Copyright (c) 2025, Антон Аксенов
|
||||
* This file is part of m3u.su project
|
||||
* MIT License: https://git.axenov.dev/IPTV/web/src/branch/master/LICENSE
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Exceptions;
|
||||
|
||||
use Exception;
|
||||
|
||||
class FileNotFoundException extends Exception
|
||||
{
|
||||
public function __construct(string $filepath)
|
||||
{
|
||||
parent::__construct('Файл не найден: ' . $filepath);
|
||||
}
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
<?php
|
||||
/*
|
||||
* Copyright (c) 2025, Антон Аксенов
|
||||
* This file is part of m3u.su project
|
||||
* MIT License: https://git.axenov.dev/IPTV/web/src/branch/master/LICENSE
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Exceptions;
|
||||
|
||||
use Exception;
|
||||
|
||||
class FileReadException extends Exception
|
||||
{
|
||||
public function __construct(string $filepath)
|
||||
{
|
||||
parent::__construct('Ошибка чтения файла: ' . $filepath);
|
||||
}
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
<?php
|
||||
/*
|
||||
* Copyright (c) 2025, Антон Аксенов
|
||||
* This file is part of m3u.su project
|
||||
* MIT License: https://git.axenov.dev/IPTV/web/src/branch/master/LICENSE
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Exceptions;
|
||||
|
||||
use Exception;
|
||||
|
||||
class IniParsingException extends Exception
|
||||
{
|
||||
public function __construct(string $filepath)
|
||||
{
|
||||
parent::__construct('Ошибка разбора файла: ' . $filepath);
|
||||
}
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
<?php
|
||||
/*
|
||||
* Copyright (c) 2025, Антон Аксенов
|
||||
* This file is part of m3u.su project
|
||||
* MIT License: https://git.axenov.dev/IPTV/web/src/branch/master/LICENSE
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Exceptions;
|
||||
|
||||
use InvalidArgumentException;
|
||||
|
||||
class PlaylistWithoutUrlException extends InvalidArgumentException
|
||||
{
|
||||
public function __construct(string $code)
|
||||
{
|
||||
parent::__construct("Плейлист '{$code}' имеет неверный url");
|
||||
}
|
||||
}
|
||||
@@ -377,7 +377,7 @@ if (!function_exists('snake2camel')) {
|
||||
}
|
||||
}
|
||||
|
||||
if (!function_exists('data_stream')) {
|
||||
if (!function_exists('as_data_url')) {
|
||||
/**
|
||||
* Создает data URL для данных
|
||||
*
|
||||
@@ -385,7 +385,7 @@ if (!function_exists('data_stream')) {
|
||||
* @param string $mimeType MIME-тип данных
|
||||
* @return string Data URL
|
||||
*/
|
||||
function data_stream(string $data, string $mimeType = 'text/plain'): string
|
||||
function as_data_url(string $data, string $mimeType = 'text/plain'): string
|
||||
{
|
||||
return "data://{$mimeType},{$data}";
|
||||
}
|
||||
|
||||
@@ -44,11 +44,6 @@
|
||||
"app/helpers.php"
|
||||
]
|
||||
},
|
||||
"autoload-dev": {
|
||||
"psr-4": {
|
||||
"Tests\\": "tests/"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"clear-views": "rm -rf cache/views",
|
||||
"post-install-cmd": [
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* Copyright (c) 2025, Антон Аксенов
|
||||
* This file is part of m3u.su project
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* Copyright (c) 2025, Антон Аксенов
|
||||
* This file is part of m3u.su project
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* Copyright (c) 2025, Антон Аксенов
|
||||
* This file is part of m3u.su project
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
/*
|
||||
* Copyright (c) 2025, Антон Аксенов
|
||||
* This file is part of m3u.su project
|
||||
@@ -11,7 +13,6 @@ use App\Controllers\BotController;
|
||||
use App\Controllers\WebController;
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Web routes
|
||||
@@ -99,4 +100,3 @@ return [
|
||||
'name' => 'not-found',
|
||||
],
|
||||
];
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* Copyright (c) 2025, Антон Аксенов
|
||||
* This file is part of m3u.su project
|
||||
|
||||
26
docker/dev/php.ini
Normal file
26
docker/dev/php.ini
Normal file
@@ -0,0 +1,26 @@
|
||||
[PHP]
|
||||
error_reporting = E_ALL & ~E_NOTICE & ~E_DEPRECATED
|
||||
expose_php = Off
|
||||
file_uploads = Off
|
||||
max_execution_time=-1
|
||||
memory_limit = 512M
|
||||
|
||||
[opcache]
|
||||
opcache.enable = 1
|
||||
opcache.enable_cli = 1
|
||||
opcache.memory_consumption = 128
|
||||
opcache.max_accelerated_files = 30000
|
||||
opcache.revalidate_freq = 0
|
||||
opcache.jit_buffer_size = 64M
|
||||
opcache.jit = tracing
|
||||
|
||||
[xdebug]
|
||||
; https://xdebug.org/docs/all_settings
|
||||
zend_extension = xdebug.so
|
||||
xdebug.mode = debug
|
||||
xdebug.start_with_request = yes
|
||||
xdebug.trigger_value = go
|
||||
xdebug.client_host = host.docker.internal
|
||||
xdebug.REQUEST = *
|
||||
xdebug.SESSION = *
|
||||
xdebug.SERVER = *
|
||||
22
docker/dev/www.conf
Normal file
22
docker/dev/www.conf
Normal file
@@ -0,0 +1,22 @@
|
||||
[www]
|
||||
user = www-data
|
||||
group = www-data
|
||||
listen = 127.0.0.1:9000
|
||||
pm = dynamic
|
||||
pm.max_children = 5
|
||||
pm.start_servers = 2
|
||||
pm.min_spare_servers = 1
|
||||
pm.max_spare_servers = 3
|
||||
pm.max_requests = 50
|
||||
pm.status_path = /status
|
||||
ping.path = /ping
|
||||
ping.response = pong
|
||||
access.log = /var/log/php/$pool.access.log
|
||||
;access.format = "%R - %u %t \"%m %r%Q%q\" %s %f %{milli}d %{kilo}M %C%%"
|
||||
; chroot = /var/www
|
||||
; chdir = /var/www
|
||||
php_flag[display_errors] = on
|
||||
php_admin_value[error_log] = /var/log/php/$pool.error.log
|
||||
php_admin_flag[log_errors] = on
|
||||
php_admin_value[memory_limit] = 512M
|
||||
php_admin_value[error_reporting] = E_ALL & ~E_NOTICE & ~E_DEPRECATED
|
||||
16
docker/prod/php.ini
Normal file
16
docker/prod/php.ini
Normal file
@@ -0,0 +1,16 @@
|
||||
[PHP]
|
||||
error_reporting = E_ALL & ~E_DEPRECATED
|
||||
expose_php = Off
|
||||
file_uploads = Off
|
||||
memory_limit = 512M
|
||||
; upload_max_filesize=10M
|
||||
; post_max_size=10M
|
||||
|
||||
[opcache]
|
||||
opcache.enable = 1
|
||||
opcache.enable_cli = 1
|
||||
opcache.memory_consumption = 128
|
||||
opcache.max_accelerated_files = 30000
|
||||
opcache.revalidate_freq = 0
|
||||
opcache.jit_buffer_size = 64M
|
||||
opcache.jit = tracing
|
||||
22
docker/prod/www.conf
Normal file
22
docker/prod/www.conf
Normal file
@@ -0,0 +1,22 @@
|
||||
[www]
|
||||
user = www-data
|
||||
group = www-data
|
||||
listen = 127.0.0.1:9000
|
||||
pm = dynamic
|
||||
pm.max_children = 5
|
||||
pm.start_servers = 2
|
||||
pm.min_spare_servers = 1
|
||||
pm.max_spare_servers = 3
|
||||
pm.max_requests = 50
|
||||
pm.status_path = /status
|
||||
ping.path = /ping
|
||||
ping.response = pong
|
||||
access.log = /var/log/php/$pool.access.log
|
||||
;access.format = "%R - %u %t \"%m %r%Q%q\" %s %f %{milli}d %{kilo}M %C%%"
|
||||
; chroot = /var/www
|
||||
; chdir = /var/www
|
||||
php_flag[display_errors] = on
|
||||
php_admin_value[error_log] = /var/log/php/$pool.error.log
|
||||
php_admin_flag[log_errors] = on
|
||||
php_admin_value[memory_limit] = 512M
|
||||
php_admin_value[error_reporting] = E_ALL & ~E_NOTICE & ~E_DEPRECATED
|
||||
21
linter
21
linter
@@ -7,15 +7,12 @@
|
||||
|
||||
# shellcheck disable=SC2015
|
||||
|
||||
# set -x
|
||||
# set -o pipefail
|
||||
|
||||
########################################################
|
||||
# Служебные исходные переменные
|
||||
########################################################
|
||||
|
||||
# имя контейнера
|
||||
CONTAINER="m3u-su-web"
|
||||
CONTAINER="iptv-web"
|
||||
|
||||
# команда для запуска
|
||||
COMMAND="$1"; shift
|
||||
@@ -30,7 +27,6 @@ IS_FROM_GIT="$(env | grep -c "GIT_EDITOR=:")"
|
||||
[[ -f /.dockerenv ]] && IS_FROM_CONTAINER=1 || IS_FROM_CONTAINER=0
|
||||
|
||||
# признак режима отладки
|
||||
[[ $LINTER_DEBUG == 1 ]] && DEBUG_MODE=1
|
||||
[[ $LINTER_DEBUG -gt 1 ]] && set -x
|
||||
|
||||
########################################################
|
||||
@@ -42,13 +38,11 @@ LINTER_COLORS=${LINTER_COLORS:-$CAN_USE_COLORS}
|
||||
[[ "$LINTER_COLORS" == 1 ]] && FRESET="$(tput sgr0)" || FRESET=''
|
||||
[[ "$LINTER_COLORS" == 1 ]] && FBOLD="$(tput bold)" || FBOLD=''
|
||||
[[ "$LINTER_COLORS" == 1 ]] && FDIM="$(tput dim)" || FDIM=''
|
||||
[[ "$LINTER_COLORS" == 1 ]] && FBLACK="$(tput setaf 0)" || FBLACK=''
|
||||
[[ "$LINTER_COLORS" == 1 ]] && FRED="$(tput setaf 1)" || FRED=''
|
||||
[[ "$LINTER_COLORS" == 1 ]] && FWHITE="$(tput setaf 7)" || FWHITE=''
|
||||
[[ "$LINTER_COLORS" == 1 ]] && FGREEN="$(tput setaf 2)" || FGREEN=''
|
||||
[[ "$LINTER_COLORS" == 1 ]] && FBRED="$(tput setab 1)" || FBRED=''
|
||||
[[ "$LINTER_COLORS" == 1 ]] && FBYELLOW="$(tput setab 3)" || FBYELLOW=''
|
||||
[[ "$LINTER_COLORS" == 1 ]] && FBLYELLOW="$(tput setab 11)" || FBLYELLOW=''
|
||||
|
||||
print() {
|
||||
echo -e "$*${FRESET}"
|
||||
@@ -162,6 +156,18 @@ install() {
|
||||
error "Pre-commit хук НЕ установлен"
|
||||
}
|
||||
|
||||
# Удаляет pre-commit git хук
|
||||
remove() {
|
||||
status
|
||||
[[ -d ./.git/hooks ]] || {
|
||||
print "Не найден репозиторий '$(pwd)', пропускаю"
|
||||
exit
|
||||
}
|
||||
rm -f ./.git/hooks/pre-commit && \
|
||||
success "Pre-commit hook удалён" || \
|
||||
error "Pre-commit хук НЕ удалён"
|
||||
}
|
||||
|
||||
# Запускает проверку код-стайла по всему проекту или только изменённым файлам
|
||||
style() {
|
||||
title "[php-cs-fixer] Запущена проверка код-стайла"
|
||||
@@ -413,6 +419,7 @@ fi
|
||||
case "$COMMAND" in
|
||||
h|help ) help "$1" ;;
|
||||
i|install ) install ;;
|
||||
r|remove ) remove ;;
|
||||
s|style ) style "$@" ;;
|
||||
f|fix ) fix "$@" ;;
|
||||
p|phpcs ) phpcs "$@" ;;
|
||||
|
||||
@@ -45,4 +45,4 @@ parameters:
|
||||
|
||||
# требует явно расписывать все итерируемые типы, структуры полей и т.д.
|
||||
# можно раскомментировать для уточнения типов при разработке, но убирать пока рано
|
||||
- identifier: missingType.iterableValue
|
||||
# - identifier: missingType.iterableValue
|
||||
|
||||
13
public/boosty.svg
Normal file
13
public/boosty.svg
Normal 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 |
@@ -1,4 +1,5 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* Copyright (c) 2025, Антон Аксенов
|
||||
* This file is part of m3u.su project
|
||||
|
||||
@@ -1,340 +0,0 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* Copyright (c) 2025, Антон Аксенов
|
||||
* This file is part of m3u.su project
|
||||
* MIT License: https://git.axenov.dev/IPTV/web/src/branch/master/LICENSE
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
|
||||
abstract class BaseTestCase extends TestCase
|
||||
{
|
||||
/**
|
||||
* Тестирует наличие методов в классе
|
||||
*
|
||||
* @param array $methods
|
||||
* @param object|string $class
|
||||
*/
|
||||
public function assertHasMethods(array $methods, object|string $class): void
|
||||
{
|
||||
foreach ($methods as $method) {
|
||||
$this->assertTrue(
|
||||
method_exists(is_object($class) ? $class::class : $class, $method),
|
||||
"Method {$class}::{$method}() does not exist"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
protected function makePlaylist(): void
|
||||
{
|
||||
}
|
||||
|
||||
protected function makeIni(?string $contents = null): string
|
||||
{
|
||||
$contents ??= <<<'EOD'
|
||||
[foo]
|
||||
name=foo name
|
||||
desc=foo description
|
||||
url=http://example.com/foo.m3u
|
||||
src=http://example.com/
|
||||
[bar]
|
||||
name=bar name
|
||||
desc=bar description
|
||||
url=http://example.com/bar.m3u
|
||||
src=http://example.com/
|
||||
EOD;
|
||||
|
||||
return data_stream($contents, 'text/ini');
|
||||
}
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Методы для заглушки объектов и методов других классов
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
/**
|
||||
* Создаёт и возвращает объект HTTP-запроса для тестирования методов контроллеров
|
||||
*
|
||||
* @param array $query The GET parameters
|
||||
* @param array $request The POST parameters
|
||||
* @param array $attributes The request attributes (parameters parsed from the PATH_INFO, ...)
|
||||
* @param array $cookies The COOKIE parameters
|
||||
* @param array $files The FILES parameters
|
||||
* @param array $server The SERVER parameters
|
||||
* @param resource|string|null $content The raw body data
|
||||
*
|
||||
* @return HttpRequest
|
||||
*
|
||||
* @see Request::__construct
|
||||
*/
|
||||
protected function makeHttpRequest(
|
||||
array $query = [],
|
||||
array $request = [],
|
||||
array $attributes = [],
|
||||
array $cookies = [],
|
||||
array $files = [],
|
||||
array $server = [],
|
||||
mixed $content = null
|
||||
): HttpRequest {
|
||||
return new HttpRequest(func_get_args());
|
||||
}
|
||||
|
||||
/**
|
||||
* Возвращает объект ответа HTTP-клиента Laravel
|
||||
*
|
||||
* @param int $status
|
||||
* @param array $headers
|
||||
* @param $body
|
||||
* @param string $version
|
||||
* @param string|null $reason
|
||||
*
|
||||
* @return HttpClientResponse
|
||||
*/
|
||||
protected function makeHttpResponse(
|
||||
int $status = 200,
|
||||
array $headers = [],
|
||||
$body = null,
|
||||
string $version = '1.1',
|
||||
?string $reason = null
|
||||
): HttpClientResponse {
|
||||
return new HttpClientResponse(new GuzzleResponse(...func_get_args()));
|
||||
}
|
||||
|
||||
/**
|
||||
* Вызывает любой метод указанного объекта с нужными аргументами и обходом его видимости
|
||||
*
|
||||
* @param class-string|object $objectOrClass
|
||||
* @param string $method
|
||||
* @param mixed ...$args
|
||||
*
|
||||
* @return mixed
|
||||
*
|
||||
* @throws ReflectionException
|
||||
*
|
||||
* @see https://stackoverflow.com/questions/249664
|
||||
* @see \Psy\Sudo::callMethod()
|
||||
* @see \Psy\Sudo::callStatic()
|
||||
*/
|
||||
protected function callMethod(object|string $objectOrClass, string $method, mixed ...$args): mixed
|
||||
{
|
||||
$reflObject = is_string($objectOrClass)
|
||||
? new ReflectionClass($objectOrClass)
|
||||
: new ReflectionObject($objectOrClass);
|
||||
$reflMethod = $reflObject->getMethod($method);
|
||||
|
||||
return $reflMethod->invokeArgs(is_string($objectOrClass) ? null : $objectOrClass, $args);
|
||||
}
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Методы-хелперы для подготовки и отладки тестов
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
/**
|
||||
* Конвертирует многоуровневый массив в html-файл с таблицей для визуальной
|
||||
* отладки и сохраняет в `"storage/app/$filename"`.
|
||||
*
|
||||
* Использование:
|
||||
* 0. предполагается во время отладки теста
|
||||
* 1. вызвать метод, который возвращает массив, приводимый к массиву или читаемый как массив объект
|
||||
* 2. вызвать `$this->toTable($array, 'test')`
|
||||
* 3. открыть в браузере файл `storage/app/test.html`
|
||||
*
|
||||
* @param array|ArrayAccess $data
|
||||
* @param string $filename
|
||||
*/
|
||||
protected static function toTable(array|ArrayAccess $data, string $filename): void
|
||||
{
|
||||
$headers = $result = $html = [];
|
||||
foreach ($data as $row) {
|
||||
$result[] = $row = Arr::dot($row);
|
||||
empty($headers) && $headers = array_keys($row);
|
||||
}
|
||||
$html[] = '<html lang="ru"><style>body{margin:0}table{font-family:monospace;border-collapse:collapse}'
|
||||
. 'thead{background:darkorange;position:sticky;top:-1}th,td{white-space:nowrap;'
|
||||
. 'border:1px solid black;padding:0 2px}tr:active{font-weight:bold}'
|
||||
. 'tr:hover{background:lightgrey}</style><body><table><thead>';
|
||||
foreach ($headers as $header) {
|
||||
$html[] = "<th>{$header}</th>";
|
||||
}
|
||||
$html[] = '</thead><tbody>';
|
||||
|
||||
foreach ($result as $row) {
|
||||
$html[] = '<tr>';
|
||||
foreach ($row as $value) {
|
||||
$value instanceof BackedEnum && $value = $value->value;
|
||||
$value = str_replace("'", '', var_export($value, true)); // строки без кавычек
|
||||
$html[] = "<td>{$value}</td>";
|
||||
}
|
||||
$html[] = '</tr>';
|
||||
}
|
||||
$html[] = '</tbody></table></body></html>';
|
||||
Storage::put("{$filename}.html", implode('', $html));
|
||||
}
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Методы проверки значений
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
/**
|
||||
* Проверяет идентичность двух классов
|
||||
*
|
||||
* @param object|string $class1
|
||||
* @param object|string $class2
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
protected function checkIsSameClass(object|string $class1, object|string $class2): bool
|
||||
{
|
||||
return (is_object($class1) ? $class1::class : $class1) === (is_object($class2) ? $class2::class : $class2);
|
||||
}
|
||||
|
||||
/**
|
||||
* Проверяет наследование других классов указанным
|
||||
*
|
||||
* @param string[] $parents Массив имён потенциальных классов-родителей
|
||||
* @param object|string $class Объект или имя класса для проверки
|
||||
*
|
||||
* @see https://www.php.net/manual/en/function.class-parents.php
|
||||
*/
|
||||
protected function checkExtendsClasses(array $parents, object|string $class): bool
|
||||
{
|
||||
return !empty(array_intersect($parents, is_object($class) ? class_parents($class) : [$class]));
|
||||
}
|
||||
|
||||
/**
|
||||
* Проверяет реализацию интерфейсов указанным классом
|
||||
*
|
||||
* @param string[] $interfaces Массив имён интерфейсов
|
||||
* @param object|string $class Объект или имя класса для проверки
|
||||
*
|
||||
* @see https://www.php.net/manual/en/function.class-parents.php
|
||||
*/
|
||||
protected function checkImplementsInterfaces(array $interfaces, object|string $class): bool
|
||||
{
|
||||
return !empty(array_intersect($interfaces, is_object($class) ? class_implements($class) : [$class]));
|
||||
}
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Методы проверки утверждений в тестах
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
/**
|
||||
* Утверждает, что в массиве имеются все указанные ключи
|
||||
*
|
||||
* @param array $keys Ключи для проверки в массиве
|
||||
* @param iterable $array Проверяемый массив, итератор или приводимый к массиву объект
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
protected function assertArrayHasKeys(array $keys, iterable $array): void
|
||||
{
|
||||
$array = iterator_to_array($array);
|
||||
|
||||
foreach ($keys as $key) {
|
||||
$this->assertArrayHasKey($key, $array);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Утверждает, что в объекте имеются все указанные свойства
|
||||
*
|
||||
* @param array $props Свойства для проверки в объекте
|
||||
* @param object $object Проверяемый объект
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
protected function assertObjectHasProperties(array $props, object $object): void
|
||||
{
|
||||
foreach ($props as $prop) {
|
||||
$this->assertObjectHasProperty($prop, $object);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Утверждает, что в массиве отсутствуют все указанные ключи
|
||||
*
|
||||
* @param array $keys Ключи для проверки в массиве
|
||||
* @param iterable $array Проверяемый массив, итератор или приводимый к массиву объект
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
protected function assertArrayNotHasKeys(array $keys, iterable $array): void
|
||||
{
|
||||
foreach ($keys as $key) {
|
||||
$this->assertArrayNotHasKey($key, $array);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Утверждает, что в объекте отсутствуют все указанные свойства
|
||||
*
|
||||
* @param array $props Свойства для проверки в объекте
|
||||
* @param object $object Проверяемый объект
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
protected function assertObjectNotHasProperties(array $props, object $object): void
|
||||
{
|
||||
foreach ($props as $prop) {
|
||||
$this->assertObjectNotHasProperty($prop, $object);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Утверждает, что в массиве элементы только указанного типа
|
||||
*
|
||||
* @param string $type Название типа, один из возможных результатов функции gettype()
|
||||
* @param iterable $array Проверяемый массив, итератор или приводимый к массиву объект
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
protected function assertArrayValuesTypeOf(string $type, iterable $array): void
|
||||
{
|
||||
foreach ($array as $key => $value) {
|
||||
$this->assertEquals($type, gettype($value), "Failed asserting that element [{$key}] is type of {$type}");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Утверждает, что в массив содержит только объекты (опционально -- указанного класса)
|
||||
*
|
||||
* Работает гибче {@link self::assertContainsOnlyInstancesOf()}
|
||||
* засчёт предварительной подготовки проверяемых данных и возможности
|
||||
* нестрогой проверки имени класса.
|
||||
*
|
||||
* @param mixed $array Массив для проверки
|
||||
* @param object|string|null $class Имя класса (если не указано, проверяется только тип)
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
protected function assertIsArrayOfObjects(mixed $array, object|string|null $class = null): void
|
||||
{
|
||||
is_object($class) && $class = $class::class;
|
||||
|
||||
if (is_string($array) && json_validate($array)) {
|
||||
$array = json_decode($array);
|
||||
}
|
||||
|
||||
$this->assertNotEmpty($array);
|
||||
|
||||
if (empty($class)) {
|
||||
$filtered = array_filter($array, static fn ($elem) => is_object($elem));
|
||||
$this->assertSame($array, $filtered, 'Failed asserting that array containts only objects');
|
||||
} else {
|
||||
$this->assertContainsOnlyInstancesOf($class, $array);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* Copyright (c) 2025, Антон Аксенов
|
||||
* This file is part of m3u.su project
|
||||
* MIT License: https://git.axenov.dev/IPTV/web/src/branch/master/LICENSE
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Controllers;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class ApiControllerTest extends TestCase
|
||||
{
|
||||
|
||||
public function testMakeQrCode()
|
||||
{
|
||||
}
|
||||
|
||||
public function testGetOne()
|
||||
{
|
||||
}
|
||||
|
||||
public function testHealth()
|
||||
{
|
||||
}
|
||||
|
||||
public function testStats()
|
||||
{
|
||||
}
|
||||
|
||||
public function testVersion()
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -1,113 +0,0 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* Copyright (c) 2025, Антон Аксенов
|
||||
* This file is part of m3u.su project
|
||||
* MIT License: https://git.axenov.dev/IPTV/web/src/branch/master/LICENSE
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Core;
|
||||
|
||||
use App\Core\IniFile;
|
||||
use App\Exceptions\FileReadException;
|
||||
use App\Exceptions\IniParsingException;
|
||||
use App\Exceptions\PlaylistNotFoundException;
|
||||
use Tests\BaseTestCase;
|
||||
use Tests\FixtureHandler;
|
||||
|
||||
class IniFileTest extends BaseTestCase
|
||||
{
|
||||
use FixtureHandler;
|
||||
|
||||
/**
|
||||
* Проверяет успешное создание объекта, чтение и парсинг файла
|
||||
*
|
||||
* @return void
|
||||
* @throws FileReadException
|
||||
* @throws IniParsingException
|
||||
*/
|
||||
public function testMain(): void
|
||||
{
|
||||
$ini = $this->makeIni();
|
||||
$ini = new IniFile($ini);
|
||||
|
||||
$this->assertNotNull($ini->updatedAt());
|
||||
}
|
||||
|
||||
/**
|
||||
* Проверяет исключение при попытке чтения ini-файла по некорректнмоу пути
|
||||
*
|
||||
* @return void
|
||||
* @throws FileReadException
|
||||
* @throws IniParsingException
|
||||
*/
|
||||
public function testFileReadException(): void
|
||||
{
|
||||
$this->expectException(FileReadException::class);
|
||||
$this->expectExceptionMessage('Ошибка чтения файла');
|
||||
$ini = '';
|
||||
|
||||
new IniFile($ini);
|
||||
}
|
||||
|
||||
/**
|
||||
* Проверяет исключение при попытке парсинга битого ini-файла
|
||||
*
|
||||
* @return void
|
||||
* @throws FileReadException
|
||||
* @throws IniParsingException
|
||||
*/
|
||||
public function testIniParsingException(): void
|
||||
{
|
||||
$this->expectException(IniParsingException::class);
|
||||
$this->expectExceptionMessage('Ошибка разбора файла');
|
||||
$ini = $this->makeIni('z]');
|
||||
|
||||
new IniFile($ini);
|
||||
}
|
||||
|
||||
/**
|
||||
* Проверяет успешное получение определение плейлиста из ini-файла
|
||||
*
|
||||
* @return void
|
||||
* @throws FileReadException
|
||||
* @throws IniParsingException
|
||||
* @throws PlaylistNotFoundException
|
||||
*/
|
||||
public function testGetPlaylist(): void
|
||||
{
|
||||
$ini = $this->makeIni();
|
||||
$ini = new IniFile($ini);
|
||||
$isset = isset($ini['foo']);
|
||||
$foo = $ini->playlist('foo');
|
||||
$foo2 = $ini['foo'];
|
||||
|
||||
$this->assertTrue($isset);
|
||||
$this->assertIsArray($foo);
|
||||
$this->assertSame('foo name', $foo['name']);
|
||||
$this->assertSame('foo description', $foo['desc']);
|
||||
$this->assertSame('http://example.com/foo.m3u', $foo['url']);
|
||||
$this->assertSame('http://example.com/', $foo['src']);
|
||||
$this->assertSame($foo, $foo2);
|
||||
}
|
||||
|
||||
/**
|
||||
* Проверяет исключение при попытке парсинга битого ini-файла
|
||||
*
|
||||
* @return void
|
||||
* @throws FileReadException
|
||||
* @throws PlaylistNotFoundException
|
||||
* @throws IniParsingException
|
||||
*/
|
||||
public function testPlaylistNotFoundException(): void
|
||||
{
|
||||
$code = 'test';
|
||||
$this->expectException(PlaylistNotFoundException::class);
|
||||
$this->expectExceptionMessage("Плейлист '{$code}' не найден");
|
||||
$ini = $this->makeIni();
|
||||
|
||||
(new IniFile($ini))->playlist($code);
|
||||
}
|
||||
}
|
||||
@@ -1,111 +0,0 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* Copyright (c) 2025, Антон Аксенов
|
||||
* This file is part of m3u.su project
|
||||
* MIT License: https://git.axenov.dev/IPTV/web/src/branch/master/LICENSE
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Core;
|
||||
|
||||
use App\Core\IniFile;
|
||||
use App\Core\Playlist;
|
||||
use App\Exceptions\FileReadException;
|
||||
use App\Exceptions\IniParsingException;
|
||||
use App\Exceptions\PlaylistNotFoundException;
|
||||
use App\Exceptions\PlaylistWithoutUrlException;
|
||||
use Redis;
|
||||
use Tests\BaseTestCase;
|
||||
use Tests\FixtureHandler;
|
||||
|
||||
class PlaylistTest extends BaseTestCase
|
||||
{
|
||||
use FixtureHandler;
|
||||
|
||||
/**
|
||||
* Проверяет успешное создание объекта
|
||||
*
|
||||
* @return void
|
||||
* @throws FileReadException
|
||||
* @throws IniParsingException
|
||||
* @throws PlaylistNotFoundException
|
||||
*/
|
||||
public function testMain(): void
|
||||
{
|
||||
$code = 'foo';
|
||||
$ini = new IniFile($this->makeIni());
|
||||
$definition = $ini->playlist($code);
|
||||
|
||||
$pls = new Playlist($code, $definition);
|
||||
|
||||
$this->assertSame($code, $pls->code);
|
||||
$this->assertSame($definition['name'], $pls->name);
|
||||
$this->assertSame($definition['desc'], $pls->desc);
|
||||
$this->assertSame($definition['url'], $pls->url);
|
||||
$this->assertSame($definition['src'], $pls->src);
|
||||
}
|
||||
|
||||
/**
|
||||
* Проверяет успешное создание объекта при отсутствии значений опциональных параметров
|
||||
*
|
||||
* @return void
|
||||
* @throws FileReadException
|
||||
* @throws IniParsingException
|
||||
* @throws PlaylistNotFoundException
|
||||
*/
|
||||
public function testOptionalParams(): void
|
||||
{
|
||||
$code = 'foo';
|
||||
$ini = new IniFile($this->makeIni());
|
||||
$definition = $ini->playlist($code);
|
||||
unset($definition['name']);
|
||||
unset($definition['desc']);
|
||||
unset($definition['src']);
|
||||
|
||||
$pls = new Playlist($code, $definition);
|
||||
|
||||
$this->assertSame($code, $pls->code);
|
||||
$this->assertNull($pls->name);
|
||||
$this->assertNull($pls->desc);
|
||||
$this->assertSame($definition['url'], $pls->url);
|
||||
$this->assertNull($pls->src);
|
||||
}
|
||||
|
||||
/**
|
||||
* Проверяет исключение при попытке чтения ini-файла по некорректнмоу пути
|
||||
*
|
||||
* @return void
|
||||
* @throws FileReadException
|
||||
* @throws IniParsingException
|
||||
* @throws PlaylistNotFoundException
|
||||
*/
|
||||
public function testPlaylistWithoutUrlException(): void
|
||||
{
|
||||
$code = 'foo';
|
||||
$this->expectException(PlaylistWithoutUrlException::class);
|
||||
$this->expectExceptionMessage("Плейлист '{$code}' имеет неверный url");
|
||||
$ini = new IniFile($this->makeIni());
|
||||
$definition = $ini->playlist($code);
|
||||
unset($definition['url']);
|
||||
|
||||
new Playlist($code, $definition);
|
||||
}
|
||||
|
||||
public function testGetCheckResult(): void
|
||||
{
|
||||
$code = 'foo';
|
||||
$ini = new IniFile($this->makeIni());
|
||||
|
||||
$definition = $ini->playlist($code);
|
||||
$pls = new Playlist($code, $definition);
|
||||
|
||||
$redis = $this->createPartialMock(Redis::class, ['get']);
|
||||
$redis->expects($this->once())->method('get')->with($code)->willReturn(null);
|
||||
|
||||
|
||||
$pls->getCheckResult();
|
||||
|
||||
}
|
||||
}
|
||||
@@ -1,109 +0,0 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* Copyright (c) 2025, Антон Аксенов
|
||||
* This file is part of m3u.su project
|
||||
* MIT License: https://git.axenov.dev/IPTV/web/src/branch/master/LICENSE
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests;
|
||||
|
||||
use InvalidArgumentException;
|
||||
|
||||
trait FixtureHandler
|
||||
{
|
||||
/**
|
||||
* Вычитывает содержимое файла строкой
|
||||
*
|
||||
* @param string $filepath
|
||||
* @return string
|
||||
* @throws InvalidArgumentException
|
||||
*/
|
||||
public function loadFixtureContent(string $filepath): string
|
||||
{
|
||||
$filepath = static::buildFixtureFilePath($filepath);
|
||||
is_file($filepath) || throw new InvalidArgumentException('File not found: ' . $filepath);
|
||||
|
||||
return (string) file_get_contents($filepath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Вычитывает .json файл в php-массив
|
||||
*
|
||||
* @param string $filepath
|
||||
* @param string|null $key
|
||||
* @return array
|
||||
* @throws InvalidArgumentException
|
||||
*/
|
||||
protected function loadJsonFixture(string $filepath, ?string $key = null): array
|
||||
{
|
||||
$contents = $this->loadFixtureContent($filepath);
|
||||
$contents = json_decode($contents, true);
|
||||
|
||||
return $key ? $contents[$key] : $contents;
|
||||
}
|
||||
|
||||
/**
|
||||
* Подгружает фиксутуру для тестов
|
||||
*
|
||||
* @param string $filepath Имя файла или путь до него внутри tests/Fixtures/...
|
||||
* @return mixed
|
||||
* @throws InvalidArgumentException
|
||||
*/
|
||||
protected function loadPhpFixture(string $filepath): mixed
|
||||
{
|
||||
$filepath = static::buildFixtureFilePath($filepath);
|
||||
is_file($filepath) || throw new InvalidArgumentException('File not found: ' . $filepath);
|
||||
|
||||
return require $filepath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Сохраняет указанные сырые данные в виде файла с данными
|
||||
* для использования в качестве фикстуры в тестах.
|
||||
*
|
||||
* Использование:
|
||||
* 0. предполагается при подготовке к написанию теста
|
||||
* 1. вызвать `makeFixture()`, передав нужные данные
|
||||
* 2. найти файл в `tests/Fixtures/...`, проверить корректность
|
||||
* 3. подгрузить фикстуру и замокать вызов курсорной БД-функции
|
||||
* ```
|
||||
* $fixture = this->loadFixture(...);
|
||||
* $this->mockDbCursor(...)->andReturn($fixture);
|
||||
* ```
|
||||
*
|
||||
* @param array|Collection $data Данные для сохранения в фикстуре
|
||||
* @param string $name Имя файла или путь до него внутри tests/Fixtures/...
|
||||
* @param bool $is_json Сохранить в json-формате
|
||||
*/
|
||||
public static function saveFixture(mixed $data, string $name, bool $is_json = false): void
|
||||
{
|
||||
$data = match (true) {
|
||||
$data instanceof Traversable => iterator_to_array($data),
|
||||
default => $data,
|
||||
};
|
||||
if ($is_json) {
|
||||
$string = json_encode($data, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
|
||||
$ext = 'json';
|
||||
} else {
|
||||
$string = var_export($data, true);
|
||||
$string = preg_replace("/(\n\\s+)?array\\s\\(/", '[', $string); // конвертим в короткий синтаксис
|
||||
$string = str_replace([')', 'NULL'], [']', 'null'], $string); // остатки
|
||||
$string = "<?php\n\ndeclare(strict_types=1);\n\nreturn {$string};\n"; // добавляем заголовок для файла
|
||||
$ext = 'php';
|
||||
}
|
||||
$filepath = __DIR__ . "/Fixtures/{$name}.{$ext}";
|
||||
!file_exists($filepath) && @mkdir(dirname($filepath), recursive: true);
|
||||
$filepath = str_replace('/', DIRECTORY_SEPARATOR, $filepath);
|
||||
file_put_contents($filepath, $string);
|
||||
}
|
||||
|
||||
protected static function buildFixtureFilePath(string $filepath): string
|
||||
{
|
||||
$filepath = trim(ltrim($filepath, DIRECTORY_SEPARATOR));
|
||||
|
||||
return __DIR__ . DIRECTORY_SEPARATOR . 'Fixtures' . DIRECTORY_SEPARATOR . $filepath;
|
||||
}
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
n]
|
||||
name=
|
||||
desc=
|
||||
url=
|
||||
src=
|
||||
@@ -1,11 +0,0 @@
|
||||
[p1]
|
||||
name=
|
||||
desc=
|
||||
url=
|
||||
src=
|
||||
|
||||
[z2]
|
||||
name=
|
||||
desc=
|
||||
url=
|
||||
src=
|
||||
@@ -10,8 +10,8 @@
|
||||
<title>{% block title %}{{ config('app.title') }}{% endblock %}</title>
|
||||
<meta charset="utf-8">
|
||||
<meta name="description" content="{% block metadescription %}{% endblock %}">
|
||||
<meta name="keywords" content="{% block metakeywords %}{% endblock %}" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="keywords" content="{% block metakeywords %}{% endblock %}" />
|
||||
<meta http-equiv="x-ua-compatible" content="ie=edge">
|
||||
<style>.cursor-pointer{cursor:pointer}.cursor-help{cursor:help}</style>
|
||||
<script async type="module" src="https://unpkg.com/ionicons@7.1.0/dist/ionicons/ionicons.esm.js"></script>
|
||||
@@ -23,7 +23,7 @@
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="/favicon/favicon-16x16.png" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon/favicon.svg" />
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/favicon/apple-touch-icon.png" />
|
||||
<meta name="apple-mobile-web-app-title" content="{{ config('app.title') }}" />
|
||||
<meta name="apple-mobile-web-app-title" content="IPTV Плейлисты" />
|
||||
<link rel="manifest" href="/favicon/site.webmanifest" />
|
||||
<meta name="msapplication-TileColor" content="#00aba9">
|
||||
<meta name="msapplication-TileImage" content="/favicon/mstile-150x150.png">
|
||||
|
||||
Reference in New Issue
Block a user