This commit is contained in:
2026-01-01 21:10:46 +08:00
parent 5c1b19c08a
commit 6c31ffa120
26 changed files with 1171 additions and 209 deletions

View File

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

View File

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

View File

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