0
0
mirror of https://github.com/anthonyaxenov/iptv.git synced 2024-11-24 22:34:34 +00:00

Compare commits

..

6 Commits

12 changed files with 270 additions and 11 deletions

View File

@ -25,6 +25,7 @@ server {
fastcgi_index index.php; fastcgi_index index.php;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
fastcgi_param PATH_INFO $fastcgi_path_info; fastcgi_param PATH_INFO $fastcgi_path_info;
fastcgi_hide_header X-Powered-By;
include fastcgi_params; include fastcgi_params;
} }
} }

View File

@ -1,5 +1,6 @@
[PHP] [PHP]
error_reporting = E_ALL error_reporting = E_ALL
expose_php = Off
file_uploads = Off file_uploads = Off
memory_limit=-1 memory_limit=-1
max_execution_time=-1 max_execution_time=-1

View File

@ -1,5 +1,6 @@
[PHP] [PHP]
error_reporting = E_ALL error_reporting = E_ALL
expose_php = Off
file_uploads = Off file_uploads = Off
; upload_max_filesize=10M ; upload_max_filesize=10M
; post_max_size=10M ; post_max_size=10M

View File

@ -4,6 +4,7 @@ declare(strict_types=1);
namespace App\Controllers; namespace App\Controllers;
use App\Core\ChannelLogo;
use App\Exceptions\PlaylistNotFoundException; use App\Exceptions\PlaylistNotFoundException;
use Exception; use Exception;
use Flight; use Flight;
@ -41,7 +42,6 @@ class PlaylistController extends Controller
public function details(string $id): void public function details(string $id): void
{ {
$result = $this->getPlaylistResponse($id); $result = $this->getPlaylistResponse($id);
view('details', $result); view('details', $result);
} }
@ -57,4 +57,33 @@ class PlaylistController extends Controller
$result = $this->getPlaylistResponse($id, true); $result = $this->getPlaylistResponse($id, true);
Flight::json($result); Flight::json($result);
} }
/**
* Возвращает логотип канала, кэшируя при необходимости
*
* @return void
*/
public function logo(): void
{
$input = Flight::request()->query['url'] ?? null;
$logo = new ChannelLogo($input);
if (!$logo->readFile()) {
$logo->fetch();
}
if ($logo->size() === 0) {
$logo->setDefault();
}
$logo->store();
$body = $logo->raw();
$size = $logo->size();
$mime = $logo->mimeType();
Flight::response()
->write($body)
->header('Content-Type', $mime)
->header('Content-Length', (string)$size);
}
} }

View File

@ -29,7 +29,7 @@ final class Bootstrapper
Flight::set('config', $config); Flight::set('config', $config);
} }
public static function bootIni(): void public static function bootCore(): void
{ {
$loader = new IniFile(); $loader = new IniFile();
$loader->load(); $loader->load();

View File

@ -0,0 +1,197 @@
<?php
declare(strict_types=1);
namespace App\Core;
class ChannelLogo implements \Stringable
{
/**
* @var string Валидированная ссылка на изображение
*/
public readonly string $url;
/**
* @var string|null Хэш от ссылки на изображение
*/
public readonly ?string $hash;
/**
* @var string|null Путь к файлу изображению на диске
*/
protected ?string $path = '';
/**
* @var string|null MIME-тип изображения
*/
protected ?string $mimeType = null;
/**
* @var false|string|null Сырое изображение:
* null -- не загружалось;
* false -- ошибка загрузки;
* string -- бинарные данные.
*/
protected false|string|null $rawData = null;
/**
* Конструктор
*
* @param string $url Внешняя ссылка на изображение
*/
public function __construct(string $url)
{
$url = $this->prepareUrl($url);
if (is_string($url)) {
$this->url = $url;
$this->hash = md5($url);
$this->path = cache_path("tv-logos/$this->hash");
}
}
/**
* Валидирует и очищает ссылку на изображение
*
* @param string $url
* @return false|string
*/
protected function prepareUrl(string $url): false|string
{
$url = filter_var(trim($url), FILTER_VALIDATE_URL);
if ($url === false) {
return false;
}
$parts = parse_url($url);
if (!is_array($parts)) {
return false;
}
return $parts['scheme'] . '://' . $parts['host'] . $parts['path'];
}
/**
* Загружает сырое изображение по ссылке и определяет его MIME-тип
*
* @return bool
*/
public function fetch(): bool
{
$this->rawData = @file_get_contents($this->url);
$isFetched = is_string($this->rawData);
if (!$isFetched) {
return false;
}
$this->mimeType = $this->mimeType();
return true;
}
/**
* Сохраняет сырое изображение в кэш
*
* @return bool
*/
public function store(): bool
{
return is_string($this->rawData)
&& $this->prepareCacheDir()
&& @file_put_contents($this->path, $this->rawData);
}
/**
* Считывает изображение из кэша
*
* @return bool
*/
public function readFile(): bool
{
if (!file_exists($this->path)) {
return false;
}
$this->rawData = @file_get_contents($this->path);
return is_string($this->rawData);
}
/**
* Считывает дефолтный эскиз вместо логотипа
*
* @return bool
*/
public function setDefault(): bool
{
$this->path = root_path('public/no-tvg-logo.png');
return$this->readFile();
}
/**
* Возвращает base64-кодированное изображение
*
* @return string|null
*/
public function asBase64(): ?string
{
if (!is_string($this->rawData)) {
return null;
}
return "data:$this->mimeType;base64," . base64_encode($this->rawData);
}
/**
* Возвращает сырое изображение
*
* @return false|string|null
*/
public function raw(): false|string|null
{
return $this->rawData;
}
/**
* Проверяет готовность директории кэша изображений, создавая её при необходимости
*
* @return bool
*/
public function prepareCacheDir(): bool
{
$cacheFileDir = cache_path('tv-logos');
return is_dir($cacheFileDir)
|| @mkdir($cacheFileDir, 0775, true);
}
/**
* Возвращает MIME-тип сырого изображения
*
* @return string|null
*/
public function mimeType(): ?string
{
if (!is_string($this->rawData)) {
return null;
}
$finfo = new \finfo(FILEINFO_MIME_TYPE);
return $finfo->buffer($this->rawData) ?: null;
}
/**
* Возвращает размер сырого изображения в байтах
*
* @return int
*/
public function size(): int
{
return strlen((string)$this->rawData);
}
/**
* @inheritDoc
*/
public function __toString(): string
{
return $this->asBase64();
}
}

View File

@ -204,6 +204,16 @@ class Playlist
if ($isChannel) { if ($isChannel) {
$channel['url'] = str_starts_with($line, 'http') ? $line : null; $channel['url'] = str_starts_with($line, 'http') ? $line : null;
$logoUrl = $channel['attributes']['tvg-logo'] ?? null;
if (is_string($logoUrl)) {
$logo = new ChannelLogo($logoUrl);
$logo->readFile();
$channel['logo'] = [
'base64' => $logo->asBase64(),
'size' => $logo->size(),
'mime-type' => $logo->mimeType(),
];
}
$result['channels'][] = $channel; $result['channels'][] = $channel;
$isChannel = false; $isChannel = false;
unset($channel); unset($channel);

View File

@ -3,6 +3,7 @@
"php": "^8.2", "php": "^8.2",
"ext-json": "*", "ext-json": "*",
"ext-curl": "*", "ext-curl": "*",
"ext-fileinfo": "*",
"mikecao/flight": "^3.12", "mikecao/flight": "^3.12",
"symfony/dotenv": "^7.1", "symfony/dotenv": "^7.1",
"twig/twig": "^3.14" "twig/twig": "^3.14"

View File

@ -9,6 +9,7 @@ return [
'GET /' => [HomeController::class, 'index'], 'GET /' => [HomeController::class, 'index'],
'GET /page/@page:[0-9]+' => [HomeController::class, 'index'], 'GET /page/@page:[0-9]+' => [HomeController::class, 'index'],
'GET /faq' => [HomeController::class, 'faq'], 'GET /faq' => [HomeController::class, 'faq'],
'GET /logo' => [PlaylistController::class, 'logo'],
'GET /@id:[a-zA-Z0-9_-]+' => [PlaylistController::class, 'download'], 'GET /@id:[a-zA-Z0-9_-]+' => [PlaylistController::class, 'download'],
'GET /?[a-zA-Z0-9_-]+' => [PlaylistController::class, 'download'], 'GET /?[a-zA-Z0-9_-]+' => [PlaylistController::class, 'download'],
'GET /@id:[a-zA-Z0-9_-]+/details' => [PlaylistController::class, 'details'], 'GET /@id:[a-zA-Z0-9_-]+/details' => [PlaylistController::class, 'details'],

View File

@ -15,6 +15,6 @@ require '../vendor/autoload.php';
(new Dotenv())->loadEnv(root_path() . '/.env'); (new Dotenv())->loadEnv(root_path() . '/.env');
Bootstrapper::bootSettings(); Bootstrapper::bootSettings();
Bootstrapper::bootTwig(); Bootstrapper::bootTwig();
Bootstrapper::bootIni(); Bootstrapper::bootCore();
Bootstrapper::bootRoutes(); Bootstrapper::bootRoutes();
Flight::start(); Flight::start();

BIN
src/public/no-tvg-logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

View File

@ -3,7 +3,13 @@
{% block title %}{{ name }} - {{ config('app.title') }}{% endblock %} {% block title %}{{ name }} - {{ config('app.title') }}{% endblock %}
{% block head %} {% block head %}
<style>.tvg-logo-background{max-width:100px;max-height:100px;background:white;padding:2px;border-radius:5px}</style> <style>
img.tvg-logo{max-width:80px;max-height:80px;padding:2px;border-radius:5px}
tr.chrow td{padding:3px}
td.chindex{width:1%}
td.chlogo{width:100px}
div.chlist-table{max-height:550px}
</style>
{% endblock %} {% endblock %}
{% block header %} {% block header %}
@ -76,19 +82,31 @@
<h4>Список каналов ({{ content.channelCount ?? 0 }})</h4> <h4>Список каналов ({{ content.channelCount ?? 0 }})</h4>
{% if (content.channelCount > 0) %} {% if (content.channelCount > 0) %}
<div id="chlist"> <div id="chlist">
<input type="text" class="form-control form-control-sm bg-dark text-light mb-2 fuzzy-search" placeholder="Поиск..."> <input type="text"
<div class="overflow-auto" style="max-height:550px"> class="form-control form-control-sm bg-dark text-light mb-2 fuzzy-search"
placeholder="Поиск..."
/>
<div class="chlist-table overflow-auto">
<table class="table table-dark table-hover small"> <table class="table table-dark table-hover small">
<tbody class="list"> <tbody class="list">
{% for channel in content.channels %} {% for channel in content.channels %}
<tr class="chrow"> <tr class="chrow">
<td class="p-1" class="chindex">{{ loop.index }}</td> <td class="chindex">{{ loop.index }}</td>
<td class="p-1"> <td class="chlogo text-center">
{% if (channel.attributes['tvg-logo']) %} <img class="tvg-logo"
<img class="tvg-logo-background" src="{{ channel.attributes['tvg-logo'] }}" /> {% if (channel.logo.base64) %}
src="{{ channel.logo.base64 }}"
{% elseif (channel.attributes['tvg-logo']) %}
src="{{ base_url('logo?url=' ~ channel.attributes['tvg-logo']) }}"
loading="lazy"
{% else %}
src="{{ base_url('no-tvg-logo.png') }}"
{% endif %} {% endif %}
alt="Логотип канала '{{ channel.name }}'"
title="Логотип канала '{{ channel.name }}'"
/>
</td> </td>
<td class="p-1 chname">{{ channel.name }}</td> <td class="chname">{{ channel.name }}</td>
</tr> </tr>
{% endfor %} {% endfor %}
</tbody> </tbody>