WIP
This commit is contained in:
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
|
||||||
|
|||||||
@@ -12,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;
|
||||||
@@ -38,7 +38,7 @@ 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']);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,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;
|
||||||
@@ -36,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');
|
||||||
@@ -45,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(),
|
||||||
@@ -75,7 +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);
|
||||||
@@ -98,7 +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` не найден");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Copyright (c) 2025, Антон Аксенов
|
* Copyright (c) 2025, Антон Аксенов
|
||||||
* This file is part of m3u.su project
|
* This file is part of m3u.su project
|
||||||
@@ -9,70 +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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -82,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
|
||||||
|
{
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -222,6 +222,6 @@ final class Kernel
|
|||||||
*/
|
*/
|
||||||
public function ini(): IniFile
|
public function ini(): IniFile
|
||||||
{
|
{
|
||||||
return $this->iniFile ??= new 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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,7 +7,7 @@
|
|||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace App\Errors;
|
namespace App\Exceptions;
|
||||||
|
|
||||||
use Psr\Http\Message\{
|
use Psr\Http\Message\{
|
||||||
ResponseInterface,
|
ResponseInterface,
|
||||||
@@ -19,7 +19,7 @@ use Throwable;
|
|||||||
/**
|
/**
|
||||||
* Обработчик ошибок
|
* Обработчик ошибок
|
||||||
*/
|
*/
|
||||||
class ErrorHandler extends SlimErrorHandler
|
class ExceptionHandler extends SlimErrorHandler
|
||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
* Логирует ошибку и отдаёт JSON-ответ с необходимым содержимым
|
* Логирует ошибку и отдаёт JSON-ответ с необходимым содержимым
|
||||||
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,7 +7,7 @@
|
|||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace App\Errors;
|
namespace App\Exceptions;
|
||||||
|
|
||||||
use Exception;
|
use Exception;
|
||||||
|
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Copyright (c) 2025, Антон Аксенов
|
* Copyright (c) 2025, Антон Аксенов
|
||||||
* This file is part of m3u.su project
|
* This file is part of m3u.su project
|
||||||
@@ -7,7 +8,7 @@
|
|||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace App\Errors;
|
namespace App\Exceptions;
|
||||||
|
|
||||||
use Exception;
|
use Exception;
|
||||||
|
|
||||||
@@ -15,6 +16,6 @@ class PlaylistNotFoundException extends Exception
|
|||||||
{
|
{
|
||||||
public function __construct(string $code)
|
public function __construct(string $code)
|
||||||
{
|
{
|
||||||
parent::__construct("Плейлист '$code' не найден");
|
parent::__construct("Плейлист '{$code}' не найден");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Copyright (c) 2025, Антон Аксенов
|
* Copyright (c) 2025, Антон Аксенов
|
||||||
* This file is part of m3u.su project
|
* This file is part of m3u.su project
|
||||||
@@ -17,8 +18,8 @@ if (!function_exists('root_path')) {
|
|||||||
/**
|
/**
|
||||||
* Возвращает абсолютный путь к корневой директории приложения.
|
* Возвращает абсолютный путь к корневой директории приложения.
|
||||||
*
|
*
|
||||||
* @param string $path Относительный путь для добавления к корневому.
|
* @param string $path относительный путь для добавления к корневому
|
||||||
* @return string Абсолютный путь.
|
* @return string абсолютный путь
|
||||||
*/
|
*/
|
||||||
function root_path(string $path = ''): string
|
function root_path(string $path = ''): string
|
||||||
{
|
{
|
||||||
@@ -32,12 +33,12 @@ if (!function_exists('config_path')) {
|
|||||||
/**
|
/**
|
||||||
* Возвращает абсолютный путь к директории конфигурации приложения.
|
* Возвращает абсолютный путь к директории конфигурации приложения.
|
||||||
*
|
*
|
||||||
* @param string $path Относительный путь для добавления к директории конфигурации.
|
* @param string $path относительный путь для добавления к директории конфигурации
|
||||||
* @return string Абсолютный путь.
|
* @return string абсолютный путь
|
||||||
*/
|
*/
|
||||||
function config_path(string $path = ''): string
|
function config_path(string $path = ''): string
|
||||||
{
|
{
|
||||||
return root_path("config/$path");
|
return root_path("config/{$path}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -45,12 +46,12 @@ if (!function_exists('cache_path')) {
|
|||||||
/**
|
/**
|
||||||
* Возвращает абсолютный путь к директории кэша приложения.
|
* Возвращает абсолютный путь к директории кэша приложения.
|
||||||
*
|
*
|
||||||
* @param string $path Относительный путь для добавления к директории кэша.
|
* @param string $path относительный путь для добавления к директории кэша
|
||||||
* @return string Абсолютный путь.
|
* @return string абсолютный путь
|
||||||
*/
|
*/
|
||||||
function cache_path(string $path = ''): string
|
function cache_path(string $path = ''): string
|
||||||
{
|
{
|
||||||
return root_path("cache/$path");
|
return root_path("cache/{$path}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -58,8 +59,8 @@ if (!function_exists('base_url')) {
|
|||||||
/**
|
/**
|
||||||
* Возвращает базовый URL приложения.
|
* Возвращает базовый URL приложения.
|
||||||
*
|
*
|
||||||
* @param string $route Дополнительный маршрут, который будет добавлен к базовому URL.
|
* @param string $route дополнительный маршрут, который будет добавлен к базовому URL
|
||||||
* @return string Полный URL.
|
* @return string полный URL
|
||||||
*/
|
*/
|
||||||
function base_url(string $route = ''): string
|
function base_url(string $route = ''): string
|
||||||
{
|
{
|
||||||
@@ -71,7 +72,7 @@ if (!function_exists('kernel')) {
|
|||||||
/**
|
/**
|
||||||
* Возвращает синглтон-экземпляр ядра приложения.
|
* Возвращает синглтон-экземпляр ядра приложения.
|
||||||
*
|
*
|
||||||
* @return Kernel Экземпляр ядра приложения.
|
* @return Kernel экземпляр ядра приложения
|
||||||
*/
|
*/
|
||||||
function kernel(): Kernel
|
function kernel(): Kernel
|
||||||
{
|
{
|
||||||
@@ -83,7 +84,7 @@ if (!function_exists('app')) {
|
|||||||
/**
|
/**
|
||||||
* Возвращает синглтон-экземпляр Slim-приложения.
|
* Возвращает синглтон-экземпляр Slim-приложения.
|
||||||
*
|
*
|
||||||
* @return App Экземпляр Slim-приложения.
|
* @return App экземпляр Slim-приложения
|
||||||
*/
|
*/
|
||||||
function app(): App
|
function app(): App
|
||||||
{
|
{
|
||||||
@@ -95,9 +96,9 @@ if (!function_exists('config')) {
|
|||||||
/**
|
/**
|
||||||
* Возвращает значение из конфигурации приложения.
|
* Возвращает значение из конфигурации приложения.
|
||||||
*
|
*
|
||||||
* @param string $key Ключ конфигурации.
|
* @param string $key ключ конфигурации
|
||||||
* @param mixed $default Значение по умолчанию, если ключ не найден.
|
* @param mixed $default значение по умолчанию, если ключ не найден
|
||||||
* @return mixed Значение конфигурации или значение по умолчанию.
|
* @return mixed значение конфигурации или значение по умолчанию
|
||||||
*/
|
*/
|
||||||
function config(string $key, mixed $default = null): mixed
|
function config(string $key, mixed $default = null): mixed
|
||||||
{
|
{
|
||||||
@@ -109,7 +110,7 @@ if (!function_exists('redis')) {
|
|||||||
/**
|
/**
|
||||||
* Возвращает синглтон-экземпляр Redis-клиента.
|
* Возвращает синглтон-экземпляр Redis-клиента.
|
||||||
*
|
*
|
||||||
* @return Redis Экземпляр Redis-клиента.
|
* @return Redis экземпляр Redis-клиента
|
||||||
*/
|
*/
|
||||||
function redis(): Redis
|
function redis(): Redis
|
||||||
{
|
{
|
||||||
@@ -121,7 +122,7 @@ if (!function_exists('ini')) {
|
|||||||
/**
|
/**
|
||||||
* Возвращает синглтон-экземпляр IniFile.
|
* Возвращает синглтон-экземпляр IniFile.
|
||||||
*
|
*
|
||||||
* @return IniFile Экземпляр IniFile.
|
* @return IniFile экземпляр IniFile
|
||||||
*/
|
*/
|
||||||
function ini(): IniFile
|
function ini(): IniFile
|
||||||
{
|
{
|
||||||
@@ -273,11 +274,11 @@ if (!function_exists('env')) {
|
|||||||
/**
|
/**
|
||||||
* Возвращает значение переменной окружения.
|
* Возвращает значение переменной окружения.
|
||||||
*
|
*
|
||||||
* @param string $key Ключ переменной окружения.
|
* @param string $key ключ переменной окружения
|
||||||
* @param mixed $default Значение по умолчанию, если переменная не найдена.
|
* @param mixed $default значение по умолчанию, если переменная не найдена
|
||||||
* @param bool $required Бросать исключение, если переменная обязательна и не найдена.
|
* @param bool $required бросать исключение, если переменная обязательна и не найдена
|
||||||
* @return mixed Значение переменной окружения или значение по умолчанию.
|
* @return mixed значение переменной окружения или значение по умолчанию
|
||||||
* @throws InvalidArgumentException Если переменная обязательна и не найдена.
|
* @throws InvalidArgumentException если переменная обязательна и не найдена
|
||||||
*/
|
*/
|
||||||
function env(string $key, mixed $default = null, bool $required = false): mixed
|
function env(string $key, mixed $default = null, bool $required = false): mixed
|
||||||
{
|
{
|
||||||
@@ -311,7 +312,7 @@ if (!function_exists('here')) {
|
|||||||
* @param bool $asArray массив или строка в формате `<file|class>:<func>():<line>`
|
* @param bool $asArray массив или строка в формате `<file|class>:<func>():<line>`
|
||||||
* @return string|array
|
* @return string|array
|
||||||
*/
|
*/
|
||||||
function here(bool $asArray = false): string|array
|
function here(bool $asArray = false): array|string
|
||||||
{
|
{
|
||||||
$trace = debug_backtrace(!DEBUG_BACKTRACE_PROVIDE_OBJECT | DEBUG_BACKTRACE_IGNORE_ARGS, 2);
|
$trace = debug_backtrace(!DEBUG_BACKTRACE_PROVIDE_OBJECT | DEBUG_BACKTRACE_IGNORE_ARGS, 2);
|
||||||
|
|
||||||
@@ -376,7 +377,7 @@ if (!function_exists('snake2camel')) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!function_exists('as_data_url')) {
|
if (!function_exists('data_stream')) {
|
||||||
/**
|
/**
|
||||||
* Создает data URL для данных
|
* Создает data URL для данных
|
||||||
*
|
*
|
||||||
@@ -384,9 +385,9 @@ 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}";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -473,7 +474,7 @@ if (!function_exists('number_format_local')) {
|
|||||||
* @return string Отформатированное число
|
* @return string Отформатированное число
|
||||||
*/
|
*/
|
||||||
function number_format_local(
|
function number_format_local(
|
||||||
int|float $number,
|
float|int $number,
|
||||||
int $decimals = 0,
|
int $decimals = 0,
|
||||||
string $decPoint = '.',
|
string $decPoint = '.',
|
||||||
string $thousandsSep = ' ',
|
string $thousandsSep = ' ',
|
||||||
@@ -494,7 +495,7 @@ if (!function_exists('format_bytes')) {
|
|||||||
{
|
{
|
||||||
$units = ['Б', 'КБ', 'МБ', 'ГБ', 'ТБ'];
|
$units = ['Б', 'КБ', 'МБ', 'ГБ', 'ТБ'];
|
||||||
|
|
||||||
for ($i = 0; $bytes > 1024 && $i < count($units) - 1; $i++) {
|
for ($i = 0; $bytes > 1024 && $i < count($units) - 1; ++$i) {
|
||||||
$bytes /= 1024;
|
$bytes /= 1024;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -581,11 +582,12 @@ if (!function_exists('get_noun_form')) {
|
|||||||
|
|
||||||
if ($lastDigit === 1) {
|
if ($lastDigit === 1) {
|
||||||
return $form1;
|
return $form1;
|
||||||
} elseif ($lastDigit >= 2 && $lastDigit <= 4) {
|
|
||||||
return $form2;
|
|
||||||
} else {
|
|
||||||
return $form5;
|
|
||||||
}
|
}
|
||||||
|
if ($lastDigit >= 2 && $lastDigit <= 4) {
|
||||||
|
return $form2;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $form5;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -605,7 +607,7 @@ if (!function_exists('random_string')) {
|
|||||||
$result = '';
|
$result = '';
|
||||||
$max = strlen($chars) - 1;
|
$max = strlen($chars) - 1;
|
||||||
|
|
||||||
for ($i = 0; $i < $length; $i++) {
|
for ($i = 0; $i < $length; ++$i) {
|
||||||
$result .= $chars[random_int(0, $max)];
|
$result .= $chars[random_int(0, $max)];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -702,7 +704,7 @@ if (!function_exists('recast')) {
|
|||||||
function recast(string $className, stdClass &$object): mixed
|
function recast(string $className, stdClass &$object): mixed
|
||||||
{
|
{
|
||||||
if (!class_exists($className)) {
|
if (!class_exists($className)) {
|
||||||
throw new InvalidArgumentException("Class not found: $className");
|
throw new InvalidArgumentException("Class not found: {$className}");
|
||||||
}
|
}
|
||||||
|
|
||||||
$new = new $className();
|
$new = new $className();
|
||||||
@@ -769,7 +771,7 @@ if (!function_exists('mb_count_chars')) {
|
|||||||
for ($i = 0; $i < $length; ++$i) {
|
for ($i = 0; $i < $length; ++$i) {
|
||||||
$char = mb_substr($string, $i, 1, 'UTF-8');
|
$char = mb_substr($string, $i, 1, 'UTF-8');
|
||||||
!array_key_exists($char, $unique) && $unique[$char] = 0;
|
!array_key_exists($char, $unique) && $unique[$char] = 0;
|
||||||
$unique[$char]++;
|
++$unique[$char];
|
||||||
}
|
}
|
||||||
|
|
||||||
return $unique;
|
return $unique;
|
||||||
@@ -853,7 +855,7 @@ if (!function_exists('array_last')) {
|
|||||||
return $array[$lastKey];
|
return $array[$lastKey];
|
||||||
}
|
}
|
||||||
|
|
||||||
for ($i = count($keys) - 1; $i >= 0; $i--) {
|
for ($i = count($keys) - 1; $i >= 0; --$i) {
|
||||||
$key = $keys[$i];
|
$key = $keys[$i];
|
||||||
if ($callback($array[$key], $key)) {
|
if ($callback($array[$key], $key)) {
|
||||||
return $array[$key];
|
return $array[$key];
|
||||||
@@ -916,7 +918,7 @@ if (!function_exists('array_get')) {
|
|||||||
* @param mixed $default Значение по умолчанию
|
* @param mixed $default Значение по умолчанию
|
||||||
* @return mixed Значение из массива или значение по умолчанию
|
* @return mixed Значение из массива или значение по умолчанию
|
||||||
*/
|
*/
|
||||||
function array_get(array $array, string|int $key, mixed $default = null): mixed
|
function array_get(array $array, int|string $key, mixed $default = null): mixed
|
||||||
{
|
{
|
||||||
return $array[$key] ?? $default;
|
return $array[$key] ?? $default;
|
||||||
}
|
}
|
||||||
@@ -1118,7 +1120,7 @@ if (!function_exists('array_recursive_diff')) {
|
|||||||
$aReturn[$key] = $aRecursiveDiff;
|
$aReturn[$key] = $aRecursiveDiff;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if ($value != $b[$key]) {
|
if ($value !== $b[$key]) {
|
||||||
$aReturn[$key] = $value;
|
$aReturn[$key] = $value;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1400,7 +1402,7 @@ if (!function_exists('flatten')) {
|
|||||||
*
|
*
|
||||||
* @param array $tree Дерево (например, результат функции tree())
|
* @param array $tree Дерево (например, результат функции tree())
|
||||||
* @param string $branching Ключ ноды, под которым находится массив с дочерними нодами
|
* @param string $branching Ключ ноды, под которым находится массив с дочерними нодами
|
||||||
* @param null|string $leafProperty Ключ ноды, значение коего определяет является ли каждая нода родителем
|
* @param string|null $leafProperty Ключ ноды, значение коего определяет является ли каждая нода родителем
|
||||||
* @return array Плоский список
|
* @return array Плоский список
|
||||||
*/
|
*/
|
||||||
function flatten(
|
function flatten(
|
||||||
@@ -1459,8 +1461,8 @@ if (!function_exists('clear_tree')) {
|
|||||||
*
|
*
|
||||||
* @param array $node Нода, которая должна быть обработана
|
* @param array $node Нода, которая должна быть обработана
|
||||||
* @param string $branching Ключ ноды, под которым находится массив с дочерними нодами
|
* @param string $branching Ключ ноды, под которым находится массив с дочерними нодами
|
||||||
* @param null|string $leafProperty Ключ ноды, значение коего определяет является ли каждая нода родителем
|
* @param string|null $leafProperty Ключ ноды, значение коего определяет является ли каждая нода родителем
|
||||||
* @return null|array Обработанная нода с хотя бы одним потомком либо null
|
* @return array|null Обработанная нода с хотя бы одним потомком либо null
|
||||||
*/
|
*/
|
||||||
function clear_tree(
|
function clear_tree(
|
||||||
array $node,
|
array $node,
|
||||||
|
|||||||
@@ -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": [
|
||||||
|
|||||||
@@ -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 |
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