Переезд в организацию на git.axenov.dev
- https://git.axenov.dev/IPTV/docker - https://git.axenov.dev/IPTV/playlists - https://git.axenov.dev/IPTV/svc-main - https://git.axenov.dev/IPTV/tools
9
.gitignore
vendored
@@ -1,12 +1,11 @@
|
|||||||
/.idea
|
/.idea
|
||||||
/.vscode
|
/.vscode
|
||||||
downloaded/
|
downloaded/
|
||||||
/src/commit
|
/svc-*
|
||||||
/src/cache/*
|
/src
|
||||||
/src/vendor
|
/tools
|
||||||
/src/config/playlists.ini
|
|
||||||
/src/views/custom.twig
|
|
||||||
/tmp
|
/tmp
|
||||||
|
|
||||||
*.log
|
*.log
|
||||||
.env
|
.env
|
||||||
*.m3u
|
*.m3u
|
||||||
|
|||||||
2
LICENSE
@@ -1,6 +1,6 @@
|
|||||||
MIT License
|
MIT License
|
||||||
|
|
||||||
Copyright (c) 2022 Антон Аксенов (aka Anthony Axenov)
|
Copyright (c) 2022 Антон Аксенов (Anthony Axenov)
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
of this software and associated documentation files (the "Software"), to deal
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
|||||||
277
README.md
@@ -1,171 +1,15 @@
|
|||||||
# Автообновляемые IPTV-плейлисты
|
# Инфраструктурный слой проекта iptv.axenov.dev
|
||||||
|
|
||||||
> **Web-версия**: [https://iptv.axenov.dev/](https://iptv.axenov.dev/)
|
**Web-версия**: https://iptv.axenov.dev
|
||||||
> **FAQ**: [https://iptv.axenov.dev/faq](https://iptv.axenov.dev/faq)
|
**FAQ**: https://iptv.axenov.dev/faq
|
||||||
> **Зеркало репозитория**: https://git.axenov.dev/anthony/iptv
|
**Исходный код**: https://git.axenov.dev/IPTV
|
||||||
|
|
||||||
Проект, содержащий в себе инструменты для работы с IPTV-плейлистами:
|
## Использованный стек
|
||||||
|
|
||||||
* список автообновляемых плейлистов, которые найдены в открытых источниках;
|
* [docker compose](https://docs.docker.com/compose/)
|
||||||
* скрипты для поиска каналов в этом списке, создания своего плейлиста;
|
* [php8.3-fpm](https://www.php.net/releases/8.3/ru.php)
|
||||||
* веб-сервис, предоставляющий короткие ссылки на эти плейлисты и отображающий список каналов.
|
* [nginx](https://nginx.org/ru/)
|
||||||
|
* bash
|
||||||
Плейлисты подбираются преимущественно для РФ и любых стран бывшего СНГ, но этими странами список не ограничивается.
|
|
||||||
|
|
||||||
Поддержкой этих плейлистов занимаются сервисы и ресурсы, указанные как источник.
|
|
||||||
Вопросы работоспособности плейлистов адресуйте тем, кто несёт за них ответственность.
|
|
||||||
|
|
||||||
Они бесплатны для использования.
|
|
||||||
Список проверяется и обновляется мной вручную.
|
|
||||||
Гарантию работоспособности никто не даёт.
|
|
||||||
|
|
||||||
* [Как использовать этот список?](#как-использовать-этот-список)
|
|
||||||
* [Формат `playlists.ini`](#формат-playlistsini)
|
|
||||||
* [API](#api)
|
|
||||||
* [Развёртывание проекта](#развёртывание-проекта)
|
|
||||||
* [Apache](#apache)
|
|
||||||
* [Nginx](#nginx)
|
|
||||||
* [Расширенные возможности](#расширенные-возможности)
|
|
||||||
* [Собственный код html/css/js](#собственный-код-htmlcssjs-)
|
|
||||||
* [Очистка кеша twig](#очистка-кеша-twig)
|
|
||||||
* [Скачать все плейлисты](#скачать-все-плейлисты)
|
|
||||||
* [Проверить каналы плейлиста](#проверить-каналы-плейлиста)
|
|
||||||
* [Поиск каналов в одном плейлисте](#поиск-каналов-в-одном-плейлисте)
|
|
||||||
* [Поиск каналов во всех плейлистах](#поиск-каналов-во-всех-плейлистах)
|
|
||||||
* [Создать плейлист из нужных каналов](#создать-плейлист-из-нужных-каналов)
|
|
||||||
* [Как создать свой собственный плейлист?](#как-создать-свой-собственный-плейлист)
|
|
||||||
* [Использованный стек](#использованный-стек)
|
|
||||||
* [Лицензия](#лицензия)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Как использовать этот список?
|
|
||||||
|
|
||||||
Чтобы подключить плейлист, нужно в настройках медиаплеера указать ссылку в следующем формате:
|
|
||||||
|
|
||||||
```
|
|
||||||
iptv.axenov.dev/<ID>
|
|
||||||
```
|
|
||||||
|
|
||||||
где `<ID>` - один из идентификаторов, указанных в [`playlists.ini`](playlists.ini) в квадратных скобках.
|
|
||||||
|
|
||||||
Либо провернуть всё то же самое через браузер.
|
|
||||||
|
|
||||||
## Формат `playlists.ini`
|
|
||||||
|
|
||||||
```ini
|
|
||||||
# ID плейлиста в рамках этого конфига (обязательно)
|
|
||||||
[1]
|
|
||||||
|
|
||||||
# Название плейлиста (необязательно)
|
|
||||||
name = 'Рабочий и актуальный IPTV плейлист M3U'
|
|
||||||
|
|
||||||
# Краткое описание из источника или от себя (необязательно)
|
|
||||||
desc = 'В этом IPTV плейлисте вы найдете очень много каналов в HD качестве'
|
|
||||||
|
|
||||||
# Прямая ссылка на m3u/m3u8 плейлист (обязательно)
|
|
||||||
pls = 'https://example.com/pls.m3u'
|
|
||||||
|
|
||||||
# Ссылка на источник, откуда взят плейлист (необязательно)
|
|
||||||
src = 'https://example.com/super-duper-playlist'
|
|
||||||
|
|
||||||
[2]
|
|
||||||
|
|
||||||
# ID другого плейлиста в этом списке, на который
|
|
||||||
# произойдёт редирект. Нужен для мягкой смены ID.
|
|
||||||
redirect = 1
|
|
||||||
```
|
|
||||||
|
|
||||||
В описании любого плейлиста обязательны:
|
|
||||||
* ID в квадратных скобках
|
|
||||||
> Для удобства ввода с пульта, рекомендуется задавать числом или короткой строкой без пробелов и др. спецсимволов.
|
|
||||||
* параметр `pls` или `redirect`
|
|
||||||
> Если указаны оба, то `redirect` приоритетен.
|
|
||||||
|
|
||||||
Плейлистов с редиректами может быть сколько угодно, но они не должны быть цикличными.
|
|
||||||
|
|
||||||
## API
|
|
||||||
|
|
||||||
Можно получать состояние плейлистов из этого сборника при помощи метода:
|
|
||||||
|
|
||||||
```
|
|
||||||
GET https://iptv.axenov.dev/<ID>/json
|
|
||||||
```
|
|
||||||
|
|
||||||
где `ID` -- один из идентификаторов, указанных в [`playlists.ini`](playlists.ini) в квадратных скобках.
|
|
||||||
|
|
||||||
В случае успеха вернётся JSON следующего содержания:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"id": "p1",
|
|
||||||
"url": "localhost:8080/p1",
|
|
||||||
"name": "Каналы в SD и HD качестве (smarttvnews.ru)",
|
|
||||||
"desc": "Рабочий и актуальный IPTV плейлист M3U — на июнь 2022 года",
|
|
||||||
"pls": "https://smarttvnews.ru/apps/iptvchannels.m3u",
|
|
||||||
"src": "https://smarttvnews.ru/rabochiy-i-aktualnyiy-iptv-pleylist-m3u-kanalyi-v-sd-i-hd-kachestve/",
|
|
||||||
"status": "online",
|
|
||||||
"encoding": {
|
|
||||||
"name": "UTF-8",
|
|
||||||
"alert": false
|
|
||||||
},
|
|
||||||
"channels": [
|
|
||||||
"Channel1",
|
|
||||||
"Channel2",
|
|
||||||
"ChannelX"
|
|
||||||
],
|
|
||||||
"count": 3
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
где:
|
|
||||||
|
|
||||||
* `id` -- идентификатор плейлиста
|
|
||||||
* `name` -- название плейлиста
|
|
||||||
* `url` -- короткая ссылка, которую можно использовать для добавления плейлиста в плеер
|
|
||||||
* `desc` -- краткое описание
|
|
||||||
* `pls` -- прямая ссылка на m3u/m3u8 плейлист
|
|
||||||
* `src` -- ссылка на источник, откуда взят плейлист
|
|
||||||
* `status` -- статус плейлиста (`"online"|"timeout"|"offline"|"error"`)
|
|
||||||
* `encoding` -- данные о кодировке файла плейлиста
|
|
||||||
* `name` -- название кодировки (`"UTF-8"|"Windows-1251"`)
|
|
||||||
* `alert` -- признак отличия кодировки от `UTF-8`, названия каналов сконвертированы в `UTF-8`, могут быть ошибки
|
|
||||||
в отображении
|
|
||||||
* `channels` -- массив названий каналов
|
|
||||||
* `count` -- количество каналов >= 0
|
|
||||||
|
|
||||||
> Название кодировки `encoding.name` может определяться неточно!
|
|
||||||
|
|
||||||
В случае ошибки вернётся JSON в следующем формате:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"id": "p1",
|
|
||||||
"url": "localhost:8080/p1",
|
|
||||||
"name": "Каналы в SD и HD качестве (smarttvnews.ru)",
|
|
||||||
"desc": "Рабочий и актуальный IPTV плейлист M3U — на июнь 2022 года",
|
|
||||||
"pls": "https://smarttvnews.ru/apps/iptvchannels.m3u",
|
|
||||||
"src": "https://smarttvnews.ru/rabochiy-i-aktualnyiy-iptv-pleylist-m3u-kanalyi-v-sd-i-hd-kachestve/",
|
|
||||||
"status": "offline",
|
|
||||||
"error": {
|
|
||||||
"code": 22,
|
|
||||||
"message": "The requested URL returned error: 404 Not Found"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
где:
|
|
||||||
|
|
||||||
* `id` -- идентификатор плейлиста
|
|
||||||
* `name` -- название плейлиста
|
|
||||||
* `url` -- короткая ссылка, которую можно использовать для добавления плейлиста в плеер
|
|
||||||
* `desc` -- краткое описание
|
|
||||||
* `pls` -- прямая ссылка на m3u/m3u8 плейлист
|
|
||||||
* `src` -- ссылка на источник, откуда взят плейлист
|
|
||||||
* `status` -- статус плейлиста (`"online"|"timeout"|"offline"|"error"`)
|
|
||||||
* `error` -- данные об ошибке при проверке плейлиста
|
|
||||||
* `code` -- [код ошибки curl](https://curl.se/libcurl/c/libcurl-errors.html)
|
|
||||||
* `message` -- текст ошибки curl
|
|
||||||
|
|
||||||
## Развёртывание проекта
|
## Развёртывание проекта
|
||||||
|
|
||||||
@@ -253,109 +97,6 @@ $ sudo ln -s /etc/nginx/sites-available/iptv.conf /etc/nginx/sites-enabled/iptv.
|
|||||||
$ sudo systemctl reload apache2
|
$ sudo systemctl reload apache2
|
||||||
```
|
```
|
||||||
|
|
||||||
## Расширенные возможности
|
|
||||||
|
|
||||||
### Собственный код html/css/js
|
|
||||||
|
|
||||||
В проекте есть директория `src/views/custom`.
|
|
||||||
Там можно размещать собственный код, который будет вставляться на каждой странице.
|
|
||||||
|
|
||||||
Для этого, в первую очередь, нужно выполнить:
|
|
||||||
|
|
||||||
```
|
|
||||||
cp src/views/custom/custom.twig.example src/views/custom/custom.twig
|
|
||||||
```
|
|
||||||
|
|
||||||
Между тегами `{% block ... %} сюда {% endblock %}` следует вставить желаемый код или текст.
|
|
||||||
Можно создавать новые twig-файлы рядом и подключать их внутри `custom.twig`.
|
|
||||||
Git будет их игнорировать, хотя можно убрать директорию из `.gitignore` и добавлять эти файлы репозиторий.
|
|
||||||
|
|
||||||
В общем случае, это можно выполнять на том сервере, на коем установлен и работает веб-сервис.
|
|
||||||
|
|
||||||
После всех правок следует очистить кеш twig (см. далее).
|
|
||||||
|
|
||||||
### Очистка кеша twig
|
|
||||||
|
|
||||||
Если в файле `./src/.env` параметр `TWIG_CACHE=1`, то макеты страниц компилируются однажды и потом переиспользуются.
|
|
||||||
Изменённые макеты не будут перекомпилироваться пока не будет очищен кеш прежних.
|
|
||||||
|
|
||||||
Для этого следует выполнить:
|
|
||||||
|
|
||||||
```
|
|
||||||
cd src && composer clear-views
|
|
||||||
```
|
|
||||||
|
|
||||||
### Скачать все плейлисты
|
|
||||||
|
|
||||||
Команда: `./tools/download-all.sh`
|
|
||||||
|
|
||||||
Скачивает все плейлисты из [`playlists.ini`](playlists.ini) в локальную директорию `./tools/downloaded/`.
|
|
||||||
|
|
||||||
### Проверить каналы плейлиста
|
|
||||||
|
|
||||||
Команда: `./tools/check-pls.sh`
|
|
||||||
|
|
||||||
Проверяет каждый канал в плейлисте на доступность и выводит результат проверки.
|
|
||||||
|
|
||||||
Поддерживаются `*.m3u` и `*.m3u8`; как локальные файлы, так по прямым ссылкам.
|
|
||||||
|
|
||||||
Коды ошибок доступны [здесь](https://everything.curl.dev/usingcurl/returns).
|
|
||||||
|
|
||||||
### Поиск каналов в одном плейлисте
|
|
||||||
|
|
||||||
Команда: `./tools/find-in-pls.sh`
|
|
||||||
|
|
||||||
Находит каналы по заданному регулярному выражению в одном указанном плейлисте.
|
|
||||||
|
|
||||||
Поддерживаются `*.m3u` и `*.m3u8`; как локальные файлы, так по прямым ссылкам.
|
|
||||||
|
|
||||||
### Поиск каналов во всех плейлистах
|
|
||||||
|
|
||||||
Команда: `./tools/find-in-all.sh`
|
|
||||||
|
|
||||||
Находит каналы по заданному регулярному выражению во всех плейлистах, скачанных через `download-all.sh`.
|
|
||||||
|
|
||||||
### Создать плейлист из нужных каналов
|
|
||||||
|
|
||||||
Команда: `./tools/make-pls.sh`
|
|
||||||
|
|
||||||
Находит каналы по заданному регулярному выражению во всех плейлистах, скачанных через `download-all.sh`.
|
|
||||||
|
|
||||||
Отличается от `find-in-all.sh` тем, что тот выводит результат в человекочитаемом формате, а этот -- в готовом m3u
|
|
||||||
формате для сохранения в файл.
|
|
||||||
|
|
||||||
Для сохранения в файл следует добавить `> myfile.m3u` или `>> myfile.m3u` в конец команды.
|
|
||||||
|
|
||||||
## Как создать свой собственный плейлист?
|
|
||||||
|
|
||||||
1. Скачать все плейлисты, указанные в [`playlists.ini`](playlists.ini):
|
|
||||||
```
|
|
||||||
$ ./tools/download-all.sh
|
|
||||||
```
|
|
||||||
2. Вытащить из них нужные каналы и сохранить в отдельный файл:
|
|
||||||
```
|
|
||||||
$ ./tools/make-pls.sh "(fox|disney)" > my.m3u8
|
|
||||||
```
|
|
||||||
Так в плейлисте `./my.m3u8` окажутся все каналы из скачанных плейлистов, в названиях которых встретились `fox`
|
|
||||||
или `disney`.
|
|
||||||
3. Проверить доступность каналов в полученном плейлисте:
|
|
||||||
```
|
|
||||||
$ ./tools/check-pls.sh my.m3u8
|
|
||||||
```
|
|
||||||
> Результат `ОК` не означает, что канал действительно работает и отдаёт видео/аудио потоки.
|
|
||||||
> Результат `ERROR` с любыми кодами ошибок гарантированно означает, что канал не работает.
|
|
||||||
4. Вручную: удалить нерабочие, мусорные и продублировавшиеся (по ссылкам) каналы.
|
|
||||||
5. Вручную: добавить плейлист в IPTV-плеер и перепроверить результат.
|
|
||||||
|
|
||||||
## Использованный стек
|
|
||||||
|
|
||||||
* [docker compose](https://docs.docker.com/compose/)
|
|
||||||
* [php8.3-fpm](https://www.php.net/releases/8.3/ru.php)
|
|
||||||
* [SlimPHP v4](https://www.slimframework.com/docs/v4/)
|
|
||||||
* [Bootstrap 5](https://getbootstrap.com/docs/5.0/getting-started/introduction/)
|
|
||||||
* [nginx](https://nginx.org/ru/)
|
|
||||||
* bash
|
|
||||||
|
|
||||||
## Лицензия
|
## Лицензия
|
||||||
|
|
||||||
[The MIT License](LICENSE)
|
[The MIT License](LICENSE)
|
||||||
|
|||||||
1549
playlists.ini
@@ -1,18 +0,0 @@
|
|||||||
# config/app.php
|
|
||||||
APP_DEBUG=false
|
|
||||||
APP_ENV=prod
|
|
||||||
APP_URL=http://localhost:8080
|
|
||||||
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
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
<?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));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,58 +0,0 @@
|
|||||||
<?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,128 +0,0 @@
|
|||||||
<?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,195 +0,0 @@
|
|||||||
<?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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,230 +0,0 @@
|
|||||||
<?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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,135 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Core;
|
|
||||||
|
|
||||||
use App\Errors\PlaylistNotFoundException;
|
|
||||||
use Exception;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Класс для работы с ini-файлом плейлистов
|
|
||||||
*/
|
|
||||||
class IniFile
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* @var array Считанное из файла содержимое ini-файла
|
|
||||||
*/
|
|
||||||
protected array $ini;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @var Playlist[] Коллекция подгруженных плейлистов
|
|
||||||
*/
|
|
||||||
protected array $playlists = [];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @var string[] Карта переадресаций плейлистов
|
|
||||||
*/
|
|
||||||
protected array $redirections = [];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @var string Дата последнего обновления списка
|
|
||||||
*/
|
|
||||||
protected string $updated_at;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Считывает ini-файл и инициализирует объекты плейлистов
|
|
||||||
*
|
|
||||||
* @return void
|
|
||||||
* @throws Exception
|
|
||||||
*/
|
|
||||||
public function load(): void
|
|
||||||
{
|
|
||||||
$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();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Возвращает объекты плейлистов
|
|
||||||
*
|
|
||||||
* @param bool $all true - получить все, false - получить только НЕпереадресованные
|
|
||||||
* @return Playlist[]
|
|
||||||
*/
|
|
||||||
public function playlists(bool $all = true): array
|
|
||||||
{
|
|
||||||
return $all
|
|
||||||
? $this->playlists
|
|
||||||
: array_filter($this->playlists, static fn ($playlist) => is_null($playlist->redirectId));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Возвращает дату обновления ini-файла
|
|
||||||
*
|
|
||||||
* @return string
|
|
||||||
*/
|
|
||||||
public function updatedAt(): string
|
|
||||||
{
|
|
||||||
return $this->updated_at;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Возвращает ID плейлиста, на который нужно переадресовать указанный
|
|
||||||
*
|
|
||||||
* @param string $id ID плейлиста
|
|
||||||
* @return string|null
|
|
||||||
*/
|
|
||||||
public function getRedirection(string $id): ?string
|
|
||||||
{
|
|
||||||
return $this->redirections[$id] ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Возвращает объект плейлиста
|
|
||||||
*
|
|
||||||
* @param string $id ID плейлиста
|
|
||||||
* @return Playlist|null
|
|
||||||
* @throws PlaylistNotFoundException
|
|
||||||
*/
|
|
||||||
public function getPlaylist(string $id): ?Playlist
|
|
||||||
{
|
|
||||||
return $this->playlists[$id] ?? throw new PlaylistNotFoundException($id);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Создаёт объекты плейлистов, рекурсивно определяя переадресации
|
|
||||||
*
|
|
||||||
* @param int|string $id ID плейлиста
|
|
||||||
* @param array $params Описание плейлиста
|
|
||||||
* @param string|null $redirectId ID для переадресации
|
|
||||||
* @return Playlist
|
|
||||||
* @throws Exception
|
|
||||||
*/
|
|
||||||
protected function makePlaylist(int|string $id, array $params, ?string $redirectId = null): Playlist
|
|
||||||
{
|
|
||||||
$id = (string)$id;
|
|
||||||
if (isset($params['redirect'])) {
|
|
||||||
$this->redirections[$id] = $redirectId = (string)$params['redirect'];
|
|
||||||
$params = $this->ini[$redirectId];
|
|
||||||
return $this->makePlaylist($id, $params, $redirectId);
|
|
||||||
}
|
|
||||||
|
|
||||||
return new Playlist($id, $params, $redirectId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,368 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Core;
|
|
||||||
|
|
||||||
use CurlHandle;
|
|
||||||
use Exception;
|
|
||||||
use Random\RandomException;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Плейлист без редиректа
|
|
||||||
*/
|
|
||||||
class Playlist
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* @var string|null Название плейлиста
|
|
||||||
*/
|
|
||||||
public ?string $name;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @var string|null Описание плейлиста
|
|
||||||
*/
|
|
||||||
public ?string $desc;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @var string Прямой URL до файла плейлиста на третьей стороне
|
|
||||||
*/
|
|
||||||
public string $pls;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @var string|null Источник плейлиста
|
|
||||||
*/
|
|
||||||
public ?string $src;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @var string Ссылка на плейлист в рамках проекта
|
|
||||||
*/
|
|
||||||
public string $url;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @var string|null Сырое содержимое плейлиста
|
|
||||||
*/
|
|
||||||
protected ?string $rawContent = null;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @var array Обработанное содержимое плейлиста
|
|
||||||
*/
|
|
||||||
protected array $parsedContent = [];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @var array Статус скачивания плейлиста
|
|
||||||
*/
|
|
||||||
protected array $downloadStatus = [
|
|
||||||
'httpCode' => 'unknown',
|
|
||||||
'errCode' => 'unknown',
|
|
||||||
'errText' => 'unknown',
|
|
||||||
'possibleStatus' => 'unknown',
|
|
||||||
];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Конструктор
|
|
||||||
*
|
|
||||||
* @param string $id ID плейлиста
|
|
||||||
* @param array $params Описание плейлиста
|
|
||||||
* @param string|null $redirectId ID для переадресации
|
|
||||||
* @throws Exception
|
|
||||||
*/
|
|
||||||
public function __construct(
|
|
||||||
public readonly string $id,
|
|
||||||
array $params,
|
|
||||||
public readonly ?string $redirectId = null
|
|
||||||
) {
|
|
||||||
empty($params['pls']) && throw new Exception(
|
|
||||||
"Плейлист с ID=$id обязан иметь параметр pls или redirect"
|
|
||||||
);
|
|
||||||
|
|
||||||
$this->url = base_url($id);
|
|
||||||
$this->name = empty($params['name']) ? "Плейлист #$id" : $params['name'];
|
|
||||||
$this->desc = empty($params['desc']) ? null : $params['desc'];
|
|
||||||
$this->pls = $params['pls'];
|
|
||||||
$this->src = empty($params['src']) ? null : $params['src'];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Получает содержимое плейлиста с третьей стороны
|
|
||||||
*
|
|
||||||
* @return void
|
|
||||||
*/
|
|
||||||
public function fetchContent(): void
|
|
||||||
{
|
|
||||||
$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);
|
|
||||||
$this->downloadStatus['errCode'] = curl_errno($curl);
|
|
||||||
$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')]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Возвращает статус проверки плейлиста по коду ошибки curl
|
|
||||||
*
|
|
||||||
* @param int $curlErrCode
|
|
||||||
* @return string
|
|
||||||
*/
|
|
||||||
protected function guessStatus(int $curlErrCode): string
|
|
||||||
{
|
|
||||||
return match ($curlErrCode) {
|
|
||||||
0 => 'online',
|
|
||||||
28 => 'timeout',
|
|
||||||
5, 6, 7, 22, 35 => 'offline',
|
|
||||||
default => 'error',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Парсит полученный от третьей стороны плейлист
|
|
||||||
*
|
|
||||||
* @return array Информация о составе плейлиста
|
|
||||||
* @throws RandomException
|
|
||||||
*/
|
|
||||||
public function parse(): array
|
|
||||||
{
|
|
||||||
if (!empty($this->parsed())) {
|
|
||||||
return $this->parsed();
|
|
||||||
}
|
|
||||||
|
|
||||||
$result = [
|
|
||||||
'attributes' => [],
|
|
||||||
'channels' => [],
|
|
||||||
'groups' => [],
|
|
||||||
'encoding' => [
|
|
||||||
'name' => 'unknown',
|
|
||||||
'alert' => false,
|
|
||||||
],
|
|
||||||
];
|
|
||||||
|
|
||||||
if (is_null($this->rawContent)) {
|
|
||||||
return $this->parsedContent = $result;
|
|
||||||
}
|
|
||||||
|
|
||||||
$enc = mb_detect_encoding($this->rawContent, config('app.pls_encodings'));
|
|
||||||
$result['encoding']['name'] = $enc;
|
|
||||||
if ($enc !== 'UTF-8') {
|
|
||||||
$result['encoding']['alert'] = true;
|
|
||||||
$this->rawContent = mb_convert_encoding($this->rawContent, 'UTF-8', $enc);
|
|
||||||
}
|
|
||||||
|
|
||||||
$lines = explode("\n", $this->rawContent);
|
|
||||||
$isHeader = $isGroup = $isChannel = false;
|
|
||||||
foreach ($lines as $line) {
|
|
||||||
if (empty($line = trim($line))) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (str_starts_with($line, '#EXTM3U ')) {
|
|
||||||
$isHeader = true;
|
|
||||||
$isGroup = $isChannel = false;
|
|
||||||
|
|
||||||
$result['attributes'] = $this->parseAttributes($line);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (str_starts_with($line, '#EXTINF:')) {
|
|
||||||
$isChannel = true;
|
|
||||||
$isHeader = $isGroup = false;
|
|
||||||
|
|
||||||
$combined = trim(substr($line, strpos($line, ',') + 1));
|
|
||||||
$exploded = explode(',', $line);
|
|
||||||
$attrs = $this->parseAttributes($exploded[0]);
|
|
||||||
$tvgid = empty($attrs['tvg-id']) ? ' неизвестен' : "='{$attrs['tvg-id']}'";
|
|
||||||
$name = trim($exploded[1] ?? "(канал без названия, tvg-id$tvgid)");
|
|
||||||
$channel = [
|
|
||||||
'_id' => md5($name . random_int(1, 99999)),
|
|
||||||
'name' => trim($name),
|
|
||||||
'url' => null,
|
|
||||||
'group' => $attrs['group-title'] ?? null,
|
|
||||||
'attributes' => $attrs,
|
|
||||||
];
|
|
||||||
|
|
||||||
unset($name, $attrs, $combined, $exploded);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (str_starts_with($line, '#EXTGRP:')) {
|
|
||||||
$isGroup = true;
|
|
||||||
$isHeader = false;
|
|
||||||
|
|
||||||
if ($isChannel) {
|
|
||||||
$exploded = explode(':', $line);
|
|
||||||
$channel['group'] = $exploded[1];
|
|
||||||
}
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$groups = [];
|
|
||||||
foreach ($result['channels'] as $channel) {
|
|
||||||
$name = $channel['group'] ?? '(без группы)';
|
|
||||||
$id = md5($name);
|
|
||||||
if (empty($groups[$id])) {
|
|
||||||
$groups[$id] = [
|
|
||||||
'_id' => $id,
|
|
||||||
'name' => $name,
|
|
||||||
'channels' => [],
|
|
||||||
];
|
|
||||||
}
|
|
||||||
$groups[$id]['channels'][] = $channel['_id'];
|
|
||||||
}
|
|
||||||
$result['groups'] = array_values($groups);
|
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Парсит атрибуты строки и возвращает ассоциативный массив
|
|
||||||
*
|
|
||||||
* @param string $line
|
|
||||||
* @return array
|
|
||||||
*/
|
|
||||||
protected function parseAttributes(string $line): array
|
|
||||||
{
|
|
||||||
if (str_starts_with($line, '#')) {
|
|
||||||
$line = trim(substr($line, strpos($line, ' ') + 1));
|
|
||||||
}
|
|
||||||
|
|
||||||
preg_match_all('#(?<key>[a-z-]+)="(?<value>.*)"#U', $line, $matches);
|
|
||||||
return array_combine($matches['key'], $matches['value']);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Возвращает содержимое объекта в виде массива
|
|
||||||
*
|
|
||||||
* @return array
|
|
||||||
*/
|
|
||||||
public function toArray(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
'id' => $this->id,
|
|
||||||
'url' => $this->url,
|
|
||||||
'name' => $this->name,
|
|
||||||
'desc' => $this->desc,
|
|
||||||
'pls' => $this->pls,
|
|
||||||
'src' => $this->src,
|
|
||||||
'status' => $this->status(),
|
|
||||||
'content' => [
|
|
||||||
...$this->parsed(),
|
|
||||||
'channelCount' => count($this->parsed()['channels'])
|
|
||||||
],
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Возвращает ссылку на плейлист в рамках проекта
|
|
||||||
*
|
|
||||||
* @return string
|
|
||||||
*/
|
|
||||||
public function url(): string
|
|
||||||
{
|
|
||||||
return sprintf('%s/%s', base_url(), $this->id);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Возвращает статус скачивания плейлиста
|
|
||||||
*
|
|
||||||
* @return array|string[]
|
|
||||||
*/
|
|
||||||
public function status(): array
|
|
||||||
{
|
|
||||||
return $this->downloadStatus;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Возвращает обработанное содержимое плейлиста
|
|
||||||
*
|
|
||||||
* @return array
|
|
||||||
*/
|
|
||||||
public function parsed(): array
|
|
||||||
{
|
|
||||||
return $this->parsedContent;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Core;
|
|
||||||
|
|
||||||
use Twig\Extension\AbstractExtension;
|
|
||||||
use Twig\TwigFunction;
|
|
||||||
|
|
||||||
class TwigExtention extends AbstractExtension
|
|
||||||
{
|
|
||||||
public function getFunctions(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
new TwigFunction('config', [$this, 'config']),
|
|
||||||
new TwigFunction('commit', [$this, 'commit']),
|
|
||||||
new TwigFunction('is_file', [$this, 'is_file']),
|
|
||||||
new TwigFunction('base_url', [$this, 'base_url']),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
public function config(string $key, mixed $default = null): mixed
|
|
||||||
{
|
|
||||||
return config($key, $default);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function commit(): string
|
|
||||||
{
|
|
||||||
return file_get_contents(root_path('commit'));
|
|
||||||
}
|
|
||||||
|
|
||||||
public function base_url(string $path = ''): string
|
|
||||||
{
|
|
||||||
return base_url($path);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function is_file(string $path): bool
|
|
||||||
{
|
|
||||||
return is_file($path);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,92 +0,0 @@
|
|||||||
<?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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Errors;
|
|
||||||
|
|
||||||
use Exception;
|
|
||||||
|
|
||||||
class PlaylistNotFoundException extends Exception
|
|
||||||
{
|
|
||||||
public function __construct(string $id)
|
|
||||||
{
|
|
||||||
parent::__construct("Плейлист $id не найден!");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
<?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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,159 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
use App\Core\Core;
|
|
||||||
use App\Core\IniFile;
|
|
||||||
use Slim\App;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns path to root application directory
|
|
||||||
*
|
|
||||||
* @param string $path
|
|
||||||
* @return string
|
|
||||||
*/
|
|
||||||
function root_path(string $path = ''): string
|
|
||||||
{
|
|
||||||
return rtrim(sprintf('%s/%s', dirname($_SERVER['DOCUMENT_ROOT']), $path), '/');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Return path to application configuration directory
|
|
||||||
*
|
|
||||||
* @param string $path
|
|
||||||
* @return string
|
|
||||||
*/
|
|
||||||
function config_path(string $path = ''): string
|
|
||||||
{
|
|
||||||
return root_path("config/$path");
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns path to app cache
|
|
||||||
*
|
|
||||||
* @param string $path
|
|
||||||
* @return string
|
|
||||||
*/
|
|
||||||
function cache_path(string $path = ''): string
|
|
||||||
{
|
|
||||||
return root_path("cache/$path");
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns path to app views
|
|
||||||
*
|
|
||||||
* @param string $path
|
|
||||||
* @return string
|
|
||||||
*/
|
|
||||||
function views_path(string $path = ''): string
|
|
||||||
{
|
|
||||||
return root_path("views/$path");
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns base URL
|
|
||||||
*
|
|
||||||
* @param string $route
|
|
||||||
* @return string
|
|
||||||
*/
|
|
||||||
function base_url(string $route = ''): string
|
|
||||||
{
|
|
||||||
return rtrim(sprintf('%s/%s', env('APP_URL'), $route), '/');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns value of environment var
|
|
||||||
*
|
|
||||||
* @param string $key
|
|
||||||
* @param mixed|null $default
|
|
||||||
* @return mixed
|
|
||||||
*/
|
|
||||||
function env(string $key, mixed $default = null): mixed
|
|
||||||
{
|
|
||||||
return $_ENV[$key] ?? $_SERVER[$key] ?? $default;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Renders template
|
|
||||||
*
|
|
||||||
* @param mixed $template
|
|
||||||
* @param array $data
|
|
||||||
* @return void
|
|
||||||
* @throws Exception
|
|
||||||
*/
|
|
||||||
function view(mixed $template, array $data = []): void
|
|
||||||
{
|
|
||||||
$template = str_contains($template, '.twig') ? $template : "$template.twig";
|
|
||||||
/** @noinspection PhpVoidFunctionResultUsedInspection */
|
|
||||||
echo Flight::view()->render($template, $data);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns core object
|
|
||||||
*
|
|
||||||
* @return Core
|
|
||||||
*/
|
|
||||||
function core(): Core
|
|
||||||
{
|
|
||||||
return Core::get();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns app object
|
|
||||||
*
|
|
||||||
* @return App
|
|
||||||
*/
|
|
||||||
function app(): App
|
|
||||||
{
|
|
||||||
return Core::get()->app();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns any value as boolean
|
|
||||||
*
|
|
||||||
* @param mixed $value
|
|
||||||
* @return bool
|
|
||||||
*/
|
|
||||||
function bool(mixed $value): bool
|
|
||||||
{
|
|
||||||
is_string($value) && $value = strtolower(trim($value));
|
|
||||||
if (in_array($value, [true, 1, '1', '+', 'y', 'yes', 'on', 'true', 'enable', 'enabled'], true)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
if (in_array($value, [false, 0, '0', '-', 'n', 'no', 'off', 'false', 'disable', 'disabled'], true)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return (bool)$value;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get config values
|
|
||||||
*
|
|
||||||
* @param string $key
|
|
||||||
* @param mixed|null $default
|
|
||||||
* @return mixed
|
|
||||||
*/
|
|
||||||
function config(string $key, mixed $default = null): mixed
|
|
||||||
{
|
|
||||||
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
@@ -1,46 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "axenov/iptv",
|
|
||||||
"type": "project",
|
|
||||||
"description": "Сервис для сбора IPTV-плейлистов и сокращения ссылок",
|
|
||||||
"authors": [
|
|
||||||
{
|
|
||||||
"name": "Anthony Axenov",
|
|
||||||
"homepage": "https://axenov.dev/",
|
|
||||||
"role": "author"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
|
||||||
"require": {
|
|
||||||
"php": "^8.3",
|
|
||||||
"ext-json": "*",
|
|
||||||
"ext-curl": "*",
|
|
||||||
"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": {
|
|
||||||
"App\\": "app/"
|
|
||||||
},
|
|
||||||
"files": [
|
|
||||||
"app/helpers.php"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"scripts": {
|
|
||||||
"clear-views": "rm -rf cache/views",
|
|
||||||
"post-install-cmd": [
|
|
||||||
"@clear-views"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"config": {
|
|
||||||
"optimize-autoloader": true,
|
|
||||||
"preferred-install": "dist",
|
|
||||||
"sort-packages": true
|
|
||||||
},
|
|
||||||
"minimum-stability": "dev",
|
|
||||||
"prefer-stable": true
|
|
||||||
}
|
|
||||||
1860
src/composer.lock
generated
@@ -1,18 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
return [
|
|
||||||
'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',
|
|
||||||
// 'ISO-8859-5',
|
|
||||||
],
|
|
||||||
];
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
<?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,52 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
use App\Controllers\ApiController;
|
|
||||||
use App\Controllers\BasicController;
|
|
||||||
use App\Controllers\WebController;
|
|
||||||
|
|
||||||
return [
|
|
||||||
[
|
|
||||||
'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',
|
|
||||||
],
|
|
||||||
// ...
|
|
||||||
];
|
|
||||||
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
return [
|
|
||||||
'cache' => bool(env('TWIG_USE_CACHE', true)) ? cache_path() . '/views' : false,
|
|
||||||
'debug' => bool(env('TWIG_DEBUG', false)),
|
|
||||||
];
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<!-- Generator: Adobe Illustrator 24.2.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
|
|
||||||
<svg version="1.1" id="Слой_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
|
|
||||||
viewBox="0 0 235.6 292.2" style="enable-background:new 0 0 235.6 292.2;" xml:space="preserve">
|
|
||||||
<style type="text/css">
|
|
||||||
.st0{fill:#FFFFFF;}
|
|
||||||
</style>
|
|
||||||
<g id="b_1_">
|
|
||||||
<path class="st0" d="M44.3,164.5L76.9,51.6H127l-10.1,35c-0.1,0.2-0.2,0.4-0.3,0.6L90,179.6h24.8c-10.4,25.9-18.5,46.2-24.3,60.9
|
|
||||||
c-45.8-0.5-58.6-33.3-47.4-72.1 M90.7,240.6l60.4-86.9h-25.6l22.3-55.7c38.2,4,56.2,34.1,45.6,70.5
|
|
||||||
c-11.3,39.1-57.1,72.1-101.7,72.1C91.3,240.6,91,240.6,90.7,240.6z"/>
|
|
||||||
</g>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 735 B |
7
src/public/css/bootstrap.min.css
vendored
|
Before Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 7.2 KiB |
@@ -1,9 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<browserconfig>
|
|
||||||
<msapplication>
|
|
||||||
<tile>
|
|
||||||
<square150x150logo src="/mstile-150x150.png"/>
|
|
||||||
<TileColor>#da532c</TileColor>
|
|
||||||
</tile>
|
|
||||||
</msapplication>
|
|
||||||
</browserconfig>
|
|
||||||
|
Before Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 1.7 KiB |
|
Before Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 7.3 KiB |
@@ -1,27 +0,0 @@
|
|||||||
<?xml version="1.0" standalone="no"?>
|
|
||||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 20010904//EN"
|
|
||||||
"http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd">
|
|
||||||
<svg version="1.0" xmlns="http://www.w3.org/2000/svg"
|
|
||||||
width="512.000000pt" height="512.000000pt" viewBox="0 0 512.000000 512.000000"
|
|
||||||
preserveAspectRatio="xMidYMid meet">
|
|
||||||
<metadata>
|
|
||||||
Created by potrace 1.14, written by Peter Selinger 2001-2017
|
|
||||||
</metadata>
|
|
||||||
<g transform="translate(0.000000,512.000000) scale(0.100000,-0.100000)"
|
|
||||||
fill="#000000" stroke="none">
|
|
||||||
<path d="M3500 4603 c-33 -40 -171 -195 -253 -286 -38 -42 -78 -88 -89 -102
|
|
||||||
-10 -13 -31 -37 -46 -52 -15 -16 -52 -57 -82 -92 -30 -35 -64 -73 -75 -85 -67
|
|
||||||
-74 -226 -253 -257 -288 -17 -20 -29 -22 -134 -25 l-115 -2 -37 41 c-20 23
|
|
||||||
-41 46 -47 52 -5 6 -41 47 -79 91 -39 44 -75 85 -80 90 -6 6 -42 46 -81 90
|
|
||||||
-38 44 -86 98 -106 120 -20 22 -78 87 -129 145 -51 58 -123 139 -160 180 -37
|
|
||||||
41 -79 90 -94 108 -30 34 -70 43 -80 16 -9 -23 -8 -31 7 -46 7 -7 64 -78 127
|
|
||||||
-158 63 -80 120 -152 127 -160 18 -22 151 -189 208 -261 28 -35 57 -73 67 -84
|
|
||||||
9 -11 48 -60 87 -110 40 -49 78 -97 85 -105 16 -20 32 -47 22 -38 -22 19 -283
|
|
||||||
-80 -322 -123 -17 -18 -46 -19 -838 -19 -775 0 -823 -1 -861 -19 -22 -10 -48
|
|
||||||
-28 -58 -39 -48 -53 -46 -3 -47 -1607 0 -1009 4 -1522 10 -1547 15 -52 53 -94
|
|
||||||
103 -113 38 -14 254 -15 2303 -13 l2260 3 43 28 c27 18 50 44 62 70 18 41 19
|
|
||||||
90 18 1562 0 1615 2 1564 -46 1617 -10 11 -36 29 -58 39 -38 18 -85 19 -821
|
|
||||||
19 l-781 0 -22 24 c-19 20 -118 72 -156 83 -5 1 -50 12 -100 24 -141 34 -136
|
|
||||||
22 -53 124 171 210 645 801 652 813 4 7 5 24 2 38 -9 33 -46 32 -76 -3z"/>
|
|
||||||
</g>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 1.5 KiB |
@@ -1,19 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "",
|
|
||||||
"short_name": "",
|
|
||||||
"icons": [
|
|
||||||
{
|
|
||||||
"src": "/android-chrome-192x192.png",
|
|
||||||
"sizes": "192x192",
|
|
||||||
"type": "image/png"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"src": "/android-chrome-512x512.png",
|
|
||||||
"sizes": "512x512",
|
|
||||||
"type": "image/png"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"theme_color": "#ffffff",
|
|
||||||
"background_color": "#ffffff",
|
|
||||||
"display": "standalone"
|
|
||||||
}
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
require '../vendor/autoload.php';
|
|
||||||
|
|
||||||
core()->boot()->run();
|
|
||||||
7
src/public/js/bootstrap.bundle.min.js
vendored
2
src/public/js/list.min.js
vendored
|
Before Width: | Height: | Size: 2.1 KiB |
@@ -1,2 +0,0 @@
|
|||||||
{# Файл для включения кастомных блоков #}
|
|
||||||
{# Переименуйте файл в custom.twig и впишите сюда свой html/js #}
|
|
||||||
@@ -1,133 +0,0 @@
|
|||||||
{% extends "template.twig" %}
|
|
||||||
|
|
||||||
{% block title %}[{{ id }}] {{ name }} - {{ config('app.title') }}{% endblock %}
|
|
||||||
|
|
||||||
{% block head %}
|
|
||||||
<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>
|
|
||||||
{% if (content.encoding.alert) %}
|
|
||||||
<div class="alert alert-warning small" role="alert">
|
|
||||||
Кодировка исходного плейлиста отличается от UTF-8.
|
|
||||||
Он был автоматически с конвертирован из {{ content.encoding.name }}, чтобы отобразить здесь список каналов.
|
|
||||||
Однако названия каналов могут отображаться некорректно, причём не только здесь, но и в плеере.
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
{% if (status.errCode > 0) %}
|
|
||||||
<div class="alert alert-danger small" role="alert">
|
|
||||||
Ошибка плейлиста: [{{ status.errCode }}] {{ status.errText }}
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
{% block footer %}
|
|
||||||
<script src="{{ base_url('js/list.min.js') }}"></script>
|
|
||||||
<script>
|
|
||||||
var list = new List('chlist',{valueNames:['chname','chindex']});
|
|
||||||
</script>
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
{% block content %}
|
|
||||||
<div class="row">
|
|
||||||
<div class="col-lg-7">
|
|
||||||
<table class="table table-dark table-hover small mb-lg-5">
|
|
||||||
<tbody>
|
|
||||||
<tr>
|
|
||||||
<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' %}
|
|
||||||
<span class="badge small text-dark bg-danger">offline</span>
|
|
||||||
{% elseif status.possibleStatus == 'timeout' %}
|
|
||||||
<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 %}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<th scope="row">Описание</th>
|
|
||||||
<td class="text-break"><p>{{ desc }}</p></td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<th scope="row">Ccылка для ТВ</th>
|
|
||||||
<td><b onclick="prompt('Скопируй адрес плейлиста', '{{ url }}')"
|
|
||||||
data-bs-toggle="tooltip"
|
|
||||||
data-bs-placement="top"
|
|
||||||
title="Нажми на ссылку, чтобы скопировать её в буфер обмена"
|
|
||||||
class="font-monospace cursor-pointer text-break">{{ url }}</b></td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<th scope="row">M3U</th>
|
|
||||||
<td class="text-break">{{ pls }}</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<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-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="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="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="chname text-break">{{ channel.name }}</td>
|
|
||||||
</tr>
|
|
||||||
{% endfor %}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endblock %}
|
|
||||||
@@ -1,297 +0,0 @@
|
|||||||
{% extends "template.twig" %}
|
|
||||||
|
|
||||||
{% block header %}
|
|
||||||
<h2>FAQ</h2>
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
{% block content %}
|
|
||||||
<div class="row">
|
|
||||||
<div class="col-md-12">
|
|
||||||
<p>
|
|
||||||
В этом сервисе собраны ссылки на IPTV-плейлисты, которые находятся в открытом доступе.
|
|
||||||
Они отбираются вручную и постоянно проверяются здесь автоматически.
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
Сервис "{{ config('app.title') }}" ({{ base_url() }}) не предназначен для хранения или трансляции
|
|
||||||
видео/аудио потоков, программ телепередач, плейлистов и их поддержки. Этим занимаются администраторы
|
|
||||||
ресурсов, указанные как источник, и те, с чьих ресурсов ведётся трансляция.
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
За содержимое плейлистов и их качество отвечают авторы плейлистов. На стороне сервиса управляются сами
|
|
||||||
плейлисты.
|
|
||||||
</p>
|
|
||||||
<p class="mb-5">
|
|
||||||
Сервис "{{ config('app.title') }}" ({{ base_url() }}) предоставляет только информацию об активности
|
|
||||||
плейлистов, найденных в открытом доступе, и короткие ссылки на них для удобства использования в ПО.
|
|
||||||
Вопросы по содержанию и работоспособности плейлистов, а также вопросы юридического характера, адресуйте
|
|
||||||
тем, кто несёт за них ответственность (см. источники плейлистов).
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div class="accordion" id="faq-accordion">
|
|
||||||
<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>
|
|
||||||
</h2>
|
|
||||||
<div id="howtouse" class="accordion-collapse collapse" aria-labelledby="h-howtouse" data-bs-parent="#faq-accordion">
|
|
||||||
<p class="accordion-body">
|
|
||||||
На главной странице отображается список доступных в плейлистов, их идентификаторы, статусы,
|
|
||||||
количество каналов и короткие ссылки.
|
|
||||||
Для просмотра списка каналов следует нажать на ссылку <b>"Подробнее..."</b> под интересующим плейлистом.
|
|
||||||
Для добавления плейлиста в свой медиаплеер удобно использовать <b>"Ссылку для ТВ"</b>.
|
|
||||||
Это делается для удобства ввода, например, на телевизоре с пульта.
|
|
||||||
На странице детальной информации также есть прямая ссылка на сам плейлист от источника.
|
|
||||||
Можно использовать и её.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<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">
|
|
||||||
Как подключить плейлист?
|
|
||||||
</button>
|
|
||||||
</h2>
|
|
||||||
<div id="howtoconnect" class="accordion-collapse collapse" aria-labelledby="h-howtoconnect" data-bs-parent="#faq-accordion">
|
|
||||||
<p class="accordion-body">
|
|
||||||
<a href="https://www.google.com/search?q=%D0%BA%D0%B0%D0%BA%20%D0%BF%D0%BE%D0%B4%D0%BA%D0%BB%D1%8E%D1%87%D0%B8%D1%82%D1%8C%20iptv%20%D0%BF%D0%BB%D0%B5%D0%B9%D0%BB%D0%B8%D1%81%D1%82%20%D0%BF%D0%BE%20%D1%81%D1%81%D1%8B%D0%BB%D0%BA%D0%B5">
|
|
||||||
Добавь в свой медиаплеер</a> "Ссылку для ТВ".
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<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>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<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">
|
|
||||||
Какие плейлисты попадают сюда?
|
|
||||||
</button>
|
|
||||||
</h2>
|
|
||||||
<div id="which" class="accordion-collapse collapse" aria-labelledby="h-which" data-bs-parent="#faq-accordion">
|
|
||||||
<div class="accordion-body">
|
|
||||||
<p>Есть некоторые критерии, по которым плейлисты отбираются в этот список:</p>
|
|
||||||
<ul>
|
|
||||||
<li>Прежде всего -- каналы РФ и бывшего СНГ, но не только</li>
|
|
||||||
<li>Открытый источник</li>
|
|
||||||
<li>Прямая ссылка на плейлист</li>
|
|
||||||
<li>Автообновление плейлиста</li>
|
|
||||||
</ul>
|
|
||||||
<p>
|
|
||||||
В основном, в плейлистах именно трансляции телеканалов, но могут быть просто список каких-то
|
|
||||||
(мульт)фильмов и передач, находящихся на чужих дисках (как если бы вы сами составили плейлист с музыкой,
|
|
||||||
например).
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<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">
|
|
||||||
Что означают статусы плейлистов?
|
|
||||||
</button>
|
|
||||||
</h2>
|
|
||||||
<div id="statuses" class="accordion-collapse collapse" aria-labelledby="h-statuses" data-bs-parent="#faq-accordion">
|
|
||||||
<div class="accordion-body">
|
|
||||||
<ul>
|
|
||||||
<li>
|
|
||||||
<span class="badge small text-dark bg-secondary">loading</span>
|
|
||||||
Загрузка данных, нужно немного подождать.
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<span class="badge small text-dark bg-success">online</span>
|
|
||||||
Плейлист, возможно, активен. <i>Если каналов 0, значит, вероятно, источник поставил
|
|
||||||
редирект с плейлиста на куда ему вздумалось. То есть плейлист, наверное, отсутствует
|
|
||||||
и, возможно, больше никогда не появится по текущему адресу.</i>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<span class="badge small text-dark bg-warning">timeout</span>
|
|
||||||
Не удалось вовремя проверить плейлист, сервер с плейлистом слишком долго запрягает.
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<span class="badge small text-dark bg-danger">offline</span>
|
|
||||||
Плейлист недоступен, вообще.
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<span class="badge small text-dark bg-danger">error</span>
|
|
||||||
Ошибка при проверке плейлиста. Пора удалять плейлист отсюда.
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<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">
|
|
||||||
Почему нельзя доверять результатам проверки?
|
|
||||||
</button>
|
|
||||||
</h2>
|
|
||||||
<div id="donttrust" class="accordion-collapse collapse" aria-labelledby="h-donttrust" data-bs-parent="#faq-accordion">
|
|
||||||
<div class="accordion-body">
|
|
||||||
<p>
|
|
||||||
Я не гарантирую корректность и актуальность информации, которую ты увидишь здесь.
|
|
||||||
Хотя я и стараюсь улучшать качество проверок, но всё же рекомендую проверять желаемые
|
|
||||||
плейлисты самостоятельно вручную, ибо нет никаких гарантий:
|
|
||||||
</p>
|
|
||||||
<ul>
|
|
||||||
<li>что это вообще плейлисты, а не чьи-то архивы с мокрыми кисками;</li>
|
|
||||||
<li>что плейлисты по разным ссылкам не дублируют друг друга и отличаются каналами хотя бы на четверть;</li>
|
|
||||||
<li>что плейлист работоспособен (каналы работают, корректно названы, имеют аудио, etc.);</li>
|
|
||||||
<li>что подгрузится корректное количество каналов и их список (хотя на это я ещё могу влиять и стараюсь как-то улучшить).</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<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">
|
|
||||||
Какова гарантия, что я добавлю себе плейлист отсюда и он работать хоть сколько-нибудь долго?
|
|
||||||
</button>
|
|
||||||
</h2>
|
|
||||||
<div id="guarantee" class="accordion-collapse collapse" aria-labelledby="h-guarantee" data-bs-parent="#faq-accordion">
|
|
||||||
<p class="accordion-body">
|
|
||||||
Никакова.
|
|
||||||
Мёртвые плейлисты я периодически вычищаю, реже -- добавляю новые.
|
|
||||||
ID плейлистов могут меняться, поэтому вполне может произойти внезапная подмена одного другим, однако
|
|
||||||
намеренно я так не делаю.
|
|
||||||
Если один плейлист переезжает на новый адрес, то я ставлю временное перенаправление со старого ID на
|
|
||||||
новый.
|
|
||||||
Плюс читай выше про доверие результатам проверки (проблема может быть не стороне сервиса).
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<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)?
|
|
||||||
</button>
|
|
||||||
</h2>
|
|
||||||
<div id="epg" class="accordion-collapse collapse" aria-labelledby="h-epg" data-bs-parent="#faq-accordion">
|
|
||||||
<div class="accordion-body">
|
|
||||||
<ul>
|
|
||||||
<li><b>https://iptvx.one/viewtopic.php?f=12&t=4</b></li>
|
|
||||||
<li>https://iptvmaster.ru/epg-for-iptv</li>
|
|
||||||
<li>https://google.com</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<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">
|
|
||||||
<p class="accordion-body">
|
|
||||||
Время от времени.
|
|
||||||
Иногда я захожу сюда и проверяю всё ли на месте, иногда занимаюсь какими-то доработками.
|
|
||||||
Если есть кандидаты на добавление, то читай ниже.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<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">
|
|
||||||
Как часто обновляется содержимое плейлистов?
|
|
||||||
</button>
|
|
||||||
</h2>
|
|
||||||
<div id="howoftench" class="accordion-collapse collapse" aria-labelledby="h-howoftench" 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-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? Как им пользоваться?
|
|
||||||
</button>
|
|
||||||
</h2>
|
|
||||||
<div id="api" class="accordion-collapse collapse" aria-labelledby="h-api" data-bs-parent="#faq-accordion">
|
|
||||||
<p class="accordion-body">
|
|
||||||
Есть, подробности <a href="https://github.com/anthonyaxenov/iptv2#api">здесь</a>.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<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">
|
|
||||||
Как пополнить этот список?
|
|
||||||
</button>
|
|
||||||
</h2>
|
|
||||||
<div id="howtoadd" class="accordion-collapse collapse" aria-labelledby="h-howtoadd" data-bs-parent="#faq-accordion">
|
|
||||||
<p class="accordion-body">
|
|
||||||
Сделать pull-request в <a href="https://github.com/anthonyaxenov/iptv">репозиторий</a>.
|
|
||||||
Я проверю плейлист и добавлю его в общий список, если всё ок.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endblock %}
|
|
||||||
@@ -1,66 +0,0 @@
|
|||||||
{% extends "template.twig" %}
|
|
||||||
|
|
||||||
{% block header %}
|
|
||||||
<p class="text-muted small">
|
|
||||||
Обновлено: {{ updated_at }} МСК<br/>
|
|
||||||
Плейлистов в списке: <strong>{{ count }}</strong>
|
|
||||||
</p>
|
|
||||||
<hr/>
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
{% block content %}
|
|
||||||
<div class="table-responsive">
|
|
||||||
<table class="table table-responsive table-dark table-hover small">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th class="text-center">ID</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 font-monospace id">{{ id }}</td>
|
|
||||||
<td class="info">
|
|
||||||
<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>
|
|
||||||
{% endif %}
|
|
||||||
<a href="{{ base_url(id ~ '/details') }}" class="text-light">Подробнее...</a>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td class="col-3 d-none d-sm-table-cell">
|
|
||||||
<span onclick="prompt('Скопируй адрес плейлиста', '{{ playlist.url }}')"
|
|
||||||
title="Нажми на ссылку, чтобы скопировать её в буфер обмена"
|
|
||||||
class="font-monospace cursor-pointer">
|
|
||||||
{{ playlist.url }}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
{% endfor %}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
{% if pageCount > 0 %}
|
|
||||||
<div aria-label="pages">
|
|
||||||
<ul class="pagination justify-content-center">
|
|
||||||
{% for page in range(1, pageCount) %}
|
|
||||||
{% if page == pageCurrent %}
|
|
||||||
<li class="page-item active" aria-current="page">
|
|
||||||
<span class="page-link">{{ page }}</span>
|
|
||||||
</li>
|
|
||||||
{% else %}
|
|
||||||
<li class="page-item">
|
|
||||||
<a class="page-link bg-dark border-secondary text-light" href="{{ base_url('page/' ~ page) }}">{{ page }}</a>
|
|
||||||
</li>
|
|
||||||
{% endif %}
|
|
||||||
{% endfor %}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
{% block footer %}
|
|
||||||
{% endblock %}
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
{% extends "template.twig" %}
|
|
||||||
|
|
||||||
{% block header %}
|
|
||||||
<h2>Плейлист не найден</h2>
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
{% block content %}
|
|
||||||
<div class="row">
|
|
||||||
<div class="col-md-12">
|
|
||||||
<p>
|
|
||||||
Плейлист {{ id }} не найден
|
|
||||||
</p>
|
|
||||||
<a class="btn btn-outline-light" href="{{ base_url() }}" title="На главную">
|
|
||||||
Перейти к списку
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endblock %}
|
|
||||||
@@ -1,81 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="ru">
|
|
||||||
<head>
|
|
||||||
<title>{% block title %}{{ config('app.title') }}{% endblock %}</title>
|
|
||||||
<meta charset="utf-8">
|
|
||||||
<meta name="description" content="Самообновляемые бесплатные IPTV-плейлисты для домашнего просмотра по коротким ссылкам, списки каналов, проверка доступности">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
||||||
<meta http-equiv="x-ua-compatible" content="ie=edge">
|
|
||||||
<style>.cursor-pointer{cursor:pointer}</style>
|
|
||||||
<link href="{{ base_url('css/bootstrap.min.css') }}" rel="stylesheet">
|
|
||||||
<link rel="apple-touch-icon" sizes="180x180" href="{{ base_url('/favicon/apple-touch-icon.png') }}">
|
|
||||||
<link rel="icon" type="image/png" sizes="32x32" href="{{ base_url('/favicon/favicon-32x32.png') }}">
|
|
||||||
<link rel="icon" type="image/png" sizes="16x16" href="{{ base_url('/favicon/favicon-16x16.png') }}">
|
|
||||||
<link rel="manifest" href="{{ base_url('/favicon/site.webmanifest') }}">
|
|
||||||
<link rel="mask-icon" href="{{ base_url('/favicon/safari-pinned-tab.svg') }}" color="#5bbad5">
|
|
||||||
<meta name="msapplication-TileColor" content="#00aba9">
|
|
||||||
<meta name="msapplication-TileImage" content="{{ base_url('/favicon/mstile-144x144.png') }}">
|
|
||||||
<meta name="theme-color" content="#212529">
|
|
||||||
<style>.boosty{vertical-align:baseline;float:left;display:inline;width:20px}</style>
|
|
||||||
{% block head %}{% endblock %}
|
|
||||||
</head>
|
|
||||||
<body class="bg-dark text-light">
|
|
||||||
<div class="container col-lg-8 mx-auto">
|
|
||||||
<header>
|
|
||||||
<nav class="navbar navbar-expand-lg navbar-dark">
|
|
||||||
<img src="{{ base_url('/favicon/favicon-32x32.png') }}" class="d-inline-block align-text-top px-lg-1" alt=""/>
|
|
||||||
<a class="navbar-brand" href="{{ base_url() }}" title="На главную">
|
|
||||||
{{ config('app.title') }}
|
|
||||||
</a>
|
|
||||||
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav" aria-controls="navbarNav" aria-expanded="false" aria-label="Toggle navigation">
|
|
||||||
<span class="navbar-toggler-icon"></span>
|
|
||||||
</button>
|
|
||||||
<div class="collapse navbar-collapse" id="navbarNav">
|
|
||||||
<ul class="navbar-nav">
|
|
||||||
<li class="nav-item">
|
|
||||||
<a class="nav-link" href="{{ base_url() }}">Главная</a>
|
|
||||||
</li>
|
|
||||||
<li class="nav-item">
|
|
||||||
<a class="nav-link" href="{{ base_url('faq') }}">FAQ</a>
|
|
||||||
</li>
|
|
||||||
<li class="nav-item">
|
|
||||||
<a class="nav-link" href="https://git.axenov.dev/anthony/iptv">Gitea</a>
|
|
||||||
</li>
|
|
||||||
<li class="nav-item">
|
|
||||||
<a class="nav-link" href="https://github.com/anthonyaxenov/iptv">GitHub</a>
|
|
||||||
</li>
|
|
||||||
<li class="nav-item">
|
|
||||||
<a class="nav-link" href="https://axenov.dev">axenov.dev</a>
|
|
||||||
</li>
|
|
||||||
<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 h-100 pt-lg-3 px-0 pb-0">
|
|
||||||
{% block header %}{% endblock %}
|
|
||||||
{% block content %}{% endblock %}
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<footer class="py-4 text-center">
|
|
||||||
<script src="{{ base_url('js/bootstrap.bundle.min.js') }}"></script>
|
|
||||||
{% block footer %}{% endblock %}
|
|
||||||
<a href="{{ base_url('faq') }}">FAQ</a> | <a
|
|
||||||
href="https://github.com/anthonyaxenov/iptv">GitHub</a> | <a
|
|
||||||
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>
|
|
||||||
</footer>
|
|
||||||
</div>
|
|
||||||
{% include("custom.twig") ignore missing %}
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,77 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
|
|
||||||
#################################################
|
|
||||||
#
|
|
||||||
# IPTV Playlist check tool
|
|
||||||
#
|
|
||||||
# Usage:
|
|
||||||
# ./check-pls.sh local/pls.m3u
|
|
||||||
# ./check-pls.sh https://example.com/pls.m3u
|
|
||||||
#
|
|
||||||
# 1st argument is playlist file name or URL.
|
|
||||||
# If it is an URL it will be saved in /tmp and
|
|
||||||
# checked as local file.
|
|
||||||
#
|
|
||||||
# Both *.m3u and *.m3u8 are supported.
|
|
||||||
#
|
|
||||||
# Anthony Axenov (c) 2022
|
|
||||||
# The MIT License:
|
|
||||||
# https://github.com/anthonyaxenov/iptv/blob/master/LICENSE
|
|
||||||
#
|
|
||||||
#################################################
|
|
||||||
|
|
||||||
awk '
|
|
||||||
BEGIN {
|
|
||||||
total_count=0
|
|
||||||
success_count=0
|
|
||||||
fail_count=0
|
|
||||||
print "\033[20m\033[97mPlaylist:\033[0m " ARGV[1]
|
|
||||||
if (ARGV[1] ~ /^http(s)?:\/\/.*/) {
|
|
||||||
parts_count = split(ARGV[1], parts, "/")
|
|
||||||
file_name = parts[parts_count]
|
|
||||||
code = system("wget " ARGV[1] " -qO /tmp/" file_name " > /dev/null")
|
|
||||||
if (code == 0) {
|
|
||||||
print "Saved in /tmp/" file_name
|
|
||||||
} else {
|
|
||||||
print "ERROR: cannot download playlist: " ARGV[1]
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
ARGV[1] = "/tmp/" file_name
|
|
||||||
}
|
|
||||||
print ""
|
|
||||||
print "\033[20m\033[97mNote 1:\033[0m operation may take some time, press CTRL+C to stop."
|
|
||||||
print "\033[20m\033[97mNote 2:\033[0m results may be inaccurate, you should use proper IPTV software to re-check."
|
|
||||||
print "\033[20m\033[97mNote 3:\033[0m error codes listed here - https://everything.curl.dev/usingcurl/returns"
|
|
||||||
print "--------------------"
|
|
||||||
}
|
|
||||||
{
|
|
||||||
sub("\r$", "", $0) # crlf -> lf
|
|
||||||
if ($0 ~ /^#EXTINF:.+,/) {
|
|
||||||
total_count++
|
|
||||||
channel_name = substr($0, index($0, ",") + 1, length($0))
|
|
||||||
print "\n[" total_count "] " channel_name
|
|
||||||
}
|
|
||||||
if ($0 ~ /^http(s)?:\/\/.*/) {
|
|
||||||
url = sprintf("%c%s%c", 34, $0, 34) # 34 is "
|
|
||||||
cmd = "curl -fs --max-time 5 -w \"%{http_code}\" --max-filesize 5000 -o /dev/null " url
|
|
||||||
cmd | getline http_code
|
|
||||||
code = close(cmd)
|
|
||||||
if (http_code == "000") {
|
|
||||||
http_code = "-"
|
|
||||||
}
|
|
||||||
if (code == 0 || code == 63) {
|
|
||||||
print "\033[32mOK:\033[0m " url
|
|
||||||
success_count++
|
|
||||||
} else {
|
|
||||||
print "\033[91mERROR\033[0m " code " (" http_code "): " url
|
|
||||||
fail_count++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
END {
|
|
||||||
print "--------------------"
|
|
||||||
print "\033[20m\033[97mPlaylist:\033[0m " ARGV[1]
|
|
||||||
print "- Success:\t\033[32m" success_count "\033[0m/" total_count
|
|
||||||
print "- Failed: \t\033[91m" fail_count "\033[0m/" total_count
|
|
||||||
}
|
|
||||||
' $1
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
|
|
||||||
#################################################
|
|
||||||
#
|
|
||||||
# IPTV Playlist download tool
|
|
||||||
#
|
|
||||||
# Usage:
|
|
||||||
# ./download-all.sh
|
|
||||||
#
|
|
||||||
# All playlists from playlists.ini will be
|
|
||||||
# downloaded in ./downloaded directory
|
|
||||||
#
|
|
||||||
# Anthony Axenov (c) 2022
|
|
||||||
# The MIT License:
|
|
||||||
# https://github.com/anthonyaxenov/iptv/blob/master/LICENSE
|
|
||||||
#
|
|
||||||
#################################################
|
|
||||||
|
|
||||||
TOOLS_DIR="$( cd -- "$( dirname -- "${BASH_SOURCE[0]:-$0}"; )" &> /dev/null && pwd 2> /dev/null; )";
|
|
||||||
DL_DIR="$TOOLS_DIR/downloaded"
|
|
||||||
INI_FILE="$(dirname "$TOOLS_DIR")/playlists.ini"
|
|
||||||
|
|
||||||
rm -rf "$DL_DIR" && \
|
|
||||||
mkdir -p "$DL_DIR" && \
|
|
||||||
cd "$DL_DIR" && \
|
|
||||||
cat "$INI_FILE" \
|
|
||||||
| grep -P "pls\s*=\s*'(.*)'" \
|
|
||||||
| sed "s#^pls\s*=\s*##g" \
|
|
||||||
| sed "s#'##g" \
|
|
||||||
| tr -d '\r' \
|
|
||||||
| xargs wget
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
|
|
||||||
#################################################
|
|
||||||
#
|
|
||||||
# IPTV channel finder (all playlists)
|
|
||||||
#
|
|
||||||
# Usage:
|
|
||||||
# ./download-all.sh
|
|
||||||
# ./find-in-all.sh "(disney|atv)"
|
|
||||||
#
|
|
||||||
# 1st argument is channel name pattern.
|
|
||||||
#
|
|
||||||
# Anthony Axenov (c) 2022
|
|
||||||
# The MIT License:
|
|
||||||
# https://github.com/anthonyaxenov/iptv/blob/master/LICENSE
|
|
||||||
#
|
|
||||||
#################################################
|
|
||||||
|
|
||||||
TOOLS_DIR="$( cd -- "$( dirname -- "${BASH_SOURCE[0]:-$0}"; )" &> /dev/null && pwd 2> /dev/null; )";
|
|
||||||
DL_DIR="$TOOLS_DIR/downloaded"
|
|
||||||
[ ! -d "$DL_DIR" ] && echo "Error: 'tools/downloaded' directory does not exist. Run tools/download-all.sh" && exit 1
|
|
||||||
[ ! "$(ls -A "$DL_DIR")" ] && echo "Error: 'tools/downloaded' directory is empty. Run tools/download-all.sh" && exit 2
|
|
||||||
for file in $TOOLS_DIR/downloaded/*; do
|
|
||||||
$TOOLS_DIR/find-in-pls.sh "$1" "$file"
|
|
||||||
done
|
|
||||||
@@ -1,84 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
|
|
||||||
#################################################
|
|
||||||
#
|
|
||||||
# IPTV channel finder (one playlist)
|
|
||||||
#
|
|
||||||
# Usage:
|
|
||||||
# ./find-in-pls.sh "disney" local/pls.m3u
|
|
||||||
# ./find-in-pls.sh "disney" https://example.com/pls.m3u
|
|
||||||
#
|
|
||||||
# 1st argument is channel name pattern.
|
|
||||||
#
|
|
||||||
# 2nd argument is playlist file name or URL.
|
|
||||||
# If it is an URL it will be saved in /tmp and
|
|
||||||
# checked as local file.
|
|
||||||
#
|
|
||||||
# Both *.m3u and *.m3u8 are supported.
|
|
||||||
#
|
|
||||||
# Anthony Axenov (c) 2022
|
|
||||||
# The MIT License:
|
|
||||||
# https://github.com/anthonyaxenov/iptv/blob/master/LICENSE
|
|
||||||
#
|
|
||||||
#################################################
|
|
||||||
|
|
||||||
channel="$1"
|
|
||||||
playlist="$2"
|
|
||||||
playlist_url="$playlist"
|
|
||||||
regex_ch="^#extinf:\s*-?[01]\s*.*,(.*${channel,,}.*)"
|
|
||||||
regex_url="^https?:\/\/.*$"
|
|
||||||
|
|
||||||
is_downloaded=0
|
|
||||||
download_dir="/tmp/$(date '+%s%N')"
|
|
||||||
|
|
||||||
found_count=0
|
|
||||||
found_last=0
|
|
||||||
line_count=1
|
|
||||||
|
|
||||||
if [[ "$playlist" =~ $regex_url ]]; then
|
|
||||||
mkdir -p "$download_dir"
|
|
||||||
cd "$download_dir"
|
|
||||||
wget "$playlist" -q > /dev/null
|
|
||||||
if [ $? -eq 0 ]; then
|
|
||||||
is_downloaded=1
|
|
||||||
playlist="$download_dir/$(ls -1 "$download_dir")"
|
|
||||||
cd - > /dev/null
|
|
||||||
else
|
|
||||||
echo "ERROR: cannot download playlist: $playlist"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "--------------------"
|
|
||||||
echo -e "\033[20m\033[97mChannel:\033[0m $channel"
|
|
||||||
echo -e "\033[20m\033[97mPlaylist:\033[0m $playlist_url"
|
|
||||||
echo -e "\033[20m\033[97mRegex:\033[0m $regex_ch"
|
|
||||||
echo "--------------------"
|
|
||||||
|
|
||||||
while read line; do
|
|
||||||
if [[ "${line,,}" =~ $regex_ch ]]; then
|
|
||||||
echo -e "\n\033[32m$line_count FOUND:\033[0m\t$line"
|
|
||||||
((found_count += 1))
|
|
||||||
found_last=$found_count
|
|
||||||
fi
|
|
||||||
if [ $found_last -gt 0 ]; then
|
|
||||||
if [[ "${line,,}" =~ $regex_url ]]; then
|
|
||||||
echo -e "\t\t$line"
|
|
||||||
found_last=0
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
((line_count += 1))
|
|
||||||
done < $playlist
|
|
||||||
|
|
||||||
if [ $found_count -eq 0 ]; then
|
|
||||||
echo -e "\033[91mNothing found\033[0m"
|
|
||||||
else
|
|
||||||
echo "--------------------"
|
|
||||||
echo -e "\033[20m\033[97mChannel:\033[0m $channel"
|
|
||||||
echo -e "\033[20m\033[97mPlaylist:\033[0m $playlist_url"
|
|
||||||
echo -e "\033[20m\033[97mFound:\033[0m\033[32m $found_count\033[0m"
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ $is_downloaded -eq 1 ]; then
|
|
||||||
rm -rf "$download_dir"
|
|
||||||
fi
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
|
|
||||||
#################################################
|
|
||||||
#
|
|
||||||
# IPTV channel maker (all playlists)
|
|
||||||
#
|
|
||||||
# Usage:
|
|
||||||
# ./download-all.sh
|
|
||||||
# ./make-pls.sh "disney"
|
|
||||||
#
|
|
||||||
# 1st argument is channel name pattern.
|
|
||||||
#
|
|
||||||
# To save output in file use redirection:
|
|
||||||
# ./make-pls.sh "disney" > disney.m3u8
|
|
||||||
#
|
|
||||||
# Anthony Axenov (c) 2022
|
|
||||||
# The MIT License:
|
|
||||||
# https://github.com/anthonyaxenov/iptv/blob/master/LICENSE
|
|
||||||
#
|
|
||||||
#################################################
|
|
||||||
|
|
||||||
TOOLS_DIR="$( cd -- "$( dirname -- "${BASH_SOURCE[0]:-$0}"; )" &> /dev/null && pwd 2> /dev/null; )";
|
|
||||||
DL_DIR="$TOOLS_DIR/downloaded"
|
|
||||||
[ ! -d "$DL_DIR" ] && echo "Error: 'tools/downloaded' directory does not exist. Run tools/download-all.sh" && exit 1
|
|
||||||
[ ! "$(ls -A "$DL_DIR")" ] && echo "Error: 'tools/downloaded' directory is empty. Run tools/download-all.sh" && exit 2
|
|
||||||
|
|
||||||
channel="$1"
|
|
||||||
playlist="$2"
|
|
||||||
|
|
||||||
regex_ch="^#extinf:\s*-?[01]\s*.*,(.*${channel,,}.*)"
|
|
||||||
regex_url="^https?:\/\/.*$"
|
|
||||||
|
|
||||||
found_count=0
|
|
||||||
found_last=0
|
|
||||||
|
|
||||||
echo "#EXTM3U"
|
|
||||||
echo "# Autogenerated at `date +%d.%m.%Y`"
|
|
||||||
echo "# https://github.com/anthonyaxenov/iptv"
|
|
||||||
echo
|
|
||||||
|
|
||||||
for file in ./downloaded/*; do
|
|
||||||
while read line; do
|
|
||||||
if [[ "${line,,}" =~ $regex_ch ]]; then
|
|
||||||
echo -e "$line"
|
|
||||||
((found_count += 1))
|
|
||||||
found_last=$found_count
|
|
||||||
fi
|
|
||||||
if [ $found_last -gt 0 ]; then
|
|
||||||
if [[ "${line,,}" =~ $regex_url ]]; then
|
|
||||||
echo -e "$line\n"
|
|
||||||
found_last=0
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
done < $file
|
|
||||||
done
|
|
||||||