2 Commits

Author SHA1 Message Date
aabad9d744 Линтовка 2026-01-03 01:12:18 +08:00
ddc4374dd6 Организация docker-окружения 2026-01-03 01:11:59 +08:00
44 changed files with 486 additions and 1271 deletions

9
.dockerignore Normal file
View File

@@ -0,0 +1,9 @@
/docker
/.git
/.idea
/.vscode
.phpunit.result.cache
.php-cs-fixer.cache
*.cache
*.log

2
.gitignore vendored
View File

@@ -8,7 +8,7 @@
.env
.env.*
!.env.example
config/playlists.ini
playlists.ini
channels.json
.phpunit.result.cache
.php-cs-fixer.cache

View File

@@ -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
View 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

View File

@@ -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;
}

View File

@@ -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);
}
}

View File

@@ -1,4 +1,5 @@
<?php
/*
* Copyright (c) 2025, Антон Аксенов
* This file is part of m3u.su project

View File

@@ -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);

View File

@@ -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` не найден");
}

View File

@@ -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;
}
$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;
}
/**
* Возвращает определение плейлиста по его коду
*
* @param TKey $code
* @return TValue
* @throws PlaylistNotFoundException
*/
public function playlist(string $code): array
public function load(): array
{
return $this->playlists[$code] ?? throw new PlaylistNotFoundException($code);
$filepath = config_path('playlists.ini');
$this->playlists = parse_ini_file($filepath, true);
$this->updatedAt = date('d.m.Y h:i', filemtime($filepath));
return $this->playlists;
}
/**
* Возвращает плейлисты
*
* @return array[]
* @throws Exception
*/
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 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,
);
}
}

View File

@@ -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'));
}
}

View File

@@ -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,
);
}
}

View File

@@ -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')),
],
];
}
}

View File

@@ -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)

View File

@@ -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;
}
}

View File

@@ -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');
}
}

View File

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

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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");
}
}

View File

@@ -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}";
}

View File

@@ -44,11 +44,6 @@
"app/helpers.php"
]
},
"autoload-dev": {
"psr-4": {
"Tests\\": "tests/"
}
},
"scripts": {
"clear-views": "rm -rf cache/views",
"post-install-cmd": [

View File

@@ -1,4 +1,5 @@
<?php
/*
* Copyright (c) 2025, Антон Аксенов
* This file is part of m3u.su project

View File

@@ -1,4 +1,5 @@
<?php
/*
* Copyright (c) 2025, Антон Аксенов
* This file is part of m3u.su project

View File

@@ -1,4 +1,5 @@
<?php
/*
* Copyright (c) 2025, Антон Аксенов
* This file is part of m3u.su project

View File

@@ -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',
],
];

View File

@@ -1,4 +1,5 @@
<?php
/*
* Copyright (c) 2025, Антон Аксенов
* This file is part of m3u.su project

26
docker/dev/php.ini Normal file
View 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
View 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
View 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
View 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
View File

@@ -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 "$@" ;;

View File

@@ -45,4 +45,4 @@ parameters:
# требует явно расписывать все итерируемые типы, структуры полей и т.д.
# можно раскомментировать для уточнения типов при разработке, но убирать пока рано
- identifier: missingType.iterableValue
# - identifier: missingType.iterableValue

13
public/boosty.svg Normal file
View File

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

After

Width:  |  Height:  |  Size: 735 B

View File

@@ -1,4 +1,5 @@
<?php
/*
* Copyright (c) 2025, Антон Аксенов
* This file is part of m3u.su project

View File

@@ -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);
}
}
}

View File

@@ -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()
{
}
}

View File

@@ -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);
}
}

View File

@@ -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();
}
}

View File

@@ -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;
}
}

View File

@@ -1,5 +0,0 @@
n]
name=
desc=
url=
src=

View File

@@ -1,11 +0,0 @@
[p1]
name=
desc=
url=
src=
[z2]
name=
desc=
url=
src=

View File

@@ -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">