Compare commits
41 Commits
d097366605
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| d52a2de888 | |||
| 62b54977c5 | |||
| e18facb4c4 | |||
| 303c0f10b4 | |||
| b88b307360 | |||
| eeb96bb438 | |||
| 2350b8b042 | |||
| 017b8d3598 | |||
|
8004d43a4a
|
|||
|
1bce9ecfe5
|
|||
|
50a5161374
|
|||
|
00d612c0e9
|
|||
|
de84bc8ae9
|
|||
|
8ef148db73
|
|||
|
56bc180de5
|
|||
|
cb38d26abe
|
|||
|
2dcd20b125
|
|||
|
c8a7cc94a4
|
|||
|
5a4bfdbe9f
|
|||
|
388ca90d48
|
|||
|
2ab7cd7378
|
|||
| d337925713 | |||
|
|
53596d5959 | ||
| 7c0d2f97f0 | |||
| b41c7e6e42 | |||
|
faec083397
|
|||
|
01504b84a3
|
|||
|
7cb226e5ed
|
|||
|
73f6c8f525
|
|||
|
ea55dcaf47
|
|||
|
0df777ef0a
|
|||
| 2a064a74e1 | |||
|
e42b8dec7d
|
|||
|
b70491e6fb
|
|||
|
c0b7dd9a40
|
|||
|
1c57f58936
|
|||
|
70e25ded66
|
|||
|
4e659c0abf
|
|||
|
688ffc547e
|
|||
|
ab23f8796e
|
|||
|
2f0186e49f
|
@@ -1 +1,2 @@
|
||||
IPTV_ENV=dev
|
||||
REDIS_PORT=6379
|
||||
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -4,11 +4,14 @@ downloaded/
|
||||
/src/commit
|
||||
/src/cache/*
|
||||
/src/vendor
|
||||
/src/config/playlists.ini
|
||||
/src/views/custom.twig
|
||||
/tmp
|
||||
*.log
|
||||
.env
|
||||
*.m3u
|
||||
*.m3u.*
|
||||
*.m3u8
|
||||
*.m3u8.*
|
||||
*.rdb
|
||||
!/**/.gitkeep
|
||||
|
||||
@@ -4,6 +4,22 @@ networks:
|
||||
|
||||
services:
|
||||
|
||||
keydb:
|
||||
container_name: iptv-keydb
|
||||
image: eqalpha/keydb:latest
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- /etc/localtime:/etc/localtime:ro
|
||||
- ./docker/keydb/keydb.conf:/etc/keydb/keydb.conf
|
||||
- ./docker/keydb/data/:/data:rw
|
||||
- ./log/keydb:/var/log/keydb/:rw
|
||||
env_file:
|
||||
- .env
|
||||
ports:
|
||||
- "${REDIS_PORT:-6379}:6379"
|
||||
networks:
|
||||
- iptv
|
||||
|
||||
php:
|
||||
container_name: iptv-php
|
||||
env_file:
|
||||
@@ -13,6 +29,8 @@ services:
|
||||
build:
|
||||
dockerfile: docker/php/${IPTV_ENV}.dockerfile
|
||||
restart: unless-stopped
|
||||
extra_hosts:
|
||||
- host.docker.internal:host-gateway
|
||||
networks:
|
||||
- iptv
|
||||
volumes:
|
||||
@@ -22,11 +40,15 @@ services:
|
||||
- ./log/php:/var/log/php:rw
|
||||
- ./src:/var/www:rw
|
||||
- ./playlists.ini:/var/www/config/playlists.ini:ro
|
||||
depends_on:
|
||||
- keydb
|
||||
|
||||
nginx:
|
||||
container_name: iptv-nginx
|
||||
image: nginx:latest
|
||||
restart: unless-stopped
|
||||
extra_hosts:
|
||||
- host.docker.internal:host-gateway
|
||||
networks:
|
||||
- iptv
|
||||
volumes:
|
||||
0
docker/keydb/data/.gitkeep
Normal file
0
docker/keydb/data/.gitkeep
Normal file
1835
docker/keydb/keydb.conf
Normal file
1835
docker/keydb/keydb.conf
Normal file
File diff suppressed because it is too large
Load Diff
@@ -25,6 +25,7 @@ server {
|
||||
fastcgi_index index.php;
|
||||
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
|
||||
fastcgi_param PATH_INFO $fastcgi_path_info;
|
||||
fastcgi_hide_header X-Powered-By;
|
||||
include fastcgi_params;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
FROM php:8.2-fpm
|
||||
FROM php:8.3-fpm
|
||||
|
||||
RUN apt update && \
|
||||
apt upgrade -y && \
|
||||
apt install -y git unzip 7zip
|
||||
|
||||
# https://pecl.php.net/package/xdebug
|
||||
# https://pecl.php.net/package/redis
|
||||
RUN pecl channel-update pecl.php.net && \
|
||||
pecl install xdebug-3.3.2 unzip && \
|
||||
mkdir -p /var/log/php
|
||||
pecl install xdebug-3.4.1 redis && \
|
||||
docker-php-ext-enable redis && \
|
||||
mkdir -p /var/run/php && \
|
||||
mkdir -p /var/log/php && \
|
||||
chmod -R 777 /var/log/php
|
||||
|
||||
COPY --from=composer /usr/bin/composer /usr/local/bin/composer
|
||||
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
[PHP]
|
||||
error_reporting = E_ALL
|
||||
expose_php = Off
|
||||
file_uploads = Off
|
||||
memory_limit=-1
|
||||
max_execution_time=-1
|
||||
; upload_max_filesize=10M
|
||||
; post_max_size=10M
|
||||
|
||||
[opcache]
|
||||
opcache.enable = 1
|
||||
@@ -21,7 +19,7 @@ zend_extension = xdebug.so
|
||||
xdebug.mode = debug
|
||||
xdebug.start_with_request = yes
|
||||
xdebug.trigger_value = go
|
||||
xdebug.client_host = 172.17.0.1
|
||||
xdebug.client_host = host.docker.internal
|
||||
xdebug.REQUEST = *
|
||||
xdebug.SESSION = *
|
||||
xdebug.SERVER = *
|
||||
|
||||
@@ -1,11 +1,19 @@
|
||||
FROM php:8.2-fpm
|
||||
FROM php:8.3-fpm
|
||||
|
||||
RUN apt update && \
|
||||
apt upgrade -y && \
|
||||
apt install -y git
|
||||
|
||||
# https://pecl.php.net/package/redis
|
||||
RUN pecl channel-update pecl.php.net && \
|
||||
pecl install redis && \
|
||||
docker-php-ext-enable redis && \
|
||||
mkdir -p /var/log/php && \
|
||||
chmod -R 777 /var/log/php
|
||||
|
||||
COPY --from=composer /usr/bin/composer /usr/local/bin/composer
|
||||
|
||||
USER www-data
|
||||
EXPOSE 9000
|
||||
WORKDIR /var/www
|
||||
CMD composer install
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
[PHP]
|
||||
error_reporting = E_ALL
|
||||
expose_php = Off
|
||||
file_uploads = Off
|
||||
; upload_max_filesize=10M
|
||||
; post_max_size=10M
|
||||
|
||||
@@ -16,6 +16,6 @@ access.log = /var/log/php/$pool.access.log
|
||||
; chroot = /var/www
|
||||
; chdir = /var/www
|
||||
php_flag[display_errors] = on
|
||||
php_admin_value[error_log] = /var/log/php/www.error.log
|
||||
php_admin_value[error_log] = /var/log/php/$pool.error.log
|
||||
php_admin_flag[log_errors] = on
|
||||
php_admin_value[memory_limit] = 32M
|
||||
php_admin_value[memory_limit] = 512M
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
#!/bin/bash
|
||||
# хук пробрасывает хэш свежего коммита в контейнер
|
||||
# для его отображения в подвале страницы
|
||||
git rev-parse HEAD > src/commit
|
||||
@@ -1,7 +0,0 @@
|
||||
#!/bin/bash
|
||||
# хук пробрасывает хэш свежего коммита в контейнер
|
||||
# для его отображения в подвале страницы и очищает
|
||||
# кеш шаблонов twig после слияния веток
|
||||
# главным образом необходимо при git pull
|
||||
git rev-parse HEAD > src/commit
|
||||
docker exec -ti iptv-php rm -rf cache/views
|
||||
6
iptv
6
iptv
@@ -3,7 +3,7 @@
|
||||
|
||||
CONTAINER="iptv-php" # the name of the container in which to 'exec' something
|
||||
CONFIG="$(dirname $([ -L $0 ] && readlink -f $0 || echo $0))/docker-compose.yml" # path to compose yml file
|
||||
CMD="docker-compose -f $CONFIG" # docker-compose command
|
||||
CMD="docker compose -f $CONFIG" # docker-compose command
|
||||
APP_URL='http://localhost:8080/'
|
||||
|
||||
open_browser() {
|
||||
@@ -15,10 +15,9 @@ open_browser() {
|
||||
}
|
||||
|
||||
case "$1" in
|
||||
'' | 'help' ) echo -e "Provide one of operations: \t init, start, stop, up, down, restart, rebuild, open, hooks";
|
||||
'' | 'help' ) echo -e "Provide one of operations: \t init, start, stop, up, down, restart, rebuild, open";
|
||||
echo "Otherwise all args will passed to 'docker exec -ti $CONTAINER ...'" ;;
|
||||
'init' ) cp src/.env.example src/.env && \
|
||||
./iptv hooks && \
|
||||
./iptv up && \
|
||||
./iptv composer i && \
|
||||
echo "Project started successfully! $APP_URL" ;;
|
||||
@@ -29,6 +28,5 @@ case "$1" in
|
||||
'restart' ) $CMD stop && $CMD start ;; # restart containers
|
||||
'rebuild' ) $CMD down --remove-orphans && $CMD up -d --build ;; # rebuild containers
|
||||
'open' ) open_browser $APP_URL && echo -e "\nYou're welcome!\n\t$APP_URL" ;;
|
||||
'hooks' ) ./hooks/post-commit && cp hooks/* .git/hooks ;;
|
||||
* ) docker exec -ti $CONTAINER $* ;; # exec anything else in container
|
||||
esac
|
||||
|
||||
0
log/redis/.gitkeep
Normal file
0
log/redis/.gitkeep
Normal file
@@ -1 +0,0 @@
|
||||
src/config/playlists.ini
|
||||
1061
playlists.ini
Normal file
1061
playlists.ini
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,18 @@
|
||||
APP_TITLE='Плейлисты IPTV'
|
||||
# config/app.php
|
||||
APP_DEBUG=false
|
||||
APP_ENV=prod
|
||||
APP_URL=http://localhost:8080
|
||||
TWIG_CACHE=1
|
||||
TWIG_DEBUG=0
|
||||
FLIGHT_CASE_SENSITIVE=0
|
||||
FLIGHT_HANDLE_ERRORS=1
|
||||
FLIGHT_LOG_ERRORS=1
|
||||
APP_TITLE='IPTV Плейлисты'
|
||||
USER_AGENT='Mozilla/5.0 (Windows NT 10.0; Win64; x99) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/133.0.0.0 Safari/537.36'
|
||||
PAGE_SIZE=10
|
||||
|
||||
# config/redis.php
|
||||
REDIS_HOST='keydb'
|
||||
REDIS_PORT=6379
|
||||
REDIS_PASSWORD=
|
||||
REDIS_DB=0
|
||||
REDIS_TTL_DAYS=14
|
||||
|
||||
# config/redis.php
|
||||
TWIG_USE_CACHE=true
|
||||
TWIG_DEBUG=false
|
||||
|
||||
36
src/app/Controllers/ApiController.php
Normal file
36
src/app/Controllers/ApiController.php
Normal file
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controllers;
|
||||
|
||||
use Psr\Http\Message\ResponseInterface;
|
||||
use Psr\Http\Message\ServerRequestInterface;
|
||||
use Random\RandomException;
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
class ApiController extends BasicController
|
||||
{
|
||||
/**
|
||||
* @param ServerRequestInterface $request
|
||||
* @param ResponseInterface $response
|
||||
* @return ResponseInterface
|
||||
* @throws RandomException
|
||||
*/
|
||||
public function json(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
|
||||
{
|
||||
$code = $request->getAttributes()['code'];
|
||||
$playlist = $this->getPlaylist($code, true);
|
||||
$playlist->fetchContent();
|
||||
$playlist->parse();
|
||||
|
||||
$json = json_encode($playlist->toArray(), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
$response->getBody()->write($json);
|
||||
|
||||
return $response
|
||||
->withHeader('Content-Type', 'application/json')
|
||||
->withHeader('Content-Length', strlen($json));
|
||||
}
|
||||
}
|
||||
58
src/app/Controllers/BasicController.php
Normal file
58
src/app/Controllers/BasicController.php
Normal file
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controllers;
|
||||
|
||||
use App\Core\Playlist;
|
||||
use App\Errors\PlaylistNotFoundException;
|
||||
use Psr\Http\Message\ResponseInterface;
|
||||
use Psr\Http\Message\ServerRequestInterface;
|
||||
use Slim\Views\Twig;
|
||||
use Twig\Error\LoaderError;
|
||||
use Twig\Error\RuntimeError;
|
||||
use Twig\Error\SyntaxError;
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
class BasicController
|
||||
{
|
||||
/**
|
||||
* Отправляет сообщение о том, что метод не найден с кодом страницы 404
|
||||
*
|
||||
* @param ServerRequestInterface $request
|
||||
* @param ResponseInterface $response
|
||||
* @return ResponseInterface
|
||||
* @throws LoaderError
|
||||
* @throws RuntimeError
|
||||
* @throws SyntaxError
|
||||
*/
|
||||
public function notFound(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
|
||||
{
|
||||
$response->withStatus(404);
|
||||
$this->view($request, $response, 'notfound.twig');
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param ServerRequestInterface $request
|
||||
* @param ResponseInterface $response
|
||||
* @param string $template
|
||||
* @param array $data
|
||||
* @return ResponseInterface
|
||||
* @throws LoaderError
|
||||
* @throws RuntimeError
|
||||
* @throws SyntaxError
|
||||
*/
|
||||
protected function view(
|
||||
ServerRequestInterface $request,
|
||||
ResponseInterface $response,
|
||||
string $template,
|
||||
array $data = [],
|
||||
): ResponseInterface {
|
||||
$view = Twig::fromRequest($request);
|
||||
return $view->render($response, $template, $data);
|
||||
}
|
||||
}
|
||||
@@ -1,85 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controllers;
|
||||
|
||||
use App\Core\IniFile;
|
||||
use App\Core\Playlist;
|
||||
use App\Exceptions\PlaylistNotFoundException;
|
||||
use Exception;
|
||||
use Flight;
|
||||
use Random\RandomException;
|
||||
|
||||
/**
|
||||
* Абстрактный контроллер для расширения
|
||||
*/
|
||||
abstract class Controller
|
||||
{
|
||||
/**
|
||||
* @var IniFile Класс для работы с ini-файлом плейлистов
|
||||
*/
|
||||
protected IniFile $ini;
|
||||
|
||||
/**
|
||||
* Конструктор
|
||||
*/
|
||||
public function __construct()
|
||||
{
|
||||
$this->ini = Flight::get('ini');
|
||||
}
|
||||
|
||||
/**
|
||||
* Возвращает плейлист по его ID для обработки
|
||||
*
|
||||
* @param string $id
|
||||
* @param bool $asJson
|
||||
* @return Playlist
|
||||
* @throws Exception
|
||||
*/
|
||||
protected function getPlaylist(string $id, bool $asJson = false): Playlist
|
||||
{
|
||||
if ($this->ini->getRedirection($id)) {
|
||||
Flight::redirect(base_url($this->ini->getRedirection($id) . ($asJson ? '/json' : '/details')));
|
||||
die;
|
||||
}
|
||||
|
||||
try {
|
||||
return $this->ini->getPlaylist($id);
|
||||
} catch (PlaylistNotFoundException) {
|
||||
$this->notFound($id, $asJson);
|
||||
die;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Возвращает обработанный плейлист для ответа
|
||||
*
|
||||
* @param string $id ID плейлиста
|
||||
* @param bool $asJson Обрабатывать как json
|
||||
* @return array
|
||||
* @throws RandomException
|
||||
* @throws Exception
|
||||
*/
|
||||
protected function getPlaylistResponse(string $id, bool $asJson = false): array
|
||||
{
|
||||
$playlist = $this->getPlaylist($id, $asJson);
|
||||
$playlist->download();
|
||||
$playlist->parse();
|
||||
return $playlist->toArray();
|
||||
}
|
||||
|
||||
/**
|
||||
* Перебрасывает на страницу 404 при ненайденном плейлисте
|
||||
*
|
||||
* @param string $id ID плейлиста
|
||||
* @param bool $asJson Обрабатывать как json
|
||||
* @return void
|
||||
* @throws Exception
|
||||
*/
|
||||
public function notFound(string $id, bool $asJson = false): void
|
||||
{
|
||||
Flight::response()->status(404)->sendHeaders();
|
||||
$asJson || view('notfound', ['id' => $id]);
|
||||
}
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controllers;
|
||||
|
||||
use Exception;
|
||||
use Flight;
|
||||
|
||||
/**
|
||||
* Контроллер домашней страницы (списка плейлистов)
|
||||
*/
|
||||
class HomeController extends Controller
|
||||
{
|
||||
/**
|
||||
* Отображает главную страницу с учётом пагинации списка плейлистов
|
||||
*
|
||||
* @param int $page Текущая страница списка
|
||||
* @return void
|
||||
* @throws Exception
|
||||
*/
|
||||
public function index(int $page = 1): void
|
||||
{
|
||||
// если пришёл любой get-параметр, то считаем его как id плейлиста и перебрасываем на страницу о нём
|
||||
if (Flight::request()->query->count() > 0) {
|
||||
$id = Flight::request()->query->keys()[0];
|
||||
Flight::redirect(base_url($id));
|
||||
die;
|
||||
}
|
||||
|
||||
// иначе формируем и сортируем список при необходимости, рисуем страницу
|
||||
$perPage = 10;
|
||||
$playlists = $this->ini->playlists(false);
|
||||
$count = count($playlists);
|
||||
$pageCount = ceil($count / $perPage);
|
||||
$offset = max(0, ($page - 1) * $perPage);
|
||||
$list = array_slice($playlists, $offset, $perPage, true);
|
||||
|
||||
view('list', [
|
||||
'updated_at' => $this->ini->updatedAt(),
|
||||
'count' => $count,
|
||||
'pages' => [
|
||||
'count' => $pageCount,
|
||||
'current' => $page,
|
||||
],
|
||||
'playlists' => $list,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Отображает страницу FAQ
|
||||
*
|
||||
* @return void
|
||||
* @throws Exception
|
||||
*/
|
||||
public function faq(): void
|
||||
{
|
||||
view('faq');
|
||||
}
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controllers;
|
||||
|
||||
use App\Exceptions\PlaylistNotFoundException;
|
||||
use Exception;
|
||||
use Flight;
|
||||
|
||||
/**
|
||||
* Контроллер методов получения описания плейлистов
|
||||
*/
|
||||
class PlaylistController extends Controller
|
||||
{
|
||||
/**
|
||||
* Отправляет запрос с клиента по прямой ссылке плейлиста
|
||||
*
|
||||
* @param string $id ID плейлиста
|
||||
* @return void
|
||||
* @throws Exception
|
||||
*/
|
||||
public function download(string $id): void
|
||||
{
|
||||
try {
|
||||
$playlist = $this->ini->getPlaylist($id);
|
||||
Flight::redirect($playlist->pls);
|
||||
} catch (PlaylistNotFoundException) {
|
||||
$this->notFound($id);
|
||||
}
|
||||
die;
|
||||
}
|
||||
|
||||
/**
|
||||
* Отображает страницу описания плейлиста
|
||||
*
|
||||
* @param string $id ID плейлиста
|
||||
* @return void
|
||||
* @throws Exception
|
||||
*/
|
||||
public function details(string $id): void
|
||||
{
|
||||
$result = $this->getPlaylistResponse($id);
|
||||
|
||||
view('details', $result);
|
||||
}
|
||||
|
||||
/**
|
||||
* Возвращает JSON с описанием плейлиста
|
||||
*
|
||||
* @param string $id ID плейлиста
|
||||
* @return void
|
||||
* @throws Exception
|
||||
*/
|
||||
public function json(string $id): void
|
||||
{
|
||||
$result = $this->getPlaylistResponse($id, true);
|
||||
Flight::json($result);
|
||||
}
|
||||
}
|
||||
128
src/app/Controllers/WebController.php
Normal file
128
src/app/Controllers/WebController.php
Normal file
@@ -0,0 +1,128 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controllers;
|
||||
|
||||
use App\Core\ChannelLogo;
|
||||
use App\Errors\PlaylistNotFoundException;
|
||||
use Exception;
|
||||
use Psr\Http\Message\ResponseInterface;
|
||||
use Psr\Http\Message\ServerRequestInterface;
|
||||
use Twig\Error\LoaderError;
|
||||
use Twig\Error\RuntimeError;
|
||||
use Twig\Error\SyntaxError;
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
class WebController extends BasicController
|
||||
{
|
||||
/**
|
||||
* @param ServerRequestInterface $request
|
||||
* @param ResponseInterface $response
|
||||
* @return ResponseInterface
|
||||
* @throws LoaderError
|
||||
* @throws RuntimeError
|
||||
* @throws SyntaxError
|
||||
* @throws Exception
|
||||
*/
|
||||
public function home(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
|
||||
{
|
||||
ini()->load();
|
||||
|
||||
$playlists = ini()->playlists(false);
|
||||
$count = count($playlists);
|
||||
$page = (int)($request->getAttributes()['page'] ?? $request->getQueryParams()['page'] ?? 1);
|
||||
$pageSize = config('app.page_size');
|
||||
$pageCount = ceil($count / $pageSize);
|
||||
$offset = max(0, ($page - 1) * $pageSize);
|
||||
$list = array_slice($playlists, $offset, $pageSize, true);
|
||||
|
||||
return $this->view($request, $response, 'list.twig', [
|
||||
'updated_at' => ini()->updatedAt(),
|
||||
'playlists' => $list,
|
||||
'count' => $count,
|
||||
'pageCount' => $pageCount,
|
||||
'pageCurrent' => $page,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param ServerRequestInterface $request
|
||||
* @param ResponseInterface $response
|
||||
* @return ResponseInterface
|
||||
* @throws LoaderError
|
||||
* @throws RuntimeError
|
||||
* @throws SyntaxError
|
||||
*/
|
||||
public function faq(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
|
||||
{
|
||||
return $this->view($request, $response, 'faq.twig');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param ServerRequestInterface $request
|
||||
* @param ResponseInterface $response
|
||||
* @return ResponseInterface
|
||||
*/
|
||||
public function redirect(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
|
||||
{
|
||||
ini()->load();
|
||||
$code = $request->getAttributes()['code'];
|
||||
try {
|
||||
$playlist = ini()->getPlaylist($code);
|
||||
return $response->withHeader('Location', $playlist->pls);
|
||||
} catch (PlaylistNotFoundException) {
|
||||
return $this->notFound($request, $response);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param ServerRequestInterface $request
|
||||
* @param ResponseInterface $response
|
||||
* @return ResponseInterface
|
||||
* @throws \Random\RandomException
|
||||
* @throws LoaderError
|
||||
* @throws RuntimeError
|
||||
* @throws SyntaxError
|
||||
*/
|
||||
public function details(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
|
||||
{
|
||||
ini()->load();
|
||||
$code = $request->getAttributes()['code'];
|
||||
try {
|
||||
$playlist = ini()->getPlaylist($code);
|
||||
$response->withHeader('Location', $playlist->pls);
|
||||
} catch (PlaylistNotFoundException) {
|
||||
return $this->notFound($request, $response);
|
||||
}
|
||||
|
||||
$playlist->fetchContent();
|
||||
$playlist->parse();
|
||||
|
||||
return $this->view($request, $response, 'details.twig', $playlist->toArray());
|
||||
}
|
||||
|
||||
/**
|
||||
* @param ServerRequestInterface $request
|
||||
* @param ResponseInterface $response
|
||||
* @return ResponseInterface
|
||||
*/
|
||||
public function logo(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
|
||||
{
|
||||
$input = $request->getQueryParams()['url'] ?? null;
|
||||
|
||||
$logo = new ChannelLogo($input);
|
||||
$logo->readFile() || $logo->fetch();
|
||||
$logo->size() === 0 && $logo->setDefault();
|
||||
$logo->store();
|
||||
$body = $logo->raw();
|
||||
$size = $logo->size();
|
||||
$mime = $logo->mimeType();
|
||||
|
||||
$response->getBody()->write($body);
|
||||
return $response->withHeader('Content-Type', $mime)
|
||||
->withHeader('Content-Length', $size);
|
||||
}
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Core;
|
||||
|
||||
use App\Extensions\TwigFunctions;
|
||||
use Flight;
|
||||
use Twig\Environment;
|
||||
use Twig\Extension\DebugExtension;
|
||||
use Twig\Loader\FilesystemLoader;
|
||||
|
||||
/**
|
||||
* Сборщик приложения
|
||||
*/
|
||||
final class Bootstrapper
|
||||
{
|
||||
/**
|
||||
* Загружает конфигурацию приложения в контейнер
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public static function bootSettings(): void
|
||||
{
|
||||
$config = require_once config_path('app.php');
|
||||
foreach ($config as $key => $value) {
|
||||
Flight::set($key, $value);
|
||||
}
|
||||
Flight::set('config', $config);
|
||||
}
|
||||
|
||||
public static function bootIni(): void
|
||||
{
|
||||
$loader = new IniFile();
|
||||
$loader->load();
|
||||
|
||||
Flight::set('ini', $loader);
|
||||
}
|
||||
|
||||
/**
|
||||
* Загружает шаблонизатор и его расширения
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public static function bootTwig(): void
|
||||
{
|
||||
$twigCfg = [
|
||||
'cache' => config('twig.cache'),
|
||||
'debug' => config('twig.debug'),
|
||||
];
|
||||
|
||||
$closure = static function ($twig) {
|
||||
/** @var Environment $twig */
|
||||
Flight::set('twig', $twig);
|
||||
$twig->addExtension(new TwigFunctions());
|
||||
$twig->addExtension(new DebugExtension());
|
||||
};
|
||||
|
||||
$loader = new FilesystemLoader(config('flight.views.path'));
|
||||
Flight::register('view', Environment::class, [$loader, $twigCfg], $closure);
|
||||
}
|
||||
|
||||
/**
|
||||
* Загружает маршруты
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public static function bootRoutes(): void
|
||||
{
|
||||
$routes = require_once config_path('routes.php');
|
||||
foreach ($routes as $route => $handler) {
|
||||
Flight::route($route, $handler);
|
||||
}
|
||||
}
|
||||
}
|
||||
195
src/app/Core/ChannelLogo.php
Normal file
195
src/app/Core/ChannelLogo.php
Normal file
@@ -0,0 +1,195 @@
|
||||
<?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 = empty($url) ? base_url('public/no-tvg-logo.png') : $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
|
||||
{
|
||||
$parts = parse_url(trim($url));
|
||||
if (!is_array($parts) || count($parts) < 2) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$result = $parts['scheme'] . '://' . $parts['host'];
|
||||
$result .= (empty($parts['port']) ? '' : ':' . $parts['port']);
|
||||
|
||||
return $result . $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();
|
||||
}
|
||||
}
|
||||
230
src/app/Core/Core.php
Normal file
230
src/app/Core/Core.php
Normal file
@@ -0,0 +1,230 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Core;
|
||||
|
||||
use App\Core\TwigExtention as IptvTwigExtension;
|
||||
use Dotenv\Dotenv;
|
||||
use InvalidArgumentException;
|
||||
use Redis;
|
||||
use Slim\App;
|
||||
use Slim\Factory\AppFactory;
|
||||
use Slim\Views\Twig;
|
||||
use Slim\Views\TwigMiddleware;
|
||||
use Twig\Error\LoaderError;
|
||||
|
||||
/**
|
||||
* Загрузчик приложения
|
||||
*/
|
||||
final class Core
|
||||
{
|
||||
/**
|
||||
* @var Core
|
||||
*/
|
||||
private static Core $instance;
|
||||
|
||||
/**
|
||||
* @var App
|
||||
*/
|
||||
protected App $app;
|
||||
|
||||
/**
|
||||
* @var array Конфигурация приложения
|
||||
*/
|
||||
protected array $config = [];
|
||||
|
||||
/**
|
||||
* @var Redis
|
||||
*/
|
||||
protected Redis $redis;
|
||||
|
||||
/**
|
||||
* @var IniFile
|
||||
*/
|
||||
protected IniFile $iniFile;
|
||||
|
||||
/**
|
||||
* Закрытый конструктор
|
||||
*/
|
||||
private function __construct()
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* Возвращает объект приложения
|
||||
*
|
||||
* @return Core
|
||||
*/
|
||||
public static function get(): Core
|
||||
{
|
||||
return self::$instance ??= new self();
|
||||
}
|
||||
|
||||
/**
|
||||
* Загружает приложение
|
||||
*
|
||||
* @return App
|
||||
* @throws LoaderError
|
||||
*/
|
||||
public function boot(): App
|
||||
{
|
||||
$this->app = AppFactory::create();
|
||||
|
||||
$this->bootSettings();
|
||||
$this->bootRoutes();
|
||||
$this->bootTwig();
|
||||
$this->bootRedis();
|
||||
$this->bootIni();
|
||||
|
||||
return $this->app;
|
||||
}
|
||||
|
||||
/**
|
||||
* Возвращает значение из конфига
|
||||
*
|
||||
* @param string $key Ключ в формате "config.key"
|
||||
* @param mixed|null $default Значение по умолчанию
|
||||
* @return mixed
|
||||
*/
|
||||
public function config(string $key, mixed $default = null): mixed
|
||||
{
|
||||
$parts = explode('.', $key);
|
||||
return $this->config[$parts[0]][$parts[1]] ?? $default;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Redis
|
||||
*/
|
||||
public function redis(): Redis
|
||||
{
|
||||
return $this->redis;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return IniFile
|
||||
*/
|
||||
public function ini(): IniFile
|
||||
{
|
||||
return $this->iniFile;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return App
|
||||
*/
|
||||
public function app(): App
|
||||
{
|
||||
return $this->app;
|
||||
}
|
||||
|
||||
/**
|
||||
* Загружает файл .env или .env.$env
|
||||
*
|
||||
* @param string $env
|
||||
* @return array
|
||||
*/
|
||||
protected function loadDotEnvFile(string $env = ''): array
|
||||
{
|
||||
$filename = empty($env) ? '.env' : ".env.$env";
|
||||
if (!file_exists(root_path($filename))) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$dotenv = Dotenv::createMutable(root_path(), $filename);
|
||||
return $dotenv->safeLoad();
|
||||
}
|
||||
|
||||
/**
|
||||
* Загружает конфигурационные файлы
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
protected function bootSettings(): void
|
||||
{
|
||||
$env = $this->loadDotEnvFile();
|
||||
|
||||
if (!empty($env['APP_ENV'])) {
|
||||
$this->loadDotEnvFile($env['APP_ENV']);
|
||||
}
|
||||
|
||||
foreach (glob(config_path() . '/*.php') as $file) {
|
||||
$key = basename($file, '.php');
|
||||
$this->config += [$key => require_once $file];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Загружает маршруты
|
||||
*
|
||||
* @return void
|
||||
* @see https://www.slimframework.com/docs/v4/objects/routing.html
|
||||
*/
|
||||
protected function bootRoutes(): void
|
||||
{
|
||||
foreach ($this->config['routes'] as $route) {
|
||||
if (is_array($route['method'])) {
|
||||
$definition = $this->app->map($route['method'], $route['path'], $route['handler']);
|
||||
} else {
|
||||
$isPossible = in_array($route['method'], ['GET', 'POST', 'OPTIONS', 'PUT', 'PATCH', 'DELETE']);
|
||||
|
||||
$func = match (true) {
|
||||
$route['method'] === '*' => 'any',
|
||||
$isPossible => strtolower($route['method']),
|
||||
default => throw new InvalidArgumentException(sprintf('Неверный HTTP метод %s', $route['method']))
|
||||
};
|
||||
|
||||
$definition = $this->app->$func($route['path'], $route['handler']);
|
||||
}
|
||||
|
||||
if (!empty($route['name'])) {
|
||||
$definition->setName($route['name']);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Загружает шаблонизатор и его расширения
|
||||
*
|
||||
* @return void
|
||||
* @throws LoaderError
|
||||
* @see https://www.slimframework.com/docs/v4/features/twig-view.html
|
||||
*/
|
||||
protected function bootTwig(): void
|
||||
{
|
||||
$twig = Twig::create(root_path('views'), $this->config['twig']);
|
||||
$twig->addExtension(new IptvTwigExtension());
|
||||
$this->app->add(TwigMiddleware::create($this->app, $twig));
|
||||
}
|
||||
|
||||
/**
|
||||
* Инициализирует подключение к Redis
|
||||
*
|
||||
* @return void
|
||||
* @see https://github.com/phpredis/phpredis/?tab=readme-ov-file
|
||||
*/
|
||||
protected function bootRedis(): void
|
||||
{
|
||||
$options = [
|
||||
'host' => $this->config['redis']['host'],
|
||||
'port' => (int)$this->config['redis']['port'],
|
||||
];
|
||||
|
||||
if (!empty($this->config['redis']['password'])) {
|
||||
$options['auth'] = $this->config['redis']['password'];
|
||||
}
|
||||
|
||||
$this->redis = new Redis($options);
|
||||
$this->redis->select((int)$this->config['redis']['db']);
|
||||
$this->redis->setOption(Redis::OPT_SERIALIZER, Redis::SERIALIZER_JSON);
|
||||
}
|
||||
|
||||
/**
|
||||
* Инициализирует объект ini-файла
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
protected function bootIni(): void
|
||||
{
|
||||
$this->iniFile = new IniFile();
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Core;
|
||||
|
||||
use App\Exceptions\PlaylistNotFoundException;
|
||||
use App\Errors\PlaylistNotFoundException;
|
||||
use Exception;
|
||||
|
||||
/**
|
||||
@@ -15,7 +15,7 @@ class IniFile
|
||||
/**
|
||||
* @var array Считанное из файла содержимое ini-файла
|
||||
*/
|
||||
protected array $rawIni;
|
||||
protected array $ini;
|
||||
|
||||
/**
|
||||
* @var Playlist[] Коллекция подгруженных плейлистов
|
||||
@@ -40,13 +40,30 @@ class IniFile
|
||||
*/
|
||||
public function load(): void
|
||||
{
|
||||
$filepath = config_path('playlists.ini');
|
||||
$this->updated_at = date('d.m.Y h:i', filemtime($filepath));
|
||||
|
||||
$this->rawIni = parse_ini_file($filepath, true);
|
||||
foreach ($this->rawIni as $id => $data) {
|
||||
$this->playlists[(string)$id] = $this->makePlaylist($id, $data);
|
||||
$ini = redis()->hGetAll('_playlists_');
|
||||
if (empty($ini)) {
|
||||
$filepath = config_path('playlists.ini');
|
||||
$ini = parse_ini_file($filepath, true);
|
||||
$this->updated_at = date('d.m.Y h:i', filemtime($filepath));
|
||||
$order = array_keys($ini);
|
||||
}
|
||||
|
||||
$order ??= redis()->get('_order_');
|
||||
$this->ini ??= $ini;
|
||||
$this->updated_at ??= redis()->get('_updated_at_');
|
||||
$transaction = redis()->multi();
|
||||
foreach ($order as $id) {
|
||||
$data = $this->ini[$id];
|
||||
$this->playlists[(string)$id] = $pls = $this->makePlaylist($id, $data);
|
||||
$transaction->hSet('_playlists_', $id, $pls);
|
||||
}
|
||||
|
||||
$expireAfter = config('redis.ttl_days');
|
||||
$transaction
|
||||
->expire('_playlists_', $expireAfter)
|
||||
->set('_order_', $order, ['EX' => $expireAfter])
|
||||
->set('_updated_at_', $this->updated_at, ['EX' => $expireAfter])
|
||||
->exec();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -57,11 +74,9 @@ class IniFile
|
||||
*/
|
||||
public function playlists(bool $all = true): array
|
||||
{
|
||||
if ($all) {
|
||||
return $this->playlists;
|
||||
}
|
||||
|
||||
return array_filter($this->playlists, static fn ($playlist) => is_null($playlist->redirectId));
|
||||
return $all
|
||||
? $this->playlists
|
||||
: array_filter($this->playlists, static fn ($playlist) => is_null($playlist->redirectId));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -111,7 +126,7 @@ class IniFile
|
||||
$id = (string)$id;
|
||||
if (isset($params['redirect'])) {
|
||||
$this->redirections[$id] = $redirectId = (string)$params['redirect'];
|
||||
$params = $this->rawIni[$redirectId];
|
||||
$params = $this->ini[$redirectId];
|
||||
return $this->makePlaylist($id, $params, $redirectId);
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Core;
|
||||
|
||||
use CurlHandle;
|
||||
use Exception;
|
||||
use Random\RandomException;
|
||||
|
||||
@@ -86,17 +87,19 @@ class Playlist
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function download(): void
|
||||
public function fetchContent(): void
|
||||
{
|
||||
$curl = curl_init();
|
||||
curl_setopt_array($curl, [
|
||||
CURLOPT_URL => $this->pls,
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_TIMEOUT => 30,
|
||||
CURLOPT_HEADER => false,
|
||||
CURLOPT_FAILONERROR => true,
|
||||
]);
|
||||
$cached = redis()->get($this->id);
|
||||
if (is_array($cached)) {
|
||||
$this->downloadStatus['httpCode'] = $cached['httpCode'];
|
||||
$this->downloadStatus['errCode'] = $cached['errCode'];
|
||||
$this->downloadStatus['errText'] = $cached['errText'];
|
||||
$this->downloadStatus['possibleStatus'] = $cached['possibleStatus'];
|
||||
$this->rawContent = $cached['content'];
|
||||
return;
|
||||
}
|
||||
|
||||
$curl = $this->makeCurl();
|
||||
$content = curl_exec($curl);
|
||||
$this->rawContent = $content === false ? null : $content;
|
||||
$this->downloadStatus['httpCode'] = curl_getinfo($curl, CURLINFO_RESPONSE_CODE);
|
||||
@@ -104,6 +107,16 @@ class Playlist
|
||||
$this->downloadStatus['errText'] = curl_error($curl);
|
||||
$this->downloadStatus['possibleStatus'] = $this->guessStatus($this->downloadStatus['errCode']);
|
||||
curl_close($curl);
|
||||
|
||||
if ($cached === false) {
|
||||
redis()->set($this->id, [
|
||||
'httpCode' => $this->downloadStatus['httpCode'],
|
||||
'errCode' => $this->downloadStatus['errCode'],
|
||||
'errText' => $this->downloadStatus['errText'],
|
||||
'possibleStatus' => $this->downloadStatus['possibleStatus'],
|
||||
'content' => $this->rawContent,
|
||||
], ['EX' => config('redis.ttl_days')]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -204,6 +217,16 @@ class Playlist
|
||||
|
||||
if ($isChannel) {
|
||||
$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;
|
||||
$isChannel = false;
|
||||
unset($channel);
|
||||
@@ -228,6 +251,53 @@ class Playlist
|
||||
return $this->parsedContent = $result;
|
||||
}
|
||||
|
||||
public function check(): bool
|
||||
{
|
||||
$curl = $this->makeCurl([
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_NOBODY => true,
|
||||
CURLOPT_HEADER => true,
|
||||
CURLOPT_CUSTOMREQUEST => 'HEAD',
|
||||
]);
|
||||
|
||||
$content = curl_exec($curl);
|
||||
$this->rawContent = $content === false ? null : $content;
|
||||
$this->downloadStatus['httpCode'] = curl_getinfo($curl, CURLINFO_RESPONSE_CODE);
|
||||
$this->downloadStatus['errCode'] = curl_errno($curl);
|
||||
$this->downloadStatus['errText'] = curl_error($curl);
|
||||
$this->downloadStatus['possibleStatus'] = $this->guessStatus($this->downloadStatus['errCode']);
|
||||
curl_close($curl);
|
||||
|
||||
return $this->downloadStatus['httpCode'] < 400;
|
||||
}
|
||||
|
||||
protected function makeCurl(array $customOptions = []): CurlHandle
|
||||
{
|
||||
$options = [
|
||||
CURLOPT_URL => $this->pls,
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_TIMEOUT => 30,
|
||||
CURLOPT_HEADER => false,
|
||||
CURLOPT_FAILONERROR => true,
|
||||
CURLOPT_FOLLOWLOCATION => true,
|
||||
CURLOPT_MAXREDIRS => 5,
|
||||
CURLOPT_USERAGENT => config('app.user_agent'),
|
||||
];
|
||||
|
||||
$curl = curl_init();
|
||||
|
||||
foreach ($options as $option => $value) {
|
||||
curl_setopt($curl, $option, $value);
|
||||
}
|
||||
|
||||
// array_merge($options, $customOptions) loses keys
|
||||
foreach ($customOptions as $option => $value) {
|
||||
curl_setopt($curl, $option, $value);
|
||||
}
|
||||
|
||||
return $curl;
|
||||
}
|
||||
|
||||
/**
|
||||
* Парсит атрибуты строки и возвращает ассоциативный массив
|
||||
*
|
||||
|
||||
@@ -2,12 +2,12 @@
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Extensions;
|
||||
namespace App\Core;
|
||||
|
||||
use Twig\Extension\AbstractExtension;
|
||||
use Twig\TwigFunction;
|
||||
|
||||
class TwigFunctions extends AbstractExtension
|
||||
class TwigExtention extends AbstractExtension
|
||||
{
|
||||
public function getFunctions(): array
|
||||
{
|
||||
92
src/app/Errors/ErrorHandler.php
Normal file
92
src/app/Errors/ErrorHandler.php
Normal file
@@ -0,0 +1,92 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Errors;
|
||||
|
||||
use Psr\Http\Message\{
|
||||
ResponseInterface,
|
||||
ServerRequestInterface};
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Slim\Handlers\ErrorHandler as SlimErrorHandler;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Обработчик ошибок
|
||||
*/
|
||||
class ErrorHandler extends SlimErrorHandler
|
||||
{
|
||||
/**
|
||||
* Логирует ошибку и отдаёт JSON-ответ с необходимым содержимым
|
||||
*
|
||||
* @param ServerRequestInterface $request
|
||||
* @param Throwable $exception
|
||||
* @param bool $displayErrorDetails
|
||||
* @param bool $logErrors
|
||||
* @param bool $logErrorDetails
|
||||
* @param LoggerInterface|null $logger
|
||||
* @return ResponseInterface
|
||||
*/
|
||||
public function __invoke(
|
||||
ServerRequestInterface $request,
|
||||
Throwable $exception,
|
||||
bool $displayErrorDetails,
|
||||
bool $logErrors,
|
||||
bool $logErrorDetails,
|
||||
?LoggerInterface $logger = null
|
||||
): ResponseInterface {
|
||||
$payload = $this->payload($exception, $displayErrorDetails);
|
||||
|
||||
$response = app()->getResponseFactory()->createResponse();
|
||||
$response->getBody()->write(json_encode($payload, JSON_UNESCAPED_UNICODE));
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
/**
|
||||
* Возвращает структуру исключения для контекста
|
||||
*
|
||||
* @param Throwable $e Исключение
|
||||
* @param bool $logErrorDetails Признак дополнения деталями
|
||||
* @return array
|
||||
*/
|
||||
protected function context(Throwable $e, bool $logErrorDetails): array
|
||||
{
|
||||
$result = ['code' => $e->getCode()];
|
||||
|
||||
$logErrorDetails && $result += [
|
||||
'class' => $e::class,
|
||||
'file' => $e->getFile(),
|
||||
'line' => $e->getLine(),
|
||||
'trace' => $e->getTrace()
|
||||
];
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Возвращает структуру исключения для передачи в ответе
|
||||
*
|
||||
* @param Throwable $e Исключение
|
||||
* @param bool $displayErrorDetails Признак дополнения деталями
|
||||
* @return array
|
||||
*/
|
||||
protected function payload(Throwable $e, bool $displayErrorDetails): array
|
||||
{
|
||||
$result = [
|
||||
'error' => [
|
||||
'code' => $e->getCode(),
|
||||
'message' => $e->getMessage(),
|
||||
],
|
||||
];
|
||||
|
||||
$displayErrorDetails && $result['error'] += [
|
||||
'class' => $e::class,
|
||||
'file' => $e->getFile(),
|
||||
'line' => $e->getLine(),
|
||||
'trace' => $e->getTrace(),
|
||||
];
|
||||
|
||||
return $result;
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Exceptions;
|
||||
namespace App\Errors;
|
||||
|
||||
use Exception;
|
||||
|
||||
29
src/app/Middleware/RequestId.php
Normal file
29
src/app/Middleware/RequestId.php
Normal file
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Middleware;
|
||||
|
||||
use Psr\Http\Message\ResponseInterface;
|
||||
use Psr\Http\Message\ServerRequestInterface;
|
||||
use Psr\Http\Server\RequestHandlerInterface;
|
||||
|
||||
/**
|
||||
* Middleware для добавления запросу заголовка X-Request-ID
|
||||
*/
|
||||
class RequestId
|
||||
{
|
||||
/**
|
||||
* Добавляет запросу заголовок X-Request-ID
|
||||
*
|
||||
* @param ServerRequestInterface $request
|
||||
* @param RequestHandlerInterface $handler
|
||||
* @return ResponseInterface
|
||||
*/
|
||||
public function __invoke(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
|
||||
{
|
||||
$request = $request->withHeader('X-Request-ID', uniqid());
|
||||
|
||||
return $handler->handle($request);
|
||||
}
|
||||
}
|
||||
@@ -2,9 +2,9 @@
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use flight\Engine;
|
||||
use flight\net\Response;
|
||||
use Illuminate\Support\Arr;
|
||||
use App\Core\Core;
|
||||
use App\Core\IniFile;
|
||||
use Slim\App;
|
||||
|
||||
/**
|
||||
* Returns path to root application directory
|
||||
@@ -58,7 +58,7 @@ function views_path(string $path = ''): string
|
||||
*/
|
||||
function base_url(string $route = ''): string
|
||||
{
|
||||
return rtrim(sprintf('%s/%s', config('flight.base_url'), $route), '/');
|
||||
return rtrim(sprintf('%s/%s', env('APP_URL'), $route), '/');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -70,7 +70,7 @@ function base_url(string $route = ''): string
|
||||
*/
|
||||
function env(string $key, mixed $default = null): mixed
|
||||
{
|
||||
return $_ENV[$key] ?? $default;
|
||||
return $_ENV[$key] ?? $_SERVER[$key] ?? $default;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -89,23 +89,23 @@ function view(mixed $template, array $data = []): void
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns response object
|
||||
* Returns core object
|
||||
*
|
||||
* @return Response
|
||||
* @return Core
|
||||
*/
|
||||
function response(): Response
|
||||
function core(): Core
|
||||
{
|
||||
return Flight::response();
|
||||
return Core::get();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns app object
|
||||
*
|
||||
* @return Engine
|
||||
* @return App
|
||||
*/
|
||||
function app(): Engine
|
||||
function app(): App
|
||||
{
|
||||
return Flight::app();
|
||||
return Core::get()->app();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -135,5 +135,25 @@ function bool(mixed $value): bool
|
||||
*/
|
||||
function config(string $key, mixed $default = null): mixed
|
||||
{
|
||||
return Flight::get('config')[$key] ?? $default;
|
||||
return Core::get()->config($key, $default);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Redis instance
|
||||
*
|
||||
* @return Redis
|
||||
*/
|
||||
function redis(): Redis
|
||||
{
|
||||
return Core::get()->redis();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get ini-file instance
|
||||
*
|
||||
* @return IniFile
|
||||
*/
|
||||
function ini(): IniFile
|
||||
{
|
||||
return Core::get()->ini();
|
||||
}
|
||||
|
||||
0
src/cache/.gitkeep
vendored
Normal file → Executable file
0
src/cache/.gitkeep
vendored
Normal file → Executable file
@@ -1,11 +1,26 @@
|
||||
{
|
||||
"name": "axenov/iptv",
|
||||
"type": "project",
|
||||
"description": "Сервис для сбора IPTV-плейлистов и сокращения ссылок",
|
||||
"authors": [
|
||||
{
|
||||
"name": "Anthony Axenov",
|
||||
"homepage": "https://axenov.dev/",
|
||||
"role": "author"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"require": {
|
||||
"php": "^8.2",
|
||||
"php": "^8.3",
|
||||
"ext-json": "*",
|
||||
"ext-curl": "*",
|
||||
"mikecao/flight": "^3.12",
|
||||
"symfony/dotenv": "^7.1",
|
||||
"twig/twig": "^3.14"
|
||||
"ext-redis": "*",
|
||||
"ext-fileinfo": "*",
|
||||
"guzzlehttp/guzzle": "^7.8",
|
||||
"nyholm/psr7": "^1.6",
|
||||
"vlucas/phpdotenv": "*",
|
||||
"slim/slim": "^4.11",
|
||||
"slim/twig-view": "^3.4"
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
@@ -23,6 +38,9 @@
|
||||
},
|
||||
"config": {
|
||||
"optimize-autoloader": true,
|
||||
"preferred-install": "dist",
|
||||
"sort-packages": true
|
||||
}
|
||||
},
|
||||
"minimum-stability": "dev",
|
||||
"prefer-stable": true
|
||||
}
|
||||
|
||||
1609
src/composer.lock
generated
1609
src/composer.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -3,17 +3,13 @@
|
||||
declare(strict_types=1);
|
||||
|
||||
return [
|
||||
// https://flightphp.com/learn#configuration
|
||||
'flight.base_url' => env('APP_URL', 'http://localhost:8080'),
|
||||
'flight.case_sensitive' => bool(env('FLIGHT_CASE_SENSITIVE', false)),
|
||||
'flight.handle_errors' => bool(env('FLIGHT_HANDLE_ERRORS', true)),
|
||||
'flight.log_errors' => bool(env('FLIGHT_LOG_ERRORS', true)),
|
||||
'flight.views.path' => views_path(),
|
||||
'flight.views.extension' => '.twig',
|
||||
'twig.cache' => bool(env('TWIG_CACHE', true)) ? cache_path() . '/views' : false,
|
||||
'twig.debug' => bool(env('TWIG_DEBUG', false)),
|
||||
'app.title' => env('APP_TITLE', 'IPTV Playlists'),
|
||||
'app.pls_encodings' => [
|
||||
'base_url' => env('APP_URL', 'http://localhost:8080'),
|
||||
'debug' => bool(env('APP_DEBUG', false)),
|
||||
'env' => env('APP_ENV', env('IPTV_ENV', 'prod')),
|
||||
'title' => env('APP_TITLE', 'IPTV Плейлисты'),
|
||||
'user_agent' => env('USER_AGENT'),
|
||||
'page_size' => (int)env('PAGE_SIZE', 10),
|
||||
'pls_encodings' => [
|
||||
'UTF-8',
|
||||
'CP1251',
|
||||
// 'CP866',
|
||||
|
||||
@@ -1,198 +0,0 @@
|
||||
[1]
|
||||
name = 'free-tv.me'
|
||||
desc = 'Каналы СНГ. Обновления бывают очень большими. Политика, мультики, новости, кино, музыка, спорт, 18+ и мн. др.'
|
||||
pls = 'https://free-tv.me/iptv/tv'
|
||||
src =
|
||||
|
||||
[2]
|
||||
name = 'Плейлист 2020 (iptv-playlisty.ru)'
|
||||
desc = 'Трансляции для детей и подростков. Сериалы и Премьеры кино. Каналы для женщин и мужских развлечений. Документалистика и исторические лента о событиях прошлого.'
|
||||
pls = 'https://iptv-playlisty.ru/wp-content/uploads/m3u/2020.m3u'
|
||||
src = 'https://iptv-playlisty.ru/collection/samyj-svezheobnovlennyj-plejlist-iptv-na-2020-god/'
|
||||
|
||||
[3]
|
||||
name = 'Плейлист newplay (iptv-playlisty.ru)'
|
||||
desc = 'Общефедеральные. Каналы фильмов. Все на русском. Имеются с зарубежными лентами. Спортивные. Как трансляции, так и кино данной тематики. Детские. Мультфильмы и передачи.'
|
||||
pls = 'https://iptv-playlisty.ru/wp-content/uploads/m3u/newplay.m3u'
|
||||
src = 'https://iptv-playlisty.ru/collection/besplatnyj-iptv-plejlist-formata-m3u/'
|
||||
|
||||
[ru]
|
||||
name = 'Русские'
|
||||
desc =
|
||||
pls = 'https://raw.githubusercontent.com/iptv-org/iptv/master/streams/ru.m3u'
|
||||
src = 'https://github.com/iptv-org/iptv'
|
||||
|
||||
[ru2]
|
||||
redirect = ru
|
||||
|
||||
[ru3]
|
||||
redirect = ru
|
||||
|
||||
[p5]
|
||||
redirect = 2
|
||||
|
||||
[np]
|
||||
redirect = 3
|
||||
|
||||
[tp]
|
||||
name = 'TaurerPlus'
|
||||
desc =
|
||||
pls = 'https://raw.githubusercontent.com/TaurerMedia/TaurerPlus/main/index.m3u8'
|
||||
src = 'https://github.com/anthonyaxenov/iptv/issues/2'
|
||||
|
||||
[mus]
|
||||
name = 'Музыкальные IPTV каналы (iptv-playlisty.ru)'
|
||||
desc = 'Музыкальный плейлист наиболее популярных каналов на нашем телевидении.'
|
||||
pls = 'https://iptv-playlisty.ru/wp-content/uploads/m3u/music.m3u'
|
||||
src = 'https://iptv-playlisty.ru/janriptv/muzykalnye-iptv-kanaly-v-formate-m3u/'
|
||||
|
||||
[det]
|
||||
name = 'Плейлист детских IPTV каналов (iptv-playlisty.ru)'
|
||||
desc =
|
||||
pls = 'https://iptv-playlisty.ru/wp-content/uploads/m3u/deti.m3u'
|
||||
src = 'https://iptv-playlisty.ru/janriptv/plejlist-detskix-iptv-kanalov-v-formate-m3u/'
|
||||
|
||||
[det2]
|
||||
name = 'Плейлист IPTV Мультфильмов для детей и подростков (iptv-playlisty.ru)'
|
||||
desc =
|
||||
pls = 'https://iptv-playlisty.ru/wp-content/uploads/m3u/multy.m3u'
|
||||
src = 'https://iptv-playlisty.ru/janriptv/plejlist-iptv-multfilmov-dlya-detej-i-podrostkov/'
|
||||
|
||||
[sng]
|
||||
name = 'Каналы СНГ'
|
||||
desc =
|
||||
pls = 'https://dl.dropboxusercontent.com/s/iw9v57cln6dfkpu/Vinnitsa.m3u'
|
||||
src = 'https://iptvsensei.ru/samoobnovlyayemyye-pleylisty-iptv'
|
||||
|
||||
[sng2]
|
||||
redirect = cam
|
||||
|
||||
[cam]
|
||||
name = 'Веб-камеры'
|
||||
desc =
|
||||
pls = 'http://gorod.tv/iptv.m3u'
|
||||
src = 'https://iptvsensei.ru/samoobnovlyayemyye-pleylisty-iptv'
|
||||
|
||||
[moto]
|
||||
name = 'Плейлист Авто и Мото каналов IPTV (iptv-playlisty.ru)'
|
||||
desc = 'В список были включены популярные не только у нас каналы авто тематики.'
|
||||
pls = 'https://iptv-playlisty.ru/wp-content/uploads/m3u/automoto.m3u'
|
||||
src = 'https://iptv-playlisty.ru/janriptv/plejlist-avto-i-moto-kanalov-iptv/'
|
||||
|
||||
[poz]
|
||||
name = 'Плейлист IPTV образовательных каналов (iptv-playlisty.ru)'
|
||||
desc = 'В список вошло более десятка трансляций. Зритель сможет найти здесь передачи для разностороннего обучения и в целом просвещения.'
|
||||
pls = 'https://iptv-playlisty.ru/wp-content/uploads/m3u/obrazovanie.m3u'
|
||||
src = 'https://iptv-playlisty.ru/janriptv/plejlist-iptv-obrazovatelnyx-kanalov-m3u/'
|
||||
|
||||
[poz2]
|
||||
name = 'Познавательные каналы IPTV плейлист (iptv-playlisty.ru)'
|
||||
desc = 'Наиболее подходящие трансляции, с выпусками интересных познавательных передач на любой вкус'
|
||||
pls = 'https://iptv-playlisty.ru/wp-content/uploads/m3u/poznav.m3u'
|
||||
src = 'https://iptv-playlisty.ru/janriptv/poznavatelnye-kanaly-iptv-plejlist-v-formate-m3u/'
|
||||
|
||||
[moda]
|
||||
name = 'Плейлист IPTV модных телеканалов (iptv-playlisty.ru)'
|
||||
desc = 'Сюда вошли только топовые компании, на которых ежедневно рассказывают о последних тенденциях в одежде и дизайне.'
|
||||
pls = 'https://iptv-playlisty.ru/wp-content/uploads/m3u/moda.m3u'
|
||||
src = 'https://iptv-playlisty.ru/janriptv/plejlist-iptv-modnyx-telekanalov-m3u/'
|
||||
|
||||
[fun]
|
||||
name = 'Плейлист IPTV каналов развлечений (iptv-playlisty.ru)'
|
||||
desc = 'Огромное количество развлекательных каналов'
|
||||
pls = 'https://iptv-playlisty.ru/wp-content/uploads/m3u/razvlechenie.m3u'
|
||||
src = 'https://iptv-playlisty.ru/janriptv/plejlist-iptv-kanalov-razvlechenij/'
|
||||
|
||||
[hd]
|
||||
name = 'Плейлист IPTV каналов в HD формате (iptv-playlisty.ru)'
|
||||
desc = 'Плейлист доступных по настоящий момент HD каналов'
|
||||
pls = 'https://iptv-playlisty.ru/wp-content/uploads/m3u/hd.m3u'
|
||||
src = 'https://iptv-playlisty.ru/janriptv/plejlist-iptv-kanalov-v-hd-formate/'
|
||||
|
||||
[nauka]
|
||||
name = 'Научные IPTV каналы в формате (iptv-playlisty.ru)'
|
||||
desc = 'Современные научные каналы: история, документальное кино, психология, культура...'
|
||||
pls = 'https://iptv-playlisty.ru/wp-content/uploads/m3u/nauka.m3u'
|
||||
src = 'https://iptv-playlisty.ru/janriptv/nauchnye-iptv-kanaly-v-formate-m3u/'
|
||||
|
||||
[eda]
|
||||
name = 'IPTV плейлист кулинарных каналов (iptv-playlisty.ru)'
|
||||
desc = 'В список вошли наиболее интересные кулинарные каналы в формате M3U. Большинство каналов на русском языке'
|
||||
pls = 'https://iptv-playlisty.ru/wp-content/uploads/m3u/eda.m3u'
|
||||
src = 'https://iptv-playlisty.ru/janriptv/iptv-plejlist-kulinarnyx-kanalov/'
|
||||
|
||||
[heal]
|
||||
name = 'IPTV плейлист каналов здоровья (iptv-playlisty.ru)'
|
||||
desc =
|
||||
pls = 'https://iptv-playlisty.ru/wp-content/uploads/m3u/zdorov.m3u'
|
||||
src = 'https://iptv-playlisty.ru/janriptv/iptv-plejlist-kanalov-zdorovya/'
|
||||
|
||||
[sport]
|
||||
name = 'Плейлист IPTV спортивных каналов (iptv-playlisty.ru)'
|
||||
desc = 'В коллекции можно встретить: футбольные матчи, баскетбольные встречи, теннис, хоккей, автоспорт и даже гольф.'
|
||||
pls = 'https://iptv-playlisty.ru/wp-content/uploads/m3u/sport.m3u'
|
||||
src = 'https://iptv-playlisty.ru/janriptv/plejlist-iptv-sportivnyx-kanalov/'
|
||||
|
||||
[strah]
|
||||
name = 'Бесплатный IPTV плейлист каналов ужасов (iptv-playlisty.ru)'
|
||||
desc =
|
||||
pls = 'https://iptv-playlisty.ru/wp-content/uploads/m3u/Strah.m3u'
|
||||
src = 'https://iptv-playlisty.ru/janriptv/besplatnyj-iptv-plejlist-kanalov-uzhasov/'
|
||||
|
||||
[his]
|
||||
name = 'IPTV плейлист телеканала History (iptv-playlisty.ru)'
|
||||
desc =
|
||||
pls = 'https://iptv-playlisty.ru/wp-content/uploads/m3u/history.m3u'
|
||||
src = 'https://iptv-playlisty.ru/iptv-kanaly/iptv-plejlist-telekanala-history/'
|
||||
|
||||
[dis]
|
||||
name = 'IPTV плейлист телеканала Discovery (iptv-playlisty.ru)'
|
||||
desc =
|
||||
pls = 'https://iptv-playlisty.ru/wp-content/uploads/m3u/discovery.m3u'
|
||||
src = 'https://iptv-playlisty.ru/iptv-kanaly/iptv-plejlist-telekanala-discovery/'
|
||||
|
||||
[ngeo]
|
||||
name = 'IPTV плейлист канала National Geographic (iptv-playlisty.ru)'
|
||||
desc =
|
||||
pls = 'https://iptv-playlisty.ru/wp-content/uploads/m3u/ngeografik.m3u'
|
||||
src = 'https://iptv-playlisty.ru/iptv-kanaly/iptv-plejlist-kanala-national-geographic/'
|
||||
|
||||
[kino5]
|
||||
name = 'Фильмы 5 (iptvsensei.ru)'
|
||||
desc =
|
||||
pls = 'https://pastebin.com/raw/jLaRge54'
|
||||
src = 'https://iptvsensei.ru/novye-samoobnovlyaemye-plejlisty'
|
||||
|
||||
[kz2]
|
||||
name = 'Казахстан'
|
||||
desc =
|
||||
pls = 'https://raw.githubusercontent.com/iptv-org/iptv/master/streams/kz.m3u'
|
||||
src = 'https://github.com/iptv-org/iptv'
|
||||
|
||||
[r1]
|
||||
name = 'Радио каналы 1'
|
||||
desc =
|
||||
pls = 'http://lradio.c1.biz/ltradio.m3u'
|
||||
src = 'https://iptvsensei.ru/novye-samoobnovlyaemye-plejlisty'
|
||||
|
||||
[sng13]
|
||||
redirect = sng
|
||||
|
||||
[sng14]
|
||||
redirect = sng2
|
||||
|
||||
[b1]
|
||||
name = 'IPTV плейлисты для GX3235T2C и AV2568T2C'
|
||||
desc =
|
||||
pls = 'https://www.digitaltv.ru/upload/iblock/034/tvlist.m3u'
|
||||
src = 'https://www.digitaltv.ru/news/iptv_pleylisty.html'
|
||||
|
||||
[az]
|
||||
name='Каналы Азербайджана'
|
||||
desc =
|
||||
pls = 'https://raw.githubusercontent.com/iptv-org/iptv/master/streams/az.m3u'
|
||||
src = 'https://github.com/iptv-org/iptv'
|
||||
|
||||
[az2]
|
||||
redirect = az
|
||||
|
||||
11
src/config/redis.php
Normal file
11
src/config/redis.php
Normal file
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
return [
|
||||
'host' => env('REDIS_HOST', 'keydb'),
|
||||
'port' => (int)env('REDIS_PORT', 6379),
|
||||
'password' => env('REDIS_PASSWORD'),
|
||||
'db' => (int)env('REDIS_DB', 0),
|
||||
'ttl_days' => (int)env('REDIS_TTL_DAYS', 14) * 60 * 60 * 24, // 2 недели
|
||||
];
|
||||
@@ -1,16 +1,52 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Controllers\HomeController;
|
||||
use App\Controllers\PlaylistController;
|
||||
use App\Controllers\ApiController;
|
||||
use App\Controllers\BasicController;
|
||||
use App\Controllers\WebController;
|
||||
|
||||
return [
|
||||
'GET /' => [HomeController::class, 'index'],
|
||||
'GET /page/@page:[0-9]+' => [HomeController::class, 'index'],
|
||||
'GET /faq' => [HomeController::class, 'faq'],
|
||||
'GET /@id:[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_-]+/json' => [PlaylistController::class, 'json'],
|
||||
[
|
||||
'method' => 'GET',
|
||||
'path' => '/[page/{page:[0-9]+}]',
|
||||
'handler' => [WebController::class, 'home'],
|
||||
'name' => 'home',
|
||||
],
|
||||
[
|
||||
'method' => 'GET',
|
||||
'path' => '/faq',
|
||||
'handler' => [WebController::class, 'faq'],
|
||||
'name' => 'faq',
|
||||
],
|
||||
[
|
||||
'method' => 'GET',
|
||||
'path' => '/logo',
|
||||
'handler' => [WebController::class, 'logo'],
|
||||
'name' => 'logo',
|
||||
],
|
||||
[
|
||||
'method' => 'GET',
|
||||
'path' => '/{code:[0-9a-zA-Z]+}',
|
||||
'handler' => [WebController::class, 'redirect'],
|
||||
'name' => 'redirect',
|
||||
],
|
||||
[
|
||||
'method' => 'GET',
|
||||
'path' => '/{code:[0-9a-zA-Z]+}/details',
|
||||
'handler' => [WebController::class, 'details'],
|
||||
'name' => 'details',
|
||||
],
|
||||
[
|
||||
'method' => 'GET',
|
||||
'path' => '/{code:[0-9a-zA-Z]+}/json',
|
||||
'handler' => [ApiController::class, 'json'],
|
||||
'name' => 'json',
|
||||
],
|
||||
[
|
||||
'method' => '*',
|
||||
'path' => '/{path:.*}',
|
||||
'handler' => [BasicController::class, 'notFound'],
|
||||
'name' => 'not-found',
|
||||
],
|
||||
// ...
|
||||
];
|
||||
|
||||
|
||||
8
src/config/twig.php
Normal file
8
src/config/twig.php
Normal file
@@ -0,0 +1,8 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
return [
|
||||
'cache' => bool(env('TWIG_USE_CACHE', true)) ? cache_path() . '/views' : false,
|
||||
'debug' => bool(env('TWIG_DEBUG', false)),
|
||||
];
|
||||
13
src/public/boosty.svg
Normal file
13
src/public/boosty.svg
Normal file
@@ -0,0 +1,13 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Generator: Adobe Illustrator 24.2.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
|
||||
<svg version="1.1" id="Слой_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
|
||||
viewBox="0 0 235.6 292.2" style="enable-background:new 0 0 235.6 292.2;" xml:space="preserve">
|
||||
<style type="text/css">
|
||||
.st0{fill:#FFFFFF;}
|
||||
</style>
|
||||
<g id="b_1_">
|
||||
<path class="st0" d="M44.3,164.5L76.9,51.6H127l-10.1,35c-0.1,0.2-0.2,0.4-0.3,0.6L90,179.6h24.8c-10.4,25.9-18.5,46.2-24.3,60.9
|
||||
c-45.8-0.5-58.6-33.3-47.4-72.1 M90.7,240.6l60.4-86.9h-25.6l22.3-55.7c38.2,4,56.2,34.1,45.6,70.5
|
||||
c-11.3,39.1-57.1,72.1-101.7,72.1C91.3,240.6,91,240.6,90.7,240.6z"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 735 B |
@@ -2,19 +2,6 @@
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Core\Bootstrapper;
|
||||
use Symfony\Component\Dotenv\Dotenv;
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Bootstrap all classes, settings, etc.
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
require '../vendor/autoload.php';
|
||||
(new Dotenv())->loadEnv(root_path() . '/.env');
|
||||
Bootstrapper::bootSettings();
|
||||
Bootstrapper::bootTwig();
|
||||
Bootstrapper::bootIni();
|
||||
Bootstrapper::bootRoutes();
|
||||
Flight::start();
|
||||
|
||||
core()->boot()->run();
|
||||
|
||||
BIN
src/public/no-tvg-logo.png
Normal file
BIN
src/public/no-tvg-logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.1 KiB |
@@ -1,13 +1,19 @@
|
||||
{% extends "template.twig" %}
|
||||
|
||||
{% block title %}{{ name }} - {{ config('app.title') }}{% endblock %}
|
||||
{% block title %}[{{ id }}] {{ name }} - {{ config('app.title') }}{% endblock %}
|
||||
|
||||
{% 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 %}
|
||||
|
||||
{% block header %}
|
||||
<h2>О плейлисте {{ name }}</h2>
|
||||
<h2>О плейлисте: {{ name }}</h2>
|
||||
{% if (content.encoding.alert) %}
|
||||
<div class="alert alert-warning small" role="alert">
|
||||
Кодировка исходного плейлиста отличается от UTF-8.
|
||||
@@ -31,12 +37,12 @@
|
||||
|
||||
{% block content %}
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<table class="table table-dark table-hover small">
|
||||
<div class="col-lg-7">
|
||||
<table class="table table-dark table-hover small mb-lg-5">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td class="w-25">ID</td>
|
||||
<td>
|
||||
<th class="w-25" scope="row">ID</th>
|
||||
<td class="text-break">
|
||||
<code>{{ id }}</code> {% if status.possibleStatus == 'online' %}
|
||||
<span class="badge small text-dark bg-success">online</span>
|
||||
{% elseif status.possibleStatus == 'offline' %}
|
||||
@@ -45,50 +51,76 @@
|
||||
<span class="badge small text-dark bg-warning">timeout</span>
|
||||
{% elseif status.possibleStatus == 'error' %}
|
||||
<span class="badge small text-dark bg-danger">error</span>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Описание</td>
|
||||
<td><p>{{ desc }}</p></td>
|
||||
<th scope="row">Описание</th>
|
||||
<td class="text-break"><p>{{ desc }}</p></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><b>Ccылка для ТВ</b></td>
|
||||
<th scope="row">Ccылка для ТВ</th>
|
||||
<td><b onclick="prompt('Скопируй адрес плейлиста', '{{ url }}')"
|
||||
data-bs-toggle="tooltip"
|
||||
data-bs-placement="top"
|
||||
title="Нажми на ссылку, чтобы скопировать её в буфер обмена"
|
||||
class="font-monospace cursor-pointer">{{ url }}</b></td>
|
||||
class="font-monospace cursor-pointer text-break">{{ url }}</b></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>M3U</td>
|
||||
<td>{{ pls }}</td>
|
||||
<th scope="row">M3U</th>
|
||||
<td class="text-break">{{ pls }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Источник</td>
|
||||
<td>{{ src }}</td>
|
||||
<th scope="row">Источник</th>
|
||||
<td class="text-break">{{ src }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{% if (content.attributes) %}
|
||||
<h4>Дополнительные атрибуты</h4>
|
||||
<table class="table table-dark table-hover small">
|
||||
<tbody>
|
||||
{% for attribute,value in content.attributes %}
|
||||
<tr>
|
||||
<th class="w-25" scope="row">{{ attribute }}</th>
|
||||
<td class="text-break">{{ value }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="col-md-6">
|
||||
<div class="col-lg-5">
|
||||
<h4>Список каналов ({{ content.channelCount ?? 0 }})</h4>
|
||||
{% if (content.channelCount > 0) %}
|
||||
<div id="chlist">
|
||||
<input type="text" class="form-control form-control-sm bg-dark text-light mb-2 fuzzy-search" placeholder="Поиск...">
|
||||
<div class="overflow-auto" style="max-height:550px">
|
||||
<input type="text"
|
||||
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">
|
||||
<tbody class="list">
|
||||
{% for channel in content.channels %}
|
||||
<tr class="chrow">
|
||||
<td class="p-1" class="chindex">{{ loop.index }}</td>
|
||||
<td class="p-1">
|
||||
{% if (channel.attributes['tvg-logo']) %}
|
||||
<img class="tvg-logo-background" src="{{ channel.attributes['tvg-logo'] }}" />
|
||||
<td class="chindex">{{ loop.index }}</td>
|
||||
<td class="chlogo text-center">
|
||||
<img class="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 %}
|
||||
alt="Логотип канала '{{ channel.name }}'"
|
||||
title="Логотип канала '{{ channel.name }}'"
|
||||
/>
|
||||
</td>
|
||||
<td class="p-1 chname">{{ channel.name }}</td>
|
||||
<td class="chname text-break">{{ channel.name }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
|
||||
@@ -16,6 +16,10 @@
|
||||
видео/аудио потоков, программ телепередач, плейлистов и их поддержки. Этим занимаются администраторы
|
||||
ресурсов, указанные как источник, и те, с чьих ресурсов ведётся трансляция.
|
||||
</p>
|
||||
<p>
|
||||
За содержимое плейлистов и их качество отвечают авторы плейлистов. На стороне сервиса управляются сами
|
||||
плейлисты.
|
||||
</p>
|
||||
<p class="mb-5">
|
||||
Сервис "{{ config('app.title') }}" ({{ base_url() }}) предоставляет только информацию об активности
|
||||
плейлистов, найденных в открытом доступе, и короткие ссылки на них для удобства использования в ПО.
|
||||
@@ -24,8 +28,26 @@
|
||||
</p>
|
||||
|
||||
<div class="accordion" id="faq-accordion">
|
||||
<div class="accordion-item bg-dark">
|
||||
<h2 class="accordion-header bg-dark" id="h-howtouse">
|
||||
<div class="accordion-item bg-dark text-light">
|
||||
<h2 class="accordion-header" id="h-purpose">
|
||||
<button class="accordion-button text-light bg-dark" type="button" data-bs-toggle="collapse" data-bs-target="#purpose" aria-expanded="false" aria-controls="purpose">
|
||||
Для чего нужен сервис?
|
||||
</button>
|
||||
</h2>
|
||||
<div id="purpose" class="accordion-collapse collapse" aria-labelledby="h-purpose" data-bs-parent="#faq-accordion">
|
||||
<div class="accordion-body">
|
||||
<p>Изначально сервис создавался "для себя", чтобы:</p>
|
||||
<ul>
|
||||
<li>сократить ссылки на сторонние плейлисты и их было проще вводить с пульта;</li>
|
||||
<li>собрать в одном месте наиболее годные плейлисты.</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="accordion-item bg-dark text-light">
|
||||
<h2 class="accordion-header" id="h-howtouse">
|
||||
<button class="accordion-button text-light bg-dark" type="button" data-bs-toggle="collapse" data-bs-target="#howtouse" aria-expanded="false" aria-controls="howtouse">
|
||||
Как пользоваться сервисом?
|
||||
</button>
|
||||
@@ -42,7 +64,8 @@
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="accordion-item bg-dark">
|
||||
|
||||
<div class="accordion-item bg-dark text-light">
|
||||
<h2 class="accordion-header bg-dark" id="h-howtoconnect">
|
||||
<button class="accordion-button text-light bg-dark" type="button" data-bs-toggle="collapse" data-bs-target="#howtoconnect" aria-expanded="false" aria-controls="howtoconnect">
|
||||
Как подключить плейлист?
|
||||
@@ -55,17 +78,35 @@
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="accordion-item bg-dark">
|
||||
|
||||
<div class="accordion-item bg-dark text-light">
|
||||
<h2 class="accordion-header bg-dark" id="h-isitfree">
|
||||
<button class="accordion-button text-light bg-dark" type="button" data-bs-toggle="collapse" data-bs-target="#isitfree" aria-expanded="false" aria-controls="isitfree">
|
||||
Эти плейлисты и каналы в них -- бесплатны?
|
||||
</button>
|
||||
</h2>
|
||||
<div id="isitfree" class="accordion-collapse collapse" aria-labelledby="h-isitfree" data-bs-parent="#faq-accordion">
|
||||
<p class="accordion-body">Возможно. По крайней мере, так утверждают источники. Но гарантий никаких никто не даёт.</p>
|
||||
<p class="accordion-body">
|
||||
Возможно. По крайней мере, так утверждают источники. Но гарантий никаких никто не даёт.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="accordion-item bg-dark">
|
||||
|
||||
<div class="accordion-item bg-dark text-light">
|
||||
<h2 class="accordion-header" id="h-logos">
|
||||
<button class="accordion-button text-light bg-dark" type="button" data-bs-toggle="collapse" data-bs-target="#logos" aria-expanded="false" aria-controls="logos">
|
||||
Откуда берутся логотипы каналов и программы передач?
|
||||
</button>
|
||||
</h2>
|
||||
<div id="logos" class="accordion-collapse collapse" aria-labelledby="h-logos" data-bs-parent="#faq-accordion">
|
||||
<p class="accordion-body">
|
||||
Всё это (не) указывается внутри плейлиста его авторами.
|
||||
Но в некоторых плеерах можно вручную указывать программу передач (см. ниже).
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="accordion-item bg-dark text-light">
|
||||
<h2 class="accordion-header bg-dark" id="h-which">
|
||||
<button class="accordion-button text-light bg-dark" type="button" data-bs-toggle="collapse" data-bs-target="#which" aria-expanded="false" aria-controls="which">
|
||||
Какие плейлисты попадают сюда?
|
||||
@@ -88,7 +129,8 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="accordion-item bg-dark">
|
||||
|
||||
<div class="accordion-item bg-dark text-light">
|
||||
<h2 class="accordion-header bg-dark" id="h-statuses">
|
||||
<button class="accordion-button text-light bg-dark" type="button" data-bs-toggle="collapse" data-bs-target="#statuses" aria-expanded="false" aria-controls="statuses">
|
||||
Что означают статусы плейлистов?
|
||||
@@ -123,7 +165,8 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="accordion-item bg-dark">
|
||||
|
||||
<div class="accordion-item bg-dark text-light">
|
||||
<h2 class="accordion-header bg-dark" id="h-donttrust">
|
||||
<button class="accordion-button text-light bg-dark" type="button" data-bs-toggle="collapse" data-bs-target="#donttrust" aria-expanded="false" aria-controls="donttrust">
|
||||
Почему нельзя доверять результатам проверки?
|
||||
@@ -145,7 +188,8 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="accordion-item bg-dark">
|
||||
|
||||
<div class="accordion-item bg-dark text-light">
|
||||
<h2 class="accordion-header bg-dark" id="h-guarantee">
|
||||
<button class="accordion-button text-light bg-dark" type="button" data-bs-toggle="collapse" data-bs-target="#guarantee" aria-expanded="false" aria-controls="guarantee">
|
||||
Какова гарантия, что я добавлю себе плейлист отсюда и он работать хоть сколько-нибудь долго?
|
||||
@@ -163,7 +207,19 @@
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="accordion-item bg-dark">
|
||||
|
||||
<div class="accordion-item bg-dark text-light">
|
||||
<h2 class="accordion-header" id="h-panic">
|
||||
<button class="accordion-button text-warning bg-dark" type="button" data-bs-toggle="collapse" data-bs-target="#panic" aria-expanded="false" aria-controls="panic">
|
||||
У меня перестал работать/исчез любимый канал/плейлист! Нет лого канала/программы передач!
|
||||
</button>
|
||||
</h2>
|
||||
<div id="panic" class="accordion-collapse collapse" aria-labelledby="h-panic" data-bs-parent="#faq-accordion">
|
||||
<p class="accordion-body">Ну штош ¯\_(ツ)_/¯</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="accordion-item bg-dark text-light">
|
||||
<h2 class="accordion-header bg-dark" id="h-epg">
|
||||
<button class="accordion-button text-light bg-dark" type="button" data-bs-toggle="collapse" data-bs-target="#epg" aria-expanded="false" aria-controls="epg">
|
||||
Где взять программу передач (EPG)?
|
||||
@@ -179,10 +235,11 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="accordion-item bg-dark">
|
||||
|
||||
<div class="accordion-item bg-dark text-light">
|
||||
<h2 class="accordion-header bg-dark" id="h-howoftenlist">
|
||||
<button class="accordion-button text-light bg-dark" type="button" data-bs-toggle="collapse" data-bs-target="#howoftenlist" aria-expanded="false" aria-controls="howoftenlist">
|
||||
Как часто обновляется этот список?
|
||||
Как часто обновляется список плейлистов?
|
||||
</button>
|
||||
</h2>
|
||||
<div id="howoftenlist" class="accordion-collapse collapse" aria-labelledby="h-howoftenlist" data-bs-parent="#faq-accordion">
|
||||
@@ -193,7 +250,8 @@
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="accordion-item bg-dark">
|
||||
|
||||
<div class="accordion-item bg-dark text-light">
|
||||
<h2 class="accordion-header bg-dark" id="h-howoftench">
|
||||
<button class="accordion-button text-light bg-dark" type="button" data-bs-toggle="collapse" data-bs-target="#howoftench" aria-expanded="false" aria-controls="howoftench">
|
||||
Как часто обновляется содержимое плейлистов?
|
||||
@@ -205,7 +263,8 @@
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="accordion-item bg-dark">
|
||||
|
||||
<div class="accordion-item bg-dark text-light">
|
||||
<h2 class="accordion-header bg-dark" id="h-api">
|
||||
<button class="accordion-button text-light bg-dark" type="button" data-bs-toggle="collapse" data-bs-target="#api" aria-expanded="false" aria-controls="api">
|
||||
Есть ли API? Как им пользоваться?
|
||||
@@ -217,7 +276,8 @@
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="accordion-item bg-dark">
|
||||
|
||||
<div class="accordion-item bg-dark text-light">
|
||||
<h2 class="accordion-header bg-dark" id="h-howtoadd">
|
||||
<button class="accordion-button text-light bg-dark" type="button" data-bs-toggle="collapse" data-bs-target="#howtoadd" aria-expanded="false" aria-controls="howtoadd">
|
||||
Как пополнить этот список?
|
||||
@@ -230,6 +290,7 @@
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -13,21 +13,17 @@
|
||||
<table class="table table-responsive table-dark table-hover small">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th class="text-center">ID</th>
|
||||
<th>Информация о плейлисте</th>
|
||||
<th>Каналов</th>
|
||||
<th class="d-none d-sm-table-cell">Ссылка для ТВ</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for id, playlist in playlists %}
|
||||
<tr class="pls" data-playlist-id="{{ id }}">
|
||||
<td class="text-center id">
|
||||
<strong>{{ id }}</strong>
|
||||
</td>
|
||||
<td class="text-center font-monospace id">{{ id }}</td>
|
||||
<td class="info">
|
||||
<span class="badge small bg-secondary text-dark status">loading</span>
|
||||
<strong>{{ playlist.name }}</strong>
|
||||
<a href="{{ base_url(id ~ '/details') }}" class="text-light fw-bold text-decoration-none">{{ playlist.name }}</a>
|
||||
<div class="small mt-2">
|
||||
{% if playlist.desc|length > 0 %}
|
||||
<p class="my-1 d-none d-lg-block">{{ playlist.desc }}</p>
|
||||
@@ -35,15 +31,8 @@
|
||||
<a href="{{ base_url(id ~ '/details') }}" class="text-light">Подробнее...</a>
|
||||
</div>
|
||||
</td>
|
||||
<td class="text-center count">
|
||||
<div class="spinner-border text-success" role="status">
|
||||
<span class="visually-hidden">загрузка...</span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="col-3 d-none d-sm-table-cell">
|
||||
<span onclick="prompt('Скопируй адрес плейлиста', '{{ playlist.url }}')"
|
||||
data-bs-toggle="tooltip"
|
||||
data-bs-placement="top"
|
||||
title="Нажми на ссылку, чтобы скопировать её в буфер обмена"
|
||||
class="font-monospace cursor-pointer">
|
||||
{{ playlist.url }}
|
||||
@@ -53,11 +42,11 @@
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% if pages.count > 0 %}
|
||||
{% if pageCount > 0 %}
|
||||
<div aria-label="pages">
|
||||
<ul class="pagination justify-content-center">
|
||||
{% for page in range(1, pages.count) %}
|
||||
{% if page == pages.current %}
|
||||
{% for page in range(1, pageCount) %}
|
||||
{% if page == pageCurrent %}
|
||||
<li class="page-item active" aria-current="page">
|
||||
<span class="page-link">{{ page }}</span>
|
||||
</li>
|
||||
@@ -74,55 +63,4 @@
|
||||
{% endblock %}
|
||||
|
||||
{% block footer %}
|
||||
<script>
|
||||
document.querySelectorAll('tr.pls').forEach((tr) => {
|
||||
const id = tr.attributes['data-playlist-id'].value
|
||||
const xhr = new XMLHttpRequest()
|
||||
xhr.responseType = 'json'
|
||||
xhr.timeout = 60000 // ms = 1 min
|
||||
let el_status = tr.querySelector('span.status')
|
||||
let el_count = tr.querySelector('td.count')
|
||||
xhr.onreadystatechange = () => {
|
||||
if (xhr.readyState === XMLHttpRequest.DONE) {
|
||||
console.log('[' + id + '] DONE', xhr.response)
|
||||
el_status.classList.remove('bg-secondary')
|
||||
el_status.innerText = xhr.response.status.possibleStatus
|
||||
el_count.innerText = xhr.response?.content.channelCount ?? 0
|
||||
switch (xhr.response.status.possibleStatus) {
|
||||
case 'online':
|
||||
el_status.classList.add('bg-success')
|
||||
break
|
||||
case 'timeout':
|
||||
el_status.classList.add('bg-warning')
|
||||
break
|
||||
default:
|
||||
el_status.classList.add('bg-danger')
|
||||
break
|
||||
}
|
||||
if (xhr.response.error) {
|
||||
el_status.title = '[' + xhr.response.error.code + '] ' + xhr.response.error.message
|
||||
}
|
||||
}
|
||||
}
|
||||
xhr.onerror = () => {
|
||||
console.log('[' + id + '] ERROR', xhr.response)
|
||||
el_status.classList.add('bg-danger')
|
||||
el_status.innerText = 'error'
|
||||
el_count.innerText = 0
|
||||
}
|
||||
xhr.onabort = () => {
|
||||
console.log('[' + id + '] ABORTED', xhr.response)
|
||||
el_status.classList.add('bg-secondary')
|
||||
el_count.innerText = 0
|
||||
}
|
||||
xhr.ontimeout = () => {
|
||||
console.log('[' + id + '] TIMEOUT', xhr.response)
|
||||
el_status.classList.add('bg-secondary')
|
||||
el_status.innerText = 'timeout'
|
||||
el_count.innerText = 0
|
||||
}
|
||||
xhr.open('GET', '/' + id + '/json')
|
||||
xhr.send()
|
||||
})
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
<meta name="msapplication-TileColor" content="#00aba9">
|
||||
<meta name="msapplication-TileImage" content="{{ base_url('/favicon/mstile-144x144.png') }}">
|
||||
<meta name="theme-color" content="#212529">
|
||||
{% include("custom.twig") ignore missing %}
|
||||
<style>.boosty{vertical-align:baseline;float:left;display:inline;width:20px}</style>
|
||||
{% block head %}{% endblock %}
|
||||
</head>
|
||||
<body class="bg-dark text-light">
|
||||
@@ -50,12 +50,18 @@
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="https://t.me/iptv_aggregator">Telegram</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="https://boosty.to/anthonyaxenov">
|
||||
<img class="boosty" src="{{ base_url('boosty.svg') }}" alt="Boosty">
|
||||
Boosty
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<section class="container-fluid h-100 pt-lg-3 px-0 pb-0">
|
||||
<section class="container h-100 pt-lg-3 px-0 pb-0">
|
||||
{% block header %}{% endblock %}
|
||||
{% block content %}{% endblock %}
|
||||
</section>
|
||||
@@ -68,12 +74,8 @@
|
||||
href="https://git.axenov.dev/anthony/iptv">Gitea</a> | <a
|
||||
href="https://axenov.dev">axenov.dev</a> | <a
|
||||
href="https://t.me/iptv_aggregator">Telegram</a><br>
|
||||
<span class="small text-muted">
|
||||
commit <a class="text-muted" target="_blank"
|
||||
href="https://github.com/anthonyaxenov/iptv/commit/{{ commit() }}"
|
||||
>{{ commit()[:8] }}</a>
|
||||
</span>
|
||||
</footer>
|
||||
</div>
|
||||
{% include("custom.twig") ignore missing %}
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Reference in New Issue
Block a user