WIP
This commit is contained in:
@@ -9,8 +9,8 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Core;
|
||||
|
||||
use App\Errors\InvalidTelegramSecretException;
|
||||
use App\Errors\PlaylistNotFoundException;
|
||||
use App\Exceptions\InvalidTelegramSecretException;
|
||||
use App\Exceptions\PlaylistNotFoundException;
|
||||
use DateTimeImmutable;
|
||||
use Exception;
|
||||
use JsonException;
|
||||
@@ -168,7 +168,7 @@ class Bot
|
||||
}
|
||||
|
||||
try {
|
||||
$pls = ini()->getPlaylist($code);
|
||||
$pls = ini()->playlist($code);
|
||||
} catch (PlaylistNotFoundException) {
|
||||
return $this->reply("Плейлист `$code` не найден");
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* Copyright (c) 2025, Антон Аксенов
|
||||
* This file is part of m3u.su project
|
||||
@@ -9,70 +10,71 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Core;
|
||||
|
||||
use App\Errors\PlaylistNotFoundException;
|
||||
use Exception;
|
||||
use App\Exceptions\FileReadException;
|
||||
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;
|
||||
|
||||
/**
|
||||
* @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
|
||||
{
|
||||
$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);
|
||||
public function __construct(
|
||||
protected string $filepath,
|
||||
) {
|
||||
try {
|
||||
$content = file_get_contents($this->filepath);
|
||||
} catch (Throwable) {
|
||||
$content = false;
|
||||
}
|
||||
|
||||
return $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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Возвращает плейлист по его коду
|
||||
* Возвращает определение плейлиста по его коду
|
||||
*
|
||||
* @param string $code Код плейлиста
|
||||
* @return array|null
|
||||
* @param TKey $code
|
||||
* @return TValue
|
||||
* @throws PlaylistNotFoundException
|
||||
* @throws Exception
|
||||
*/
|
||||
public function getPlaylist(string $code): ?array
|
||||
public function playlist(string $code): array
|
||||
{
|
||||
empty($this->playlists) && $this->load();
|
||||
$data = redis()->get($code);
|
||||
return $this->initPlaylist($code, $data);
|
||||
return $this->playlists[$code] ?? throw new PlaylistNotFoundException($code);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -82,107 +84,50 @@ class IniFile
|
||||
*/
|
||||
public function updatedAt(): string
|
||||
{
|
||||
return $this->updatedAt;
|
||||
return date('d.m.Y h:i', $this->updatedAt);
|
||||
}
|
||||
|
||||
/**
|
||||
* Подготавливает данные о плейлисте в расширенном формате
|
||||
*
|
||||
* @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
|
||||
* @inheritDoc
|
||||
* @param non-falsy-string $offset
|
||||
* @return bool
|
||||
*/
|
||||
protected function hasTokens(array $data): bool
|
||||
#[Override]
|
||||
public function offsetExists(mixed $offset): bool
|
||||
{
|
||||
$string = ($data['url'] ?? '') . ($data['content'] ?? '');
|
||||
if (empty($string)) {
|
||||
return false;
|
||||
}
|
||||
return isset($this->playlists[$offset]);
|
||||
}
|
||||
|
||||
$badAttributes = [
|
||||
// токены и ключи
|
||||
'[?&]token=',
|
||||
'[?&]drmreq=',
|
||||
// логины
|
||||
'[?&]u=',
|
||||
'[?&]user=',
|
||||
'[?&]username=',
|
||||
// пароли
|
||||
'[?&]p=',
|
||||
'[?&]pwd=',
|
||||
'[?&]password=',
|
||||
// неизвестные
|
||||
// 'free=true',
|
||||
// 'uid=',
|
||||
// 'c_uniq_tag=',
|
||||
// 'rlkey=',
|
||||
// '?s=',
|
||||
// '&s=',
|
||||
// '?q=',
|
||||
// '&q=',
|
||||
];
|
||||
/**
|
||||
* @inheritDoc
|
||||
* @param TKey $offset
|
||||
* @return TPlaylistDefinition
|
||||
* @throws PlaylistNotFoundException
|
||||
*/
|
||||
#[Override]
|
||||
public function offsetGet(mixed $offset): array
|
||||
{
|
||||
return $this->playlist($offset);
|
||||
}
|
||||
|
||||
return array_any(
|
||||
$badAttributes,
|
||||
static fn (string $badAttribute) => preg_match_all("/$badAttribute/", $string) >= 1,
|
||||
);
|
||||
/**
|
||||
* @inheritDoc
|
||||
* @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
|
||||
{
|
||||
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,
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user