Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
6c31ffa120
|
@@ -1,9 +0,0 @@
|
|||||||
/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.*
|
.env.*
|
||||||
!.env.example
|
!.env.example
|
||||||
playlists.ini
|
config/playlists.ini
|
||||||
channels.json
|
channels.json
|
||||||
.phpunit.result.cache
|
.phpunit.result.cache
|
||||||
.php-cs-fixer.cache
|
.php-cs-fixer.cache
|
||||||
|
|||||||
@@ -725,7 +725,7 @@ $finder = (new Finder())
|
|||||||
->notPath($excludeNames); // исключаем файлы
|
->notPath($excludeNames); // исключаем файлы
|
||||||
|
|
||||||
return (new Config())
|
return (new Config())
|
||||||
// спорная фигня, пока не активирую
|
// спорная фигня, пока не
|
||||||
// ->setParallelConfig(PhpCsFixer\Runner\Parallel\ParallelConfigFactory::detect());
|
// ->setParallelConfig(PhpCsFixer\Runner\Parallel\ParallelConfigFactory::detect());
|
||||||
->setFinder($finder)
|
->setFinder($finder)
|
||||||
->setRules($rules) // ставим правила
|
->setRules($rules) // ставим правила
|
||||||
|
|||||||
48
Dockerfile
48
Dockerfile
@@ -1,48 +0,0 @@
|
|||||||
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,5 +1,4 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Copyright (c) 2025, Антон Аксенов
|
* Copyright (c) 2025, Антон Аксенов
|
||||||
* This file is part of m3u.su project
|
* This file is part of m3u.su project
|
||||||
@@ -13,7 +12,7 @@ namespace App\Controllers;
|
|||||||
use App\Core\Bot;
|
use App\Core\Bot;
|
||||||
use App\Core\Kernel;
|
use App\Core\Kernel;
|
||||||
use App\Core\StatisticsService;
|
use App\Core\StatisticsService;
|
||||||
use App\Errors\PlaylistNotFoundException;
|
use App\Exceptions\PlaylistNotFoundException;
|
||||||
use chillerlan\QRCode\QRCode;
|
use chillerlan\QRCode\QRCode;
|
||||||
use chillerlan\QRCode\QROptions;
|
use chillerlan\QRCode\QROptions;
|
||||||
use Exception;
|
use Exception;
|
||||||
@@ -39,11 +38,10 @@ class ApiController extends BasicController
|
|||||||
$code = $request->getAttributes()['code'] ?? null;
|
$code = $request->getAttributes()['code'] ?? null;
|
||||||
empty($code) && throw new PlaylistNotFoundException('');
|
empty($code) && throw new PlaylistNotFoundException('');
|
||||||
|
|
||||||
$playlist = ini()->getPlaylist($code);
|
$playlist = ini()->playlist($code);
|
||||||
if ($playlist['isOnline'] === true) {
|
if ($playlist['isOnline'] === true) {
|
||||||
unset($playlist['content']);
|
unset($playlist['content']);
|
||||||
}
|
}
|
||||||
|
|
||||||
return $this->responseJson($response, 200, $playlist);
|
return $this->responseJson($response, 200, $playlist);
|
||||||
} catch (PlaylistNotFoundException $e) {
|
} catch (PlaylistNotFoundException $e) {
|
||||||
return $this->responseJsonError($response, 404, $e);
|
return $this->responseJsonError($response, 404, $e);
|
||||||
@@ -67,7 +65,7 @@ class ApiController extends BasicController
|
|||||||
return $response->withStatus(404);
|
return $response->withStatus(404);
|
||||||
}
|
}
|
||||||
|
|
||||||
$filePath = cache_path("qr-codes/{$code}.jpg");
|
$filePath = cache_path("qr-codes/$code.jpg");
|
||||||
if (file_exists($filePath)) {
|
if (file_exists($filePath)) {
|
||||||
$raw = file_get_contents($filePath);
|
$raw = file_get_contents($filePath);
|
||||||
} else {
|
} else {
|
||||||
@@ -76,14 +74,13 @@ class ApiController extends BasicController
|
|||||||
'outputType' => QRCode::OUTPUT_IMAGE_JPG,
|
'outputType' => QRCode::OUTPUT_IMAGE_JPG,
|
||||||
'eccLevel' => QRCode::ECC_L,
|
'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 = new QRCode($options)->render($data, $filePath);
|
||||||
$raw = base64_decode(str_replace('data:image/jpg;base64,', '', $raw));
|
$raw = base64_decode(str_replace('data:image/jpg;base64,', '', $raw));
|
||||||
}
|
}
|
||||||
|
|
||||||
$mime = mime_content_type($filePath);
|
$mime = mime_content_type($filePath);
|
||||||
$response->getBody()->write($raw);
|
$response->getBody()->write($raw);
|
||||||
|
|
||||||
return $response->withStatus(200)
|
return $response->withStatus(200)
|
||||||
->withHeader('Content-Type', $mime);
|
->withHeader('Content-Type', $mime);
|
||||||
}
|
}
|
||||||
@@ -123,7 +120,6 @@ class ApiController extends BasicController
|
|||||||
is_file($path) && $size += filesize($path);
|
is_file($path) && $size += filesize($path);
|
||||||
is_dir($path) && $size += getSize($path);
|
is_dir($path) && $size += getSize($path);
|
||||||
}
|
}
|
||||||
|
|
||||||
return $size;
|
return $size;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Copyright (c) 2025, Антон Аксенов
|
* Copyright (c) 2025, Антон Аксенов
|
||||||
* This file is part of m3u.su project
|
* This file is part of m3u.su project
|
||||||
@@ -57,7 +56,6 @@ class BasicController
|
|||||||
$json = json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
$json = json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||||
|
|
||||||
$response->getBody()->write($json);
|
$response->getBody()->write($json);
|
||||||
|
|
||||||
return $response->withStatus($status)
|
return $response->withStatus($status)
|
||||||
->withHeader('Content-Type', 'application/json');
|
->withHeader('Content-Type', 'application/json');
|
||||||
}
|
}
|
||||||
@@ -101,7 +99,6 @@ class BasicController
|
|||||||
array $data = [],
|
array $data = [],
|
||||||
): ResponseInterface {
|
): ResponseInterface {
|
||||||
$view = Twig::fromRequest($request);
|
$view = Twig::fromRequest($request);
|
||||||
|
|
||||||
return $view->render($response, $template, $data);
|
return $view->render($response, $template, $data);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Copyright (c) 2025, Антон Аксенов
|
* Copyright (c) 2025, Антон Аксенов
|
||||||
* This file is part of m3u.su project
|
* This file is part of m3u.su project
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Copyright (c) 2025, Антон Аксенов
|
* Copyright (c) 2025, Антон Аксенов
|
||||||
* This file is part of m3u.su project
|
* This file is part of m3u.su project
|
||||||
@@ -10,7 +9,7 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\Controllers;
|
namespace App\Controllers;
|
||||||
|
|
||||||
use App\Errors\PlaylistNotFoundException;
|
use App\Exceptions\PlaylistNotFoundException;
|
||||||
use Exception;
|
use Exception;
|
||||||
use Psr\Http\Message\ResponseInterface;
|
use Psr\Http\Message\ResponseInterface;
|
||||||
use Psr\Http\Message\ServerRequestInterface;
|
use Psr\Http\Message\ServerRequestInterface;
|
||||||
@@ -37,7 +36,7 @@ class WebController extends BasicController
|
|||||||
*/
|
*/
|
||||||
public function home(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
|
public function home(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
|
||||||
{
|
{
|
||||||
$ini = ini()->load();
|
$ini = ini();
|
||||||
$keys = [];
|
$keys = [];
|
||||||
$count = count($ini);
|
$count = count($ini);
|
||||||
$pageSize = config('app.page_size');
|
$pageSize = config('app.page_size');
|
||||||
@@ -46,11 +45,11 @@ class WebController extends BasicController
|
|||||||
$pageCurrent = (int)($request->getAttributes()['page'] ?? $request->getQueryParams()['page'] ?? 1);
|
$pageCurrent = (int)($request->getAttributes()['page'] ?? $request->getQueryParams()['page'] ?? 1);
|
||||||
$pageCount = ceil($count / $pageSize);
|
$pageCount = ceil($count / $pageSize);
|
||||||
$offset = max(0, ($pageCurrent - 1) * $pageSize);
|
$offset = max(0, ($pageCurrent - 1) * $pageSize);
|
||||||
$ini = array_slice($ini, $offset, $pageSize, true);
|
$ini = array_slice($ini->get, $offset, $pageSize, true);
|
||||||
$keys = array_keys($ini);
|
$keys = array_keys($ini);
|
||||||
}
|
}
|
||||||
|
|
||||||
$playlists = ini()->getPlaylists($keys);
|
$playlists = ini()->playlists($keys);
|
||||||
|
|
||||||
return $this->view($request, $response, 'list.twig', [
|
return $this->view($request, $response, 'list.twig', [
|
||||||
'updatedAt' => ini()->updatedAt(),
|
'updatedAt' => ini()->updatedAt(),
|
||||||
@@ -76,8 +75,7 @@ class WebController extends BasicController
|
|||||||
$code = $request->getAttributes()['code'];
|
$code = $request->getAttributes()['code'];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$playlist = ini()->getPlaylist($code);
|
$playlist = ini()->playlist($code);
|
||||||
|
|
||||||
return $response->withHeader('Location', $playlist['url']);
|
return $response->withHeader('Location', $playlist['url']);
|
||||||
} catch (Throwable) {
|
} catch (Throwable) {
|
||||||
return $this->notFound($request, $response);
|
return $this->notFound($request, $response);
|
||||||
@@ -100,8 +98,7 @@ class WebController extends BasicController
|
|||||||
$code = $request->getAttributes()['code'];
|
$code = $request->getAttributes()['code'];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$playlist = ini()->getPlaylist($code);
|
$playlist = ini()->playlist($code);
|
||||||
|
|
||||||
return $this->view($request, $response, 'details.twig', ['playlist' => $playlist]);
|
return $this->view($request, $response, 'details.twig', ['playlist' => $playlist]);
|
||||||
} catch (PlaylistNotFoundException) {
|
} catch (PlaylistNotFoundException) {
|
||||||
return $this->notFound($request, $response);
|
return $this->notFound($request, $response);
|
||||||
|
|||||||
@@ -9,8 +9,8 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\Core;
|
namespace App\Core;
|
||||||
|
|
||||||
use App\Errors\InvalidTelegramSecretException;
|
use App\Exceptions\InvalidTelegramSecretException;
|
||||||
use App\Errors\PlaylistNotFoundException;
|
use App\Exceptions\PlaylistNotFoundException;
|
||||||
use DateTimeImmutable;
|
use DateTimeImmutable;
|
||||||
use Exception;
|
use Exception;
|
||||||
use JsonException;
|
use JsonException;
|
||||||
@@ -168,7 +168,7 @@ class Bot
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$pls = ini()->getPlaylist($code);
|
$pls = ini()->playlist($code);
|
||||||
} catch (PlaylistNotFoundException) {
|
} catch (PlaylistNotFoundException) {
|
||||||
return $this->reply("Плейлист `$code` не найден");
|
return $this->reply("Плейлист `$code` не найден");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,71 +10,71 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\Core;
|
namespace App\Core;
|
||||||
|
|
||||||
use App\Errors\PlaylistNotFoundException;
|
use App\Exceptions\FileReadException;
|
||||||
use Exception;
|
use App\Exceptions\IniParsingException;
|
||||||
|
use App\Exceptions\PlaylistNotFoundException;
|
||||||
|
use ArrayAccess;
|
||||||
|
use Override;
|
||||||
|
use Throwable;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Класс для работы со списком плейлистов
|
* Класс для работы со списком плейлистов
|
||||||
|
*
|
||||||
|
* @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
|
class IniFile implements ArrayAccess
|
||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
* @var array[] Коллекция подгруженных плейлистов
|
* @var array{}|array<TKey, TValue> Коллекция подгруженных плейлистов
|
||||||
*/
|
*/
|
||||||
protected array $playlists;
|
protected array $playlists;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @var string Дата последнего обновления списка
|
* @var positive-int Дата последнего обновления списка
|
||||||
*/
|
*/
|
||||||
protected string $updatedAt;
|
protected int $updatedAt;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Считывает ini-файл и инициализирует плейлисты
|
* Загружает ini-файл и инициализирует плейлисты
|
||||||
*
|
*
|
||||||
* @return array
|
* @param string $filepath
|
||||||
|
* @throws FileReadException
|
||||||
|
* @throws IniParsingException
|
||||||
*/
|
*/
|
||||||
public function load(): array
|
public function __construct(
|
||||||
{
|
protected string $filepath,
|
||||||
$filepath = config_path('playlists.ini');
|
) {
|
||||||
$this->playlists = parse_ini_file($filepath, true);
|
try {
|
||||||
$this->updatedAt = date('d.m.Y h:i', filemtime($filepath));
|
$content = file_get_contents($this->filepath);
|
||||||
|
} catch (Throwable) {
|
||||||
|
$content = false;
|
||||||
|
}
|
||||||
|
|
||||||
return $this->playlists;
|
$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 array[]
|
* @param TKey $code
|
||||||
* @throws Exception
|
* @return TValue
|
||||||
*/
|
|
||||||
public function getPlaylists(array $plsCodes = []): array
|
|
||||||
{
|
|
||||||
$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 PlaylistNotFoundException
|
||||||
* @throws Exception
|
|
||||||
*/
|
*/
|
||||||
public function getPlaylist(string $code): ?array
|
public function playlist(string $code): array
|
||||||
{
|
{
|
||||||
empty($this->playlists) && $this->load();
|
return $this->playlists[$code] ?? throw new PlaylistNotFoundException($code);
|
||||||
$data = redis()->get($code);
|
|
||||||
|
|
||||||
return $this->initPlaylist($code, $data);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -84,107 +84,50 @@ class IniFile
|
|||||||
*/
|
*/
|
||||||
public function updatedAt(): string
|
public function updatedAt(): string
|
||||||
{
|
{
|
||||||
return $this->updatedAt;
|
return date('d.m.Y h:i', $this->updatedAt);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Подготавливает данные о плейлисте в расширенном формате
|
* @inheritDoc
|
||||||
*
|
* @param non-falsy-string $offset
|
||||||
* @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['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;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Проверяет наличие токенов в плейлисте
|
|
||||||
*
|
|
||||||
* Сделано именно так, а не через тег unstable, чтобы разделить логику: есть заведомо нестабильные каналы,
|
|
||||||
* которые могут не транслироваться круглосуточно, а есть платные круглосуточные, которые могут оборваться
|
|
||||||
* в любой момент.
|
|
||||||
*
|
|
||||||
* @param array $data
|
|
||||||
* @return bool
|
* @return bool
|
||||||
*/
|
*/
|
||||||
protected function hasTokens(array $data): bool
|
#[Override]
|
||||||
|
public function offsetExists(mixed $offset): bool
|
||||||
{
|
{
|
||||||
$string = ($data['url'] ?? '') . ($data['content'] ?? '');
|
return isset($this->playlists[$offset]);
|
||||||
if (empty($string)) {
|
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$badAttributes = [
|
/**
|
||||||
// токены и ключи
|
* @inheritDoc
|
||||||
'[?&]token=',
|
* @param TKey $offset
|
||||||
'[?&]drmreq=',
|
* @return TPlaylistDefinition
|
||||||
// логины
|
* @throws PlaylistNotFoundException
|
||||||
'[?&]u=',
|
*/
|
||||||
'[?&]user=',
|
#[Override]
|
||||||
'[?&]username=',
|
public function offsetGet(mixed $offset): array
|
||||||
// пароли
|
{
|
||||||
'[?&]p=',
|
return $this->playlist($offset);
|
||||||
'[?&]pwd=',
|
}
|
||||||
'[?&]password=',
|
|
||||||
// неизвестные
|
|
||||||
// 'free=true',
|
|
||||||
// 'uid=',
|
|
||||||
// 'c_uniq_tag=',
|
|
||||||
// 'rlkey=',
|
|
||||||
// '?s=',
|
|
||||||
// '&s=',
|
|
||||||
// '?q=',
|
|
||||||
// '&q=',
|
|
||||||
];
|
|
||||||
|
|
||||||
return array_any(
|
/**
|
||||||
$badAttributes,
|
* @inheritDoc
|
||||||
static fn (string $badAttribute) => preg_match_all("/{$badAttribute}/", $string) >= 1,
|
* @param TKey $offset
|
||||||
);
|
* @param TValue $value
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
#[Override]
|
||||||
|
public function offsetSet(mixed $offset, mixed $value): void
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @inheritDoc
|
||||||
|
* @param non-falsy-string $offset
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
#[Override]
|
||||||
|
public function offsetUnset(mixed $offset): void
|
||||||
|
{
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Copyright (c) 2025, Антон Аксенов
|
* Copyright (c) 2025, Антон Аксенов
|
||||||
* This file is part of m3u.su project
|
* This file is part of m3u.su project
|
||||||
@@ -31,6 +30,11 @@ final class Kernel
|
|||||||
*/
|
*/
|
||||||
public const string VERSION = '1.0.0';
|
public const string VERSION = '1.0.0';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var Kernel
|
||||||
|
*/
|
||||||
|
private static Kernel $instance;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @var App
|
* @var App
|
||||||
*/
|
*/
|
||||||
@@ -51,11 +55,6 @@ final class Kernel
|
|||||||
*/
|
*/
|
||||||
protected ?Redis $cache = null;
|
protected ?Redis $cache = null;
|
||||||
|
|
||||||
/**
|
|
||||||
* @var Kernel
|
|
||||||
*/
|
|
||||||
private static Kernel $instance;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Закрытый конструктор
|
* Закрытый конструктор
|
||||||
*
|
*
|
||||||
@@ -76,73 +75,11 @@ final class Kernel
|
|||||||
*
|
*
|
||||||
* @return Kernel
|
* @return Kernel
|
||||||
*/
|
*/
|
||||||
public static function instance(): self
|
public static function instance(): Kernel
|
||||||
{
|
{
|
||||||
return self::$instance ??= new 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
|
* Загружает файл .env или .env.$env
|
||||||
*
|
*
|
||||||
@@ -151,13 +88,12 @@ final class Kernel
|
|||||||
*/
|
*/
|
||||||
protected function loadDotEnvFile(string $env = ''): array
|
protected function loadDotEnvFile(string $env = ''): array
|
||||||
{
|
{
|
||||||
$filename = empty($env) ? '.env' : ".env.{$env}";
|
$filename = empty($env) ? '.env' : ".env.$env";
|
||||||
if (!file_exists(root_path($filename))) {
|
if (!file_exists(root_path($filename))) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
$dotenv = Dotenv::createMutable(root_path(), $filename);
|
$dotenv = Dotenv::createMutable(root_path(), $filename);
|
||||||
|
|
||||||
return $dotenv->safeLoad();
|
return $dotenv->safeLoad();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -202,7 +138,7 @@ final class Kernel
|
|||||||
default => throw new InvalidArgumentException(sprintf('Неверный HTTP метод %s', $method))
|
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'])) {
|
if (!empty($route['name'])) {
|
||||||
@@ -227,4 +163,65 @@ final class Kernel
|
|||||||
$twig->addExtension(new DebugExtension());
|
$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'));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
216
app/Core/Playlist.php
Normal file
216
app/Core/Playlist.php
Normal file
@@ -0,0 +1,216 @@
|
|||||||
|
<?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,5 +1,4 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Copyright (c) 2025, Антон Аксенов
|
* Copyright (c) 2025, Антон Аксенов
|
||||||
* This file is part of m3u.su project
|
* This file is part of m3u.su project
|
||||||
@@ -30,36 +29,7 @@ class StatisticsService
|
|||||||
$this->channels = $this->getAllChannels();
|
$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(
|
return array_filter(
|
||||||
$this->playlists,
|
$this->playlists,
|
||||||
@@ -115,7 +85,7 @@ class StatisticsService
|
|||||||
return count($this->channels);
|
return count($this->channels);
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function getChannelsByField(string $field, bool|int|string|null $value): array
|
protected function getChannelsByField(string $field, int|string|bool|null $value): array
|
||||||
{
|
{
|
||||||
return array_filter(
|
return array_filter(
|
||||||
$this->channels,
|
$this->channels,
|
||||||
@@ -130,4 +100,33 @@ class StatisticsService
|
|||||||
static fn (array $channel) => in_array($tag, $channel['tags']),
|
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,5 +1,4 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Copyright (c) 2025, Антон Аксенов
|
* Copyright (c) 2025, Антон Аксенов
|
||||||
* This file is part of m3u.su project
|
* This file is part of m3u.su project
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Copyright (c) 2025, Антон Аксенов
|
* Copyright (c) 2025, Антон Аксенов
|
||||||
* This file is part of m3u.su project
|
* This file is part of m3u.su project
|
||||||
@@ -8,10 +7,11 @@
|
|||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace App\Errors;
|
namespace App\Exceptions;
|
||||||
|
|
||||||
use Psr\Http\Message\ResponseInterface;
|
use Psr\Http\Message\{
|
||||||
use Psr\Http\Message\ServerRequestInterface;
|
ResponseInterface,
|
||||||
|
ServerRequestInterface};
|
||||||
use Psr\Log\LoggerInterface;
|
use Psr\Log\LoggerInterface;
|
||||||
use Slim\Handlers\ErrorHandler as SlimErrorHandler;
|
use Slim\Handlers\ErrorHandler as SlimErrorHandler;
|
||||||
use Throwable;
|
use Throwable;
|
||||||
@@ -19,8 +19,35 @@ use Throwable;
|
|||||||
/**
|
/**
|
||||||
* Обработчик ошибок
|
* Обработчик ошибок
|
||||||
*/
|
*/
|
||||||
class ErrorHandler extends SlimErrorHandler
|
class ExceptionHandler 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;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Возвращает структуру исключения для контекста
|
* Возвращает структуру исключения для контекста
|
||||||
*
|
*
|
||||||
@@ -36,7 +63,7 @@ class ErrorHandler extends SlimErrorHandler
|
|||||||
'class' => $e::class,
|
'class' => $e::class,
|
||||||
'file' => $e->getFile(),
|
'file' => $e->getFile(),
|
||||||
'line' => $e->getLine(),
|
'line' => $e->getLine(),
|
||||||
'trace' => $e->getTrace(),
|
'trace' => $e->getTrace()
|
||||||
];
|
];
|
||||||
|
|
||||||
return $result;
|
return $result;
|
||||||
@@ -67,31 +94,4 @@ class ErrorHandler extends SlimErrorHandler
|
|||||||
|
|
||||||
return $result;
|
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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
20
app/Exceptions/FileNotFoundException.php
Normal file
20
app/Exceptions/FileNotFoundException.php
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
<?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);
|
||||||
|
}
|
||||||
|
}
|
||||||
20
app/Exceptions/FileReadException.php
Normal file
20
app/Exceptions/FileReadException.php
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
<?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);
|
||||||
|
}
|
||||||
|
}
|
||||||
20
app/Exceptions/IniParsingException.php
Normal file
20
app/Exceptions/IniParsingException.php
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
<?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,5 +1,4 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Copyright (c) 2025, Антон Аксенов
|
* Copyright (c) 2025, Антон Аксенов
|
||||||
* This file is part of m3u.su project
|
* This file is part of m3u.su project
|
||||||
@@ -8,7 +7,7 @@
|
|||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace App\Errors;
|
namespace App\Exceptions;
|
||||||
|
|
||||||
use Exception;
|
use Exception;
|
||||||
|
|
||||||
@@ -16,6 +15,6 @@ class InvalidTelegramSecretException extends Exception
|
|||||||
{
|
{
|
||||||
public function __construct()
|
public function __construct()
|
||||||
{
|
{
|
||||||
parent::__construct('Ошибка валидации запроса от Telegram Bot API');
|
parent::__construct("Ошибка валидации запроса от Telegram Bot API");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -8,7 +8,7 @@
|
|||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace App\Errors;
|
namespace App\Exceptions;
|
||||||
|
|
||||||
use Exception;
|
use Exception;
|
||||||
|
|
||||||
20
app/Exceptions/PlaylistWithoutUrlException.php
Normal file
20
app/Exceptions/PlaylistWithoutUrlException.php
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
<?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('as_data_url')) {
|
if (!function_exists('data_stream')) {
|
||||||
/**
|
/**
|
||||||
* Создает data URL для данных
|
* Создает data URL для данных
|
||||||
*
|
*
|
||||||
@@ -385,7 +385,7 @@ if (!function_exists('as_data_url')) {
|
|||||||
* @param string $mimeType MIME-тип данных
|
* @param string $mimeType MIME-тип данных
|
||||||
* @return string Data URL
|
* @return string Data URL
|
||||||
*/
|
*/
|
||||||
function as_data_url(string $data, string $mimeType = 'text/plain'): string
|
function data_stream(string $data, string $mimeType = 'text/plain'): string
|
||||||
{
|
{
|
||||||
return "data://{$mimeType},{$data}";
|
return "data://{$mimeType},{$data}";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,6 +44,11 @@
|
|||||||
"app/helpers.php"
|
"app/helpers.php"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"autoload-dev": {
|
||||||
|
"psr-4": {
|
||||||
|
"Tests\\": "tests/"
|
||||||
|
}
|
||||||
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"clear-views": "rm -rf cache/views",
|
"clear-views": "rm -rf cache/views",
|
||||||
"post-install-cmd": [
|
"post-install-cmd": [
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Copyright (c) 2025, Антон Аксенов
|
* Copyright (c) 2025, Антон Аксенов
|
||||||
* This file is part of m3u.su project
|
* This file is part of m3u.su project
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Copyright (c) 2025, Антон Аксенов
|
* Copyright (c) 2025, Антон Аксенов
|
||||||
* This file is part of m3u.su project
|
* This file is part of m3u.su project
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Copyright (c) 2025, Антон Аксенов
|
* Copyright (c) 2025, Антон Аксенов
|
||||||
* This file is part of m3u.su project
|
* This file is part of m3u.su project
|
||||||
|
|||||||
@@ -1,6 +1,4 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
/*
|
/*
|
||||||
* Copyright (c) 2025, Антон Аксенов
|
* Copyright (c) 2025, Антон Аксенов
|
||||||
* This file is part of m3u.su project
|
* This file is part of m3u.su project
|
||||||
@@ -13,6 +11,7 @@ use App\Controllers\BotController;
|
|||||||
use App\Controllers\WebController;
|
use App\Controllers\WebController;
|
||||||
|
|
||||||
return [
|
return [
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|--------------------------------------------------------------------------
|
|--------------------------------------------------------------------------
|
||||||
| Web routes
|
| Web routes
|
||||||
@@ -100,3 +99,4 @@ return [
|
|||||||
'name' => 'not-found',
|
'name' => 'not-found',
|
||||||
],
|
],
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Copyright (c) 2025, Антон Аксенов
|
* Copyright (c) 2025, Антон Аксенов
|
||||||
* This file is part of m3u.su project
|
* This file is part of m3u.su project
|
||||||
|
|||||||
@@ -1,26 +0,0 @@
|
|||||||
[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 = *
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
[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
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
[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
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
[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,12 +7,15 @@
|
|||||||
|
|
||||||
# shellcheck disable=SC2015
|
# shellcheck disable=SC2015
|
||||||
|
|
||||||
|
# set -x
|
||||||
|
# set -o pipefail
|
||||||
|
|
||||||
########################################################
|
########################################################
|
||||||
# Служебные исходные переменные
|
# Служебные исходные переменные
|
||||||
########################################################
|
########################################################
|
||||||
|
|
||||||
# имя контейнера
|
# имя контейнера
|
||||||
CONTAINER="iptv-web"
|
CONTAINER="m3u-su-web"
|
||||||
|
|
||||||
# команда для запуска
|
# команда для запуска
|
||||||
COMMAND="$1"; shift
|
COMMAND="$1"; shift
|
||||||
@@ -27,6 +30,7 @@ IS_FROM_GIT="$(env | grep -c "GIT_EDITOR=:")"
|
|||||||
[[ -f /.dockerenv ]] && IS_FROM_CONTAINER=1 || IS_FROM_CONTAINER=0
|
[[ -f /.dockerenv ]] && IS_FROM_CONTAINER=1 || IS_FROM_CONTAINER=0
|
||||||
|
|
||||||
# признак режима отладки
|
# признак режима отладки
|
||||||
|
[[ $LINTER_DEBUG == 1 ]] && DEBUG_MODE=1
|
||||||
[[ $LINTER_DEBUG -gt 1 ]] && set -x
|
[[ $LINTER_DEBUG -gt 1 ]] && set -x
|
||||||
|
|
||||||
########################################################
|
########################################################
|
||||||
@@ -38,11 +42,13 @@ LINTER_COLORS=${LINTER_COLORS:-$CAN_USE_COLORS}
|
|||||||
[[ "$LINTER_COLORS" == 1 ]] && FRESET="$(tput sgr0)" || FRESET=''
|
[[ "$LINTER_COLORS" == 1 ]] && FRESET="$(tput sgr0)" || FRESET=''
|
||||||
[[ "$LINTER_COLORS" == 1 ]] && FBOLD="$(tput bold)" || FBOLD=''
|
[[ "$LINTER_COLORS" == 1 ]] && FBOLD="$(tput bold)" || FBOLD=''
|
||||||
[[ "$LINTER_COLORS" == 1 ]] && FDIM="$(tput dim)" || FDIM=''
|
[[ "$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 ]] && FRED="$(tput setaf 1)" || FRED=''
|
||||||
[[ "$LINTER_COLORS" == 1 ]] && FWHITE="$(tput setaf 7)" || FWHITE=''
|
[[ "$LINTER_COLORS" == 1 ]] && FWHITE="$(tput setaf 7)" || FWHITE=''
|
||||||
[[ "$LINTER_COLORS" == 1 ]] && FGREEN="$(tput setaf 2)" || FGREEN=''
|
[[ "$LINTER_COLORS" == 1 ]] && FGREEN="$(tput setaf 2)" || FGREEN=''
|
||||||
[[ "$LINTER_COLORS" == 1 ]] && FBRED="$(tput setab 1)" || FBRED=''
|
[[ "$LINTER_COLORS" == 1 ]] && FBRED="$(tput setab 1)" || FBRED=''
|
||||||
[[ "$LINTER_COLORS" == 1 ]] && FBYELLOW="$(tput setab 3)" || FBYELLOW=''
|
[[ "$LINTER_COLORS" == 1 ]] && FBYELLOW="$(tput setab 3)" || FBYELLOW=''
|
||||||
|
[[ "$LINTER_COLORS" == 1 ]] && FBLYELLOW="$(tput setab 11)" || FBLYELLOW=''
|
||||||
|
|
||||||
print() {
|
print() {
|
||||||
echo -e "$*${FRESET}"
|
echo -e "$*${FRESET}"
|
||||||
@@ -156,18 +162,6 @@ install() {
|
|||||||
error "Pre-commit хук НЕ установлен"
|
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() {
|
style() {
|
||||||
title "[php-cs-fixer] Запущена проверка код-стайла"
|
title "[php-cs-fixer] Запущена проверка код-стайла"
|
||||||
@@ -419,7 +413,6 @@ fi
|
|||||||
case "$COMMAND" in
|
case "$COMMAND" in
|
||||||
h|help ) help "$1" ;;
|
h|help ) help "$1" ;;
|
||||||
i|install ) install ;;
|
i|install ) install ;;
|
||||||
r|remove ) remove ;;
|
|
||||||
s|style ) style "$@" ;;
|
s|style ) style "$@" ;;
|
||||||
f|fix ) fix "$@" ;;
|
f|fix ) fix "$@" ;;
|
||||||
p|phpcs ) phpcs "$@" ;;
|
p|phpcs ) phpcs "$@" ;;
|
||||||
|
|||||||
@@ -45,4 +45,4 @@ parameters:
|
|||||||
|
|
||||||
# требует явно расписывать все итерируемые типы, структуры полей и т.д.
|
# требует явно расписывать все итерируемые типы, структуры полей и т.д.
|
||||||
# можно раскомментировать для уточнения типов при разработке, но убирать пока рано
|
# можно раскомментировать для уточнения типов при разработке, но убирать пока рано
|
||||||
# - identifier: missingType.iterableValue
|
- identifier: missingType.iterableValue
|
||||||
|
|||||||
@@ -1,13 +0,0 @@
|
|||||||
<?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>
|
|
||||||
|
Before Width: | Height: | Size: 735 B |
@@ -1,5 +1,4 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Copyright (c) 2025, Антон Аксенов
|
* Copyright (c) 2025, Антон Аксенов
|
||||||
* This file is part of m3u.su project
|
* This file is part of m3u.su project
|
||||||
|
|||||||
340
tests/BaseTestCase.php
Normal file
340
tests/BaseTestCase.php
Normal file
@@ -0,0 +1,340 @@
|
|||||||
|
<?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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
37
tests/Controllers/ApiControllerTest.php
Normal file
37
tests/Controllers/ApiControllerTest.php
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
<?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()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
113
tests/Core/IniFileTest.php
Normal file
113
tests/Core/IniFileTest.php
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
<?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);
|
||||||
|
}
|
||||||
|
}
|
||||||
111
tests/Core/PlaylistTest.php
Normal file
111
tests/Core/PlaylistTest.php
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
<?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();
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
109
tests/FixtureHandler.php
Normal file
109
tests/FixtureHandler.php
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
<?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;
|
||||||
|
}
|
||||||
|
}
|
||||||
5
tests/Fixtures/broken.ini
Normal file
5
tests/Fixtures/broken.ini
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
n]
|
||||||
|
name=
|
||||||
|
desc=
|
||||||
|
url=
|
||||||
|
src=
|
||||||
11
tests/Fixtures/playlists.ini
Normal file
11
tests/Fixtures/playlists.ini
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
[p1]
|
||||||
|
name=
|
||||||
|
desc=
|
||||||
|
url=
|
||||||
|
src=
|
||||||
|
|
||||||
|
[z2]
|
||||||
|
name=
|
||||||
|
desc=
|
||||||
|
url=
|
||||||
|
src=
|
||||||
@@ -10,8 +10,8 @@
|
|||||||
<title>{% block title %}{{ config('app.title') }}{% endblock %}</title>
|
<title>{% block title %}{{ config('app.title') }}{% endblock %}</title>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="description" content="{% block metadescription %}{% endblock %}">
|
<meta name="description" content="{% block metadescription %}{% endblock %}">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
||||||
<meta name="keywords" content="{% block metakeywords %}{% endblock %}" />
|
<meta name="keywords" content="{% block metakeywords %}{% endblock %}" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<meta http-equiv="x-ua-compatible" content="ie=edge">
|
<meta http-equiv="x-ua-compatible" content="ie=edge">
|
||||||
<style>.cursor-pointer{cursor:pointer}.cursor-help{cursor:help}</style>
|
<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>
|
<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/png" sizes="16x16" href="/favicon/favicon-16x16.png" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/favicon/favicon.svg" />
|
<link rel="icon" type="image/svg+xml" href="/favicon/favicon.svg" />
|
||||||
<link rel="apple-touch-icon" sizes="180x180" href="/favicon/apple-touch-icon.png" />
|
<link rel="apple-touch-icon" sizes="180x180" href="/favicon/apple-touch-icon.png" />
|
||||||
<meta name="apple-mobile-web-app-title" content="IPTV Плейлисты" />
|
<meta name="apple-mobile-web-app-title" content="{{ config('app.title') }}" />
|
||||||
<link rel="manifest" href="/favicon/site.webmanifest" />
|
<link rel="manifest" href="/favicon/site.webmanifest" />
|
||||||
<meta name="msapplication-TileColor" content="#00aba9">
|
<meta name="msapplication-TileColor" content="#00aba9">
|
||||||
<meta name="msapplication-TileImage" content="/favicon/mstile-150x150.png">
|
<meta name="msapplication-TileImage" content="/favicon/mstile-150x150.png">
|
||||||
|
|||||||
Reference in New Issue
Block a user