wip7
@@ -1,4 +1,10 @@
|
|||||||
|
# Config file path (default: config.yml)
|
||||||
|
# CONFIG_PATH=config.yml
|
||||||
|
|
||||||
APP_DEBUG=false
|
APP_DEBUG=false
|
||||||
|
SITE_TITLE=IPTV Checker
|
||||||
|
APP_TIMEZONE=GMT
|
||||||
|
SITE_BASE_URL=http://localhost:8800
|
||||||
|
|
||||||
CACHE_ENABLED=false
|
CACHE_ENABLED=false
|
||||||
CACHE_HOST=localhost
|
CACHE_HOST=localhost
|
||||||
@@ -7,3 +13,8 @@ CACHE_USERNAME=
|
|||||||
CACHE_PASSWORD=
|
CACHE_PASSWORD=
|
||||||
CACHE_DB=1
|
CACHE_DB=1
|
||||||
CACHE_TTL=1800
|
CACHE_TTL=1800
|
||||||
|
|
||||||
|
SERVER_PORT=8800
|
||||||
|
SERVER_HOST=
|
||||||
|
SITE_PAGE_SIZE=0
|
||||||
|
SITE_REPO_URL=https://git.axenov.dev/IPTV
|
||||||
|
|||||||
@@ -22,11 +22,8 @@ jobs:
|
|||||||
with: # https://github.com/actions/setup-go
|
with: # https://github.com/actions/setup-go
|
||||||
go-version: '>=1.24.2'
|
go-version: '>=1.24.2'
|
||||||
|
|
||||||
- name: Build release files (amd64)
|
- name: Build release files
|
||||||
run: make release ARCH=amd64
|
run: make release
|
||||||
|
|
||||||
- name: Build release files (arm64)
|
|
||||||
run: make release ARCH=arm64
|
|
||||||
|
|
||||||
- name: Create new release
|
- name: Create new release
|
||||||
id: use-go-action
|
id: use-go-action
|
||||||
@@ -40,7 +37,6 @@ jobs:
|
|||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@v3
|
uses: docker/setup-buildx-action@v3
|
||||||
with: # https://github.com/docker/setup-buildx-action
|
with: # https://github.com/docker/setup-buildx-action
|
||||||
build-args: "IPTVC_VERSION=${{ github.ref_name }}"
|
|
||||||
buildkitd-config-inline: |
|
buildkitd-config-inline: |
|
||||||
# https://github.com/moby/buildkit/blob/master/docs/buildkitd.toml.md
|
# https://github.com/moby/buildkit/blob/master/docs/buildkitd.toml.md
|
||||||
[ registry."docker.io" ]
|
[ registry."docker.io" ]
|
||||||
@@ -60,6 +56,8 @@ jobs:
|
|||||||
with: # https://github.com/docker/build-push-action
|
with: # https://github.com/docker/build-push-action
|
||||||
context: .
|
context: .
|
||||||
push: true
|
push: true
|
||||||
|
build-args: |
|
||||||
|
IPTVC_VERSION=${{ github.ref_name }}
|
||||||
tags: |
|
tags: |
|
||||||
git.axenov.dev/iptv/iptvc:${{ github.ref_name }}
|
git.axenov.dev/iptv/iptvc:${{ github.ref_name }}
|
||||||
git.axenov.dev/iptv/iptvc:latest
|
git.axenov.dev/iptv/iptvc:latest
|
||||||
|
|||||||
@@ -1,14 +1,15 @@
|
|||||||
.idea/
|
/.idea/
|
||||||
.vscode/
|
/bin/
|
||||||
bin/
|
/output/
|
||||||
output/
|
/docker/keydb/data/
|
||||||
|
# /docs/
|
||||||
|
.DS_Store
|
||||||
.env
|
.env
|
||||||
|
*.env
|
||||||
*.bak
|
*.bak
|
||||||
*.m3u
|
*.m3u
|
||||||
*.m3u8
|
*.m3u8
|
||||||
*.json
|
|
||||||
*.ini
|
*.ini
|
||||||
|
*.rdb
|
||||||
iptvc
|
iptvc
|
||||||
|
|
||||||
!/**/*.gitkeep
|
!/**/*.gitkeep
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"recommendations": [
|
||||||
|
"jeff-hykin.better-go-syntax",
|
||||||
|
"ms-azuretools.vscode-containers",
|
||||||
|
"docker.docker",
|
||||||
|
"editorconfig.editorconfig",
|
||||||
|
"mhutchie.git-graph",
|
||||||
|
"golang.go",
|
||||||
|
"koda.koda",
|
||||||
|
"esbenp.prettier-vscode",
|
||||||
|
"cweijan.vscode-redis-client",
|
||||||
|
"redhat.vscode-yaml"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
// See https://go.microsoft.com/fwlink/?LinkId=733558
|
||||||
|
// for the documentation about the tasks.json format
|
||||||
|
"version": "2.0.0",
|
||||||
|
"tasks": [
|
||||||
|
{
|
||||||
|
"label": "Живой предпросмотр (localhost:8000)",
|
||||||
|
"type": "shell",
|
||||||
|
"command": "make сдуфт"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "Собрать статический сайт (./site)",
|
||||||
|
"type": "shell",
|
||||||
|
"command": "make site"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "Собрать docker-образ со стат. сайтом",
|
||||||
|
"type": "shell",
|
||||||
|
"command": "make image"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,206 @@
|
|||||||
|
# Инструкции для работы с репозиторием
|
||||||
|
|
||||||
|
## Обзор проекта
|
||||||
|
|
||||||
|
`iptvc` (IPTV Checker) — консольное приложение на Go для проверки IPTV-плейлистов в форматах M3U/M3U8. Программа умеет загружать плейлисты из локальных файлов, по URL и из ini-файла, проверять доступность плейлистов и отдельных каналов, выводить результаты в журнале или JSON, а также запускать встроенный веб-интерфейс.
|
||||||
|
|
||||||
|
Проект является частью экосистемы `m3u.su`. Исходный код размещается на Gitea: `https://git.axenov.dev/IPTV/iptvc`.
|
||||||
|
|
||||||
|
Основные технологии:
|
||||||
|
|
||||||
|
- Go 1.23.6 или новее согласно `go.mod` и README.
|
||||||
|
- Cobra для CLI-команд и флагов.
|
||||||
|
- YAML для конфигурации.
|
||||||
|
- INI для списка плейлистов.
|
||||||
|
- Redis/KeyDB через `github.com/redis/go-redis/v9` для кеширования результатов.
|
||||||
|
- `godotenv` для автоматической загрузки `.env`.
|
||||||
|
- Docker и Docker Compose для контейнерного запуска.
|
||||||
|
- Встроенный HTTP-веб-сервер для просмотра плейлистов и результатов проверки.
|
||||||
|
|
||||||
|
## Структура репозитория
|
||||||
|
|
||||||
|
- `main.go` — минимальная точка входа, передающая управление в пакет `cmd`.
|
||||||
|
- `cmd/` — CLI-слой на Cobra: корневая команда, команды `check` и `serve`, флаги, версия и тесты флагов.
|
||||||
|
- `app/` — прикладная логика приложения.
|
||||||
|
- `app/checker/` — подготовка и проверка плейлистов и каналов.
|
||||||
|
- `app/cache/` — абстракции и работа с кешем.
|
||||||
|
- `app/config/` — структуры конфигурации, значения по умолчанию, YAML и переменные окружения.
|
||||||
|
- `app/inifile/` — чтение ini-файлов со списками плейлистов.
|
||||||
|
- `app/playlist/` — модели и обработка IPTV-плейлистов.
|
||||||
|
- `app/tagfile/` — чтение файла тегов каналов.
|
||||||
|
- `app/web/` — веб-сервер, маршруты и отображение данных.
|
||||||
|
- `app/logger/` — настройка журналирования.
|
||||||
|
- `app/utils/` — вспомогательные функции.
|
||||||
|
- `cmd/keydb/` — вспомогательные материалы для KeyDB, если используются соответствующие компоненты.
|
||||||
|
- `docker/keydb/` — конфигурация и entrypoint для контейнера KeyDB.
|
||||||
|
- `channels.json` — пример или локальный файл тегов каналов.
|
||||||
|
- `config.yml.example` — пример конфигурации приложения.
|
||||||
|
- `.env.example` — пример переменных окружения.
|
||||||
|
- `compose.yml` — окружение из приложения, документации и KeyDB.
|
||||||
|
- `Dockerfile` — многоэтапная сборка бинарного файла и минимального Alpine-образа.
|
||||||
|
- `Makefile` — очистка, кросс-компиляция и сборка релизных архивов.
|
||||||
|
- `README.md` — пользовательская документация, команды запуска, конфигурация и API-маршруты.
|
||||||
|
- `LICENSE` — лицензия MIT.
|
||||||
|
|
||||||
|
## Архитектура и поток выполнения
|
||||||
|
|
||||||
|
1. `main.go` вызывает `cmd.Execute()`.
|
||||||
|
2. `cmd/root.go` регистрирует глобальные флаги и дочерние Cobra-команды.
|
||||||
|
3. Команда `check` инициализирует приложение, применяет CLI-переопределения, подключает кеш, получает плейлисты и запускает проверку.
|
||||||
|
4. Команда `serve` поднимает веб-сервер и при необходимости запускает фоновую проверку через флаг `--check` или настройку `check.start-on-serve`.
|
||||||
|
5. Конфигурация загружается со следующим приоритетом: встроенные значения по умолчанию, `config.yml`, переменные окружения и явно переданные CLI-флаги.
|
||||||
|
6. Результаты проверки могут сохраняться в KeyDB/Redis и отображаться веб-сервером. При недоступном кеше веб-интерфейс показывает неизвестный статус.
|
||||||
|
|
||||||
|
Не следует помещать бизнес-логику в `main.go` или CLI-команды, если её можно разместить в соответствующем пакете `app/`. CLI-слой должен отвечать за разбор аргументов, инициализацию и координацию прикладных компонентов.
|
||||||
|
|
||||||
|
## Сборка и запуск
|
||||||
|
|
||||||
|
Для локальной разработки требуется Go версии 1.23.6 или новее. В `Dockerfile` используется `golang:1.25-alpine`, поэтому контейнерная сборка ориентируется на Go 1.25.
|
||||||
|
|
||||||
|
Основные команды:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Показать доступные цели Makefile
|
||||||
|
make help
|
||||||
|
|
||||||
|
# Быстро запустить приложение из исходников
|
||||||
|
go run .
|
||||||
|
|
||||||
|
# Запустить CLI-справку
|
||||||
|
go run . help
|
||||||
|
|
||||||
|
# Проверить локальный плейлист
|
||||||
|
go run . check -f mypls.m3u
|
||||||
|
|
||||||
|
# Проверить плейлист по URL
|
||||||
|
go run . check -u https://example.com/playlist.m3u
|
||||||
|
|
||||||
|
# Запустить веб-интерфейс на порту 8800
|
||||||
|
go run . serve -p 8800
|
||||||
|
|
||||||
|
# Запустить веб-интерфейс с фоновой проверкой
|
||||||
|
go run . serve -i playlists.ini -p 8800 --check
|
||||||
|
|
||||||
|
# Собрать текущую платформу
|
||||||
|
go build -o iptvc .
|
||||||
|
|
||||||
|
# Запустить тесты всех пакетов
|
||||||
|
go test ./...
|
||||||
|
|
||||||
|
# Проверить форматирование
|
||||||
|
|
||||||
|
gofmt -w .
|
||||||
|
```
|
||||||
|
|
||||||
|
В блоке выше команда форматирования приведена как ориентир: `gofmt` следует запускать по Go-файлам, например `gofmt -w main.go cmd app`, если оболочка или используемая версия инструмента не поддерживает обработку директорий так, как ожидается.
|
||||||
|
|
||||||
|
Цели Makefile:
|
||||||
|
|
||||||
|
- `make clear` — удалить результаты сборки и очистить кеш Go.
|
||||||
|
- `make linux GOARCH=amd64` — собрать Linux-бинарный файл и ZIP-архив.
|
||||||
|
- `make win GOARCH=amd64` — собрать Windows-бинарный файл и ZIP-архив.
|
||||||
|
- `make darwin GOARCH=amd64` — собрать macOS-бинарный файл и ZIP-архив.
|
||||||
|
- `make release` — собрать архивы для Linux, Windows и macOS под `amd64` и `arm64`.
|
||||||
|
|
||||||
|
Перед запуском приложения для конфигурации обычно следует скопировать `.env.example` в `.env`, а `config.yml.example` — в `config.yml`, после чего адаптировать значения под окружение. Не добавляйте реальные пароли, токены или локальные URL в Git.
|
||||||
|
|
||||||
|
## Docker
|
||||||
|
|
||||||
|
Для контейнерного окружения используется файл `compose.yml`, а не устаревшее имя `docker-compose.yml`.
|
||||||
|
|
||||||
|
Ожидаемые локальные файлы для сервиса `iptvc`:
|
||||||
|
|
||||||
|
- `playlists.ini` монтируется в `/app/playlists.ini`.
|
||||||
|
- `channels.json` монтируется в `/app/channels.json`.
|
||||||
|
- `config.yml` монтируется в `/app/config.yml`.
|
||||||
|
- `.env` передаётся через `env_file`.
|
||||||
|
|
||||||
|
Основные команды:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Собрать и запустить сервисы в фоне
|
||||||
|
docker compose up -d --build
|
||||||
|
|
||||||
|
# Посмотреть логи приложения
|
||||||
|
docker compose logs -f iptvc
|
||||||
|
|
||||||
|
# Остановить окружение
|
||||||
|
docker compose down
|
||||||
|
```
|
||||||
|
|
||||||
|
В compose-окружение входят:
|
||||||
|
|
||||||
|
- `iptvc` — веб-интерфейс и фоновая проверка плейлистов.
|
||||||
|
- `keydb` — хранилище результатов проверки.
|
||||||
|
- `docs` — отдельный сервис документации, если его контекст доступен в окружении.
|
||||||
|
|
||||||
|
Обратите внимание: текущий `compose.yml` ссылается на контексты `./iptvc` и `./docs`, поэтому запуск из каталога, содержащего только этот репозиторий, может потребовать внешней структуры проекта или корректировки контекстов.
|
||||||
|
|
||||||
|
## Конфигурация
|
||||||
|
|
||||||
|
Поддерживаются `config.yml`, `.env` и CLI-флаги. CLI-флаги имеют наивысший приоритет только при явной передаче.
|
||||||
|
|
||||||
|
Ключевые группы настроек:
|
||||||
|
|
||||||
|
- `app` — часовой пояс, режим отладки, уровень журнала и пути к `playlists.ini` и `channels.json`.
|
||||||
|
- `server`/`site` — адрес и порт веб-сервера, базовый URL, размер страницы, ссылки сайта и заголовок.
|
||||||
|
- `check.playlists` — User-Agent, тайм-ауты, задержки и параллелизм проверки плейлистов.
|
||||||
|
- `check.channels` — User-Agent, тайм-аут, размер диапазона байтов, задержки и параллелизм проверки каналов.
|
||||||
|
- `cache` — включение KeyDB/Redis, адрес подключения, база и TTL.
|
||||||
|
|
||||||
|
Значения задержек и некоторых параметров могут задаваться как числом или диапазоном `[min, max]`. User-Agent может быть строкой или массивом строк; при массиве выбирается случайное значение.
|
||||||
|
|
||||||
|
Веб-команда поддерживает как минимум следующие сценарии:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Сервер без фоновой проверки
|
||||||
|
./iptvc serve -i playlists.ini -p 8800
|
||||||
|
|
||||||
|
# Сервер с бесконечной фоновой проверкой каждые 60 секунд
|
||||||
|
./iptvc serve --check --repeat 0 --playlists-all-cooldown 60
|
||||||
|
```
|
||||||
|
|
||||||
|
Основные HTTP-маршруты описаны в README: `/`, `/page/{N}`, `/{code}`, `/{code}/details`, `/api/playlists/{code}`, `/api/version`, `/api/health` и `/api/stats`.
|
||||||
|
|
||||||
|
## Тестирование
|
||||||
|
|
||||||
|
Тесты находятся рядом с тестируемым кодом и используют стандартный пакет `testing`. Сейчас явно присутствуют тесты CLI-флагов в `cmd/flags_test.go`; они проверяют применение переопределений приложения, кеша и параметров проверки.
|
||||||
|
|
||||||
|
Перед изменением CLI или конфигурации запускайте:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go test ./...
|
||||||
|
go vet ./...
|
||||||
|
```
|
||||||
|
|
||||||
|
При обновлении конфигурационных структур синхронно проверяйте `config.yml.example`, `.env.example`, `README.md` и документацию в `docs/content/common/config/config.md`, `docs/content/iptvc/commands/check.md`, `docs/content/iptvc/commands/serve.md` и связанных страницах.
|
||||||
|
|
||||||
|
При добавлении флага желательно добавить тесты на:
|
||||||
|
|
||||||
|
- значение по умолчанию без флага;
|
||||||
|
- явное включение и выключение boolean-флага;
|
||||||
|
- переопределение значения из конфигурации;
|
||||||
|
- корректный разбор чисел, диапазонов и списков;
|
||||||
|
- взаимодействие с приоритетом `config.yml`, окружения и CLI.
|
||||||
|
|
||||||
|
Отдельных интеграционных тестов веб-сервера, KeyDB и сетевой проверки плейлистов в просмотренных файлах не обнаружено. Такие тесты требуют изолированных фикстур и не должны зависеть от случайных внешних IPTV-ресурсов.
|
||||||
|
|
||||||
|
## Стиль разработки
|
||||||
|
|
||||||
|
- Соблюдайте стандартный стиль Go и форматируйте изменённые Go-файлы через `gofmt`.
|
||||||
|
- Используйте имена пакетов и экспортируемых сущностей в соответствии с идиомами Go.
|
||||||
|
- Сохраняйте существующие заголовки лицензии в файлах, где они уже присутствуют.
|
||||||
|
- Комментарии к экспортируемым типам и функциям должны объяснять их назначение.
|
||||||
|
- Не добавляйте секреты, реальные данные доступа к KeyDB/Redis и рабочие плейлисты в репозиторий.
|
||||||
|
- Для ошибок и сетевых операций сохраняйте существующую практику явной обработки ошибок и логирования.
|
||||||
|
- Не меняйте формат CLI, конфигурации или API-маршрутов без обновления README и соответствующих тестов.
|
||||||
|
- При работе с конфигурацией учитывайте различие между отсутствующим CLI-флагом и флагом, переданным явно: в `cmd/serve.go` это используется для корректного переопределения порта и хоста.
|
||||||
|
- Избегайте сетевых запросов и зависимости от внешних сервисов в модульных тестах.
|
||||||
|
|
||||||
|
## Важные ограничения и наблюдения
|
||||||
|
|
||||||
|
- `channels.json`, `playlists.ini`, `config.yml` и `.env` могут быть локальными входными данными; проверяйте их наличие перед запуском сценариев из документации.
|
||||||
|
- Кеш по умолчанию отключён в конфигурации приложения, но compose-окружение запускает KeyDB и передаёт настройки через `.env`.
|
||||||
|
- Бинарные файлы и архивы сборки создаются в `bin/` и не должны вручную добавляться в исходный код.
|
||||||
|
- README указывает минимальную версию Go 1.23.6, а Dockerfile собирает проект на Go 1.25; при изменении зависимостей учитывайте обе среды.
|
||||||
|
- При обновлении конфигурационных структур синхронно проверяйте `config.yml.example`, `.env.example`, README и тесты CLI-флагов.
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
FROM golang:1.25-alpine AS iptv-img-builder
|
FROM golang:alpine AS iptv-img-builder
|
||||||
ARG GOOS
|
ARG GOOS
|
||||||
ARG GOARCH
|
ARG GOARCH
|
||||||
ARG IPTVC_VERSION
|
ARG IPTVC_VERSION
|
||||||
@@ -14,7 +14,7 @@ RUN go build \
|
|||||||
-o /app/iptvc \
|
-o /app/iptvc \
|
||||||
.
|
.
|
||||||
|
|
||||||
FROM alpine:3.22.2 AS iptv-img-checker
|
FROM alpine:3.22.5 AS iptv-img-checker
|
||||||
LABEL org.opencontainers.image.authors="Anthony Axenov <anthonyaxenov@gmail.com>"
|
LABEL org.opencontainers.image.authors="Anthony Axenov <anthonyaxenov@gmail.com>"
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
RUN apk add --no-cache ca-certificates tzdata
|
RUN apk add --no-cache ca-certificates tzdata
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2025-2026 Антон Аксенов (Anthony Axenov)
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
|
|||||||
@@ -1,44 +1,60 @@
|
|||||||
.DEFAULT_GOAL=help
|
.DEFAULT_GOAL=help
|
||||||
|
.PHONY: clear linux win darwin release image image-push up down help
|
||||||
|
|
||||||
BINARY_NAME := iptvc
|
BINARY_NAME := iptvc
|
||||||
GOARCH ?= amd64
|
IMAGE_TAG ?= latest
|
||||||
|
GOARCH ?= $(shell go env GOARCH)
|
||||||
|
GOOS ?= $(shell go env GOOS)
|
||||||
|
|
||||||
## clean: Remove all compiled binaries
|
VERSION := $(shell git describe --tags --always --dirty 2>/dev/null | sed 's/^v//' || echo "dev")
|
||||||
clean:
|
COMMIT := $(shell git rev-parse --short HEAD 2>/dev/null || echo "unknown")
|
||||||
|
LDFLAGS := -s -w -X axenov/iptv-checker/app.VERSION=$(VERSION) -X axenov/iptv-checker/app.COMMIT=$(COMMIT)
|
||||||
|
|
||||||
|
# Internal build target
|
||||||
|
internal-build:
|
||||||
|
@rm -rf $(dir $(OUTPUT))
|
||||||
|
@mkdir -p $(dir $(OUTPUT))
|
||||||
|
@CGO_ENABLED=0 GOOS=$(GOOS) GOARCH=$(GOARCH) go build -trimpath -ldflags="$(LDFLAGS)" -o $(OUTPUT) .
|
||||||
|
@zip -j $(dir $(OUTPUT))../$(notdir $(patsubst %/,%,$(dir $(OUTPUT)))).zip $(OUTPUT) >/dev/null
|
||||||
|
@echo "Compiled: $(OUTPUT) ($(GOOS)/$(GOARCH))"
|
||||||
|
|
||||||
|
## clear = Remove all compiled binaries
|
||||||
|
clear:
|
||||||
@go clean
|
@go clean
|
||||||
@rm -rf bin/
|
@rm -rf bin/
|
||||||
|
|
||||||
## linux: Build new binaries for linux
|
## build = Build binary for current platform
|
||||||
|
build:
|
||||||
|
@mkdir -p bin
|
||||||
|
@CGO_ENABLED=0 GOOS=$(GOOS) GOARCH=$(GOARCH) go build -trimpath -ldflags="$(LDFLAGS)" -o bin/$(BINARY_NAME) .
|
||||||
|
@echo "Compiled: bin/$(BINARY_NAME) ($(GOOS)/$(GOARCH))"
|
||||||
|
|
||||||
|
## linux = Build new binaries for linux
|
||||||
linux:
|
linux:
|
||||||
@rm -rf bin/linux_$(GOARCH)
|
@$(MAKE) internal-build GOOS=linux GOARCH=$(GOARCH) OUTPUT=bin/linux_$(GOARCH)/$(BINARY_NAME)
|
||||||
@GOARCH=$(GOARCH) GOOS=linux go build -o bin/linux_$(GOARCH)/$(BINARY_NAME) .
|
|
||||||
@zip -j bin/linux_$(GOARCH).zip bin/linux_$(GOARCH)/$(BINARY_NAME)
|
|
||||||
@echo "Compiled: bin/linux_$(GOARCH)/$(BINARY_NAME) ($(GOARCH))"
|
|
||||||
|
|
||||||
## win: Build new binaries for windows
|
## win = Build new binaries for windows
|
||||||
win:
|
win:
|
||||||
@rm -rf bin/windows_$(GOARCH)
|
@$(MAKE) internal-build GOOS=windows GOARCH=$(GOARCH) OUTPUT=bin/windows_$(GOARCH)/$(BINARY_NAME).exe
|
||||||
@GOARCH=$(GOARCH) GOOS=windows go build -o bin/windows_$(GOARCH)/$(BINARY_NAME).exe .
|
|
||||||
@zip -j bin/windows_$(GOARCH).zip bin/windows_$(GOARCH)/$(BINARY_NAME).exe
|
|
||||||
@echo "Compiled: bin/windows_$(GOARCH)/$(BINARY_NAME).exe ($(GOARCH))"
|
|
||||||
|
|
||||||
## darwin: Build new binaries for darwin
|
## darwin = Build new binaries for darwin
|
||||||
darwin:
|
darwin:
|
||||||
@rm -rf bin/darwin_$(GOARCH)
|
@$(MAKE) internal-build GOOS=darwin GOARCH=$(GOARCH) OUTPUT=bin/darwin_$(GOARCH)/$(BINARY_NAME)
|
||||||
@GOARCH=$(GOARCH) GOOS=darwin go build -o bin/darwin_$(GOARCH)/$(BINARY_NAME) .
|
|
||||||
@zip -j bin/darwin_$(GOARCH).zip bin/darwin_$(GOARCH)/$(BINARY_NAME)
|
|
||||||
@echo "Compiled: bin/darwin_$(GOARCH)/$(BINARY_NAME) ($(GOARCH))"
|
|
||||||
|
|
||||||
## release: Build all binaries and zip them
|
## release = Build all binaries and zip them
|
||||||
release: clean
|
release: clear
|
||||||
@make linux GOARCH=amd64
|
@$(MAKE) -j2 linux GOARCH=amd64
|
||||||
@make linux GOARCH=arm64
|
@$(MAKE) -j2 linux GOARCH=arm64
|
||||||
@make win GOARCH=amd64
|
@$(MAKE) -j2 win GOARCH=amd64
|
||||||
@make win GOARCH=arm64
|
@$(MAKE) -j2 win GOARCH=arm64
|
||||||
@make darwin GOARCH=amd64
|
@$(MAKE) -j2 darwin GOARCH=amd64
|
||||||
@make darwin GOARCH=arm64
|
@$(MAKE) -j2 darwin GOARCH=arm64
|
||||||
|
|
||||||
## help: Show this message and exit
|
## image = Build and push the Docker image
|
||||||
|
image:
|
||||||
|
@GOOS=$(GOOS) GOARCH=$(GOARCH) ./build-docker-image.sh $(IMAGE_TAG)
|
||||||
|
|
||||||
|
## help = Show this message and exit
|
||||||
help: Makefile
|
help: Makefile
|
||||||
@echo "Available recipes:"
|
@echo "Available recipes:"
|
||||||
@sed -n 's/^##//p' $< | column -t -s ':' | sed -e 's/^/ /'
|
@sed -n 's/^##/ /p' $< | column -t -s '='
|
||||||
|
|||||||
@@ -1,34 +1,94 @@
|
|||||||
# IPTV Checker (iptvc)
|
# IPTV Checker (iptvc)
|
||||||
|
|
||||||
[](https://git.axenov.dev/IPTV/iptvc/releases/latest)
|
[](https://git.axenov.dev/IPTV/iptvc/releases/latest)
|
||||||
|
|
||||||
Консольная программа для проверки IPTV-плейлистов в формате m3u или m3u8.
|
Консольная программа для проверки IPTV-плейлистов в формате m3u или m3u8.
|
||||||
|
|
||||||
> **Веб-сайт:** [m3u.su](https://m3u.su)
|
> **Веб-сайт:** [m3u.su](https://m3u.su)
|
||||||
> **Документация:** [m3u.su/docs](https://m3u.su/docs)
|
> Документация: [m3u.su/docs](https://m3u.su/docs)
|
||||||
> Исходный код: [git.axenov.dev/IPTV](https://git.axenov.dev/IPTV)
|
|
||||||
> Telegram-канал: [@iptv_aggregator](https://t.me/iptv_aggregator)
|
> Telegram-канал: [@iptv_aggregator](https://t.me/iptv_aggregator)
|
||||||
> Обсуждение: [@iptv_aggregator_chat](https://t.me/iptv_aggregator_chat)
|
> Исходный код: [git.axenov.dev/IPTV](https://git.axenov.dev/IPTV)
|
||||||
> Бот: [@iptv_aggregator_bot](https://t.me/iptv_aggregator_bot)
|
|
||||||
|
|
||||||
## Установка
|
## Установка
|
||||||
|
|
||||||
Достаточно скачать и распаковать архив с подходящим исполняемым файлом [со страницы последнего релиза](https://git.axenov.dev/IPTV/iptvc/releases/latest):
|
Достаточно скачать и распаковать архив с подходящим исполняемым файлом [со страницы последнего релиза](https://git.axenov.dev/IPTV/iptvc/releases/latest):
|
||||||
|
|
||||||
| ОС | Скачать для `amd64` | Скачать для `arm64` |
|
| ОС | Скачать для `amd64` | Скачать для `arm64` |
|
||||||
| ------- | ------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------- |
|
| ------- | ---------------------------------- | ---------------------------------- |
|
||||||
| Linux | [linux_amd64.zip](https://git.axenov.dev/IPTV/iptvc/releases/download/latest/linux_amd64.zip) | [linux_arm64.zip](https://git.axenov.dev/IPTV/iptvc/releases/download/latest/linux_arm64.zip) |
|
| Linux | [linux_amd64.zip][linux_amd64] | [linux_arm64.zip][linux_arm64] |
|
||||||
| MacOS | [darwin_amd64.zip](https://git.axenov.dev/IPTV/iptvc/releases/download/latest/darwin_amd64.zip) | [darwin_arm64.zip](https://git.axenov.dev/IPTV/iptvc/releases/download/latest/darwin_arm64.zip) |
|
| MacOS | [darwin_amd64.zip][darwin_amd64] | [darwin_arm64.zip][darwin_arm64] |
|
||||||
| Windows | [windows_amd64.zip](https://git.axenov.dev/IPTV/iptvc/releases/download/latest/windows_amd64.zip) | [windows_arm64.zip](https://git.axenov.dev/IPTV/iptvc/releases/download/latest/windows_arm64.zip) |
|
| Windows | [windows_amd64.zip][windows_amd64] | [windows_arm64.zip][windows_arm64] |
|
||||||
|
|
||||||
|
[linux_amd64]: https://git.axenov.dev/IPTV/iptvc/releases/download/latest/linux_amd64.zip
|
||||||
|
[darwin_amd64]: https://git.axenov.dev/IPTV/iptvc/releases/download/latest/darwin_amd64.zip
|
||||||
|
[windows_amd64]: https://git.axenov.dev/IPTV/iptvc/releases/download/latest/windows_amd64.zip
|
||||||
|
[linux_arm64]: https://git.axenov.dev/IPTV/iptvc/releases/download/latest/linux_arm64.zip
|
||||||
|
[darwin_arm64]: https://git.axenov.dev/IPTV/iptvc/releases/download/latest/darwin_arm64.zip
|
||||||
|
[windows_arm64]: https://git.axenov.dev/IPTV/iptvc/releases/download/latest/windows_arm64.zip
|
||||||
|
|
||||||
## Компиляция
|
## Компиляция
|
||||||
|
|
||||||
Для сборки потребуется golang v1.23.6 и выше.
|
Для сборки потребуется Go версии 1.23.6 или выше.
|
||||||
На версиях ниже не проверялось.
|
Контейнерная сборка выполняется на Go 1.25.
|
||||||
|
|
||||||
1. Склонировать репозиторий
|
```bash
|
||||||
2. Находясь в корне репозитория, следует выполнить `make` или `make help` для получения справки.
|
git clone https://git.axenov.dev/IPTV/iptvc.git
|
||||||
3. Другой способ — выполнить `go run .` для быстрого запуска.
|
cd iptvc
|
||||||
|
make help
|
||||||
|
```
|
||||||
|
|
||||||
|
### Цели Makefile
|
||||||
|
|
||||||
|
| Цель | Описание |
|
||||||
|
| --------- | --------------------------------------------------------------- |
|
||||||
|
| `help` | Показать список доступных целей (по умолчанию) |
|
||||||
|
| `build` | Собрать бинарный файл для текущей платформы в `bin/iptvc` |
|
||||||
|
| `linux` | Собрать Linux-бинарник и ZIP-архив в `bin/linux_$(GOARCH)/` |
|
||||||
|
| `win` | Собрать Windows-бинарник и ZIP-архив в `bin/windows_$(GOARCH)/` |
|
||||||
|
| `darwin` | Собрать macOS-бинарник и ZIP-архив в `bin/darwin_$(GOARCH)/` |
|
||||||
|
| `release` | Собрать все платформы (`amd64` и `arm64`) и упаковать в ZIP |
|
||||||
|
| `clear` | Удалить все собранные бинарники и очистить кеш Go |
|
||||||
|
| `image` | Собрать и отправить Docker-образ |
|
||||||
|
|
||||||
|
### Переменные Makefile
|
||||||
|
|
||||||
|
| Переменная | По умолчанию | Описание |
|
||||||
|
| ------------- | ---------------------------- | ----------------------------------------- |
|
||||||
|
| `BINARY_NAME` | `iptvc` | Имя бинарного файла |
|
||||||
|
| `GOOS` | `go env GOOS` | Целевая ОС (`linux`, `windows`, `darwin`) |
|
||||||
|
| `GOARCH` | `go env GOARCH` | Целевая архитектура (`amd64`, `arm64`) |
|
||||||
|
| `IMAGE_TAG` | `latest` | Тег Docker-образа |
|
||||||
|
| `VERSION` | `git describe --tags ...` | Версия, вшиваемая в бинарник |
|
||||||
|
| `COMMIT` | `git rev-parse --short HEAD` | Коммит, вшиваемый в бинарник |
|
||||||
|
|
||||||
|
### Примеры сборки
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Быстрая сборка для текущей платформы
|
||||||
|
make build
|
||||||
|
|
||||||
|
# Сборка для Linux amd64
|
||||||
|
make linux GOARCH=amd64
|
||||||
|
|
||||||
|
# Сборка для Windows arm64
|
||||||
|
make win GOARCH=arm64
|
||||||
|
|
||||||
|
# Сборка всех релизных архивов
|
||||||
|
make release
|
||||||
|
|
||||||
|
# Сборка Docker-образа с тегом
|
||||||
|
make image IMAGE_TAG=1.0.0
|
||||||
|
```
|
||||||
|
|
||||||
|
### Запуск из исходников
|
||||||
|
|
||||||
|
Для быстрого запуска без сборки используйте `go run .`.
|
||||||
|
Для запуска тестов и статического анализа:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go test ./...
|
||||||
|
go vet ./...
|
||||||
|
```
|
||||||
|
|
||||||
## Быстрый старт
|
## Быстрый старт
|
||||||
|
|
||||||
@@ -36,7 +96,7 @@
|
|||||||
|
|
||||||
Выполнить `./iptvc help` для получения краткой справки.
|
Выполнить `./iptvc help` для получения краткой справки.
|
||||||
|
|
||||||
Если был клонирован репозиторий, то вместо `./iptvc` можно запустить `go run .`
|
Если был клонирован репозиторий, то вместо `./iptvc` можно запустить `go run .` или `make help`.
|
||||||
|
|
||||||
Ниже рассмотрены простые примеры использования программы для проверки плейлистов.
|
Ниже рассмотрены простые примеры использования программы для проверки плейлистов.
|
||||||
|
|
||||||
@@ -66,13 +126,27 @@
|
|||||||
|
|
||||||
Аргумент `-i` можно указывать только однажды, но его можно комбинировать с `-f` и `-u`.
|
Аргумент `-i` можно указывать только однажды, но его можно комбинировать с `-f` и `-u`.
|
||||||
|
|
||||||
### Другие возможности команды `check`
|
### Флаги команды `check`
|
||||||
|
|
||||||
* `--random|-r X` — проверить X случайных плейлистов из ini-файла
|
Источники плейлистов:
|
||||||
* `--json|-j` — вывести результаты проверки в формате JSON
|
|
||||||
* `--quiet|-q` — полностью подавить вывод лога (включая отладочную информацию)
|
* `--file|-f PATH` — локальный файл плейлиста M3U/M3U8; флаг можно указывать несколько раз;
|
||||||
* `--verbose|-v` — добавить в лог более подробную отладочную информацию (значительно увеличит количество строк!)
|
* `--url|-u URL` — удалённый плейлист по HTTP/HTTPS; флаг можно указывать несколько раз;
|
||||||
* `--tags|-t` — файл с перечислением тегов (подробности см. [здесь](https://git.axenov.dev/IPTV/playlists#файл-channelsjson))
|
* `--code|-c CODE` — код плейлиста из ini-файла; флаг можно указывать несколько раз;
|
||||||
|
* `--ini|-i PATH` — путь к ini-файлу;
|
||||||
|
* `--tags|-t PATH` — путь к файлу тегов каналов.
|
||||||
|
|
||||||
|
Управление выводом и повторными проверками:
|
||||||
|
|
||||||
|
* `--json|-j` — вывести результаты проверки в формате JSON;
|
||||||
|
* `--quiet|-q` — подавить журналы, не отключая вывод JSON;
|
||||||
|
* `--verbose|-v` — включить подробный журнал;
|
||||||
|
* `--random|-r N` — выбрать N случайных плейлистов из ini-файла;
|
||||||
|
* `--repeat N` — повторить проверку N раз, значение `0` означает бесконечный цикл;
|
||||||
|
* `--playlists-all-cooldown N` — ждать N секунд между полными циклами проверки; значение также можно задать через `check.playlists.all-cooldown`.
|
||||||
|
|
||||||
|
Параметры проверки и кеша также можно переопределить CLI-флагами.
|
||||||
|
Полный список доступен через `./iptvc check --help`.
|
||||||
|
|
||||||
Например, можно получить только json с результатами, передать его в `jq` и, отфильтровав результат, вывести названия оффлайн каналов:
|
Например, можно получить только json с результатами, передать его в `jq` и, отфильтровав результат, вывести названия оффлайн каналов:
|
||||||
|
|
||||||
@@ -83,6 +157,251 @@
|
|||||||
> [!NOTE]
|
> [!NOTE]
|
||||||
> Набери `./iptvc help` для получения помощи.
|
> Набери `./iptvc help` для получения помощи.
|
||||||
|
|
||||||
|
## Веб-интерфейс
|
||||||
|
|
||||||
|
Программа включает встроенный веб-сервер для просмотра плейлистов и результатов их проверки.
|
||||||
|
|
||||||
|
### Запуск
|
||||||
|
|
||||||
|
```
|
||||||
|
./iptvc serve -i playlists.ini -p 8800
|
||||||
|
```
|
||||||
|
|
||||||
|
### Параметры команды `serve`
|
||||||
|
|
||||||
|
* `-p, --port` — порт для веб-сервера (переопределяет `config.yml` и `SERVER_PORT`);
|
||||||
|
* `--host` — хост для привязки (переопределяет `config.yml` и `SERVER_HOST`);
|
||||||
|
* `--check` — включить фоновую проверку плейлистов (по умолчанию выключена).
|
||||||
|
|
||||||
|
При указании `--check` доступны флаги проверки:
|
||||||
|
|
||||||
|
* `-i, --ini` — путь к ini-файлу (по умолчанию `./playlists.ini`);
|
||||||
|
* `-t, --tags` — путь к файлу тегов (по умолчанию `./channels.json`);
|
||||||
|
* `--playlists-all-cooldown N` — пауза между полными циклами проверки в секундах; если флаг не указан, используется `check.playlists.all-cooldown` из конфигурации;
|
||||||
|
* `--repeat` — количество циклов проверки (по умолчанию `0` — бесконечно).
|
||||||
|
|
||||||
|
### Конфигурация
|
||||||
|
|
||||||
|
Приоритет настроек (от низшего к высшему):
|
||||||
|
|
||||||
|
1. **Значения по умолчанию** — встроены в код;
|
||||||
|
2. **`config.yml`** — YAML-файл в корне проекта (путь можно задать через флаг `--config`);
|
||||||
|
3. **Переменные окружения** — переопределяют значения из `config.yml` (если заданы);
|
||||||
|
4. **CLI-флаги** — переопределяют значения из окружения и `config.yml` (если заданы явно).
|
||||||
|
|
||||||
|
Флаги `--port` и `--host` переопределяют конфигурацию только если переданы явно.
|
||||||
|
Если флаг не указан, используется значение из переменной окружения, затем из `config.yml`,
|
||||||
|
затем значение по умолчанию.
|
||||||
|
|
||||||
|
Файл `.env` загружается автоматически, переменные из него применяются как переменные окружения.
|
||||||
|
|
||||||
|
#### `config.yml`
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
app:
|
||||||
|
timezone: GMT
|
||||||
|
debug: false
|
||||||
|
log_level: info
|
||||||
|
playlists: ./playlists.ini
|
||||||
|
tags: ./channels.json
|
||||||
|
|
||||||
|
server:
|
||||||
|
host: ""
|
||||||
|
port: 8800
|
||||||
|
|
||||||
|
site:
|
||||||
|
base-url: http://localhost:8800
|
||||||
|
repo-url: https://git.axenov.dev/IPTV
|
||||||
|
page-size: 0
|
||||||
|
favicon: # опциональный путь к иконке
|
||||||
|
title: IPTV Checker
|
||||||
|
meta:
|
||||||
|
description: Самообновляемые бесплатные IPTV-плейлисты для домашнего просмотра по коротким ссылкам, списки каналов, проверка доступности
|
||||||
|
keywords: самообновляемые,бесплатные,iptv-плейлисты,iptv,плейлисты
|
||||||
|
tabs:
|
||||||
|
raw:
|
||||||
|
visible: true
|
||||||
|
legal:
|
||||||
|
visible: true
|
||||||
|
text: |
|
||||||
|
Данные, представленные на данной странице, получены автоматически из открыто доступных в интернете IPTV-плейлистов, опубликованных третьими лицами.
|
||||||
|
|
||||||
|
Сервис не размещает и не транслирует медиаконтент, не создаёт, не призывает использовать и распространять плейлисты третьих лиц, а также не оказывает услуг по ретрансляции телепрограмм.
|
||||||
|
|
||||||
|
Информация о телеканалах формируется исключительно путём обработки содержимого самого плейлиста.
|
||||||
|
header:
|
||||||
|
menu:
|
||||||
|
- title: Помощь
|
||||||
|
icon: help-circle-outline
|
||||||
|
children:
|
||||||
|
- title: Документация
|
||||||
|
url: https://m3u.su/docs
|
||||||
|
icon: document-text-outline
|
||||||
|
- title: Исходники
|
||||||
|
url: https://git.axenov.dev/IPTV
|
||||||
|
icon: code-slash-outline
|
||||||
|
- title: "@iptv_aggregator"
|
||||||
|
url: https://t.me/iptv_aggregator
|
||||||
|
icon: paper-plane-outline
|
||||||
|
footer:
|
||||||
|
links:
|
||||||
|
- title: Исходники
|
||||||
|
url: https://git.axenov.dev/IPTV
|
||||||
|
icon: code-slash-outline
|
||||||
|
- title: axenov.dev
|
||||||
|
url: https://axenov.dev
|
||||||
|
icon: person-outline
|
||||||
|
- title: "@iptv_aggregator"
|
||||||
|
url: https://t.me/iptv_aggregator
|
||||||
|
icon: megaphone-outline
|
||||||
|
|
||||||
|
check:
|
||||||
|
start-on-serve: false
|
||||||
|
playlists:
|
||||||
|
user-agent:
|
||||||
|
- Mozilla/5.0 WINK/1.31.1 (AndroidTV/9) HlsWinkPlayer
|
||||||
|
timeout: 10
|
||||||
|
all-cooldown: 1800
|
||||||
|
one-cooldown: 2
|
||||||
|
max-routines: 1
|
||||||
|
per-routine: 1
|
||||||
|
channels:
|
||||||
|
user-agent: Mozilla/5.0 WINK/1.31.1 (AndroidTV/9) HlsWinkPlayer
|
||||||
|
timeout: 10
|
||||||
|
byte-range: 512
|
||||||
|
cooldown: 0
|
||||||
|
max-routines: 50
|
||||||
|
per-routine: 10
|
||||||
|
|
||||||
|
cache:
|
||||||
|
enabled: false
|
||||||
|
host: localhost
|
||||||
|
port: 6379
|
||||||
|
username:
|
||||||
|
password:
|
||||||
|
db: 0
|
||||||
|
ttl: 30
|
||||||
|
```
|
||||||
|
|
||||||
|
### Часовой пояс
|
||||||
|
|
||||||
|
Параметр `app.timezone` задаёт часовой пояс, в котором отображается время последнего изменения плейлистов на сайте.
|
||||||
|
Поддерживаются значения из базы IANA (`Europe/Moscow`, `UTC`, `GMT` и т.д.) и фиксированные смещения (`UTC+3`, `UTC+03:00`).
|
||||||
|
Если указано некорректное значение, используется `UTC`.
|
||||||
|
|
||||||
|
### SEO-метаданные
|
||||||
|
|
||||||
|
Секция `site.meta` позволяет задать описание и ключевые слова для главной страницы и других страниц, где мета-данные не переопределяются шаблоном:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
site:
|
||||||
|
meta:
|
||||||
|
description: Самообновляемые бесплатные IPTV-плейлисты...
|
||||||
|
keywords: самообновляемые,бесплатные,iptv-плейлисты,iptv,плейлисты
|
||||||
|
```
|
||||||
|
|
||||||
|
Те же значения можно переопределить через переменные окружения `SITE_META_DESCRIPTION` и `SITE_META_KEYWORDS`.
|
||||||
|
|
||||||
|
`check.playlists.all-cooldown` задаёт паузу между полными циклами проверки.
|
||||||
|
Она используется командами `check` и `serve` и больше не применяется дополнительно внутри одного цикла. `check.playlists.one-cooldown` задаёт паузу между отдельными плейлистами, а `check.channels.cooldown` — между каналами одного плейлиста.
|
||||||
|
|
||||||
|
Параметры задержек (`timeout`, `all-cooldown`, `one-cooldown`, `cooldown`) и переменные окружения `CHECK_*_TIMEOUT`, `CHECK_*_COOLDOWN` задаются в секундах.
|
||||||
|
Они могут быть заданы как число или диапазон `[min, max]` в секундах.
|
||||||
|
При диапазоне для каждой операции выбирается случайное значение.
|
||||||
|
|
||||||
|
#### Переменные окружения
|
||||||
|
|
||||||
|
| Переменная | Соответствует в `config.yml` | CLI-флаг | Описание |
|
||||||
|
| ----------------------------------- | ------------------------------ | -------------------------- | ----------------------------------- |
|
||||||
|
| `APP_DEBUG` | `app.debug` | `--debug` | Режим отладки |
|
||||||
|
| `APP_LOG_LEVEL` | `app.log_level` | `--log-level` | Уровень журналирования |
|
||||||
|
| `APP_TIMEZONE` | `app.timezone` | — | Часовой пояс |
|
||||||
|
| `APP_PLAYLISTS` | `app.playlists` | `-i`, `--ini` | Путь к `playlists.ini` |
|
||||||
|
| `APP_TAGS` | `app.tags` | `-t`, `--tags` | Путь к `channels.json` |
|
||||||
|
| `SERVER_HOST` | `server.host` | `serve --host` | Хост для привязки |
|
||||||
|
| `SERVER_PORT` | `server.port` | `-p`, `serve --port` | Порт веб-сервера |
|
||||||
|
| `SITE_BASE_URL` | `site.base-url` | — | Базовый URL для ссылок |
|
||||||
|
| `SITE_PAGE_SIZE` | `site.page-size` | — | Размер страницы (0 — без пагинации) |
|
||||||
|
| `SITE_REPO_URL` | `site.repo-url` | — | Ссылка на репозиторий |
|
||||||
|
| `SITE_FAVICON` | `site.favicon` | — | Путь к иконке сайта |
|
||||||
|
| `SITE_TITLE` | `site.title` | — | Заголовок сайта |
|
||||||
|
| `SITE_META_DESCRIPTION` | `site.meta.description` | — | Описание сайта (meta) |
|
||||||
|
| `SITE_META_KEYWORDS` | `site.meta.keywords` | — | Ключевые слова сайта (meta) |
|
||||||
|
| `CHECK_START_ON_SERVE` | `check.start-on-serve` | `serve --check` | Запускать проверку вместе с `serve` |
|
||||||
|
| `CHECK_PLAYLISTS_TIMEOUT` | `check.playlists.timeout` | `--playlists-timeout` | Таймаут запроса плейлиста (с) |
|
||||||
|
| `CHECK_PLAYLISTS_ALL_COOLDOWN` | `check.playlists.all-cooldown` | `--playlists-all-cooldown` | Пауза между циклами проверки (с) |
|
||||||
|
| `CHECK_PLAYLISTS_ONE_COOLDOWN` | `check.playlists.one-cooldown` | `--playlists-one-cooldown` | Пауза между плейлистами (с) |
|
||||||
|
| `CHECK_PLAYLISTS_MAX_ROUTINES` | `check.playlists.max-routines` | `--playlists-max-routines` | Параллельных проверок плейлистов |
|
||||||
|
| `CHECK_PLAYLISTS_PER_ROUTINE` | `check.playlists.per-routine` | `--playlists-per-routine` | Плейлистов на одну процедуру |
|
||||||
|
| `CHECK_PLAYLISTS_USER_AGENT_[1..*]` | `check.playlists.user-agent` | `--playlists-user-agent` | User-Agent для проверки плейлистов |
|
||||||
|
| `CHECK_CHANNELS_TIMEOUT` | `check.channels.timeout` | `--channels-timeout` | Таймаут запроса канала (с) |
|
||||||
|
| `CHECK_CHANNELS_BYTE_RANGE` | `check.channels.byte-range` | `--channels-byte-range` | Объём данных для получения (байт) |
|
||||||
|
| `CHECK_CHANNELS_COOLDOWN` | `check.channels.cooldown` | `--channels-cooldown` | Пауза между каналами (с) |
|
||||||
|
| `CHECK_CHANNELS_MAX_ROUTINES` | `check.channels.max-routines` | `--channels-max-routines` | Параллельных проверок каналов |
|
||||||
|
| `CHECK_CHANNELS_PER_ROUTINE` | `check.channels.per-routine` | `--channels-per-routine` | Каналов на одну процедуру |
|
||||||
|
| `CHECK_CHANNELS_USER_AGENT_[1..*]` | `check.channels.user-agent` | `--channels-user-agent` | User-Agent для проверки каналов |
|
||||||
|
| `CACHE_ENABLED` | `cache.enabled` | `--cache-enabled` | Включить кеш (KeyDB/Redis) |
|
||||||
|
| `CACHE_HOST` | `cache.host` | `--cache-host` | Хост KeyDB/Redis |
|
||||||
|
| `CACHE_PORT` | `cache.port` | `--cache-port` | Порт KeyDB/Redis |
|
||||||
|
| `CACHE_USERNAME` | `cache.username` | `--cache-username` | Имя пользователя KeyDB/Redis |
|
||||||
|
| `CACHE_PASSWORD` | `cache.password` | `--cache-password` | Пароль KeyDB/Redis |
|
||||||
|
| `CACHE_DB` | `cache.db` | `--cache-db` | Номер БД KeyDB/Redis |
|
||||||
|
| `CACHE_TTL` | `cache.ttl` | `--cache-ttl` | TTL записей в кеше (сек) |
|
||||||
|
|
||||||
|
### Маршруты
|
||||||
|
|
||||||
|
| Метод | Путь | Описание |
|
||||||
|
| ----- | -------------------------------- | ---------------------------------------- |
|
||||||
|
| GET | `/` | Главная страница со списком плейлистов |
|
||||||
|
| GET | `/page/{N}` | Страница N списка плейлистов |
|
||||||
|
| GET | `/{code}` | Редирект на прямую ссылку плейлиста |
|
||||||
|
| GET | `/{code}.m3u` | Редирект на прямую ссылку плейлиста |
|
||||||
|
| GET | `/{code}.m3u8` | Редирект на прямую ссылку плейлиста |
|
||||||
|
| GET | `/{code}/details` | Страница с описанием плейлиста |
|
||||||
|
| GET | `/api/playlists` | JSON: список плейлистов |
|
||||||
|
| GET | `/api/playlists/{code}` | JSON: информация о плейлисте |
|
||||||
|
| GET | `/api/playlists/{code}/channels` | JSON: каналы плейлиста |
|
||||||
|
| GET | `/api/version` | JSON: версии компонентов |
|
||||||
|
| GET | `/api/health` | JSON: состояние сервиса |
|
||||||
|
| GET | `/api/stats` | JSON: статистика по плейлистам и каналам |
|
||||||
|
| GET | `/api` | Swagger UI документация API |
|
||||||
|
|
||||||
|
### Связь с проверкой
|
||||||
|
|
||||||
|
Веб-сервер отображает данные из кеша KeyDB/Redis, который заполняется командой `check`.
|
||||||
|
Если кеш недоступен, все плейлисты отображаются со статусом `unknown`.
|
||||||
|
|
||||||
|
Для обновления данных в фоне запустите `serve` с флагом `--check`:
|
||||||
|
|
||||||
|
```
|
||||||
|
./iptvc serve -i playlists.ini -p 8800 --check
|
||||||
|
```
|
||||||
|
|
||||||
|
Результаты проверок появляются на веб-страницах немедленно после проверки каждого
|
||||||
|
плейлиста, не дожидаясь завершения полного цикла.
|
||||||
|
После завершения цикла приложение
|
||||||
|
ждёт значение `check.playlists.all-cooldown` и затем начинает следующий цикл.
|
||||||
|
|
||||||
|
Можно запускать `serve` и `check` отдельными процессами:
|
||||||
|
|
||||||
|
```
|
||||||
|
./iptvc check --repeat 0 --playlists-all-cooldown 60 &
|
||||||
|
./iptvc serve
|
||||||
|
```
|
||||||
|
|
||||||
|
Или используйте Docker Compose. Перед запуском подготовьте `.env`, `config.yml`, `playlists.ini` и `channels.json` в рабочем каталоге:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
cp config.yml.example config.yml
|
||||||
|
docker compose up -d --build
|
||||||
|
docker compose logs -f iptvc
|
||||||
|
docker compose down
|
||||||
|
```
|
||||||
|
|
||||||
|
Compose запускает сервисы `iptvc`, `keydb` и `docs`.
|
||||||
|
В текущем `compose.yml` контексты сборки указаны как `./iptvc` и `./docs`, поэтому запуск из одного только каталога этого репозитория может потребовать внешней структуры проекта или корректировки контекстов.
|
||||||
|
|
||||||
## Результаты проверки
|
## Результаты проверки
|
||||||
|
|
||||||
Программа логирует процесс и результаты своей работы.
|
Программа логирует процесс и результаты своей работы.
|
||||||
@@ -104,55 +423,30 @@ pls='https://example.com/list2.m3u'
|
|||||||
```
|
```
|
||||||
2025/01/02 12:34:00 Loading playlists from ini-file: /home/user/playlists.ini
|
2025/01/02 12:34:00 Loading playlists from ini-file: /home/user/playlists.ini
|
||||||
2025/01/02 12:34:00 Loaded 2 playlists
|
2025/01/02 12:34:00 Loaded 2 playlists
|
||||||
2025/01/02 12:34:00 [001/001] Playlist [first]
|
2025/01/02 12:34:00 1 playlists will be checked (max-routines=1)
|
||||||
2025/01/02 12:34:00 Fetching... (https://example.com/list1.m3u)
|
2025/01/02 12:34:00 Fetching playlist [first] (https://example.com/list1.m3u)
|
||||||
2025/01/02 12:34:00 Parsing content...
|
2025/01/02 12:34:00 Parsing content...
|
||||||
2025/01/02 12:34:00 Parsed, checking channels (114)...
|
2025/01/02 12:34:00 Parsed, checking channels (114)...
|
||||||
2025/01/02 12:34:00 Check parameters calculated: count=114 timeout=8.00s routines=12
|
2025/01/02 12:34:00 Check channels parameters: timeout=10.00s byte-range=512 max-routines=50
|
||||||
2025/01/02 12:34:12 Checked successfully! online=101 onlinePercent=88.60% offline=13 offlinePercent=11.40% elapsedTime=12.39s
|
2025/01/02 12:34:12 Checked successfully! online=101 onlinePercent=88.60% offline=13 offlinePercent=11.40% elapsedTime=12.39s
|
||||||
|
2025/01/02 12:34:12 Done! count=1 online=1 offline=0 elapsedTime=12.39s
|
||||||
```
|
```
|
||||||
|
|
||||||
Разберём построчно:
|
Разберём построчно:
|
||||||
1. Загрузка ini-файла
|
1. Загрузка ini-файла
|
||||||
2. Файл загружен, в нём 2 плейлиста
|
2. Файл загружен, в нём 2 плейлиста
|
||||||
3. Начало обработки плейлиста `first`
|
3. Начало проверки 1 плейлиста (выбран кодом `first`)
|
||||||
4. Скачивание плейлиста `first` по ссылке `https://example.com/list1.m3u` в оперативную память
|
4. Скачивание плейлиста `first` по ссылке `https://example.com/list1.m3u`
|
||||||
5. Плейлист скачан, начало разбора плейлиста
|
5. Плейлист скачан, начало разбора
|
||||||
6. Плейлист разобран, начало проверки каналов (114 штук)
|
6. Плейлист разобран, начало проверки каналов (114 штук)
|
||||||
7. Определены параметры проверки (подробности ниже)
|
7. Параметры проверки каналов (таймаут, объём данных, число горутин)
|
||||||
8. (спустя время) Проверка успешно завершена: онлайн каналов 101 (88.60% от всех), оффлайн каналов 13 (11.40% от всех), затрачено 12 секунд.
|
8. (спустя время) Проверка каналов завершена
|
||||||
|
9. Проверка плейлиста завершена
|
||||||
### Параметры проверки
|
|
||||||
|
|
||||||
Выше в п.7 видно некоторые служебные данные:
|
|
||||||
* `timeout` — таймаут каждого запроса в секундах (макс. время ожидания ответа канала);
|
|
||||||
* `routines` — количество одновременных проверок.
|
|
||||||
|
|
||||||
Эти параметры рассчитываются динамически для каждого плейлиста в отдельности, исходя из количества каналов в каждом (`count`).
|
|
||||||
См. [app/checker/checker.go](app/checker/checker.go) для подробностей.
|
|
||||||
|
|
||||||
Идея в том, чтобы найти баланс между скоростью проверки и качеством:
|
|
||||||
* чем ниже коэффициент `k`, тем больше горутин, меньше таймаут, быстрее проверка, хуже результаты;
|
|
||||||
* чем выше коэффициент `k`, тем меньше горутин, больше таймаут, медленнее проверка, лучше результаты.
|
|
||||||
|
|
||||||
На скорость проверки влияют:
|
|
||||||
* количество горутин (чем выше, тем больше каналов проверяется параллельно);
|
|
||||||
* таймаут (тем ниже, тем быстрее закончится проверка канала);
|
|
||||||
* количество каналов (чем больше, тем дольше общий процесс);
|
|
||||||
* скорость ответа сервера (чем выше, тем хуже) и количество данных, которые он отдаёт (чем больше, тем хуже).
|
|
||||||
|
|
||||||
На качество проверки влияет таймаут:
|
|
||||||
* чем выше, тем выше вероятность получить успешный ответ от сервера, транслирующего поток;
|
|
||||||
* чем ниже, тем выше вероятность не дождаться успешного ответа и засчитать канал нерабочим.
|
|
||||||
|
|
||||||
> [!NOTE]
|
|
||||||
> Логика балансирования будет уточняться и корректироваться по мере развития проекта, исходя из реального применения.
|
|
||||||
|
|
||||||
### Коды возврата
|
### Коды возврата
|
||||||
|
|
||||||
* 0 — успех
|
* 0 — успех
|
||||||
* 1 — общая ошибка, см. вывод
|
* 1 — общая ошибка
|
||||||
* 2 — команде `check` не переданы параметры `--file`, `--url` и `--code`
|
|
||||||
|
|
||||||
## Лицензия
|
## Лицензия
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/*
|
/*
|
||||||
* Copyright (c) 2025, Антон Аксенов
|
* Copyright (c) 2025-2026, Антон Аксенов
|
||||||
* This file is part of iptvc project
|
* This file is part of iptvc project
|
||||||
* MIT License: https://git.axenov.dev/IPTV/iptvc/src/branch/master/LICENSE
|
* MIT License: https://git.axenov.dev/IPTV/iptvc/src/branch/master/LICENSE
|
||||||
*/
|
*/
|
||||||
@@ -14,7 +14,11 @@ import (
|
|||||||
"github.com/redis/go-redis/v9"
|
"github.com/redis/go-redis/v9"
|
||||||
)
|
)
|
||||||
|
|
||||||
const VERSION = "1.1.3"
|
// Version информация, заполняется при сборке через ldflags
|
||||||
|
var (
|
||||||
|
VERSION = "dev"
|
||||||
|
COMMIT = "unknown"
|
||||||
|
)
|
||||||
|
|
||||||
// Arguments описывает аргументы командной строки
|
// Arguments описывает аргументы командной строки
|
||||||
type Arguments struct {
|
type Arguments struct {
|
||||||
@@ -22,10 +26,45 @@ type Arguments struct {
|
|||||||
TagsPath string
|
TagsPath string
|
||||||
RandomCount uint
|
RandomCount uint
|
||||||
RepeatCount uint
|
RepeatCount uint
|
||||||
RepeatEverySec uint
|
|
||||||
NeedJson bool
|
NeedJson bool
|
||||||
NeedQuiet bool
|
NeedQuiet bool
|
||||||
Verbose bool
|
Verbose bool
|
||||||
|
ServerPort uint
|
||||||
|
ServerHost string
|
||||||
|
ConfigPath string
|
||||||
|
NeedCheck bool
|
||||||
|
Files []string
|
||||||
|
Urls []string
|
||||||
|
Codes []string
|
||||||
|
|
||||||
|
// app
|
||||||
|
Debug bool
|
||||||
|
LogLevel string
|
||||||
|
|
||||||
|
// check.playlists
|
||||||
|
PlTimeout int
|
||||||
|
PlAllCooldown int
|
||||||
|
PlOneCooldown int
|
||||||
|
PlMaxRoutines int
|
||||||
|
PlPerRoutine int
|
||||||
|
PlUserAgent []string
|
||||||
|
|
||||||
|
// check.channels
|
||||||
|
ChTimeout int
|
||||||
|
ChByteRange int
|
||||||
|
ChCooldown int
|
||||||
|
ChMaxRoutines int
|
||||||
|
ChPerRoutine int
|
||||||
|
ChUserAgent []string
|
||||||
|
|
||||||
|
// cache
|
||||||
|
CacheEnabled bool
|
||||||
|
CacheHost string
|
||||||
|
CachePort uint
|
||||||
|
CacheUsername string
|
||||||
|
CachePassword string
|
||||||
|
CacheDb uint
|
||||||
|
CacheTtl uint
|
||||||
}
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@@ -34,11 +73,18 @@ var (
|
|||||||
Config *config.Config
|
Config *config.Config
|
||||||
)
|
)
|
||||||
|
|
||||||
// Init инициализирует конфигурацию и подключение к keydb
|
// Init загружает конфигурацию и инициализирует логгер.
|
||||||
|
// CLI-override применяются через applyAppOverrides(), applyCacheOverrides()
|
||||||
|
// и applyCheckOverrides() в обработчиках команд после Init().
|
||||||
func Init() {
|
func Init() {
|
||||||
Config = config.Init()
|
Config = config.Init(Args.ConfigPath)
|
||||||
logger.Init(Args.NeedQuiet)
|
logger.Init(Args.NeedQuiet)
|
||||||
if Config.Cache.IsEnabled {
|
}
|
||||||
|
|
||||||
|
// InitCache инициализирует подключение к KeyDB/Redis, если кеш включён.
|
||||||
|
// Вызывайте после применения всех cache-override.
|
||||||
|
func InitCache() {
|
||||||
|
if Config.Cache.Enabled {
|
||||||
Cache = cache.Init(&Config.Cache)
|
Cache = cache.Init(&Config.Cache)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/*
|
/*
|
||||||
* Copyright (c) 2025, Антон Аксенов
|
* Copyright (c) 2025-2026, Антон Аксенов
|
||||||
* This file is part of iptvc project
|
* This file is part of iptvc project
|
||||||
* MIT License: https://git.axenov.dev/IPTV/iptvc/src/branch/master/LICENSE
|
* MIT License: https://git.axenov.dev/IPTV/iptvc/src/branch/master/LICENSE
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/*
|
/*
|
||||||
* Copyright (c) 2025, Антон Аксенов
|
* Copyright (c) 2025-2026, Антон Аксенов
|
||||||
* This file is part of iptvc project
|
* This file is part of iptvc project
|
||||||
* MIT License: https://git.axenov.dev/IPTV/iptvc/src/branch/master/LICENSE
|
* MIT License: https://git.axenov.dev/IPTV/iptvc/src/branch/master/LICENSE
|
||||||
*/
|
*/
|
||||||
@@ -15,13 +15,12 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"crypto/tls"
|
"crypto/tls"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"log"
|
"log"
|
||||||
"maps"
|
"maps"
|
||||||
"math"
|
|
||||||
"math/rand"
|
"math/rand"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
|
||||||
"slices"
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
@@ -31,6 +30,7 @@ import (
|
|||||||
var (
|
var (
|
||||||
tagBlocks []tagfile.TagBlock
|
tagBlocks []tagfile.TagBlock
|
||||||
ctx = context.Background()
|
ctx = context.Background()
|
||||||
|
OnPlaylistChecked func(playlist.Playlist) // вызывается после проверки каждого плейлиста
|
||||||
)
|
)
|
||||||
|
|
||||||
// PrepareListsToCheck готовит список плейлистов для проверки
|
// PrepareListsToCheck готовит список плейлистов для проверки
|
||||||
@@ -57,7 +57,7 @@ func PrepareListsToCheck(files []string, urls []string, codes []string) []playli
|
|||||||
}
|
}
|
||||||
|
|
||||||
if len(lists) == 0 || len(codes) > 0 {
|
if len(lists) == 0 || len(codes) > 0 {
|
||||||
ini, err := inifile.Init(app.Args.IniPath)
|
ini, err := inifile.Init(app.Config.App.Playlists)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("Warning: %s, all --code flags will be ignored\n", err)
|
log.Printf("Warning: %s, all --code flags will be ignored\n", err)
|
||||||
return lists
|
return lists
|
||||||
@@ -109,56 +109,100 @@ func getCachedPlaylists() map[string]playlist.Playlist {
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
// CheckPlaylists проверяет плейлисты и возвращает их же с результатами проверки
|
// CheckPlaylists проверяет плейлисты и возвращает их же с результатами проверки.
|
||||||
|
// Параметры (user-agent, timeout, cooldown, per-routine) берутся из config.Check.Playlists.
|
||||||
func CheckPlaylists(lists []playlist.Playlist) (int, int) {
|
func CheckPlaylists(lists []playlist.Playlist) (int, int) {
|
||||||
count := len(lists)
|
count := len(lists)
|
||||||
if count == 0 {
|
if count == 0 {
|
||||||
log.Println("There are no playlists to check")
|
log.Println("There are no playlists to check")
|
||||||
os.Exit(0)
|
return 0, 0
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Printf("%d playlists will be checked\n", len(lists))
|
pc := app.Config.Check.Playlists
|
||||||
step, onlineCount, offlineCount := 0, 0, 0
|
maxRoutines := pc.MaxRoutines
|
||||||
tagBlocks = tagfile.Init(app.Args.TagsPath)
|
if maxRoutines < 1 {
|
||||||
|
maxRoutines = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("%d playlists will be checked (max-routines=%d)\n", count, maxRoutines)
|
||||||
|
tagBlocks = tagfile.Init(app.Config.App.Tags)
|
||||||
|
|
||||||
|
type checkResult struct {
|
||||||
|
idx int
|
||||||
|
pls playlist.Playlist
|
||||||
|
online bool
|
||||||
|
}
|
||||||
|
|
||||||
|
sem := make(chan struct{}, maxRoutines)
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
results := make([]checkResult, count)
|
||||||
|
|
||||||
for idx := range lists {
|
for idx := range lists {
|
||||||
|
wg.Add(1)
|
||||||
|
go func(idx int) {
|
||||||
|
sem <- struct{}{}
|
||||||
|
defer func() {
|
||||||
|
<-sem
|
||||||
|
wg.Done()
|
||||||
|
}()
|
||||||
|
|
||||||
pls := lists[idx]
|
pls := lists[idx]
|
||||||
step++
|
pls.CheckedAt = time.Now().Unix()
|
||||||
|
|
||||||
|
userAgent := pc.UserAgent.Pick()
|
||||||
|
timeout := time.Duration(pc.Timeout) * time.Millisecond
|
||||||
|
|
||||||
var err error
|
var err error
|
||||||
if pls.Source == "-f" {
|
if pls.Source == "-f" {
|
||||||
// direct m3u path
|
log.Printf("Playlist from filesystem (%s)\n", pls.Url)
|
||||||
log.Printf("[%.3d/%.3d] Playlist from filesystem\n", step, count)
|
|
||||||
log.Printf("Reading file... (%s)\n", pls.Url)
|
|
||||||
err = pls.ReadFromFs()
|
err = pls.ReadFromFs()
|
||||||
} else if pls.Source == "-u" {
|
|
||||||
// direct m3u url
|
|
||||||
log.Printf("[%.3d/%.3d] Playlist [%s]\n", step, count, pls.Url)
|
|
||||||
log.Printf("Fetching... (%s)\n", pls.Url)
|
|
||||||
err = pls.Download()
|
|
||||||
} else {
|
} else {
|
||||||
// from ini
|
log.Printf("Fetching playlist [%s] (%s)\n", pls.Code, pls.Url)
|
||||||
log.Printf("[%.3d/%.3d] Playlist [%s]\n", step, count, pls.Code)
|
err = pls.Download(userAgent, timeout)
|
||||||
log.Printf("Fetching... (%s)\n", pls.Url)
|
|
||||||
err = pls.Download()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("Cannot read playlist [%s]: %s\n", pls.Url, err)
|
log.Printf("Cannot read playlist [%s]: %s\n", pls.Url, err)
|
||||||
offlineCount++
|
pls.IsOnline = false
|
||||||
|
results[idx] = checkResult{idx: idx, pls: pls, online: false}
|
||||||
cachePlaylist(pls)
|
cachePlaylist(pls)
|
||||||
continue
|
if OnPlaylistChecked != nil {
|
||||||
|
OnPlaylistChecked(pls)
|
||||||
|
}
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Println("Parsing content...")
|
log.Println("Parsing content...")
|
||||||
pls.IsOnline = true
|
pls.IsOnline = true
|
||||||
onlineCount++
|
|
||||||
pls = pls.Parse()
|
pls = pls.Parse()
|
||||||
|
|
||||||
log.Printf("Parsed, checking channels (%d)...\n", len(pls.Channels))
|
log.Printf("Parsed, checking channels (%d)...\n", len(pls.Channels))
|
||||||
pls = CheckChannels(pls)
|
pls = CheckChannels(pls)
|
||||||
lists[idx] = pls
|
results[idx] = checkResult{idx: idx, pls: pls, online: true}
|
||||||
cachePlaylist(pls)
|
cachePlaylist(pls)
|
||||||
|
if OnPlaylistChecked != nil {
|
||||||
|
OnPlaylistChecked(pls)
|
||||||
|
}
|
||||||
|
|
||||||
|
// one-cooldown: задержка после проверки каждого плейлиста
|
||||||
|
// pc.OneCooldown хранится в миллисекундах, поэтому переводим в time.Millisecond
|
||||||
|
oneCd := time.Duration(pc.OneCooldown.Value()) * time.Millisecond
|
||||||
|
if oneCd > 0 {
|
||||||
|
time.Sleep(oneCd)
|
||||||
|
}
|
||||||
|
}(idx)
|
||||||
|
}
|
||||||
|
|
||||||
|
wg.Wait()
|
||||||
|
|
||||||
|
onlineCount, offlineCount := 0, 0
|
||||||
|
for _, r := range results {
|
||||||
|
lists[r.idx] = r.pls
|
||||||
|
if r.online {
|
||||||
|
onlineCount++
|
||||||
|
} else {
|
||||||
|
offlineCount++
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return onlineCount, offlineCount
|
return onlineCount, offlineCount
|
||||||
@@ -183,7 +227,8 @@ func cachePlaylist(pls playlist.Playlist) {
|
|||||||
log.Println("Cached sucessfully")
|
log.Println("Cached sucessfully")
|
||||||
}
|
}
|
||||||
|
|
||||||
// CheckChannels проверяет каналы и возвращает их же с результатами проверки
|
// CheckChannels проверяет каналы и возвращает их же с результатами проверки.
|
||||||
|
// Параметры (user-agent, timeout, byte-range, cooldown, per-routine) берутся из config.Check.Channels.
|
||||||
func CheckChannels(pls playlist.Playlist) playlist.Playlist {
|
func CheckChannels(pls playlist.Playlist) playlist.Playlist {
|
||||||
type errorData struct {
|
type errorData struct {
|
||||||
tvChannel playlist.Channel
|
tvChannel playlist.Channel
|
||||||
@@ -199,14 +244,27 @@ func CheckChannels(pls playlist.Playlist) playlist.Playlist {
|
|||||||
pls.OnlineCount = 0
|
pls.OnlineCount = 0
|
||||||
pls.OfflineCount = 0
|
pls.OfflineCount = 0
|
||||||
|
|
||||||
timeout, routines := calcParameters(count)
|
cc := app.Config.Check.Channels
|
||||||
|
timeoutMs := cc.Timeout
|
||||||
|
byteRange := cc.ByteRange
|
||||||
|
if byteRange <= 0 {
|
||||||
|
byteRange = 512
|
||||||
|
}
|
||||||
|
maxRoutines := cc.MaxRoutines
|
||||||
|
if maxRoutines < 1 {
|
||||||
|
maxRoutines = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
timeout := time.Duration(timeoutMs) * time.Millisecond
|
||||||
httpClient := http.Client{Timeout: timeout}
|
httpClient := http.Client{Timeout: timeout}
|
||||||
chSemaphores := make(chan struct{}, routines)
|
chSemaphores := make(chan struct{}, maxRoutines)
|
||||||
chOnline := make(chan playlist.Channel, len(pls.Channels))
|
chOnline := make(chan playlist.Channel, len(pls.Channels))
|
||||||
chOffline := make(chan playlist.Channel, len(pls.Channels))
|
chOffline := make(chan playlist.Channel, len(pls.Channels))
|
||||||
chError := make(chan errorData, len(pls.Channels))
|
chError := make(chan errorData, len(pls.Channels))
|
||||||
var wg sync.WaitGroup
|
var wg sync.WaitGroup
|
||||||
|
|
||||||
|
log.Printf("Check channels parameters: timeout=%.2fs byte-range=%d max-routines=%d\n", float64(timeoutMs)/1000, byteRange, maxRoutines)
|
||||||
|
|
||||||
startTime := time.Now()
|
startTime := time.Now()
|
||||||
for _, tvChannel := range pls.Channels {
|
for _, tvChannel := range pls.Channels {
|
||||||
wg.Add(1)
|
wg.Add(1)
|
||||||
@@ -227,8 +285,8 @@ func CheckChannels(pls playlist.Playlist) playlist.Playlist {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
req.Header.Set("User-Agent", "Mozilla/5.0 WINK/1.31.1 (AndroidTV/9) HlsWinkPlayer")
|
req.Header.Set("User-Agent", cc.UserAgent.Pick())
|
||||||
req.Header.Set("Range", "bytes=0-511") // 512 B, but sometimes servers ignore it
|
req.Header.Set("Range", fmt.Sprintf("bytes=0-%d", byteRange-1))
|
||||||
resp, err := httpClient.Do(req)
|
resp, err := httpClient.Do(req)
|
||||||
tvChannel.CheckedAt = time.Now().Unix()
|
tvChannel.CheckedAt = time.Now().Unix()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -240,7 +298,7 @@ func CheckChannels(pls playlist.Playlist) playlist.Playlist {
|
|||||||
tvChannel.Status = resp.StatusCode
|
tvChannel.Status = resp.StatusCode
|
||||||
tvChannel.IsOnline = tvChannel.Status < http.StatusBadRequest
|
tvChannel.IsOnline = tvChannel.Status < http.StatusBadRequest
|
||||||
tvChannel.ContentType = resp.Header.Get("Content-Type")
|
tvChannel.ContentType = resp.Header.Get("Content-Type")
|
||||||
chunk := io.LimitReader(resp.Body, 512) // just for sure
|
chunk := io.LimitReader(resp.Body, int64(byteRange))
|
||||||
bodyBytes, _ := io.ReadAll(chunk)
|
bodyBytes, _ := io.ReadAll(chunk)
|
||||||
bodyString := string(bodyBytes)
|
bodyString := string(bodyBytes)
|
||||||
_ = resp.Body.Close()
|
_ = resp.Body.Close()
|
||||||
@@ -263,7 +321,13 @@ func CheckChannels(pls playlist.Playlist) playlist.Playlist {
|
|||||||
}
|
}
|
||||||
|
|
||||||
chOnline <- tvChannel
|
chOnline <- tvChannel
|
||||||
return
|
|
||||||
|
// cooldown: задержка после проверки каждого канала
|
||||||
|
// cc.Cooldown хранится в миллисекундах, поэтому переводим в time.Millisecond
|
||||||
|
cd := time.Duration(cc.Cooldown.Value()) * time.Millisecond
|
||||||
|
if cd > 0 {
|
||||||
|
time.Sleep(cd)
|
||||||
|
}
|
||||||
}(tvChannel)
|
}(tvChannel)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -323,42 +387,6 @@ func CheckChannels(pls playlist.Playlist) playlist.Playlist {
|
|||||||
return pls
|
return pls
|
||||||
}
|
}
|
||||||
|
|
||||||
// calcParameters вычисляет оптимальное количество горутин и таймаут запроса
|
|
||||||
func calcParameters(count int) (time.Duration, int) {
|
|
||||||
routines := count
|
|
||||||
if routines > 3000 {
|
|
||||||
routines = 3000
|
|
||||||
}
|
|
||||||
if routines < 1 {
|
|
||||||
routines = 1
|
|
||||||
}
|
|
||||||
|
|
||||||
var digits = 1
|
|
||||||
x := count
|
|
||||||
for x >= 10 {
|
|
||||||
digits++
|
|
||||||
x /= 10
|
|
||||||
}
|
|
||||||
|
|
||||||
timeout := 10 - int(math.Ceil(float64(digits)*1.5))
|
|
||||||
if timeout > 10 {
|
|
||||||
timeout = 10
|
|
||||||
}
|
|
||||||
if timeout < 1 {
|
|
||||||
timeout = 1
|
|
||||||
}
|
|
||||||
|
|
||||||
duration := time.Duration(timeout) * time.Second
|
|
||||||
log.Printf(
|
|
||||||
"Check parameters calculated count=%d timeout=%.2fs routines=%d\n",
|
|
||||||
count,
|
|
||||||
duration.Seconds(),
|
|
||||||
routines,
|
|
||||||
)
|
|
||||||
|
|
||||||
return duration, routines
|
|
||||||
}
|
|
||||||
|
|
||||||
// getTagsForChannel ищет и возвращает теги для канала
|
// getTagsForChannel ищет и возвращает теги для канала
|
||||||
func getTagsForChannel(tvChannel playlist.Channel) []string {
|
func getTagsForChannel(tvChannel playlist.Channel) []string {
|
||||||
var foundTags []string
|
var foundTags []string
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/*
|
/*
|
||||||
* Copyright (c) 2025, Антон Аксенов
|
* Copyright (c) 2025-2026, Антон Аксенов
|
||||||
* This file is part of iptvc project
|
* This file is part of iptvc project
|
||||||
* MIT License: https://git.axenov.dev/IPTV/iptvc/src/branch/master/LICENSE
|
* MIT License: https://git.axenov.dev/IPTV/iptvc/src/branch/master/LICENSE
|
||||||
*/
|
*/
|
||||||
@@ -7,71 +7,664 @@
|
|||||||
package config
|
package config
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"github.com/joho/godotenv"
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"math/rand"
|
||||||
"os"
|
"os"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/joho/godotenv"
|
||||||
|
"gopkg.in/yaml.v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Константы значений по умолчанию для временных параметров.
|
||||||
|
// Внешний интерфейс (YAML, env, CLI) задаёт их в секундах,
|
||||||
|
// внутреннее представление — в миллисекундах.
|
||||||
|
const (
|
||||||
|
defaultTimeoutSeconds = 10
|
||||||
|
defaultAllCooldownSeconds = 1800 // 30 минут
|
||||||
|
defaultOneCooldownSeconds = 2
|
||||||
|
defaultChannelsCooldownSeconds = 0
|
||||||
|
defaultCacheTtlSeconds = 30
|
||||||
)
|
)
|
||||||
|
|
||||||
// Config описывает конфигурацию
|
// Config описывает конфигурацию
|
||||||
type Config struct {
|
type Config struct {
|
||||||
DebugMode bool
|
App AppConfig `yaml:"app"`
|
||||||
Cache CacheConfig
|
Server ServerConfig `yaml:"server"`
|
||||||
|
Site SiteConfig `yaml:"site"`
|
||||||
|
Check CheckConfig `yaml:"check"`
|
||||||
|
Cache CacheConfig `yaml:"cache"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// CacheConfig описывает конфигурацию подключения к keydb
|
// AppConfig описывает общие настройки приложения
|
||||||
|
type AppConfig struct {
|
||||||
|
Timezone string `yaml:"timezone"`
|
||||||
|
Debug bool `yaml:"debug"`
|
||||||
|
LogLevel string `yaml:"log_level"`
|
||||||
|
Playlists string `yaml:"playlists"`
|
||||||
|
Tags string `yaml:"tags"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ServerConfig описывает конфигурацию веб-сервера
|
||||||
|
type ServerConfig struct {
|
||||||
|
Port uint `yaml:"port"`
|
||||||
|
Host string `yaml:"host"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Link описывает ссылку в навигации или подвале сайта.
|
||||||
|
// Если заданы Children, рендерится как выпадающее меню.
|
||||||
|
type Link struct {
|
||||||
|
Title string `yaml:"title"`
|
||||||
|
Url string `yaml:"url"`
|
||||||
|
Icon string `yaml:"icon"`
|
||||||
|
Children []Link `yaml:"children"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TabConfig описывает настройку одной вкладки на странице плейлиста.
|
||||||
|
type TabConfig struct {
|
||||||
|
Visible bool `yaml:"visible"`
|
||||||
|
Text string `yaml:"text"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TabsConfig описывает группу вкладок на странице плейлиста.
|
||||||
|
type TabsConfig struct {
|
||||||
|
Raw TabConfig `yaml:"raw"`
|
||||||
|
Legal TabConfig `yaml:"legal"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// HeaderConfig описывает настройки шапки сайта
|
||||||
|
type HeaderConfig struct {
|
||||||
|
Title string `yaml:"title"`
|
||||||
|
Menu []Link `yaml:"menu"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// FooterConfig описывает настройки подвала сайта
|
||||||
|
type FooterConfig struct {
|
||||||
|
Links []Link `yaml:"links"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SiteConfig описывает настройки сайта
|
||||||
|
type SiteConfig struct {
|
||||||
|
BaseUrl string `yaml:"base-url"`
|
||||||
|
RepoUrl string `yaml:"repo-url"`
|
||||||
|
PageSize uint `yaml:"page-size"`
|
||||||
|
Favicon string `yaml:"favicon"`
|
||||||
|
Meta MetaConfig `yaml:"meta"`
|
||||||
|
Tabs TabsConfig `yaml:"tabs"`
|
||||||
|
Header HeaderConfig `yaml:"header"`
|
||||||
|
Footer FooterConfig `yaml:"footer"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// MetaConfig описывает SEO-метаданные сайта.
|
||||||
|
type MetaConfig struct {
|
||||||
|
Description string `yaml:"description"`
|
||||||
|
Keywords string `yaml:"keywords"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CacheConfig описывает конфигурацию подключения к keydb.
|
||||||
|
// Ttl задаётся в секундах.
|
||||||
type CacheConfig struct {
|
type CacheConfig struct {
|
||||||
IsEnabled bool
|
Enabled bool `yaml:"enabled"`
|
||||||
Host string
|
Host string `yaml:"host"`
|
||||||
Port uint
|
Port uint `yaml:"port"`
|
||||||
Username string
|
Username string `yaml:"username"`
|
||||||
Password string
|
Password string `yaml:"password"`
|
||||||
Db uint
|
Db uint `yaml:"db"`
|
||||||
Ttl uint
|
Ttl uint `yaml:"ttl"`
|
||||||
IsActive bool
|
IsActive bool `yaml:"-"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Init инициализирует объект конфигурации из переменных окружения
|
// CheckConfig описывает параметры проверки плейлистов и каналов
|
||||||
func Init() *Config {
|
type CheckConfig struct {
|
||||||
_ = godotenv.Load(".env")
|
StartOnServe bool `yaml:"start-on-serve"`
|
||||||
|
Playlists CheckPlaylistsConfig `yaml:"playlists"`
|
||||||
|
Channels CheckChannelsConfig `yaml:"channels"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// DurationSeconds — тип для временных интервалов, задаваемых в секундах,
|
||||||
|
// но хранящихся внутри в миллисекундах.
|
||||||
|
type DurationSeconds int
|
||||||
|
|
||||||
|
// UnmarshalYAML декодирует значение из секунд в миллисекунды.
|
||||||
|
func (d *DurationSeconds) UnmarshalYAML(value *yaml.Node) error {
|
||||||
|
var v float64
|
||||||
|
if err := value.Decode(&v); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
*d = DurationSeconds(v * 1000)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CheckPlaylistsConfig — параметры проверки плейлистов.
|
||||||
|
// Значения Timeout, AllCooldown, OneCooldown задаются в секундах
|
||||||
|
// в YAML/CLI/env, но хранятся в миллисекундах для использования в коде.
|
||||||
|
type CheckPlaylistsConfig struct {
|
||||||
|
UserAgent UserAgents `yaml:"user-agent"`
|
||||||
|
Timeout DurationSeconds `yaml:"timeout"` // ms internally
|
||||||
|
AllCooldown IntRange `yaml:"all-cooldown"` // ms internally
|
||||||
|
OneCooldown IntRange `yaml:"one-cooldown"` // ms internally
|
||||||
|
MaxRoutines int `yaml:"max-routines"` // count
|
||||||
|
PerRoutine int `yaml:"per-routine"` // count
|
||||||
|
}
|
||||||
|
|
||||||
|
// CheckChannelsConfig — параметры проверки каналов.
|
||||||
|
// Значения Timeout и Cooldown задаются в секундах
|
||||||
|
// в YAML/CLI/env, но хранятся в миллисекундах для использования в коде.
|
||||||
|
type CheckChannelsConfig struct {
|
||||||
|
UserAgent UserAgents `yaml:"user-agent"`
|
||||||
|
Timeout DurationSeconds `yaml:"timeout"` // ms internally
|
||||||
|
ByteRange int `yaml:"byte-range"` // bytes
|
||||||
|
Cooldown IntRange `yaml:"cooldown"` // ms internally
|
||||||
|
MaxRoutines int `yaml:"max-routines"` // count
|
||||||
|
PerRoutine int `yaml:"per-routine"` // count
|
||||||
|
}
|
||||||
|
|
||||||
|
// IntRange — значение, которое может быть скаляром или [min, max].
|
||||||
|
// Если скаляр: Min == Max. Если [min, max]: Min <= Max.
|
||||||
|
type IntRange struct {
|
||||||
|
Min int
|
||||||
|
Max int
|
||||||
|
}
|
||||||
|
|
||||||
|
// UnmarshalYAML реализует кастомный разбор IntRange из YAML.
|
||||||
|
// В YAML значение задаётся в секундах, результат сохраняется в миллисекундах.
|
||||||
|
// Поддерживает скаляр (int/float) или массив из двух значений [min, max].
|
||||||
|
func (r *IntRange) UnmarshalYAML(value *yaml.Node) error {
|
||||||
|
decodeNode := func(n *yaml.Node) (int, error) {
|
||||||
|
var f float64
|
||||||
|
if err := n.Decode(&f); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return int(f * 1000), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if value.Kind == yaml.ScalarNode {
|
||||||
|
v, err := decodeNode(value)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
r.Min = v
|
||||||
|
r.Max = v
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if value.Kind == yaml.SequenceNode {
|
||||||
|
if len(value.Content) == 0 {
|
||||||
|
r.Min, r.Max = 0, 0
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
min, err := decodeNode(value.Content[0])
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
r.Min = min
|
||||||
|
if len(value.Content) >= 2 {
|
||||||
|
max, err := decodeNode(value.Content[1])
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
r.Max = max
|
||||||
|
} else {
|
||||||
|
r.Max = min
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Value возвращает значение: если Min == Max — константу, иначе случайное в [Min, Max].
|
||||||
|
func (r IntRange) Value() int {
|
||||||
|
if r.Min == r.Max {
|
||||||
|
return r.Min
|
||||||
|
}
|
||||||
|
return r.Min + rand.Intn(r.Max-r.Min+1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// UserAgents — список User-Agent строк. Может быть задан как скаляр или массив.
|
||||||
|
type UserAgents []string
|
||||||
|
|
||||||
|
// UnmarshalYAML реализует кастомный разбор UserAgents из YAML.
|
||||||
|
func (u *UserAgents) UnmarshalYAML(value *yaml.Node) error {
|
||||||
|
if value.Kind == yaml.ScalarNode {
|
||||||
|
var s string
|
||||||
|
if err := value.Decode(&s); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
*u = []string{s}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if value.Kind == yaml.SequenceNode {
|
||||||
|
var arr []string
|
||||||
|
if err := value.Decode(&arr); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
*u = arr
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pick возвращает случайный User-Agent или пустую строку если список пуст.
|
||||||
|
func (u UserAgents) Pick() string {
|
||||||
|
if len(u) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if len(u) == 1 {
|
||||||
|
return u[0]
|
||||||
|
}
|
||||||
|
return u[rand.Intn(len(u))]
|
||||||
|
}
|
||||||
|
|
||||||
|
// defaults возвращает конфигурацию со значениями по умолчанию.
|
||||||
|
// Временные параметры хранятся в миллисекундах.
|
||||||
|
func defaults() *Config {
|
||||||
return &Config{
|
return &Config{
|
||||||
//DebugMode: readEnvBoolean("APP_DEBUG", false),
|
App: AppConfig{
|
||||||
|
Timezone: "GMT",
|
||||||
|
Debug: false,
|
||||||
|
LogLevel: "info",
|
||||||
|
Playlists: "./playlists.ini",
|
||||||
|
Tags: "./channels.json",
|
||||||
|
},
|
||||||
|
Server: ServerConfig{
|
||||||
|
Port: 8800,
|
||||||
|
Host: "",
|
||||||
|
},
|
||||||
|
Site: SiteConfig{
|
||||||
|
BaseUrl: "http://localhost:8800",
|
||||||
|
PageSize: 0,
|
||||||
|
RepoUrl: "https://git.axenov.dev/IPTV",
|
||||||
|
Meta: MetaConfig{
|
||||||
|
Description: "Самообновляемые бесплатные IPTV-плейлисты для домашнего просмотра по коротким ссылкам, списки каналов, проверка доступности",
|
||||||
|
Keywords: "самообновляемые,бесплатные,iptv-плейлисты,iptv,плейлисты",
|
||||||
|
},
|
||||||
|
Tabs: TabsConfig{
|
||||||
|
Raw: TabConfig{Visible: true},
|
||||||
|
Legal: TabConfig{Visible: true},
|
||||||
|
},
|
||||||
|
Header: HeaderConfig{
|
||||||
|
Title: "IPTV Checker",
|
||||||
|
Menu: []Link{
|
||||||
|
{Title: "Помощь", Icon: "help-circle-outline", Children: []Link{
|
||||||
|
{Title: "Документация", Url: "https://m3u.su/docs", Icon: "document-text-outline"},
|
||||||
|
{Title: "Исходники", Url: "https://git.axenov.dev/IPTV", Icon: "code-slash-outline"},
|
||||||
|
{Title: "@iptv_aggregator", Url: "https://t.me/iptv_aggregator", Icon: "paper-plane-outline"},
|
||||||
|
}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Footer: FooterConfig{
|
||||||
|
Links: []Link{
|
||||||
|
{Title: "Исходники", Url: "https://git.axenov.dev/IPTV", Icon: "code-slash-outline"},
|
||||||
|
{Title: "axenov.dev", Url: "https://axenov.dev", Icon: "person-outline"},
|
||||||
|
{Title: "@iptv_aggregator", Url: "https://t.me/iptv_aggregator", Icon: "megaphone-outline"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Check: CheckConfig{
|
||||||
|
StartOnServe: false,
|
||||||
|
Playlists: CheckPlaylistsConfig{
|
||||||
|
UserAgent: UserAgents{"Mozilla/5.0 WINK/1.31.1 (AndroidTV/9) HlsWinkPlayer"},
|
||||||
|
Timeout: defaultTimeoutSeconds * 1000,
|
||||||
|
AllCooldown: IntRange{Min: defaultAllCooldownSeconds * 1000, Max: defaultAllCooldownSeconds * 1000},
|
||||||
|
OneCooldown: IntRange{Min: defaultOneCooldownSeconds * 1000, Max: defaultOneCooldownSeconds * 1000},
|
||||||
|
MaxRoutines: 1,
|
||||||
|
PerRoutine: 1,
|
||||||
|
},
|
||||||
|
Channels: CheckChannelsConfig{
|
||||||
|
UserAgent: UserAgents{"Mozilla/5.0 WINK/1.31.1 (AndroidTV/9) HlsWinkPlayer"},
|
||||||
|
Timeout: defaultTimeoutSeconds * 1000,
|
||||||
|
ByteRange: 512,
|
||||||
|
Cooldown: IntRange{Min: defaultChannelsCooldownSeconds * 1000, Max: defaultChannelsCooldownSeconds * 1000},
|
||||||
|
MaxRoutines: 50,
|
||||||
|
PerRoutine: 10,
|
||||||
|
},
|
||||||
|
},
|
||||||
Cache: CacheConfig{
|
Cache: CacheConfig{
|
||||||
IsEnabled: readEnvBoolean("CACHE_ENABLED", false),
|
Enabled: false,
|
||||||
Host: readEnv("CACHE_HOST", "localhost"),
|
Host: "localhost",
|
||||||
Port: readEnvInteger("CACHE_PORT", 6379),
|
Port: 6379,
|
||||||
Username: readEnv("CACHE_USERNAME", ""),
|
Username: "",
|
||||||
Password: readEnv("CACHE_PASSWORD", ""),
|
Password: "",
|
||||||
Db: readEnvInteger("CACHE_DB", 0),
|
Db: 0,
|
||||||
Ttl: readEnvInteger("CACHE_TTL", 1800),
|
Ttl: defaultCacheTtlSeconds,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// readEnv считывает строковую переменную окружения с заданным именем или возвращает значение по умолчанию
|
// Init инициализирует объект конфигурации.
|
||||||
func readEnv(key string, defaultValue string) string {
|
// Порядок приоритета (от низшего к высшему):
|
||||||
value, exists := os.LookupEnv(key)
|
// 1. Значения по умолчанию
|
||||||
if exists {
|
// 2. config.yml (путь можно задать через configPath)
|
||||||
return value
|
// 3. Переменные окружения (.env загружается автоматически)
|
||||||
|
func Init(configPath string) *Config {
|
||||||
|
_ = godotenv.Load(".env")
|
||||||
|
|
||||||
|
cfg := defaults()
|
||||||
|
|
||||||
|
// загружаем config.yml
|
||||||
|
if configPath == "" {
|
||||||
|
configPath = "config.yml"
|
||||||
}
|
}
|
||||||
return defaultValue
|
if data, err := os.ReadFile(configPath); err == nil {
|
||||||
|
if err := yaml.Unmarshal(data, cfg); err != nil {
|
||||||
|
log.Printf("Warning: cannot parse %s: %s", configPath, err)
|
||||||
|
}
|
||||||
|
} else if !os.IsNotExist(err) {
|
||||||
|
log.Printf("Warning: cannot read %s: %s", configPath, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// readEnvBoolean считывает булеву переменную окружения с заданным именем или возвращает значение по умолчанию
|
// переопределяем значениями из переменных окружения
|
||||||
func readEnvBoolean(name string, defaultValue bool) bool {
|
applyEnv(cfg)
|
||||||
valStr := readEnv(name, "")
|
|
||||||
val, err := strconv.ParseBool(valStr)
|
// валидируем и исправляем
|
||||||
|
validate(cfg)
|
||||||
|
|
||||||
|
return cfg
|
||||||
|
}
|
||||||
|
|
||||||
|
// applyEnv переопределяет поля конфигурации значениями из переменных окружения.
|
||||||
|
func applyEnv(cfg *Config) {
|
||||||
|
// app
|
||||||
|
if v, ok := os.LookupEnv("APP_DEBUG"); ok {
|
||||||
|
cfg.App.Debug = parseBool(v, cfg.App.Debug)
|
||||||
|
}
|
||||||
|
if v, ok := os.LookupEnv("APP_LOG_LEVEL"); ok && v != "" {
|
||||||
|
cfg.App.LogLevel = v
|
||||||
|
}
|
||||||
|
if v, ok := os.LookupEnv("APP_TIMEZONE"); ok && v != "" {
|
||||||
|
cfg.App.Timezone = v
|
||||||
|
}
|
||||||
|
if v, ok := os.LookupEnv("APP_PLAYLISTS"); ok && v != "" {
|
||||||
|
cfg.App.Playlists = v
|
||||||
|
}
|
||||||
|
if v, ok := os.LookupEnv("APP_TAGS"); ok && v != "" {
|
||||||
|
cfg.App.Tags = v
|
||||||
|
}
|
||||||
|
|
||||||
|
// server
|
||||||
|
if v, ok := os.LookupEnv("SERVER_PORT"); ok && v != "" {
|
||||||
|
cfg.Server.Port = parseUint(v, cfg.Server.Port)
|
||||||
|
}
|
||||||
|
if v, ok := os.LookupEnv("SERVER_HOST"); ok {
|
||||||
|
cfg.Server.Host = v
|
||||||
|
}
|
||||||
|
|
||||||
|
// site
|
||||||
|
if v, ok := os.LookupEnv("SITE_BASE_URL"); ok && v != "" {
|
||||||
|
cfg.Site.BaseUrl = v
|
||||||
|
}
|
||||||
|
if v, ok := os.LookupEnv("SITE_PAGE_SIZE"); ok && v != "" {
|
||||||
|
cfg.Site.PageSize = parseUint(v, cfg.Site.PageSize)
|
||||||
|
}
|
||||||
|
if v, ok := os.LookupEnv("SITE_REPO_URL"); ok && v != "" {
|
||||||
|
cfg.Site.RepoUrl = v
|
||||||
|
}
|
||||||
|
if v, ok := os.LookupEnv("SITE_FAVICON"); ok {
|
||||||
|
cfg.Site.Favicon = v
|
||||||
|
}
|
||||||
|
if v, ok := os.LookupEnv("SITE_META_DESCRIPTION"); ok && v != "" {
|
||||||
|
cfg.Site.Meta.Description = v
|
||||||
|
}
|
||||||
|
if v, ok := os.LookupEnv("SITE_META_KEYWORDS"); ok && v != "" {
|
||||||
|
cfg.Site.Meta.Keywords = v
|
||||||
|
}
|
||||||
|
if v, ok := os.LookupEnv("SITE_TITLE"); ok && v != "" {
|
||||||
|
cfg.Site.Header.Title = v
|
||||||
|
}
|
||||||
|
|
||||||
|
// check
|
||||||
|
if v, ok := os.LookupEnv("CHECK_START_ON_SERVE"); ok {
|
||||||
|
cfg.Check.StartOnServe = parseBool(v, cfg.Check.StartOnServe)
|
||||||
|
}
|
||||||
|
|
||||||
|
// check.playlists
|
||||||
|
pp := &cfg.Check.Playlists
|
||||||
|
if v, ok := os.LookupEnv("CHECK_PLAYLISTS_TIMEOUT"); ok && v != "" {
|
||||||
|
pp.Timeout = DurationSeconds(parseSecondsToMillis(v, int(pp.Timeout)))
|
||||||
|
}
|
||||||
|
applyEnvIntRange(&pp.AllCooldown, "CHECK_PLAYLISTS_ALL_COOLDOWN")
|
||||||
|
applyEnvIntRange(&pp.OneCooldown, "CHECK_PLAYLISTS_ONE_COOLDOWN")
|
||||||
|
if v, ok := os.LookupEnv("CHECK_PLAYLISTS_MAX_ROUTINES"); ok && v != "" {
|
||||||
|
pp.MaxRoutines = parseInt(v, pp.MaxRoutines)
|
||||||
|
}
|
||||||
|
if v, ok := os.LookupEnv("CHECK_PLAYLISTS_PER_ROUTINE"); ok && v != "" {
|
||||||
|
pp.PerRoutine = parseInt(v, pp.PerRoutine)
|
||||||
|
}
|
||||||
|
if uas := applyEnvStringSlice("CHECK_PLAYLISTS_USER_AGENT"); uas != nil {
|
||||||
|
pp.UserAgent = UserAgents(uas)
|
||||||
|
}
|
||||||
|
|
||||||
|
// check.channels
|
||||||
|
cc := &cfg.Check.Channels
|
||||||
|
if v, ok := os.LookupEnv("CHECK_CHANNELS_TIMEOUT"); ok && v != "" {
|
||||||
|
cc.Timeout = DurationSeconds(parseSecondsToMillis(v, int(cc.Timeout)))
|
||||||
|
}
|
||||||
|
if v, ok := os.LookupEnv("CHECK_CHANNELS_BYTE_RANGE"); ok && v != "" {
|
||||||
|
cc.ByteRange = parseInt(v, cc.ByteRange)
|
||||||
|
}
|
||||||
|
applyEnvIntRange(&cc.Cooldown, "CHECK_CHANNELS_COOLDOWN")
|
||||||
|
if v, ok := os.LookupEnv("CHECK_CHANNELS_MAX_ROUTINES"); ok && v != "" {
|
||||||
|
cc.MaxRoutines = parseInt(v, cc.MaxRoutines)
|
||||||
|
}
|
||||||
|
if v, ok := os.LookupEnv("CHECK_CHANNELS_PER_ROUTINE"); ok && v != "" {
|
||||||
|
cc.PerRoutine = parseInt(v, cc.PerRoutine)
|
||||||
|
}
|
||||||
|
if uas := applyEnvStringSlice("CHECK_CHANNELS_USER_AGENT"); uas != nil {
|
||||||
|
cc.UserAgent = UserAgents(uas)
|
||||||
|
}
|
||||||
|
|
||||||
|
// cache
|
||||||
|
if v, ok := os.LookupEnv("CACHE_ENABLED"); ok {
|
||||||
|
cfg.Cache.Enabled = parseBool(v, cfg.Cache.Enabled)
|
||||||
|
}
|
||||||
|
if v, ok := os.LookupEnv("CACHE_HOST"); ok && v != "" {
|
||||||
|
cfg.Cache.Host = v
|
||||||
|
}
|
||||||
|
if v, ok := os.LookupEnv("CACHE_PORT"); ok && v != "" {
|
||||||
|
cfg.Cache.Port = parseUint(v, cfg.Cache.Port)
|
||||||
|
}
|
||||||
|
if v, ok := os.LookupEnv("CACHE_USERNAME"); ok {
|
||||||
|
cfg.Cache.Username = v
|
||||||
|
}
|
||||||
|
if v, ok := os.LookupEnv("CACHE_PASSWORD"); ok {
|
||||||
|
cfg.Cache.Password = v
|
||||||
|
}
|
||||||
|
if v, ok := os.LookupEnv("CACHE_DB"); ok && v != "" {
|
||||||
|
cfg.Cache.Db = parseUint(v, cfg.Cache.Db)
|
||||||
|
}
|
||||||
|
if v, ok := os.LookupEnv("CACHE_TTL"); ok && v != "" {
|
||||||
|
cfg.Cache.Ttl = parseUint(v, cfg.Cache.Ttl)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseBool разбирает строку в bool, при ошибке возвращает значение по умолчанию
|
||||||
|
func parseBool(s string, defaultValue bool) bool {
|
||||||
|
val, err := strconv.ParseBool(s)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
return val
|
return val
|
||||||
}
|
}
|
||||||
return defaultValue
|
return defaultValue
|
||||||
}
|
}
|
||||||
|
|
||||||
// readEnvInteger считывает целочисленную переменную окружения с заданным именем или возвращает значение по умолчанию
|
// parseUint разбирает строку в uint, при ошибке возвращает значение по умолчанию
|
||||||
func readEnvInteger(name string, defaultValue uint) uint {
|
func parseUint(s string, defaultValue uint) uint {
|
||||||
valueStr := readEnv(name, "")
|
val, err := strconv.Atoi(s)
|
||||||
value, err := strconv.Atoi(valueStr)
|
|
||||||
if err == nil {
|
if err == nil {
|
||||||
return uint(value)
|
return uint(val)
|
||||||
}
|
}
|
||||||
return defaultValue
|
return defaultValue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// parseInt разбирает строку в int, при ошибке возвращает значение по умолчанию
|
||||||
|
func parseInt(s string, defaultValue int) int {
|
||||||
|
val, err := strconv.Atoi(s)
|
||||||
|
if err == nil {
|
||||||
|
return val
|
||||||
|
}
|
||||||
|
return defaultValue
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseSecondsToMillis разбирает строку с секундами и возвращает миллисекунды.
|
||||||
|
// При ошибке возвращает значение по умолчанию.
|
||||||
|
func parseSecondsToMillis(s string, defaultValue int) int {
|
||||||
|
val, err := strconv.ParseFloat(s, 64)
|
||||||
|
if err == nil && val >= 0 {
|
||||||
|
return int(val * 1000)
|
||||||
|
}
|
||||||
|
return defaultValue
|
||||||
|
}
|
||||||
|
|
||||||
|
// applyEnvIntRange читает одно env-значение (в секундах) для IntRange,
|
||||||
|
// умножая результат на 1000, так как внутри значения хранятся в миллисекундах.
|
||||||
|
// Одно число задаёт фиксированное значение, два числа через запятую — диапазон.
|
||||||
|
func applyEnvIntRange(r *IntRange, name string) {
|
||||||
|
value, ok := os.LookupEnv(name)
|
||||||
|
if !ok || strings.TrimSpace(value) == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
parse := func(s string) (int, bool) {
|
||||||
|
s = strings.TrimSpace(s)
|
||||||
|
v, err := strconv.ParseFloat(s, 64)
|
||||||
|
if err != nil || v < 0 {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
return int(v * 1000), true
|
||||||
|
}
|
||||||
|
|
||||||
|
parts := strings.Split(value, ",")
|
||||||
|
if len(parts) == 1 {
|
||||||
|
n, ok := parse(parts[0])
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
r.Min = n
|
||||||
|
r.Max = n
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(parts) != 2 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
min, minOk := parse(parts[0])
|
||||||
|
max, maxOk := parse(parts[1])
|
||||||
|
if !minOk || !maxOk {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
r.Min = min
|
||||||
|
r.Max = max
|
||||||
|
}
|
||||||
|
|
||||||
|
// applyEnvStringSlice читает индексированные env-переменные PREFIX_1, PREFIX_2, ...
|
||||||
|
// Возвращает nil, если ни одна не задана.
|
||||||
|
func applyEnvStringSlice(prefix string) []string {
|
||||||
|
var result []string
|
||||||
|
for i := 1; ; i++ {
|
||||||
|
key := fmt.Sprintf("%s_%d", prefix, i)
|
||||||
|
v, ok := os.LookupEnv(key)
|
||||||
|
if !ok {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if v != "" {
|
||||||
|
result = append(result, v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// validate проверяет значения конфигурации и исправляет некорректные,
|
||||||
|
// логируя предупреждения для каждого исправления.
|
||||||
|
func validate(cfg *Config) {
|
||||||
|
// server
|
||||||
|
if cfg.Server.Port == 0 || cfg.Server.Port > 65535 {
|
||||||
|
log.Println("Config: server.port is not in the 0..65535 range, using default 8800")
|
||||||
|
cfg.Server.Port = 8800
|
||||||
|
}
|
||||||
|
if cfg.Site.BaseUrl == "" {
|
||||||
|
log.Println("Config: site.base-url is empty, using default")
|
||||||
|
cfg.Site.BaseUrl = fmt.Sprintf("http://localhost:%d", cfg.Server.Port)
|
||||||
|
}
|
||||||
|
|
||||||
|
// cache
|
||||||
|
if cfg.Cache.Enabled {
|
||||||
|
if cfg.Cache.Host == "" {
|
||||||
|
log.Println("Config: cache.host is empty, using default 'localhost'")
|
||||||
|
cfg.Cache.Host = "localhost"
|
||||||
|
}
|
||||||
|
if cfg.Cache.Port == 0 {
|
||||||
|
log.Println("Config: cache.port is 0, using default 6379")
|
||||||
|
cfg.Cache.Port = 6379
|
||||||
|
}
|
||||||
|
if cfg.Cache.Ttl == 0 {
|
||||||
|
log.Printf("Config: cache.ttl is 0, using default %d s", defaultCacheTtlSeconds)
|
||||||
|
cfg.Cache.Ttl = defaultCacheTtlSeconds
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// check.playlists
|
||||||
|
pp := &cfg.Check.Playlists
|
||||||
|
if pp.Timeout <= 0 {
|
||||||
|
log.Printf("Config: check.playlists.timeout <= 0, using default %d s", defaultTimeoutSeconds)
|
||||||
|
pp.Timeout = defaultTimeoutSeconds * 1000
|
||||||
|
}
|
||||||
|
validateIntRange(&pp.AllCooldown, "check.playlists.all-cooldown", 0, 3600000)
|
||||||
|
validateIntRange(&pp.OneCooldown, "check.playlists.one-cooldown", 0, 3600000)
|
||||||
|
if pp.MaxRoutines < 1 {
|
||||||
|
log.Println("Config: check.playlists.max-routines < 1, using default 5")
|
||||||
|
pp.MaxRoutines = 5
|
||||||
|
}
|
||||||
|
if pp.PerRoutine < 1 {
|
||||||
|
log.Println("Config: check.playlists.per-routine < 1, using default 1")
|
||||||
|
pp.PerRoutine = 1
|
||||||
|
}
|
||||||
|
if len(pp.UserAgent) == 0 {
|
||||||
|
log.Println("Config: check.playlists.user-agent is empty, using default")
|
||||||
|
pp.UserAgent = UserAgents{"Mozilla/5.0 WINK/1.31.1 (AndroidTV/9) HlsWinkPlayer"}
|
||||||
|
}
|
||||||
|
|
||||||
|
// check.channels
|
||||||
|
cc := &cfg.Check.Channels
|
||||||
|
if cc.Timeout <= 0 {
|
||||||
|
log.Printf("Config: check.channels.timeout <= 0, using default %d s", defaultTimeoutSeconds)
|
||||||
|
cc.Timeout = defaultTimeoutSeconds * 1000
|
||||||
|
}
|
||||||
|
validateIntRange(&cc.Cooldown, "check.channels.cooldown", 0, 3600000)
|
||||||
|
if cc.MaxRoutines < 1 {
|
||||||
|
log.Println("Config: check.channels.max-routines < 1, using default 50")
|
||||||
|
cc.MaxRoutines = 50
|
||||||
|
}
|
||||||
|
if cc.PerRoutine < 1 {
|
||||||
|
log.Println("Config: check.channels.per-routine < 1, using default 10")
|
||||||
|
cc.PerRoutine = 10
|
||||||
|
}
|
||||||
|
if cc.ByteRange <= 0 {
|
||||||
|
log.Println("Config: check.channels.byte-range <= 0, using default 512")
|
||||||
|
cc.ByteRange = 512
|
||||||
|
}
|
||||||
|
if len(cc.UserAgent) == 0 {
|
||||||
|
log.Println("Config: check.channels.user-agent is empty, using default")
|
||||||
|
cc.UserAgent = UserAgents{"Mozilla/5.0 WINK/1.31.1 (AndroidTV/9) HlsWinkPlayer"}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// validateIntRange проверяет IntRange: Min <= Max, оба в пределах [minVal, maxVal].
|
||||||
|
// Некорректные значения исправляются с логированием предупреждения.
|
||||||
|
func validateIntRange(r *IntRange, name string, minVal, maxVal int) {
|
||||||
|
if r.Min > r.Max {
|
||||||
|
log.Printf("Config: %s min=%d > max=%d, swapping", name, r.Min, r.Max)
|
||||||
|
r.Min, r.Max = r.Max, r.Min
|
||||||
|
}
|
||||||
|
if r.Min < minVal {
|
||||||
|
log.Printf("Config: %s min=%d < %d, clamped", name, r.Min, minVal)
|
||||||
|
r.Min = minVal
|
||||||
|
}
|
||||||
|
if r.Max > maxVal {
|
||||||
|
log.Printf("Config: %s max=%d > %d, clamped", name, r.Max, maxVal)
|
||||||
|
r.Max = maxVal
|
||||||
|
}
|
||||||
|
if r.Min > r.Max {
|
||||||
|
r.Min = minVal
|
||||||
|
r.Max = minVal
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,545 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gopkg.in/yaml.v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
// --- IntRange ---
|
||||||
|
|
||||||
|
func TestIntRangeValue_Scalar(t *testing.T) {
|
||||||
|
r := IntRange{Min: 5, Max: 5}
|
||||||
|
if v := r.Value(); v != 5 {
|
||||||
|
t.Errorf("expected 5, got %d", v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIntRangeValue_Range(t *testing.T) {
|
||||||
|
r := IntRange{Min: 10, Max: 20}
|
||||||
|
for i := 0; i < 100; i++ {
|
||||||
|
v := r.Value()
|
||||||
|
if v < 10 || v > 20 {
|
||||||
|
t.Errorf("value %d out of [10, 20]", v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIntRangeUnmarshalYAML_Scalar(t *testing.T) {
|
||||||
|
yamlData := []byte("timeout: 5000\n")
|
||||||
|
type wrapper struct {
|
||||||
|
Timeout int `yaml:"timeout"`
|
||||||
|
}
|
||||||
|
var w wrapper
|
||||||
|
if err := yaml.Unmarshal(yamlData, &w); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if w.Timeout != 5000 {
|
||||||
|
t.Errorf("expected 5000, got %d", w.Timeout)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIntRangeUnmarshalYAML_FromFullConfig(t *testing.T) {
|
||||||
|
yamlData := []byte(`
|
||||||
|
check:
|
||||||
|
playlists:
|
||||||
|
all-cooldown: 1
|
||||||
|
one-cooldown: [2, 3]
|
||||||
|
channels:
|
||||||
|
cooldown: [0, 1]
|
||||||
|
`)
|
||||||
|
cfg := defaults()
|
||||||
|
if err := yaml.Unmarshal(yamlData, cfg); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if cfg.Check.Playlists.AllCooldown.Min != 1000 || cfg.Check.Playlists.AllCooldown.Max != 1000 {
|
||||||
|
t.Errorf("scalar all-cooldown: expected Min=Max=1000 ms from 1 s, got Min=%d Max=%d",
|
||||||
|
cfg.Check.Playlists.AllCooldown.Min, cfg.Check.Playlists.AllCooldown.Max)
|
||||||
|
}
|
||||||
|
if cfg.Check.Playlists.OneCooldown.Min != 2000 || cfg.Check.Playlists.OneCooldown.Max != 3000 {
|
||||||
|
t.Errorf("range one-cooldown: expected [2000,3000] ms from [2,3] s, got [%d,%d]",
|
||||||
|
cfg.Check.Playlists.OneCooldown.Min, cfg.Check.Playlists.OneCooldown.Max)
|
||||||
|
}
|
||||||
|
if cfg.Check.Channels.Cooldown.Min != 0 || cfg.Check.Channels.Cooldown.Max != 1000 {
|
||||||
|
t.Errorf("range cooldown: expected [0,1000] ms from [0,1] s, got [%d,%d]",
|
||||||
|
cfg.Check.Channels.Cooldown.Min, cfg.Check.Channels.Cooldown.Max)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- UserAgents ---
|
||||||
|
|
||||||
|
func TestUserAgentsPick_Empty(t *testing.T) {
|
||||||
|
ua := UserAgents{}
|
||||||
|
if ua.Pick() != "" {
|
||||||
|
t.Error("expected empty string for empty UserAgents")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUserAgentsPick_Single(t *testing.T) {
|
||||||
|
ua := UserAgents{"Mozilla"}
|
||||||
|
if ua.Pick() != "Mozilla" {
|
||||||
|
t.Errorf("expected 'Mozilla', got '%s'", ua.Pick())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUserAgentsPick_Multiple(t *testing.T) {
|
||||||
|
ua := UserAgents{"A", "B", "C"}
|
||||||
|
for i := 0; i < 100; i++ {
|
||||||
|
v := ua.Pick()
|
||||||
|
found := false
|
||||||
|
for _, s := range ua {
|
||||||
|
if v == s {
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Errorf("Pick returned '%s' which is not in list", v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUserAgentsUnmarshalYAML_Scalar(t *testing.T) {
|
||||||
|
yamlData := []byte(`
|
||||||
|
check:
|
||||||
|
channels:
|
||||||
|
user-agent: Mozilla/5.0
|
||||||
|
`)
|
||||||
|
cfg := defaults()
|
||||||
|
if err := yaml.Unmarshal(yamlData, cfg); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(cfg.Check.Channels.UserAgent) != 1 || cfg.Check.Channels.UserAgent[0] != "Mozilla/5.0" {
|
||||||
|
t.Errorf("expected single 'Mozilla/5.0', got %v", cfg.Check.Channels.UserAgent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUserAgentsUnmarshalYAML_Array(t *testing.T) {
|
||||||
|
yamlData := []byte(`
|
||||||
|
check:
|
||||||
|
channels:
|
||||||
|
user-agent:
|
||||||
|
- Mozilla/5.0
|
||||||
|
- curl/8.0
|
||||||
|
`)
|
||||||
|
cfg := defaults()
|
||||||
|
if err := yaml.Unmarshal(yamlData, cfg); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(cfg.Check.Channels.UserAgent) != 2 {
|
||||||
|
t.Errorf("expected 2 user-agents, got %d", len(cfg.Check.Channels.UserAgent))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- parseBool / parseUint / parseInt ---
|
||||||
|
|
||||||
|
func TestParseBool(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
in string
|
||||||
|
def bool
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{"true", false, true},
|
||||||
|
{"1", false, true},
|
||||||
|
{"false", true, false},
|
||||||
|
{"0", true, false},
|
||||||
|
{"yes", false, false},
|
||||||
|
{"", true, true},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
got := parseBool(c.in, c.def)
|
||||||
|
if got != c.want {
|
||||||
|
t.Errorf("parseBool(%q, %v) = %v, want %v", c.in, c.def, got, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseUint(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
in string
|
||||||
|
def uint
|
||||||
|
want uint
|
||||||
|
}{
|
||||||
|
{"8800", 0, 8800},
|
||||||
|
{"0", 999, 0},
|
||||||
|
{"abc", 999, 999},
|
||||||
|
{"", 999, 999},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
got := parseUint(c.in, c.def)
|
||||||
|
if got != c.want {
|
||||||
|
t.Errorf("parseUint(%q, %d) = %d, want %d", c.in, c.def, got, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseInt(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
in string
|
||||||
|
def int
|
||||||
|
want int
|
||||||
|
}{
|
||||||
|
{"100", 0, 100},
|
||||||
|
{"-5", 0, -5},
|
||||||
|
{"abc", 999, 999},
|
||||||
|
{"", 999, 999},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
got := parseInt(c.in, c.def)
|
||||||
|
if got != c.want {
|
||||||
|
t.Errorf("parseInt(%q, %d) = %d, want %d", c.in, c.def, got, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- applyEnvIntRange ---
|
||||||
|
|
||||||
|
func TestApplyEnvIntRange_Scalar(t *testing.T) {
|
||||||
|
t.Setenv("TEST_COOLDOWN", "1.5")
|
||||||
|
r := IntRange{Min: 0, Max: 0}
|
||||||
|
applyEnvIntRange(&r, "TEST_COOLDOWN")
|
||||||
|
if r.Min != 1500 || r.Max != 1500 {
|
||||||
|
t.Errorf("expected Min=Max=1500, got Min=%d Max=%d", r.Min, r.Max)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApplyEnvIntRange_Range(t *testing.T) {
|
||||||
|
t.Setenv("TEST_COOLDOWN", "1,2")
|
||||||
|
r := IntRange{Min: 0, Max: 0}
|
||||||
|
applyEnvIntRange(&r, "TEST_COOLDOWN")
|
||||||
|
if r.Min != 1000 || r.Max != 2000 {
|
||||||
|
t.Errorf("expected [1000,2000], got [%d,%d]", r.Min, r.Max)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApplyEnvIntRange_Invalid(t *testing.T) {
|
||||||
|
t.Setenv("TEST_COOLDOWN", "1,invalid")
|
||||||
|
r := IntRange{Min: 5, Max: 5}
|
||||||
|
applyEnvIntRange(&r, "TEST_COOLDOWN")
|
||||||
|
if r.Min != 5 || r.Max != 5 {
|
||||||
|
t.Errorf("expected unchanged [5,5], got [%d,%d]", r.Min, r.Max)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApplyEnvIntRange_None(t *testing.T) {
|
||||||
|
r := IntRange{Min: 7, Max: 7}
|
||||||
|
applyEnvIntRange(&r, "TEST_COOLDOWN")
|
||||||
|
if r.Min != 7 || r.Max != 7 {
|
||||||
|
t.Errorf("expected unchanged [7,7], got [%d,%d]", r.Min, r.Max)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- applyEnvStringSlice ---
|
||||||
|
|
||||||
|
func TestApplyEnvStringSlice_Multiple(t *testing.T) {
|
||||||
|
t.Setenv("MY_PREFIX_1", "foo")
|
||||||
|
t.Setenv("MY_PREFIX_2", "bar")
|
||||||
|
t.Setenv("MY_PREFIX_3", "baz")
|
||||||
|
result := applyEnvStringSlice("MY_PREFIX")
|
||||||
|
if len(result) != 3 {
|
||||||
|
t.Fatalf("expected 3 items, got %d", len(result))
|
||||||
|
}
|
||||||
|
if result[0] != "foo" || result[1] != "bar" || result[2] != "baz" {
|
||||||
|
t.Errorf("unexpected result: %v", result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApplyEnvStringSlice_EmptyValue(t *testing.T) {
|
||||||
|
t.Setenv("MY_PREFIX_1", "foo")
|
||||||
|
t.Setenv("MY_PREFIX_2", "")
|
||||||
|
t.Setenv("MY_PREFIX_3", "baz")
|
||||||
|
result := applyEnvStringSlice("MY_PREFIX")
|
||||||
|
if len(result) != 2 {
|
||||||
|
t.Fatalf("expected 2 items (empty skipped), got %d: %v", len(result), result)
|
||||||
|
}
|
||||||
|
if result[0] != "foo" || result[1] != "baz" {
|
||||||
|
t.Errorf("unexpected result: %v", result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApplyEnvStringSlice_None(t *testing.T) {
|
||||||
|
result := applyEnvStringSlice("NONEXISTENT_PREFIX")
|
||||||
|
if result != nil {
|
||||||
|
t.Errorf("expected nil, got %v", result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- validate ---
|
||||||
|
|
||||||
|
func TestValidate_ServerPort(t *testing.T) {
|
||||||
|
cfg := defaults()
|
||||||
|
cfg.Server.Port = 0
|
||||||
|
validate(cfg)
|
||||||
|
if cfg.Server.Port != 8800 {
|
||||||
|
t.Errorf("expected 8800, got %d", cfg.Server.Port)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidate_ServerPortTooHigh(t *testing.T) {
|
||||||
|
cfg := defaults()
|
||||||
|
cfg.Server.Port = 99999
|
||||||
|
validate(cfg)
|
||||||
|
if cfg.Server.Port != 8800 {
|
||||||
|
t.Errorf("expected 8800, got %d", cfg.Server.Port)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidate_BaseUrlEmpty(t *testing.T) {
|
||||||
|
cfg := defaults()
|
||||||
|
cfg.Site.BaseUrl = ""
|
||||||
|
cfg.Server.Port = 3000
|
||||||
|
validate(cfg)
|
||||||
|
if cfg.Site.BaseUrl != "http://localhost:3000" {
|
||||||
|
t.Errorf("expected http://localhost:3000, got %s", cfg.Site.BaseUrl)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidate_CacheEnabled(t *testing.T) {
|
||||||
|
cfg := defaults()
|
||||||
|
cfg.Cache.Enabled = true
|
||||||
|
cfg.Cache.Host = ""
|
||||||
|
cfg.Cache.Port = 0
|
||||||
|
cfg.Cache.Ttl = 0
|
||||||
|
validate(cfg)
|
||||||
|
if cfg.Cache.Host != "localhost" {
|
||||||
|
t.Errorf("expected localhost, got %s", cfg.Cache.Host)
|
||||||
|
}
|
||||||
|
if cfg.Cache.Port != 6379 {
|
||||||
|
t.Errorf("expected 6379, got %d", cfg.Cache.Port)
|
||||||
|
}
|
||||||
|
if cfg.Cache.Ttl != 30 {
|
||||||
|
t.Errorf("expected 30, got %d", cfg.Cache.Ttl)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidate_PlaylistsTimeout(t *testing.T) {
|
||||||
|
cfg := defaults()
|
||||||
|
cfg.Check.Playlists.Timeout = 0
|
||||||
|
validate(cfg)
|
||||||
|
if cfg.Check.Playlists.Timeout != 10000 {
|
||||||
|
t.Errorf("expected 10000, got %d", cfg.Check.Playlists.Timeout)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidate_ChannelsTimeout(t *testing.T) {
|
||||||
|
cfg := defaults()
|
||||||
|
cfg.Check.Channels.Timeout = -5
|
||||||
|
validate(cfg)
|
||||||
|
if cfg.Check.Channels.Timeout != 10000 {
|
||||||
|
t.Errorf("expected 10000, got %d", cfg.Check.Channels.Timeout)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidate_PlaylistsMaxRoutines(t *testing.T) {
|
||||||
|
cfg := defaults()
|
||||||
|
cfg.Check.Playlists.MaxRoutines = 0
|
||||||
|
validate(cfg)
|
||||||
|
if cfg.Check.Playlists.MaxRoutines != 5 {
|
||||||
|
t.Errorf("expected 5, got %d", cfg.Check.Playlists.MaxRoutines)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidate_ChannelsMaxRoutines(t *testing.T) {
|
||||||
|
cfg := defaults()
|
||||||
|
cfg.Check.Channels.MaxRoutines = 0
|
||||||
|
validate(cfg)
|
||||||
|
if cfg.Check.Channels.MaxRoutines != 50 {
|
||||||
|
t.Errorf("expected 50, got %d", cfg.Check.Channels.MaxRoutines)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidate_ChannelsByteRange(t *testing.T) {
|
||||||
|
cfg := defaults()
|
||||||
|
cfg.Check.Channels.ByteRange = 0
|
||||||
|
validate(cfg)
|
||||||
|
if cfg.Check.Channels.ByteRange != 512 {
|
||||||
|
t.Errorf("expected 512, got %d", cfg.Check.Channels.ByteRange)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidate_UserAgentEmpty(t *testing.T) {
|
||||||
|
cfg := defaults()
|
||||||
|
cfg.Check.Playlists.UserAgent = nil
|
||||||
|
cfg.Check.Channels.UserAgent = nil
|
||||||
|
validate(cfg)
|
||||||
|
if len(cfg.Check.Playlists.UserAgent) == 0 {
|
||||||
|
t.Error("expected non-empty playlists user-agent")
|
||||||
|
}
|
||||||
|
if len(cfg.Check.Channels.UserAgent) == 0 {
|
||||||
|
t.Error("expected non-empty channels user-agent")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- validateIntRange ---
|
||||||
|
|
||||||
|
func TestValidateIntRange_Swap(t *testing.T) {
|
||||||
|
r := IntRange{Min: 100, Max: 10}
|
||||||
|
validateIntRange(&r, "test", 0, 1000)
|
||||||
|
if r.Min != 10 || r.Max != 100 {
|
||||||
|
t.Errorf("expected [10,100] after swap, got [%d,%d]", r.Min, r.Max)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateIntRange_ClampMin(t *testing.T) {
|
||||||
|
r := IntRange{Min: -5, Max: 10}
|
||||||
|
validateIntRange(&r, "test", 0, 1000)
|
||||||
|
if r.Min != 0 {
|
||||||
|
t.Errorf("expected Min=0 after clamp, got %d", r.Min)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateIntRange_ClampMax(t *testing.T) {
|
||||||
|
r := IntRange{Min: 0, Max: 999999}
|
||||||
|
validateIntRange(&r, "test", 0, 1000)
|
||||||
|
if r.Max != 1000 {
|
||||||
|
t.Errorf("expected Max=1000 after clamp, got %d", r.Max)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateIntRange_SwapThenClamp(t *testing.T) {
|
||||||
|
r := IntRange{Min: 2000, Max: -10}
|
||||||
|
validateIntRange(&r, "test", 0, 1000)
|
||||||
|
// swap → [−10, 2000], clamp → [0, 1000]
|
||||||
|
if r.Min != 0 || r.Max != 1000 {
|
||||||
|
t.Errorf("expected [0,1000], got [%d,%d]", r.Min, r.Max)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- defaults ---
|
||||||
|
|
||||||
|
func TestDefaults(t *testing.T) {
|
||||||
|
cfg := defaults()
|
||||||
|
if cfg.App.Timezone != "GMT" {
|
||||||
|
t.Errorf("expected GMT, got %s", cfg.App.Timezone)
|
||||||
|
}
|
||||||
|
if cfg.Server.Port != 8800 {
|
||||||
|
t.Errorf("expected 8800, got %d", cfg.Server.Port)
|
||||||
|
}
|
||||||
|
if cfg.Check.Playlists.Timeout != 10000 {
|
||||||
|
t.Errorf("expected 10000 ms, got %d", cfg.Check.Playlists.Timeout)
|
||||||
|
}
|
||||||
|
if cfg.Check.Channels.Timeout != 10000 {
|
||||||
|
t.Errorf("expected 10000 ms, got %d", cfg.Check.Channels.Timeout)
|
||||||
|
}
|
||||||
|
if cfg.Cache.Port != 6379 {
|
||||||
|
t.Errorf("expected 6379, got %d", cfg.Cache.Port)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Init (integration) ---
|
||||||
|
|
||||||
|
func TestInit_NoConfigFile(t *testing.T) {
|
||||||
|
cfg := Init("/nonexistent/config.yml")
|
||||||
|
if cfg == nil {
|
||||||
|
t.Fatal("expected non-nil config")
|
||||||
|
}
|
||||||
|
if cfg.Server.Port != 8800 {
|
||||||
|
t.Errorf("expected default port 8800, got %d", cfg.Server.Port)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInit_WithConfigFile(t *testing.T) {
|
||||||
|
tmp := t.TempDir() + "/config.yml"
|
||||||
|
data := []byte(`
|
||||||
|
app:
|
||||||
|
timezone: UTC+3
|
||||||
|
debug: true
|
||||||
|
server:
|
||||||
|
port: 3000
|
||||||
|
host: 0.0.0.0
|
||||||
|
check:
|
||||||
|
playlists:
|
||||||
|
timeout: 5
|
||||||
|
all-cooldown: [1, 2]
|
||||||
|
channels:
|
||||||
|
byte-range: 1024
|
||||||
|
cache:
|
||||||
|
enabled: true
|
||||||
|
host: db.local
|
||||||
|
port: 6380
|
||||||
|
`)
|
||||||
|
if err := os.WriteFile(tmp, data, 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
cfg := Init(tmp)
|
||||||
|
if cfg.App.Timezone != "UTC+3" {
|
||||||
|
t.Errorf("expected UTC+3, got %s", cfg.App.Timezone)
|
||||||
|
}
|
||||||
|
if !cfg.App.Debug {
|
||||||
|
t.Error("expected debug=true")
|
||||||
|
}
|
||||||
|
if cfg.Server.Port != 3000 {
|
||||||
|
t.Errorf("expected 3000, got %d", cfg.Server.Port)
|
||||||
|
}
|
||||||
|
if cfg.Server.Host != "0.0.0.0" {
|
||||||
|
t.Errorf("expected 0.0.0.0, got %s", cfg.Server.Host)
|
||||||
|
}
|
||||||
|
if cfg.Check.Playlists.Timeout != 5000 {
|
||||||
|
t.Errorf("expected 5000 ms from 5 s, got %d", cfg.Check.Playlists.Timeout)
|
||||||
|
}
|
||||||
|
if cfg.Check.Playlists.AllCooldown.Min != 1000 || cfg.Check.Playlists.AllCooldown.Max != 2000 {
|
||||||
|
t.Errorf("expected [1000,2000] ms from [1,2] s, got [%d,%d]",
|
||||||
|
cfg.Check.Playlists.AllCooldown.Min, cfg.Check.Playlists.AllCooldown.Max)
|
||||||
|
}
|
||||||
|
if cfg.Check.Channels.ByteRange != 1024 {
|
||||||
|
t.Errorf("expected 1024, got %d", cfg.Check.Channels.ByteRange)
|
||||||
|
}
|
||||||
|
if !cfg.Cache.Enabled {
|
||||||
|
t.Error("expected cache enabled")
|
||||||
|
}
|
||||||
|
if cfg.Cache.Host != "db.local" {
|
||||||
|
t.Errorf("expected db.local, got %s", cfg.Cache.Host)
|
||||||
|
}
|
||||||
|
if cfg.Cache.Port != 6380 {
|
||||||
|
t.Errorf("expected 6380, got %d", cfg.Cache.Port)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInit_EnvOverridesConfig(t *testing.T) {
|
||||||
|
tmp := t.TempDir() + "/config.yml"
|
||||||
|
data := []byte("server:\n port: 3000\n")
|
||||||
|
if err := os.WriteFile(tmp, data, 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Setenv("SERVER_PORT", "9090")
|
||||||
|
t.Setenv("APP_DEBUG", "true")
|
||||||
|
t.Setenv("CHECK_PLAYLISTS_TIMEOUT", "7")
|
||||||
|
t.Setenv("CHECK_PLAYLISTS_USER_AGENT_1", "CustomUA")
|
||||||
|
t.Setenv("CHECK_PLAYLISTS_ALL_COOLDOWN", "1.5,2.5")
|
||||||
|
|
||||||
|
cfg := Init(tmp)
|
||||||
|
if cfg.Server.Port != 9090 {
|
||||||
|
t.Errorf("expected 9090 from env, got %d", cfg.Server.Port)
|
||||||
|
}
|
||||||
|
if !cfg.App.Debug {
|
||||||
|
t.Error("expected debug=true from env")
|
||||||
|
}
|
||||||
|
if cfg.Check.Playlists.Timeout != 7000 {
|
||||||
|
t.Errorf("expected 7000 ms from 7 s env, got %d", cfg.Check.Playlists.Timeout)
|
||||||
|
}
|
||||||
|
if len(cfg.Check.Playlists.UserAgent) != 1 || cfg.Check.Playlists.UserAgent[0] != "CustomUA" {
|
||||||
|
t.Errorf("expected [CustomUA], got %v", cfg.Check.Playlists.UserAgent)
|
||||||
|
}
|
||||||
|
if cfg.Check.Playlists.AllCooldown.Min != 1500 || cfg.Check.Playlists.AllCooldown.Max != 2500 {
|
||||||
|
t.Errorf("expected [1500,2500] ms from [1.5,2.5] s env, got [%d,%d]",
|
||||||
|
cfg.Check.Playlists.AllCooldown.Min, cfg.Check.Playlists.AllCooldown.Max)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInit_EnvScalarCooldownOverridesRange(t *testing.T) {
|
||||||
|
tmp := t.TempDir() + "/config.yml"
|
||||||
|
data := []byte("check:\n playlists:\n all-cooldown: [1, 2]\n")
|
||||||
|
if err := os.WriteFile(tmp, data, 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Setenv("CHECK_PLAYLISTS_ALL_COOLDOWN", "2.5")
|
||||||
|
|
||||||
|
cfg := Init(tmp)
|
||||||
|
if cfg.Check.Playlists.AllCooldown.Min != 2500 || cfg.Check.Playlists.AllCooldown.Max != 2500 {
|
||||||
|
t.Errorf("expected scalar [2500,2500] ms from 2.5 s env, got [%d,%d]",
|
||||||
|
cfg.Check.Playlists.AllCooldown.Min, cfg.Check.Playlists.AllCooldown.Max)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// helper removed — using yaml.Unmarshal directly
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
/*
|
/*
|
||||||
* Copyright (c) 2025, Антон Аксенов
|
* Copyright (c) 2025-2026, Антон Аксенов
|
||||||
* This file is part of iptvc project
|
* This file is part of iptvc project
|
||||||
* MIT License: https://git.axenov.dev/IPTV/iptvc/src/branch/master/LICENSE
|
* MIT License: https://git.axenov.dev/IPTV/iptvc/src/branch/master/LICENSE
|
||||||
*/
|
*/
|
||||||
@@ -9,10 +9,11 @@ package inifile
|
|||||||
import (
|
import (
|
||||||
"axenov/iptv-checker/app/playlist"
|
"axenov/iptv-checker/app/playlist"
|
||||||
"axenov/iptv-checker/app/utils"
|
"axenov/iptv-checker/app/utils"
|
||||||
"gopkg.in/ini.v1"
|
|
||||||
"log"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"gopkg.in/ini.v1"
|
||||||
)
|
)
|
||||||
|
|
||||||
// IniFile описывает ini-файл c плейлистами
|
// IniFile описывает ini-файл c плейлистами
|
||||||
|
|||||||
@@ -0,0 +1,91 @@
|
|||||||
|
package inifile
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestInit_Success(t *testing.T) {
|
||||||
|
tmp := t.TempDir()
|
||||||
|
path := filepath.Join(tmp, "playlists.ini")
|
||||||
|
data := `[RU]
|
||||||
|
pls = http://example.com/ru.m3u
|
||||||
|
name = Russian Playlist
|
||||||
|
desc = Russian channels
|
||||||
|
src = public
|
||||||
|
|
||||||
|
[EN]
|
||||||
|
pls = http://example.com/en.m3u
|
||||||
|
name = English Playlist
|
||||||
|
`
|
||||||
|
if err := os.WriteFile(path, []byte(data), 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
ini, err := Init(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(ini.Lists) != 2 {
|
||||||
|
t.Fatalf("expected 2 playlists, got %d", len(ini.Lists))
|
||||||
|
}
|
||||||
|
ru, ok := ini.Lists["RU"]
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("expected RU playlist")
|
||||||
|
}
|
||||||
|
if ru.Name != "Russian Playlist" {
|
||||||
|
t.Errorf("expected 'Russian Playlist', got '%s'", ru.Name)
|
||||||
|
}
|
||||||
|
if ru.Description != "Russian channels" {
|
||||||
|
t.Errorf("expected 'Russian channels', got '%s'", ru.Description)
|
||||||
|
}
|
||||||
|
if ru.Url != "http://example.com/ru.m3u" {
|
||||||
|
t.Errorf("expected URL, got '%s'", ru.Url)
|
||||||
|
}
|
||||||
|
if ru.Source != "public" {
|
||||||
|
t.Errorf("expected 'public', got '%s'", ru.Source)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInit_NotFound(t *testing.T) {
|
||||||
|
_, err := Init("/nonexistent/playlists.ini")
|
||||||
|
if err == nil {
|
||||||
|
t.Error("expected error for non-existent file")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInit_EmptyPls(t *testing.T) {
|
||||||
|
tmp := t.TempDir()
|
||||||
|
path := filepath.Join(tmp, "playlists.ini")
|
||||||
|
data := `[BAD]
|
||||||
|
name = Bad Playlist
|
||||||
|
`
|
||||||
|
os.WriteFile(path, []byte(data), 0644)
|
||||||
|
ini, err := Init(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(ini.Lists) != 0 {
|
||||||
|
t.Errorf("expected 0 playlists (empty pls), got %d", len(ini.Lists))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInit_DefaultName(t *testing.T) {
|
||||||
|
tmp := t.TempDir()
|
||||||
|
path := filepath.Join(tmp, "playlists.ini")
|
||||||
|
data := `[CODE1]
|
||||||
|
pls = http://example.com/list.m3u
|
||||||
|
`
|
||||||
|
os.WriteFile(path, []byte(data), 0644)
|
||||||
|
ini, err := Init(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
pls, ok := ini.Lists["CODE1"]
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("expected CODE1 playlist")
|
||||||
|
}
|
||||||
|
if pls.Name != "Playlist #CODE1" {
|
||||||
|
t.Errorf("expected 'Playlist #CODE1', got '%s'", pls.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
/*
|
/*
|
||||||
* Copyright (c) 2025, Антон Аксенов
|
* Copyright (c) 2025-2026, Антон Аксенов
|
||||||
* This file is part of iptvc project
|
* This file is part of iptvc project
|
||||||
* MIT License: https://git.axenov.dev/IPTV/iptvc/src/branch/master/LICENSE
|
* MIT License: https://git.axenov.dev/IPTV/iptvc/src/branch/master/LICENSE
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/*
|
/*
|
||||||
* Copyright (c) 2025, Антон Аксенов
|
* Copyright (c) 2025-2026, Антон Аксенов
|
||||||
* This file is part of iptvc project
|
* This file is part of iptvc project
|
||||||
* MIT License: https://git.axenov.dev/IPTV/iptvc/src/branch/master/LICENSE
|
* MIT License: https://git.axenov.dev/IPTV/iptvc/src/branch/master/LICENSE
|
||||||
*/
|
*/
|
||||||
@@ -54,9 +54,6 @@ type Playlist struct {
|
|||||||
CheckedAt int64 `json:"checkedAt"` // Время проверки в формате UNIX timestamp
|
CheckedAt int64 `json:"checkedAt"` // Время проверки в формате UNIX timestamp
|
||||||
}
|
}
|
||||||
|
|
||||||
// tmpChannel хранит временные данные о канале, который обрабатывается в Parse
|
|
||||||
var tmpChannel = Channel{}
|
|
||||||
|
|
||||||
// MakeFromFile создаёт экземпляр плейлиста из файла
|
// MakeFromFile создаёт экземпляр плейлиста из файла
|
||||||
func MakeFromFile(filepath string) (Playlist, error) {
|
func MakeFromFile(filepath string) (Playlist, error) {
|
||||||
expandedPath, err := utils.ExpandPath(filepath)
|
expandedPath, err := utils.ExpandPath(filepath)
|
||||||
@@ -131,8 +128,10 @@ func parseTitle(line string) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Download загружает плейлист по URL-адресу
|
// Download загружает плейлист по URL-адресу
|
||||||
func (pls *Playlist) Download() error {
|
// Download скачивает плейлист по URL.
|
||||||
content, err := utils.Fetch(pls.Url)
|
// userAgent и timeout передаются в HTTP-клиент.
|
||||||
|
func (pls *Playlist) Download(userAgent string, timeout time.Duration) error {
|
||||||
|
content, err := utils.Fetch(pls.Url, userAgent, timeout)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
pls.Content = err.Error()
|
pls.Content = err.Error()
|
||||||
pls.CheckedAt = time.Now().Unix()
|
pls.CheckedAt = time.Now().Unix()
|
||||||
@@ -159,6 +158,7 @@ func (pls *Playlist) ReadFromFs() error {
|
|||||||
// Parse разбирает плейлист
|
// Parse разбирает плейлист
|
||||||
func (pls *Playlist) Parse() Playlist {
|
func (pls *Playlist) Parse() Playlist {
|
||||||
isChannel := false
|
isChannel := false
|
||||||
|
var ch Channel
|
||||||
pls.Attributes = make(map[string]string)
|
pls.Attributes = make(map[string]string)
|
||||||
pls.Channels = make(map[string]Channel)
|
pls.Channels = make(map[string]Channel)
|
||||||
pls.Groups = make(map[string]Group)
|
pls.Groups = make(map[string]Group)
|
||||||
@@ -180,20 +180,20 @@ func (pls *Playlist) Parse() Playlist {
|
|||||||
|
|
||||||
if strings.HasPrefix(line, "#EXTINF") {
|
if strings.HasPrefix(line, "#EXTINF") {
|
||||||
isChannel = true
|
isChannel = true
|
||||||
tmpChannel.Attributes = parseAttributes(line)
|
ch = Channel{Attributes: parseAttributes(line)}
|
||||||
tmpChannel.Title = parseTitle(line)
|
ch.Title = parseTitle(line)
|
||||||
|
|
||||||
if tmpChannel.Title == "" {
|
if ch.Title == "" {
|
||||||
if tvgid, ok := tmpChannel.Attributes["tvg-id"]; ok {
|
if tvgid, ok := ch.Attributes["tvg-id"]; ok {
|
||||||
tmpChannel.Title = "(канал без названия, tvg-id=" + tvgid + ")"
|
ch.Title = "(канал без названия, tvg-id=" + tvgid + ")"
|
||||||
} else {
|
} else {
|
||||||
tmpChannel.Title = "(канал без названия, tvg-id неизвестен)"
|
ch.Title = "(канал без названия, tvg-id неизвестен)"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if groupName, ok := tmpChannel.Attributes["group-title"]; ok {
|
if groupName, ok := ch.Attributes["group-title"]; ok {
|
||||||
id := utils.Md5str(groupName)
|
id := utils.Md5str(groupName)
|
||||||
tmpChannel.GroupId = id
|
ch.GroupId = id
|
||||||
pls.Groups[id] = Group{
|
pls.Groups[id] = Group{
|
||||||
Id: id,
|
Id: id,
|
||||||
Name: groupName,
|
Name: groupName,
|
||||||
@@ -207,7 +207,7 @@ func (pls *Playlist) Parse() Playlist {
|
|||||||
parts := strings.Split(line, ":")
|
parts := strings.Split(line, ":")
|
||||||
groupName := strings.Trim(parts[1], " ")
|
groupName := strings.Trim(parts[1], " ")
|
||||||
id := utils.Md5str(groupName)
|
id := utils.Md5str(groupName)
|
||||||
tmpChannel.GroupId = id
|
ch.GroupId = id
|
||||||
pls.Groups[id] = Group{
|
pls.Groups[id] = Group{
|
||||||
Id: id,
|
Id: id,
|
||||||
Name: groupName,
|
Name: groupName,
|
||||||
@@ -217,13 +217,12 @@ func (pls *Playlist) Parse() Playlist {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if isChannel && strings.HasPrefix(line, "http") {
|
if isChannel && strings.HasPrefix(line, "http") {
|
||||||
tmpChannel.URL = strings.Trim(line, " ")
|
ch.URL = strings.Trim(line, " ")
|
||||||
tmpChannel.Id = utils.Md5str(tmpChannel.URL)
|
ch.Id = utils.Md5str(ch.URL)
|
||||||
if tmpChannel.Id != "" {
|
if ch.Id != "" {
|
||||||
pls.Channels[tmpChannel.Id] = tmpChannel
|
pls.Channels[ch.Id] = ch
|
||||||
isChannel = false
|
isChannel = false
|
||||||
tmpChannel = Channel{}
|
ch = Channel{}
|
||||||
tmpChannel.Attributes = make(map[string]string)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,266 @@
|
|||||||
|
package playlist
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestParseAttributes(t *testing.T) {
|
||||||
|
line := `#EXTINF:-1 tvg-id="123" tvg-name="Channel" group-title="News",Channel Name`
|
||||||
|
attrs := parseAttributes(line)
|
||||||
|
if attrs["tvg-id"] != "123" {
|
||||||
|
t.Errorf("expected tvg-id=123, got %s", attrs["tvg-id"])
|
||||||
|
}
|
||||||
|
if attrs["tvg-name"] != "Channel" {
|
||||||
|
t.Errorf("expected tvg-name=Channel, got %s", attrs["tvg-name"])
|
||||||
|
}
|
||||||
|
if attrs["group-title"] != "News" {
|
||||||
|
t.Errorf("expected group-title=News, got %s", attrs["group-title"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseAttributes_Empty(t *testing.T) {
|
||||||
|
attrs := parseAttributes("#EXTINF:-1,Plain Channel")
|
||||||
|
if len(attrs) != 0 {
|
||||||
|
t.Errorf("expected 0 attrs, got %d", len(attrs))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseTitle_WithComma(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
in string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{`#EXTINF:-1 tvg-id="x",My Channel`, "My Channel"},
|
||||||
|
{`#EXTINF:-1,Hello, World`, "Hello, World"},
|
||||||
|
{`#EXTINF:-1 tvg-name="Test",Test Channel`, "Test Channel"},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
got := parseTitle(c.in)
|
||||||
|
if got != c.want {
|
||||||
|
t.Errorf("parseTitle(%q) = %q, want %q", c.in, got, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseTitle_NoComma(t *testing.T) {
|
||||||
|
got := parseTitle(`#EXTINF:-1 tvg-name="Foo"`)
|
||||||
|
if got == "" {
|
||||||
|
t.Error("expected non-empty title")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParse_SimplePlaylist(t *testing.T) {
|
||||||
|
content := `#EXTM3U
|
||||||
|
#EXTINF:-1 tvg-id="ch1" tvg-name="Channel 1" group-title="News",Channel 1
|
||||||
|
http://example.com/ch1.m3u8
|
||||||
|
#EXTINF:-1 tvg-id="ch2" tvg-name="Channel 2" group-title="Movies",Channel 2
|
||||||
|
http://example.com/ch2.m3u8
|
||||||
|
`
|
||||||
|
pls := &Playlist{Content: content}
|
||||||
|
pls.Parse()
|
||||||
|
|
||||||
|
if len(pls.Channels) != 2 {
|
||||||
|
t.Fatalf("expected 2 channels, got %d", len(pls.Channels))
|
||||||
|
}
|
||||||
|
if len(pls.Groups) != 2 {
|
||||||
|
t.Fatalf("expected 2 groups, got %d", len(pls.Groups))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParse_WithBOM(t *testing.T) {
|
||||||
|
content := "\xef\xbb\xbf#EXTM3U\n#EXTINF:-1,Test\nhttp://example.com/test.m3u8\n"
|
||||||
|
pls := &Playlist{Content: content}
|
||||||
|
pls.Parse()
|
||||||
|
if len(pls.Channels) != 1 {
|
||||||
|
t.Fatalf("expected 1 channel (BOM removed), got %d", len(pls.Channels))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParse_WithWindowsLineEndings(t *testing.T) {
|
||||||
|
content := "#EXTM3U\r\n#EXTINF:-1,Test\r\nhttp://example.com/test.m3u8\r\n"
|
||||||
|
pls := &Playlist{Content: content}
|
||||||
|
pls.Parse()
|
||||||
|
if len(pls.Channels) != 1 {
|
||||||
|
t.Fatalf("expected 1 channel (CRLF), got %d", len(pls.Channels))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParse_EmptyContent(t *testing.T) {
|
||||||
|
pls := &Playlist{Content: ""}
|
||||||
|
pls.Parse()
|
||||||
|
if len(pls.Channels) != 0 {
|
||||||
|
t.Errorf("expected 0 channels, got %d", len(pls.Channels))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParse_Extgrp(t *testing.T) {
|
||||||
|
content := `#EXTM3U
|
||||||
|
#EXTINF:-1,Channel A
|
||||||
|
#EXTGRP:Sports
|
||||||
|
http://example.com/a.m3u8
|
||||||
|
`
|
||||||
|
pls := &Playlist{Content: content}
|
||||||
|
pls.Parse()
|
||||||
|
if len(pls.Groups) != 1 {
|
||||||
|
t.Fatalf("expected 1 group, got %d", len(pls.Groups))
|
||||||
|
}
|
||||||
|
for _, ch := range pls.Channels {
|
||||||
|
if ch.GroupId == "" {
|
||||||
|
t.Error("expected non-empty group ID")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParse_ExtinfAttributes(t *testing.T) {
|
||||||
|
content := `#EXTM3U
|
||||||
|
#EXTINF:-1 tvg-id="abc" tvg-logo="logo.png" group-title="HD",HD Channel
|
||||||
|
http://example.com/hd.m3u8
|
||||||
|
`
|
||||||
|
pls := &Playlist{Content: content}
|
||||||
|
pls.Parse()
|
||||||
|
for _, ch := range pls.Channels {
|
||||||
|
if ch.Attributes["tvg-id"] != "abc" {
|
||||||
|
t.Errorf("expected tvg-id=abc, got %s", ch.Attributes["tvg-id"])
|
||||||
|
}
|
||||||
|
if ch.Attributes["tvg-logo"] != "logo.png" {
|
||||||
|
t.Errorf("expected tvg-logo=logo.png, got %s", ch.Attributes["tvg-logo"])
|
||||||
|
}
|
||||||
|
if ch.Title != "HD Channel" {
|
||||||
|
t.Errorf("expected title 'HD Channel', got '%s'", ch.Title)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParse_ChannelWithoutTitle(t *testing.T) {
|
||||||
|
content := `#EXTM3U
|
||||||
|
#EXTINF:-1 tvg-id="notitle"
|
||||||
|
http://example.com/notitle.m3u8
|
||||||
|
`
|
||||||
|
pls := &Playlist{Content: content}
|
||||||
|
pls.Parse()
|
||||||
|
for _, ch := range pls.Channels {
|
||||||
|
if ch.Title == "" {
|
||||||
|
t.Error("expected non-empty title for channel without name")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParse_Extm3uAttributes(t *testing.T) {
|
||||||
|
content := `#EXTM3U url-tvg="http://example.com/epg.xml"
|
||||||
|
#EXTINF:-1,Test
|
||||||
|
http://example.com/test.m3u8
|
||||||
|
`
|
||||||
|
pls := &Playlist{Content: content}
|
||||||
|
pls.Parse()
|
||||||
|
if pls.Attributes["url-tvg"] != "http://example.com/epg.xml" {
|
||||||
|
t.Errorf("expected url-tvg attr, got %s", pls.Attributes["url-tvg"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMakeFromUrl(t *testing.T) {
|
||||||
|
pls, err := MakeFromUrl("http://example.com/list.m3u8")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if pls.Url != "http://example.com/list.m3u8" {
|
||||||
|
t.Errorf("expected URL, got %s", pls.Url)
|
||||||
|
}
|
||||||
|
if pls.Source != "-u" {
|
||||||
|
t.Errorf("expected source '-u', got '%s'", pls.Source)
|
||||||
|
}
|
||||||
|
if !pls.IsOnline {
|
||||||
|
t.Error("expected IsOnline=true")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMakeFromFile_NotFound(t *testing.T) {
|
||||||
|
_, err := MakeFromFile("/nonexistent/file.m3u")
|
||||||
|
if err == nil {
|
||||||
|
t.Error("expected error for non-existent file")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMakeFromFile_Success(t *testing.T) {
|
||||||
|
tmp := t.TempDir()
|
||||||
|
path := filepath.Join(tmp, "test.m3u")
|
||||||
|
content := "#EXTM3U\n#EXTINF:-1,Test\nhttp://example.com/test.m3u8\n"
|
||||||
|
if err := os.WriteFile(path, []byte(content), 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
pls, err := MakeFromFile(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if pls.Source != "-f" {
|
||||||
|
t.Errorf("expected source '-f', got '%s'", pls.Source)
|
||||||
|
}
|
||||||
|
if pls.Content != content {
|
||||||
|
t.Error("content mismatch")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReadFromFs(t *testing.T) {
|
||||||
|
tmp := t.TempDir()
|
||||||
|
path := filepath.Join(tmp, "test.m3u")
|
||||||
|
content := "#EXTM3U\n"
|
||||||
|
if err := os.WriteFile(path, []byte(content), 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
pls := &Playlist{Url: path}
|
||||||
|
if err := pls.ReadFromFs(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if pls.Content != content {
|
||||||
|
t.Errorf("expected '%s', got '%s'", content, pls.Content)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReadFromFs_NotFound(t *testing.T) {
|
||||||
|
pls := &Playlist{Url: "/nonexistent/file.m3u"}
|
||||||
|
err := pls.ReadFromFs()
|
||||||
|
if err == nil {
|
||||||
|
t.Error("expected error for non-existent file")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestParseConcurrent проверяет, что парсинг нескольких плейлистов
|
||||||
|
// в параллельных горутинах не приводит к потере каналов из-за data race.
|
||||||
|
// До фикса tmpChannel была пакетной переменной, и каналы терялись.
|
||||||
|
func TestParseConcurrent(t *testing.T) {
|
||||||
|
const goroutines = 20
|
||||||
|
const channelsPerPlaylist = 50
|
||||||
|
|
||||||
|
// генерируем плейлист с channelsPerPlaylist каналами
|
||||||
|
content := "#EXTM3U\n"
|
||||||
|
for i := 0; i < channelsPerPlaylist; i++ {
|
||||||
|
content += fmt.Sprintf("#EXTINF:-1 tvg-id=\"ch%d\",Channel %d\n", i, i)
|
||||||
|
content += fmt.Sprintf("http://example.com/stream%d.m3u8\n", i)
|
||||||
|
}
|
||||||
|
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
wg.Add(goroutines)
|
||||||
|
lost := make([]int, goroutines)
|
||||||
|
|
||||||
|
for i := 0; i < goroutines; i++ {
|
||||||
|
go func(idx int) {
|
||||||
|
defer wg.Done()
|
||||||
|
pls := &Playlist{Content: content}
|
||||||
|
pls.Parse()
|
||||||
|
lost[idx] = channelsPerPlaylist - len(pls.Channels)
|
||||||
|
}(i)
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
|
||||||
|
totalLost := 0
|
||||||
|
for _, l := range lost {
|
||||||
|
totalLost += l
|
||||||
|
}
|
||||||
|
if totalLost > 0 {
|
||||||
|
t.Errorf("data race detected: %d channels lost across %d goroutines (per-goroutine losses: %v)",
|
||||||
|
totalLost, goroutines, lost)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
/*
|
/*
|
||||||
* Copyright (c) 2025, Антон Аксенов
|
* Copyright (c) 2025-2026, Антон Аксенов
|
||||||
* This file is part of iptvc project
|
* This file is part of iptvc project
|
||||||
* MIT License: https://git.axenov.dev/IPTV/iptvc/src/branch/master/LICENSE
|
* MIT License: https://git.axenov.dev/IPTV/iptvc/src/branch/master/LICENSE
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -0,0 +1,163 @@
|
|||||||
|
package tagfile
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"axenov/iptv-checker/app/playlist"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestGetTags_TvgId(t *testing.T) {
|
||||||
|
block := TagBlock{
|
||||||
|
TvgId: "^ru-",
|
||||||
|
Tags: []string{"russian"},
|
||||||
|
}
|
||||||
|
ch := playlist.Channel{
|
||||||
|
Attributes: map[string]string{"tvg-id": "ru-first"},
|
||||||
|
}
|
||||||
|
tags := block.GetTags(ch)
|
||||||
|
if len(tags) != 1 || tags[0] != "russian" {
|
||||||
|
t.Errorf("expected [russian], got %v", tags)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetTags_TvgId_NoMatch(t *testing.T) {
|
||||||
|
block := TagBlock{
|
||||||
|
TvgId: "^en-",
|
||||||
|
Tags: []string{"english"},
|
||||||
|
}
|
||||||
|
ch := playlist.Channel{
|
||||||
|
Attributes: map[string]string{"tvg-id": "ru-first"},
|
||||||
|
}
|
||||||
|
tags := block.GetTags(ch)
|
||||||
|
if len(tags) != 0 {
|
||||||
|
t.Errorf("expected empty, got %v", tags)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetTags_TvgId_NoAttribute(t *testing.T) {
|
||||||
|
block := TagBlock{
|
||||||
|
TvgId: "^ru-",
|
||||||
|
Tags: []string{"russian"},
|
||||||
|
}
|
||||||
|
ch := playlist.Channel{
|
||||||
|
Attributes: map[string]string{},
|
||||||
|
}
|
||||||
|
tags := block.GetTags(ch)
|
||||||
|
if len(tags) != 0 {
|
||||||
|
t.Errorf("expected empty, got %v", tags)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetTags_TvgName(t *testing.T) {
|
||||||
|
block := TagBlock{
|
||||||
|
TvgName: "sport",
|
||||||
|
Tags: []string{"sports"},
|
||||||
|
}
|
||||||
|
ch := playlist.Channel{
|
||||||
|
Attributes: map[string]string{"tvg-name": "Sports Channel"},
|
||||||
|
}
|
||||||
|
tags := block.GetTags(ch)
|
||||||
|
if len(tags) != 1 || tags[0] != "sports" {
|
||||||
|
t.Errorf("expected [sports], got %v", tags)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetTags_Title(t *testing.T) {
|
||||||
|
block := TagBlock{
|
||||||
|
Title: "movie",
|
||||||
|
Tags: []string{"movies"},
|
||||||
|
}
|
||||||
|
ch := playlist.Channel{
|
||||||
|
Title: "Best Movie HD",
|
||||||
|
}
|
||||||
|
tags := block.GetTags(ch)
|
||||||
|
if len(tags) != 1 || tags[0] != "movies" {
|
||||||
|
t.Errorf("expected [movies], got %v", tags)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetTags_TitleCaseInsensitive(t *testing.T) {
|
||||||
|
block := TagBlock{
|
||||||
|
Title: "hd",
|
||||||
|
Tags: []string{"hd"},
|
||||||
|
}
|
||||||
|
ch := playlist.Channel{
|
||||||
|
Title: "some HD channel",
|
||||||
|
}
|
||||||
|
tags := block.GetTags(ch)
|
||||||
|
if len(tags) != 1 || tags[0] != "hd" {
|
||||||
|
t.Errorf("expected [hd], got %v", tags)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetTags_EmptyBlock(t *testing.T) {
|
||||||
|
block := TagBlock{}
|
||||||
|
ch := playlist.Channel{
|
||||||
|
Title: "Test",
|
||||||
|
Attributes: map[string]string{},
|
||||||
|
}
|
||||||
|
tags := block.GetTags(ch)
|
||||||
|
if len(tags) != 0 {
|
||||||
|
t.Errorf("expected empty, got %v", tags)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetTags_InvalidRegex(t *testing.T) {
|
||||||
|
block := TagBlock{
|
||||||
|
TvgId: "[invalid",
|
||||||
|
Tags: []string{"bad"},
|
||||||
|
}
|
||||||
|
ch := playlist.Channel{
|
||||||
|
Attributes: map[string]string{"tvg-id": "test"},
|
||||||
|
}
|
||||||
|
tags := block.GetTags(ch)
|
||||||
|
if len(tags) != 0 {
|
||||||
|
t.Errorf("expected empty for invalid regex, got %v", tags)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetTags_TvgIdEmptyValue(t *testing.T) {
|
||||||
|
block := TagBlock{
|
||||||
|
TvgId: "test",
|
||||||
|
Tags: []string{"tag"},
|
||||||
|
}
|
||||||
|
ch := playlist.Channel{
|
||||||
|
Attributes: map[string]string{"tvg-id": ""},
|
||||||
|
}
|
||||||
|
tags := block.GetTags(ch)
|
||||||
|
if len(tags) != 0 {
|
||||||
|
t.Errorf("expected empty for empty tvg-id value, got %v", tags)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInit_Success(t *testing.T) {
|
||||||
|
tmp := t.TempDir()
|
||||||
|
path := filepath.Join(tmp, "channels.json")
|
||||||
|
data := `[{"tvg-id":"^ru-","tags":["russian"]},{"title":"movie","tags":["movies"]}]`
|
||||||
|
if err := os.WriteFile(path, []byte(data), 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
blocks := Init(path)
|
||||||
|
if len(blocks) != 2 {
|
||||||
|
t.Fatalf("expected 2 blocks, got %d", len(blocks))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInit_NotFound(t *testing.T) {
|
||||||
|
blocks := Init("/nonexistent/channels.json")
|
||||||
|
if blocks != nil {
|
||||||
|
t.Errorf("expected nil for non-existent file, got %v", blocks)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInit_InvalidJSON(t *testing.T) {
|
||||||
|
tmp := t.TempDir()
|
||||||
|
path := filepath.Join(tmp, "channels.json")
|
||||||
|
os.WriteFile(path, []byte("not json"), 0644)
|
||||||
|
blocks := Init(path)
|
||||||
|
if blocks != nil {
|
||||||
|
t.Errorf("expected nil for invalid JSON, got %v", blocks)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
/*
|
/*
|
||||||
* Copyright (c) 2025, Антон Аксенов
|
* Copyright (c) 2025-2026, Антон Аксенов
|
||||||
* This file is part of iptvc project
|
* This file is part of iptvc project
|
||||||
* MIT License: https://git.axenov.dev/IPTV/iptvc/src/branch/master/LICENSE
|
* MIT License: https://git.axenov.dev/IPTV/iptvc/src/branch/master/LICENSE
|
||||||
*/
|
*/
|
||||||
@@ -44,16 +44,19 @@ func ArrayUnique(arr []string) []string {
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch выполняет GET запрос и возвращает результат в виде массива байт
|
// Fetch выполняет GET запрос и возвращает результат в виде массива байт.
|
||||||
func Fetch(url string) ([]byte, error) {
|
// userAgent — значение заголовка User-Agent, timeout — таймаут запроса.
|
||||||
|
func Fetch(url string, userAgent string, timeout time.Duration) ([]byte, error) {
|
||||||
req, err := http.NewRequest("GET", url, nil)
|
req, err := http.NewRequest("GET", url, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
req.Header.Set("User-Agent", "Mozilla/5.0 WINK/1.31.1 (AndroidTV/9) HlsWinkPlayer")
|
if userAgent != "" {
|
||||||
|
req.Header.Set("User-Agent", userAgent)
|
||||||
|
}
|
||||||
http.DefaultTransport.(*http.Transport).TLSClientConfig = &tls.Config{InsecureSkipVerify: true}
|
http.DefaultTransport.(*http.Transport).TLSClientConfig = &tls.Config{InsecureSkipVerify: true}
|
||||||
httpClient := http.Client{Timeout: 10 * time.Second}
|
httpClient := http.Client{Timeout: timeout}
|
||||||
resp, err := httpClient.Do(req)
|
resp, err := httpClient.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|||||||
@@ -0,0 +1,155 @@
|
|||||||
|
package utils
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestMd5str(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
in string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"", "d41d8cd98f00b204e9800998ecf8427e"},
|
||||||
|
{"hello", "5d41402abc4b2a76b9719d911017c592"},
|
||||||
|
{"test", "098f6bcd4621d373cade4e832627b4f6"},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
got := Md5str(c.in)
|
||||||
|
if got != c.want {
|
||||||
|
t.Errorf("Md5str(%q) = %s, want %s", c.in, got, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMd5str_Consistency(t *testing.T) {
|
||||||
|
h1 := Md5str("same")
|
||||||
|
h2 := Md5str("same")
|
||||||
|
if h1 != h2 {
|
||||||
|
t.Error("Md5str should be deterministic")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestArrayUnique(t *testing.T) {
|
||||||
|
in := []string{"a", "b", "a", "c", "b", "d"}
|
||||||
|
got := ArrayUnique(in)
|
||||||
|
if len(got) != 4 {
|
||||||
|
t.Fatalf("expected 4, got %d: %v", len(got), got)
|
||||||
|
}
|
||||||
|
seen := map[string]bool{}
|
||||||
|
for _, v := range got {
|
||||||
|
if seen[v] {
|
||||||
|
t.Errorf("duplicate found: %s", v)
|
||||||
|
}
|
||||||
|
seen[v] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestArrayUnique_Empty(t *testing.T) {
|
||||||
|
got := ArrayUnique([]string{})
|
||||||
|
if len(got) != 0 {
|
||||||
|
t.Errorf("expected empty, got %v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestArrayUnique_Single(t *testing.T) {
|
||||||
|
got := ArrayUnique([]string{"only"})
|
||||||
|
if len(got) != 1 || got[0] != "only" {
|
||||||
|
t.Errorf("expected [only], got %v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestArrayUnique_AllSame(t *testing.T) {
|
||||||
|
got := ArrayUnique([]string{"x", "x", "x"})
|
||||||
|
if len(got) != 1 || got[0] != "x" {
|
||||||
|
t.Errorf("expected [x], got %v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExpandPath_Relative(t *testing.T) {
|
||||||
|
got, err := ExpandPath("foo/bar")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got == "" {
|
||||||
|
t.Error("expected non-empty path")
|
||||||
|
}
|
||||||
|
// should be absolute
|
||||||
|
if got[0] != '/' {
|
||||||
|
t.Errorf("expected absolute path, got %s", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExpandPath_Tilde(t *testing.T) {
|
||||||
|
got, err := ExpandPath("~/test")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got == "" {
|
||||||
|
t.Error("expected non-empty path")
|
||||||
|
}
|
||||||
|
// should not contain ~
|
||||||
|
for _, c := range got {
|
||||||
|
if c == '~' {
|
||||||
|
t.Error("path should not contain ~")
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFetch_Success(t *testing.T) {
|
||||||
|
body := "hello world"
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if ua := r.Header.Get("User-Agent"); ua != "TestUA" {
|
||||||
|
t.Errorf("expected User-Agent 'TestUA', got '%s'", ua)
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
w.Write([]byte(body))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
data, err := Fetch(srv.URL, "TestUA", 5*time.Second)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if string(data) != body {
|
||||||
|
t.Errorf("expected '%s', got '%s'", body, string(data))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFetch_NonOK(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusNotFound)
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
_, err := Fetch(srv.URL, "", 5*time.Second)
|
||||||
|
if err == nil {
|
||||||
|
t.Error("expected error for non-200 status")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFetch_EmptyUserAgent(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
w.Write([]byte("ok"))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
data, err := Fetch(srv.URL, "", 5*time.Second)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if string(data) != "ok" {
|
||||||
|
t.Errorf("expected 'ok', got '%s'", string(data))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFetch_InvalidURL(t *testing.T) {
|
||||||
|
_, err := Fetch("http://[::1]:named", "", 1*time.Second)
|
||||||
|
if err == nil {
|
||||||
|
t.Error("expected error for invalid URL")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,255 @@
|
|||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"axenov/iptv-checker/app/config"
|
||||||
|
"axenov/iptv-checker/app/playlist"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// makeTestServer создаёт HTTP-сервер с тестовыми плейлистами в memCache.
|
||||||
|
// Возвращает сервер и обёртку для добавления плейлистов в memCache.
|
||||||
|
func makeTestServer(t *testing.T) (*httptest.Server, *Server) {
|
||||||
|
t.Helper()
|
||||||
|
cfg := config.Init("")
|
||||||
|
cfg.App.Playlists = "testdata/playlists.ini"
|
||||||
|
|
||||||
|
srv := NewServer(cfg, nil)
|
||||||
|
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
// API routes
|
||||||
|
mux.HandleFunc("GET /api/playlists", srv.handleAPIGetAll)
|
||||||
|
mux.HandleFunc("GET /api/playlists/{code}", srv.handleAPIGetOne)
|
||||||
|
mux.HandleFunc("GET /api/playlists/{code}/channels", srv.handleAPIGetChannels)
|
||||||
|
mux.HandleFunc("GET /api/version", srv.handleAPIVersion)
|
||||||
|
mux.HandleFunc("GET /api/health", srv.handleAPIHealth)
|
||||||
|
mux.HandleFunc("GET /api/stats", srv.handleAPIStats)
|
||||||
|
mux.HandleFunc("GET /api/openapi.json", srv.handleOpenAPISpec)
|
||||||
|
mux.HandleFunc("GET /api", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
http.Redirect(w, r, "/api/", http.StatusMovedPermanently)
|
||||||
|
})
|
||||||
|
mux.Handle("GET /api/", http.StripPrefix("/api/", swaggerFileServer))
|
||||||
|
// page routes
|
||||||
|
mux.HandleFunc("GET /{$}", srv.handleHome)
|
||||||
|
mux.HandleFunc("GET /{path...}", srv.handleRoute)
|
||||||
|
ts := httptest.NewServer(mux)
|
||||||
|
t.Cleanup(ts.Close)
|
||||||
|
return ts, srv
|
||||||
|
}
|
||||||
|
|
||||||
|
// putPlaylist кладёт плейлист в in-memory кеш сервера
|
||||||
|
func (s *Server) putPlaylist(pls playlist.Playlist) {
|
||||||
|
s.memMu.Lock()
|
||||||
|
defer s.memMu.Unlock()
|
||||||
|
if s.memCache == nil {
|
||||||
|
s.memCache = make(map[string]playlist.Playlist)
|
||||||
|
}
|
||||||
|
s.memCache[pls.Code] = pls
|
||||||
|
}
|
||||||
|
|
||||||
|
// makeOnlinePlaylist создаёт тестовый плейлист с онлайн/оффлайн каналами
|
||||||
|
func makeOnlinePlaylist() playlist.Playlist {
|
||||||
|
return playlist.Playlist{
|
||||||
|
Code: "test1",
|
||||||
|
Name: "Test Playlist 1",
|
||||||
|
Description: "A test playlist",
|
||||||
|
Url: "http://example.com/test1.m3u8",
|
||||||
|
Source: "test source",
|
||||||
|
IsOnline: true,
|
||||||
|
CheckedAt: 1700000000,
|
||||||
|
Attributes: map[string]string{"url-tvg": "http://example.com/epg.xml"},
|
||||||
|
Groups: map[string]playlist.Group{
|
||||||
|
"group1": {Id: "group1", Name: "Group One"},
|
||||||
|
"group2": {Id: "group2", Name: "Group Two"},
|
||||||
|
},
|
||||||
|
Channels: map[string]playlist.Channel{
|
||||||
|
"id1": {
|
||||||
|
Id: "id1",
|
||||||
|
Title: "Channel One",
|
||||||
|
URL: "http://example.com/stream1.m3u8",
|
||||||
|
GroupId: "group1",
|
||||||
|
Attributes: map[string]string{"tvg-id": "ch1", "tvg-logo": "http://example.com/logo1.png"},
|
||||||
|
Status: 200,
|
||||||
|
IsOnline: true,
|
||||||
|
Tags: []string{"hd"},
|
||||||
|
},
|
||||||
|
"id2": {
|
||||||
|
Id: "id2",
|
||||||
|
Title: "Channel Two",
|
||||||
|
URL: "http://example.com/stream2.m3u8",
|
||||||
|
GroupId: "group2",
|
||||||
|
Attributes: map[string]string{"tvg-id": "ch2", "tvg-logo": "http://example.com/logo2.png"},
|
||||||
|
Status: 404,
|
||||||
|
IsOnline: false,
|
||||||
|
Tags: []string{"untagged"},
|
||||||
|
},
|
||||||
|
"id3": {
|
||||||
|
Id: "id3",
|
||||||
|
Title: "Channel Three",
|
||||||
|
URL: "http://example.com/stream3.m3u8",
|
||||||
|
GroupId: "group1",
|
||||||
|
Attributes: map[string]string{"tvg-id": "ch3", "tvg-logo": "http://example.com/logo3.png"},
|
||||||
|
Status: 200,
|
||||||
|
IsOnline: true,
|
||||||
|
Tags: []string{"hd", "news"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
OnlineCount: 2,
|
||||||
|
OfflineCount: 1,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// fetchBody читает тело HTTP-ответа целиком и возвращает его как строку
|
||||||
|
func fetchBody(t *testing.T, resp *http.Response) string {
|
||||||
|
t.Helper()
|
||||||
|
defer resp.Body.Close()
|
||||||
|
b, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read body: %s", err)
|
||||||
|
}
|
||||||
|
return string(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDetailsPageRendersChannelsTable проверяет, что на странице деталей
|
||||||
|
// онлайн-плейлиста присутствует таблица с каналами и данными для list.js
|
||||||
|
func TestDetailsPageRendersChannelsTable(t *testing.T) {
|
||||||
|
ts, srv := makeTestServer(t)
|
||||||
|
srv.putPlaylist(makeOnlinePlaylist())
|
||||||
|
|
||||||
|
// Главная страница должна быть доступна
|
||||||
|
resp, err := http.Get(ts.URL + "/")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GET /: %s", err)
|
||||||
|
}
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
t.Errorf("GET /: expected 200, got %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
resp.Body.Close()
|
||||||
|
|
||||||
|
// Страница деталей — основной сценарий
|
||||||
|
resp, err = http.Get(ts.URL + "/test1/details")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GET /test1/details: %s", err)
|
||||||
|
}
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
t.Fatalf("GET /test1/details: expected 200, got %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
body := fetchBody(t, resp)
|
||||||
|
|
||||||
|
checks := []struct {
|
||||||
|
name string
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
// Заголовок и базовая структура
|
||||||
|
{"заголовок 'Список каналов'", strings.Contains(body, "Список каналов")},
|
||||||
|
{"id таблицы 'chlist'", strings.Contains(body, `id="chlist"`)},
|
||||||
|
{"класс 'list' у tbody", strings.Contains(body, `class="list"`)},
|
||||||
|
{"chcount = 3", strings.Contains(body, `<span id="chcount">3</span>`)},
|
||||||
|
|
||||||
|
// Данные каналов в JSON
|
||||||
|
{"values JSON", strings.Contains(body, "const values =")},
|
||||||
|
{"Channel One в JSON", strings.Contains(body, "Channel One")},
|
||||||
|
{"Channel Two в JSON", strings.Contains(body, "Channel Two")},
|
||||||
|
{"Channel Three в JSON", strings.Contains(body, "Channel Three")},
|
||||||
|
|
||||||
|
// Группы (отображаются когда > 1)
|
||||||
|
{"id группы group1", strings.Contains(body, `value="group1"`)},
|
||||||
|
{"id группы group2", strings.Contains(body, `value="group2"`)},
|
||||||
|
|
||||||
|
// Теги
|
||||||
|
{"тег #hd", strings.Contains(body, "btn-tag-hd")},
|
||||||
|
{"тег #news", strings.Contains(body, "btn-tag-news")},
|
||||||
|
{"тег #untagged", strings.Contains(body, "btn-tag-untagged")},
|
||||||
|
|
||||||
|
// list.js
|
||||||
|
{"list.js подключён", strings.Contains(body, "list.js@2.3.1")},
|
||||||
|
{"getChannelTemplate функция", strings.Contains(body, "function getChannelTemplate")},
|
||||||
|
{"опции list.js", strings.Contains(body, "const options =")},
|
||||||
|
{"new List('chlist')", strings.Contains(body, "new List('chlist'")},
|
||||||
|
{"getChannelTemplate возвращает <tr>", strings.Contains(body, "chrow")},
|
||||||
|
|
||||||
|
// Счётчики
|
||||||
|
{"OnlineCount отображается (~66%)", strings.Contains(body, "2 (66%)") || strings.Contains(body, "2 (67%)")},
|
||||||
|
{"OfflineCount отображается (~33%)", strings.Contains(body, "1 (33%)") || strings.Contains(body, "1 (34%)")},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, c := range checks {
|
||||||
|
if !c.want {
|
||||||
|
t.Errorf("page missing: %s", c.name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDetailsPageOfflinePlaylist проверяет, что для оффлайн-плейлиста
|
||||||
|
// (IsKnown=true, IsOnline=false) вместо таблицы каналов показывается
|
||||||
|
// сообщение об ошибке
|
||||||
|
func TestDetailsPageOfflinePlaylist(t *testing.T) {
|
||||||
|
ts, srv := makeTestServer(t)
|
||||||
|
srv.putPlaylist(playlist.Playlist{
|
||||||
|
Code: "test1",
|
||||||
|
Name: "Test Playlist 1",
|
||||||
|
Url: "http://example.com/test1.m3u8",
|
||||||
|
IsOnline: false,
|
||||||
|
Content: "HTTP status 404",
|
||||||
|
CheckedAt: 1700000000,
|
||||||
|
})
|
||||||
|
|
||||||
|
resp, err := http.Get(ts.URL + "/test1/details")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GET /test1/details: %s", err)
|
||||||
|
}
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
t.Fatalf("expected 200, got %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
body := fetchBody(t, resp)
|
||||||
|
|
||||||
|
if !strings.Contains(body, "Ошибка плейлиста") {
|
||||||
|
t.Error("offline playlist should show error message")
|
||||||
|
}
|
||||||
|
if !strings.Contains(body, "HTTP status 404") {
|
||||||
|
t.Error("offline playlist should show error text from Content")
|
||||||
|
}
|
||||||
|
if strings.Contains(body, `id="chlist"`) {
|
||||||
|
t.Error("offline playlist should NOT render channels table")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDetailsPageUnknownPlaylist проверяет, что для непроверенного плейлиста
|
||||||
|
// (IsKnown=false) вместо таблицы каналов показывается предупреждение
|
||||||
|
func TestDetailsPageUnknownPlaylist(t *testing.T) {
|
||||||
|
ts, _ := makeTestServer(t)
|
||||||
|
|
||||||
|
resp, err := http.Get(ts.URL + "/test1/details")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GET /test1/details: %s", err)
|
||||||
|
}
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
t.Fatalf("expected 200, got %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
body := fetchBody(t, resp)
|
||||||
|
|
||||||
|
if !strings.Contains(body, "Список каналов сейчас неизвестен") {
|
||||||
|
t.Error("unknown playlist should show 'list unknown' warning")
|
||||||
|
}
|
||||||
|
if strings.Contains(body, `id="chlist"`) {
|
||||||
|
t.Error("unknown playlist should NOT render channels table")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDetailsPageNonexistentPlaylist проверяет, что несуществующий код
|
||||||
|
// возвращает 404
|
||||||
|
func TestDetailsPageNonexistentPlaylist(t *testing.T) {
|
||||||
|
ts, _ := makeTestServer(t)
|
||||||
|
|
||||||
|
resp, err := http.Get(ts.URL + "/nonexistent/details")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GET /nonexistent/details: %s", err)
|
||||||
|
}
|
||||||
|
if resp.StatusCode != http.StatusNotFound {
|
||||||
|
t.Errorf("expected 404, got %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
resp.Body.Close()
|
||||||
|
}
|
||||||
@@ -0,0 +1,535 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2025-2026, Антон Аксенов
|
||||||
|
* This file is part of iptvc project
|
||||||
|
* MIT License: https://git.axenov.dev/IPTV/iptvc/src/branch/master/LICENSE
|
||||||
|
*/
|
||||||
|
|
||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"axenov/iptv-checker/app"
|
||||||
|
"axenov/iptv-checker/app/playlist"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"math"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// handleHome — главная страница со списком плейлистов
|
||||||
|
func (s *Server) handleHome(w http.ResponseWriter, r *http.Request) {
|
||||||
|
playlists, updatedAt, err := s.getPlaylistsView()
|
||||||
|
errorMsg := ""
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Error loading playlists: %s", err)
|
||||||
|
errorMsg = "Не удалось загрузить список плейлистов. Проверьте наличие файла playlists.ini."
|
||||||
|
playlists = []PlaylistView{}
|
||||||
|
updatedAt = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
count := len(playlists)
|
||||||
|
pageSize := int(s.cfg.Site.PageSize)
|
||||||
|
pageCurrent := 1
|
||||||
|
pageCount := 1
|
||||||
|
|
||||||
|
// pagination
|
||||||
|
if pageSize > 0 && count > pageSize {
|
||||||
|
pageCount = int(math.Ceil(float64(count) / float64(pageSize)))
|
||||||
|
if pageArg := r.PathValue("page"); pageArg != "" {
|
||||||
|
if p, err := strconv.Atoi(pageArg); err == nil && p >= 1 && p <= pageCount {
|
||||||
|
pageCurrent = p
|
||||||
|
}
|
||||||
|
}
|
||||||
|
offset := (pageCurrent - 1) * pageSize
|
||||||
|
if offset >= count {
|
||||||
|
offset = 0
|
||||||
|
pageCurrent = 1
|
||||||
|
}
|
||||||
|
end := offset + pageSize
|
||||||
|
if end > count {
|
||||||
|
end = count
|
||||||
|
}
|
||||||
|
playlists = playlists[offset:end]
|
||||||
|
}
|
||||||
|
|
||||||
|
data := &PageData{
|
||||||
|
Title: s.cfg.Site.Header.Title,
|
||||||
|
BaseUrl: s.cfg.Site.BaseUrl,
|
||||||
|
RepoUrl: s.cfg.Site.RepoUrl,
|
||||||
|
Version: app.VERSION,
|
||||||
|
UpdatedAt: updatedAt,
|
||||||
|
UpdatedAtTZ: s.getTimezoneLabel(),
|
||||||
|
Meta: s.cfg.Site.Meta,
|
||||||
|
Menu: s.cfg.Site.Header.Menu,
|
||||||
|
FooterLinks: s.cfg.Site.Footer.Links,
|
||||||
|
Tabs: s.cfg.Site.Tabs,
|
||||||
|
Playlists: playlists,
|
||||||
|
Count: count,
|
||||||
|
PageCount: pageCount,
|
||||||
|
PageCurrent: pageCurrent,
|
||||||
|
Error: errorMsg,
|
||||||
|
}
|
||||||
|
|
||||||
|
s.templates.Render(w, "list", data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleDetails — страница с описанием плейлиста
|
||||||
|
func (s *Server) handleDetails(w http.ResponseWriter, r *http.Request) {
|
||||||
|
code := r.PathValue("code")
|
||||||
|
if code == "" {
|
||||||
|
s.renderNotFound(w, r, "")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
view, ok := s.getPlaylistView(code)
|
||||||
|
if !ok {
|
||||||
|
s.renderNotFound(w, r, code)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
data := &PageData{
|
||||||
|
Title: s.cfg.Site.Header.Title,
|
||||||
|
BaseUrl: s.cfg.Site.BaseUrl,
|
||||||
|
RepoUrl: s.cfg.Site.RepoUrl,
|
||||||
|
Version: app.VERSION,
|
||||||
|
Meta: s.cfg.Site.Meta,
|
||||||
|
Menu: s.cfg.Site.Header.Menu,
|
||||||
|
FooterLinks: s.cfg.Site.Footer.Links,
|
||||||
|
Tabs: s.cfg.Site.Tabs,
|
||||||
|
Playlist: view,
|
||||||
|
}
|
||||||
|
|
||||||
|
s.templates.Render(w, "details", data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleRoute — единый обработчик для не-API маршрутов
|
||||||
|
// Разбирает путь и направляет на нужный обработчик:
|
||||||
|
// /page/N → главная с пагинацией
|
||||||
|
// /CODE/details → страница плейлиста
|
||||||
|
// /CODE или /CODE.m3u[8] → редирект на URL плейлиста
|
||||||
|
// остальное → 404
|
||||||
|
func (s *Server) handleRoute(w http.ResponseWriter, r *http.Request) {
|
||||||
|
path := r.PathValue("path")
|
||||||
|
if path == "" {
|
||||||
|
s.handleHome(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
segments := strings.Split(path, "/")
|
||||||
|
|
||||||
|
switch len(segments) {
|
||||||
|
case 1:
|
||||||
|
// /page/N → пагинация
|
||||||
|
if segments[0] == "page" {
|
||||||
|
s.renderNotFound(w, r, "")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(segments[0], "page/") {
|
||||||
|
// shouldn't happen with single segment, but just in case
|
||||||
|
pageStr := strings.TrimPrefix(segments[0], "page/")
|
||||||
|
if _, err := strconv.Atoi(pageStr); err == nil {
|
||||||
|
r.SetPathValue("page", pageStr)
|
||||||
|
s.handleHome(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// /CODE or /CODE.m3u[8] → redirect
|
||||||
|
s.handleRedirect(w, r, segments[0])
|
||||||
|
|
||||||
|
case 2:
|
||||||
|
// /page/N → пагинация
|
||||||
|
if segments[0] == "page" {
|
||||||
|
if _, err := strconv.Atoi(segments[1]); err == nil {
|
||||||
|
r.SetPathValue("page", segments[1])
|
||||||
|
s.handleHome(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.renderNotFound(w, r, "")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// /CODE/details → страница плейлиста
|
||||||
|
if segments[1] == "details" {
|
||||||
|
r.SetPathValue("code", segments[0])
|
||||||
|
s.handleDetails(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.renderNotFound(w, r, segments[0])
|
||||||
|
|
||||||
|
default:
|
||||||
|
s.renderNotFound(w, r, "")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleRedirect — перенаправляет на URL плейлиста по коду
|
||||||
|
func (s *Server) handleRedirect(w http.ResponseWriter, r *http.Request, path string) {
|
||||||
|
// strip .m3u / .m3u8 suffix
|
||||||
|
code := path
|
||||||
|
code = strings.TrimSuffix(code, ".m3u8")
|
||||||
|
code = strings.TrimSuffix(code, ".m3u")
|
||||||
|
|
||||||
|
// validate: alphanumeric
|
||||||
|
if !isAlphanumeric(code) {
|
||||||
|
s.renderNotFound(w, r, code)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// look up in ini
|
||||||
|
ini, err := s.loadIni()
|
||||||
|
if err != nil {
|
||||||
|
s.renderNotFound(w, r, code)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
pls, exists := ini.Lists[code]
|
||||||
|
if !exists || pls.Url == "" {
|
||||||
|
s.renderNotFound(w, r, code)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
http.Redirect(w, r, pls.Url, http.StatusFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleAPIGetAll — API: список плейлистов
|
||||||
|
func (s *Server) handleAPIGetAll(w http.ResponseWriter, r *http.Request) {
|
||||||
|
playlists, _, err := s.getPlaylistsView()
|
||||||
|
if err != nil {
|
||||||
|
s.jsonError(w, http.StatusInternalServerError, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
items := make([]map[string]interface{}, 0, len(playlists))
|
||||||
|
for _, view := range playlists {
|
||||||
|
items = append(items, buildAPIPlaylist(view))
|
||||||
|
}
|
||||||
|
|
||||||
|
s.jsonResponse(w, http.StatusOK, map[string]interface{}{
|
||||||
|
"items": items,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleAPIGetOne — API: информация о плейлисте
|
||||||
|
func (s *Server) handleAPIGetOne(w http.ResponseWriter, r *http.Request) {
|
||||||
|
code := r.PathValue("code")
|
||||||
|
if code == "" {
|
||||||
|
s.jsonError(w, http.StatusBadRequest, fmt.Errorf("code is required"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
view, ok := s.getPlaylistView(code)
|
||||||
|
if !ok {
|
||||||
|
s.jsonError(w, http.StatusNotFound, fmt.Errorf("playlist '%s' not found", code))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
s.jsonResponse(w, http.StatusOK, buildAPIPlaylist(*view))
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleAPIGetChannels — API: каналы плейлиста
|
||||||
|
func (s *Server) handleAPIGetChannels(w http.ResponseWriter, r *http.Request) {
|
||||||
|
code := r.PathValue("code")
|
||||||
|
if code == "" {
|
||||||
|
s.jsonError(w, http.StatusBadRequest, fmt.Errorf("code is required"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
view, ok := s.getPlaylistView(code)
|
||||||
|
if !ok {
|
||||||
|
s.jsonError(w, http.StatusNotFound, fmt.Errorf("playlist '%s' not found", code))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
channels := make([]map[string]interface{}, 0, len(view.Channels))
|
||||||
|
for _, ch := range view.ViewChannels {
|
||||||
|
channels = append(channels, map[string]interface{}{
|
||||||
|
"id": ch.Id,
|
||||||
|
"title": ch.Title,
|
||||||
|
"url": ch.URL,
|
||||||
|
"groupId": ch.GroupId,
|
||||||
|
"attributes": mapToAttributeArray(ch.Attributes),
|
||||||
|
"status": ch.Status,
|
||||||
|
"isOnline": ch.IsOnline,
|
||||||
|
"error": ch.Error,
|
||||||
|
"contentType": ch.ContentType,
|
||||||
|
"tags": ch.Tags,
|
||||||
|
"checkedAt": ch.CheckedAt,
|
||||||
|
"hasToken": ch.HasToken,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
s.jsonResponse(w, http.StatusOK, map[string]interface{}{
|
||||||
|
"items": channels,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildAPIPlaylist формирует JSON-представление плейлиста для API
|
||||||
|
func buildAPIPlaylist(view PlaylistView) map[string]interface{} {
|
||||||
|
resp := map[string]interface{}{
|
||||||
|
"code": view.Code,
|
||||||
|
"name": view.Name,
|
||||||
|
"description": view.Description,
|
||||||
|
"url": view.Url,
|
||||||
|
"source": view.Source,
|
||||||
|
"isOnline": nil,
|
||||||
|
"onlineCount": view.OnlineCount,
|
||||||
|
"offlineCount": view.OfflineCount,
|
||||||
|
"checkedAt": view.CheckedAt,
|
||||||
|
"attributes": mapToAttributeArray(view.Attributes),
|
||||||
|
"groups": groupsToArray(view.Groups),
|
||||||
|
"tags": view.Tags,
|
||||||
|
}
|
||||||
|
|
||||||
|
if view.IsKnown {
|
||||||
|
resp["isOnline"] = view.IsOnline
|
||||||
|
if view.IsOnline {
|
||||||
|
delete(resp, "content")
|
||||||
|
} else {
|
||||||
|
resp["content"] = view.Content
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return resp
|
||||||
|
}
|
||||||
|
|
||||||
|
// mapToAttributeArray преобразует map атрибутов в массив {name, value}
|
||||||
|
func mapToAttributeArray(attrs map[string]string) []map[string]string {
|
||||||
|
result := make([]map[string]string, 0, len(attrs))
|
||||||
|
for name, value := range attrs {
|
||||||
|
result = append(result, map[string]string{
|
||||||
|
"name": name,
|
||||||
|
"value": value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// groupsToArray преобразует map групп в массив объектов
|
||||||
|
func groupsToArray(groups map[string]playlist.Group) []map[string]interface{} {
|
||||||
|
result := make([]map[string]interface{}, 0, len(groups))
|
||||||
|
for _, g := range groups {
|
||||||
|
result = append(result, map[string]interface{}{
|
||||||
|
"id": g.Id,
|
||||||
|
"name": g.Name,
|
||||||
|
"attributes": mapToAttributeArray(g.Attributes),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleAPIVersion — API: версия
|
||||||
|
func (s *Server) handleAPIVersion(w http.ResponseWriter, r *http.Request) {
|
||||||
|
resp := map[string]interface{}{
|
||||||
|
"iptvc": app.VERSION,
|
||||||
|
}
|
||||||
|
|
||||||
|
if s.cache != nil {
|
||||||
|
ctx := r.Context()
|
||||||
|
info, err := s.cache.Info(ctx, "server").Result()
|
||||||
|
if err == nil {
|
||||||
|
resp["keydb"] = parseRedisVersion(info)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
s.jsonResponse(w, http.StatusOK, resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleAPIHealth — API: состояние
|
||||||
|
func (s *Server) handleAPIHealth(w http.ResponseWriter, r *http.Request) {
|
||||||
|
health := map[string]interface{}{
|
||||||
|
"timestamp": time.Now().Unix(),
|
||||||
|
}
|
||||||
|
|
||||||
|
if s.cache != nil {
|
||||||
|
ctx := r.Context()
|
||||||
|
health["redis"] = map[string]interface{}{
|
||||||
|
"isConnected": s.cache.Ping(ctx).Err() == nil,
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
health["redis"] = map[string]interface{}{
|
||||||
|
"isConnected": false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
health["iniFile"] = map[string]interface{}{
|
||||||
|
"path": s.cfg.App.Playlists,
|
||||||
|
}
|
||||||
|
|
||||||
|
s.jsonResponse(w, http.StatusOK, health)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleAPIStats — API: статистика
|
||||||
|
func (s *Server) handleAPIStats(w http.ResponseWriter, r *http.Request) {
|
||||||
|
playlists, _, err := s.getPlaylistsView()
|
||||||
|
if err != nil {
|
||||||
|
s.jsonError(w, http.StatusInternalServerError, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
stats := computeStats(playlists)
|
||||||
|
s.jsonResponse(w, http.StatusOK, stats)
|
||||||
|
}
|
||||||
|
|
||||||
|
// renderNotFound — отображает страницу 404
|
||||||
|
func (s *Server) renderNotFound(w http.ResponseWriter, r *http.Request, code string) {
|
||||||
|
w.WriteHeader(http.StatusNotFound)
|
||||||
|
data := &PageData{
|
||||||
|
Title: s.cfg.Site.Header.Title,
|
||||||
|
BaseUrl: s.cfg.Site.BaseUrl,
|
||||||
|
RepoUrl: s.cfg.Site.RepoUrl,
|
||||||
|
Version: app.VERSION,
|
||||||
|
Meta: s.cfg.Site.Meta,
|
||||||
|
Menu: s.cfg.Site.Header.Menu,
|
||||||
|
FooterLinks: s.cfg.Site.Footer.Links,
|
||||||
|
Tabs: s.cfg.Site.Tabs,
|
||||||
|
Code: code,
|
||||||
|
}
|
||||||
|
s.templates.Render(w, "notfound", data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// jsonResponse отправляет JSON-ответ
|
||||||
|
func (s *Server) jsonResponse(w http.ResponseWriter, status int, data interface{}) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(status)
|
||||||
|
resp := map[string]interface{}{
|
||||||
|
"timestamp": time.Now().Unix(),
|
||||||
|
}
|
||||||
|
if m, ok := data.(map[string]interface{}); ok {
|
||||||
|
for k, v := range m {
|
||||||
|
resp[k] = v
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
resp["data"] = data
|
||||||
|
}
|
||||||
|
json.NewEncoder(w).Encode(resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
// jsonError отправляет JSON-ошибку
|
||||||
|
func (s *Server) jsonError(w http.ResponseWriter, status int, err error) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(status)
|
||||||
|
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||||
|
"timestamp": time.Now().Unix(),
|
||||||
|
"error": map[string]string{
|
||||||
|
"message": err.Error(),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// isAlphanumeric проверяет, состоит ли строка только из букв и цифр
|
||||||
|
func isAlphanumeric(s string) bool {
|
||||||
|
for _, c := range s {
|
||||||
|
if !((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9')) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return len(s) > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseRedisVersion извлекает версию Redis из INFO-ответа
|
||||||
|
func parseRedisVersion(info string) string {
|
||||||
|
for _, line := range strings.Split(info, "\n") {
|
||||||
|
line = strings.TrimSpace(line)
|
||||||
|
if strings.HasPrefix(line, "redis_version:") {
|
||||||
|
return strings.TrimPrefix(line, "redis_version:")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "unknown"
|
||||||
|
}
|
||||||
|
|
||||||
|
// computeStats вычисляет статистику по плейлистам и каналам
|
||||||
|
func computeStats(playlists []PlaylistView) map[string]interface{} {
|
||||||
|
plsOnline := 0
|
||||||
|
plsOffline := 0
|
||||||
|
plsUnknown := 0
|
||||||
|
plsAdult := 0
|
||||||
|
plsCatchup := 0
|
||||||
|
plsTvg := 0
|
||||||
|
plsGroupped := 0
|
||||||
|
|
||||||
|
channelsAll := 0
|
||||||
|
channelsOnline := 0
|
||||||
|
channelsOffline := 0
|
||||||
|
channelsAdult := 0
|
||||||
|
|
||||||
|
var latestCode string
|
||||||
|
var latestTime int64
|
||||||
|
|
||||||
|
for _, pls := range playlists {
|
||||||
|
if !pls.IsKnown {
|
||||||
|
plsUnknown++
|
||||||
|
} else if pls.IsOnline {
|
||||||
|
plsOnline++
|
||||||
|
} else {
|
||||||
|
plsOffline++
|
||||||
|
}
|
||||||
|
|
||||||
|
if containsTag(pls.Tags, "adult") {
|
||||||
|
plsAdult++
|
||||||
|
}
|
||||||
|
if pls.HasCatchup {
|
||||||
|
plsCatchup++
|
||||||
|
}
|
||||||
|
if pls.HasTvg {
|
||||||
|
plsTvg++
|
||||||
|
}
|
||||||
|
if len(pls.Groups) > 0 {
|
||||||
|
plsGroupped++
|
||||||
|
}
|
||||||
|
|
||||||
|
if pls.CheckedAt > latestTime {
|
||||||
|
latestTime = pls.CheckedAt
|
||||||
|
latestCode = pls.Code
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, ch := range pls.Channels {
|
||||||
|
channelsAll++
|
||||||
|
if ch.IsOnline {
|
||||||
|
channelsOnline++
|
||||||
|
} else {
|
||||||
|
channelsOffline++
|
||||||
|
}
|
||||||
|
if containsTag(ch.Tags, "adult") {
|
||||||
|
channelsAdult++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
latest := map[string]interface{}{
|
||||||
|
"code": latestCode,
|
||||||
|
"time": latestTime,
|
||||||
|
}
|
||||||
|
if latestTime > 0 {
|
||||||
|
latest["timeFmt"] = time.Unix(latestTime, 0).Format("15:04:05 02.01.2006")
|
||||||
|
}
|
||||||
|
|
||||||
|
return map[string]interface{}{
|
||||||
|
"playlists": map[string]interface{}{
|
||||||
|
"all": len(playlists),
|
||||||
|
"online": plsOnline,
|
||||||
|
"offline": plsOffline,
|
||||||
|
"unknown": plsUnknown,
|
||||||
|
"adult": plsAdult,
|
||||||
|
"hasCatchup": plsCatchup,
|
||||||
|
"hasTvg": plsTvg,
|
||||||
|
"groupped": plsGroupped,
|
||||||
|
"latest": latest,
|
||||||
|
},
|
||||||
|
"channels": map[string]interface{}{
|
||||||
|
"all": channelsAll,
|
||||||
|
"online": channelsOnline,
|
||||||
|
"offline": channelsOffline,
|
||||||
|
"adult": channelsAdult,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// containsTag проверяет наличие тега в списке
|
||||||
|
func containsTag(tags []string, tag string) bool {
|
||||||
|
for _, t := range tags {
|
||||||
|
if t == tag {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
@@ -0,0 +1,444 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2025-2026, Антон Аксенов
|
||||||
|
* This file is part of iptvc project
|
||||||
|
* MIT License: https://git.axenov.dev/IPTV/iptvc/src/branch/master/LICENSE
|
||||||
|
*/
|
||||||
|
|
||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"axenov/iptv-checker/app"
|
||||||
|
"embed"
|
||||||
|
"encoding/json"
|
||||||
|
"io/fs"
|
||||||
|
"net/http"
|
||||||
|
)
|
||||||
|
|
||||||
|
//go:embed swagger
|
||||||
|
var swaggerFS embed.FS
|
||||||
|
|
||||||
|
//go:embed static
|
||||||
|
var staticFS embed.FS
|
||||||
|
|
||||||
|
// handleOpenAPISpec — отдаёт динамически сгенерированную OpenAPI 3.0 спецификацию
|
||||||
|
func (s *Server) handleOpenAPISpec(w http.ResponseWriter, r *http.Request) {
|
||||||
|
spec := s.generateOpenAPISpec()
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(spec)
|
||||||
|
}
|
||||||
|
|
||||||
|
// swaggerFileServer раздаёт встроенные файлы Swagger UI (CSS, JS, HTML)
|
||||||
|
var swaggerFileServer http.Handler
|
||||||
|
|
||||||
|
// staticFileServer раздаёт встроенные статические файлы (JS, CSS)
|
||||||
|
var staticFileServer http.Handler
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
sub, err := fs.Sub(swaggerFS, "swagger")
|
||||||
|
if err != nil {
|
||||||
|
panic("cannot create sub-FS for swagger: " + err.Error())
|
||||||
|
}
|
||||||
|
swaggerFileServer = http.FileServer(http.FS(sub))
|
||||||
|
|
||||||
|
staticSub, err := fs.Sub(staticFS, "static")
|
||||||
|
if err != nil {
|
||||||
|
panic("cannot create sub-FS for static: " + err.Error())
|
||||||
|
}
|
||||||
|
staticFileServer = http.FileServer(http.FS(staticSub))
|
||||||
|
}
|
||||||
|
// generateOpenAPISpec строит OpenAPI 3.0.3 спецификацию динамически
|
||||||
|
func (s *Server) generateOpenAPISpec() map[string]interface{} {
|
||||||
|
return map[string]interface{}{
|
||||||
|
"openapi": "3.0.3",
|
||||||
|
"info": map[string]interface{}{
|
||||||
|
"title": s.cfg.Site.Header.Title + " API",
|
||||||
|
"version": app.VERSION,
|
||||||
|
"description": "API для проверки IPTV-плейлистов и доступности каналов.",
|
||||||
|
"license": map[string]interface{}{
|
||||||
|
"name": "MIT",
|
||||||
|
"url": "https://git.axenov.dev/IPTV/iptvc/src/branch/master/LICENSE",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"servers": []map[string]interface{}{
|
||||||
|
{
|
||||||
|
"url": s.cfg.Site.BaseUrl,
|
||||||
|
"description": "Текущий сервер",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"paths": map[string]interface{}{
|
||||||
|
"/api/playlists": map[string]interface{}{
|
||||||
|
"get": map[string]interface{}{
|
||||||
|
"tags": []string{"Playlists"},
|
||||||
|
"summary": "Список плейлистов",
|
||||||
|
"description": "Возвращает список всех плейлистов из ini-файла с результатами проверки.",
|
||||||
|
"responses": map[string]interface{}{
|
||||||
|
"200": map[string]interface{}{
|
||||||
|
"description": "Список плейлистов",
|
||||||
|
"content": map[string]interface{}{
|
||||||
|
"application/json": map[string]interface{}{
|
||||||
|
"schema": map[string]interface{}{"$ref": "#/components/schemas/PlaylistListResponse"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"500": map[string]interface{}{
|
||||||
|
"description": "Ошибка загрузки плейлистов",
|
||||||
|
"content": map[string]interface{}{
|
||||||
|
"application/json": map[string]interface{}{
|
||||||
|
"schema": map[string]interface{}{"$ref": "#/components/schemas/ErrorResponse"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"/api/playlists/{code}": map[string]interface{}{
|
||||||
|
"get": map[string]interface{}{
|
||||||
|
"tags": []string{"Playlists"},
|
||||||
|
"summary": "Информация о плейлисте",
|
||||||
|
"description": "Возвращает информацию о плейлисте по его коду, включая группы, атрибуты и результаты проверки.",
|
||||||
|
"parameters": []map[string]interface{}{
|
||||||
|
{
|
||||||
|
"name": "code",
|
||||||
|
"in": "path",
|
||||||
|
"required": true,
|
||||||
|
"description": "Код плейлиста (из ini-файла)",
|
||||||
|
"schema": map[string]interface{}{"type": "string"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"responses": map[string]interface{}{
|
||||||
|
"200": map[string]interface{}{
|
||||||
|
"description": "Информация о плейлисте",
|
||||||
|
"content": map[string]interface{}{
|
||||||
|
"application/json": map[string]interface{}{
|
||||||
|
"schema": map[string]interface{}{"$ref": "#/components/schemas/PlaylistResponse"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"400": map[string]interface{}{
|
||||||
|
"description": "Код плейлиста не передан",
|
||||||
|
"content": map[string]interface{}{
|
||||||
|
"application/json": map[string]interface{}{
|
||||||
|
"schema": map[string]interface{}{"$ref": "#/components/schemas/ErrorResponse"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"404": map[string]interface{}{
|
||||||
|
"description": "Плейлист не найден",
|
||||||
|
"content": map[string]interface{}{
|
||||||
|
"application/json": map[string]interface{}{
|
||||||
|
"schema": map[string]interface{}{"$ref": "#/components/schemas/ErrorResponse"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"/api/playlists/{code}/channels": map[string]interface{}{
|
||||||
|
"get": map[string]interface{}{
|
||||||
|
"tags": []string{"Playlists"},
|
||||||
|
"summary": "Каналы плейлиста",
|
||||||
|
"description": "Возвращает каналы плейлиста по его коду.",
|
||||||
|
"parameters": []map[string]interface{}{
|
||||||
|
{
|
||||||
|
"name": "code",
|
||||||
|
"in": "path",
|
||||||
|
"required": true,
|
||||||
|
"description": "Код плейлиста (из ini-файла)",
|
||||||
|
"schema": map[string]interface{}{"type": "string"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"responses": map[string]interface{}{
|
||||||
|
"200": map[string]interface{}{
|
||||||
|
"description": "Каналы плейлиста",
|
||||||
|
"content": map[string]interface{}{
|
||||||
|
"application/json": map[string]interface{}{
|
||||||
|
"schema": map[string]interface{}{"$ref": "#/components/schemas/ChannelListResponse"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"400": map[string]interface{}{
|
||||||
|
"description": "Код плейлиста не передан",
|
||||||
|
"content": map[string]interface{}{
|
||||||
|
"application/json": map[string]interface{}{
|
||||||
|
"schema": map[string]interface{}{"$ref": "#/components/schemas/ErrorResponse"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"404": map[string]interface{}{
|
||||||
|
"description": "Плейлист не найден",
|
||||||
|
"content": map[string]interface{}{
|
||||||
|
"application/json": map[string]interface{}{
|
||||||
|
"schema": map[string]interface{}{"$ref": "#/components/schemas/ErrorResponse"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"/api/version": map[string]interface{}{
|
||||||
|
"get": map[string]interface{}{
|
||||||
|
"tags": []string{"System"},
|
||||||
|
"summary": "Версии компонентов",
|
||||||
|
"description": "Возвращает версию iptvc и (при наличии) версию KeyDB/Redis.",
|
||||||
|
"responses": map[string]interface{}{
|
||||||
|
"200": map[string]interface{}{
|
||||||
|
"description": "Информация о версиях",
|
||||||
|
"content": map[string]interface{}{
|
||||||
|
"application/json": map[string]interface{}{
|
||||||
|
"schema": map[string]interface{}{"$ref": "#/components/schemas/VersionResponse"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"/api/health": map[string]interface{}{
|
||||||
|
"get": map[string]interface{}{
|
||||||
|
"tags": []string{"System"},
|
||||||
|
"summary": "Состояние сервиса",
|
||||||
|
"description": "Возвращает состояние подключения к KeyDB/Redis и путь к ini-файлу.",
|
||||||
|
"responses": map[string]interface{}{
|
||||||
|
"200": map[string]interface{}{
|
||||||
|
"description": "Состояние сервиса",
|
||||||
|
"content": map[string]interface{}{
|
||||||
|
"application/json": map[string]interface{}{
|
||||||
|
"schema": map[string]interface{}{"$ref": "#/components/schemas/HealthResponse"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"/api/stats": map[string]interface{}{
|
||||||
|
"get": map[string]interface{}{
|
||||||
|
"tags": []string{"Statistics"},
|
||||||
|
"summary": "Статистика",
|
||||||
|
"description": "Возвращает агрегированную статистику по плейлистам и каналам.",
|
||||||
|
"responses": map[string]interface{}{
|
||||||
|
"200": map[string]interface{}{
|
||||||
|
"description": "Статистика",
|
||||||
|
"content": map[string]interface{}{
|
||||||
|
"application/json": map[string]interface{}{
|
||||||
|
"schema": map[string]interface{}{"$ref": "#/components/schemas/StatsResponse"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"500": map[string]interface{}{
|
||||||
|
"description": "Ошибка загрузки плейлистов",
|
||||||
|
"content": map[string]interface{}{
|
||||||
|
"application/json": map[string]interface{}{
|
||||||
|
"schema": map[string]interface{}{"$ref": "#/components/schemas/ErrorResponse"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"components": map[string]interface{}{
|
||||||
|
"schemas": map[string]interface{}{
|
||||||
|
"PlaylistListResponse": map[string]interface{}{
|
||||||
|
"type": "object",
|
||||||
|
"description": "Список плейлистов",
|
||||||
|
"properties": map[string]interface{}{
|
||||||
|
"timestamp": map[string]interface{}{"type": "integer", "description": "UNIX timestamp ответа"},
|
||||||
|
"items": map[string]interface{}{
|
||||||
|
"type": "array",
|
||||||
|
"items": map[string]interface{}{"$ref": "#/components/schemas/Playlist"},
|
||||||
|
"description": "Массив плейлистов",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"PlaylistResponse": map[string]interface{}{
|
||||||
|
"type": "object",
|
||||||
|
"description": "Информация о плейлисте с результатами проверки",
|
||||||
|
"allOf": []map[string]interface{}{
|
||||||
|
{"$ref": "#/components/schemas/Playlist"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"Playlist": map[string]interface{}{
|
||||||
|
"type": "object",
|
||||||
|
"description": "Информация о плейлисте",
|
||||||
|
"properties": map[string]interface{}{
|
||||||
|
"code": map[string]interface{}{"type": "string", "description": "Код плейлиста"},
|
||||||
|
"name": map[string]interface{}{"type": "string", "description": "Название плейлиста"},
|
||||||
|
"description": map[string]interface{}{"type": "string", "description": "Описание плейлиста"},
|
||||||
|
"url": map[string]interface{}{"type": "string", "description": "URL плейлиста"},
|
||||||
|
"source": map[string]interface{}{"type": "string", "description": "Источник плейлиста"},
|
||||||
|
"isOnline": map[string]interface{}{
|
||||||
|
"type": "boolean",
|
||||||
|
"nullable": true,
|
||||||
|
"description": "Доступность плейлиста (null если не проверялся)",
|
||||||
|
},
|
||||||
|
"onlineCount": map[string]interface{}{"type": "integer", "description": "Количество рабочих каналов"},
|
||||||
|
"offlineCount": map[string]interface{}{"type": "integer", "description": "Количество нерабочих каналов"},
|
||||||
|
"checkedAt": map[string]interface{}{"type": "integer", "description": "UNIX timestamp последней проверки"},
|
||||||
|
"content": map[string]interface{}{
|
||||||
|
"type": "string",
|
||||||
|
"description": "Содержимое плейлиста (только для оффлайн-плейлистов)",
|
||||||
|
},
|
||||||
|
"attributes": map[string]interface{}{
|
||||||
|
"type": "array",
|
||||||
|
"items": map[string]interface{}{"$ref": "#/components/schemas/Attribute"},
|
||||||
|
"description": "Атрибуты тега #EXTM3U",
|
||||||
|
},
|
||||||
|
"groups": map[string]interface{}{
|
||||||
|
"type": "array",
|
||||||
|
"items": map[string]interface{}{"$ref": "#/components/schemas/Group"},
|
||||||
|
"description": "Группы каналов",
|
||||||
|
},
|
||||||
|
"tags": map[string]interface{}{
|
||||||
|
"type": "array",
|
||||||
|
"items": map[string]interface{}{"type": "string"},
|
||||||
|
"description": "Теги плейлиста",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"ChannelListResponse": map[string]interface{}{
|
||||||
|
"type": "object",
|
||||||
|
"description": "Список каналов плейлиста",
|
||||||
|
"properties": map[string]interface{}{
|
||||||
|
"timestamp": map[string]interface{}{"type": "integer", "description": "UNIX timestamp ответа"},
|
||||||
|
"items": map[string]interface{}{
|
||||||
|
"type": "array",
|
||||||
|
"items": map[string]interface{}{"$ref": "#/components/schemas/Channel"},
|
||||||
|
"description": "Массив каналов",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"Channel": map[string]interface{}{
|
||||||
|
"type": "object",
|
||||||
|
"description": "Информация о канале и статусе его проверки",
|
||||||
|
"properties": map[string]interface{}{
|
||||||
|
"id": map[string]interface{}{"type": "string", "description": "MD5-хэш URL канала"},
|
||||||
|
"title": map[string]interface{}{"type": "string", "description": "Название канала"},
|
||||||
|
"url": map[string]interface{}{"type": "string", "description": "URL потока канала"},
|
||||||
|
"groupId": map[string]interface{}{"type": "string", "description": "MD5-хэш названия группы"},
|
||||||
|
"hasToken": map[string]interface{}{"type": "boolean", "description": "Признак наличия токена/ключа в URL"},
|
||||||
|
"attributes": map[string]interface{}{
|
||||||
|
"type": "array",
|
||||||
|
"items": map[string]interface{}{"$ref": "#/components/schemas/Attribute"},
|
||||||
|
"description": "Атрибуты тега #EXTINF",
|
||||||
|
},
|
||||||
|
"status": map[string]interface{}{"type": "integer", "description": "HTTP-код статуса"},
|
||||||
|
"isOnline": map[string]interface{}{"type": "boolean", "description": "Доступность канала (status < 400)"},
|
||||||
|
"error": map[string]interface{}{"type": "string", "description": "Текст ошибки"},
|
||||||
|
"contentType": map[string]interface{}{"type": "string", "description": "MIME-тип ответа"},
|
||||||
|
"tags": map[string]interface{}{
|
||||||
|
"type": "array",
|
||||||
|
"items": map[string]interface{}{"type": "string"},
|
||||||
|
"description": "Теги канала",
|
||||||
|
},
|
||||||
|
"checkedAt": map[string]interface{}{"type": "integer", "description": "UNIX timestamp проверки"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"Group": map[string]interface{}{
|
||||||
|
"type": "object",
|
||||||
|
"description": "Группа каналов",
|
||||||
|
"properties": map[string]interface{}{
|
||||||
|
"id": map[string]interface{}{"type": "string", "description": "MD5-хэш названия группы"},
|
||||||
|
"name": map[string]interface{}{"type": "string", "description": "Название группы"},
|
||||||
|
"attributes": map[string]interface{}{
|
||||||
|
"type": "array",
|
||||||
|
"items": map[string]interface{}{"$ref": "#/components/schemas/Attribute"},
|
||||||
|
"description": "Атрибуты тега #EXTGRP",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"Attribute": map[string]interface{}{
|
||||||
|
"type": "object",
|
||||||
|
"description": "Именованный атрибут тега",
|
||||||
|
"properties": map[string]interface{}{
|
||||||
|
"name": map[string]interface{}{"type": "string", "description": "Имя атрибута"},
|
||||||
|
"value": map[string]interface{}{"type": "string", "description": "Значение атрибута"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"VersionResponse": map[string]interface{}{
|
||||||
|
"type": "object",
|
||||||
|
"description": "Информация о версиях компонентов",
|
||||||
|
"properties": map[string]interface{}{
|
||||||
|
"timestamp": map[string]interface{}{"type": "integer", "description": "UNIX timestamp ответа"},
|
||||||
|
"iptvc": map[string]interface{}{"type": "string", "description": "Версия iptvc"},
|
||||||
|
"keydb": map[string]interface{}{
|
||||||
|
"type": "string",
|
||||||
|
"description": "Версия KeyDB/Redis (если подключён)",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"HealthResponse": map[string]interface{}{
|
||||||
|
"type": "object",
|
||||||
|
"description": "Состояние сервиса",
|
||||||
|
"properties": map[string]interface{}{
|
||||||
|
"timestamp": map[string]interface{}{"type": "integer", "description": "UNIX timestamp ответа"},
|
||||||
|
"redis": map[string]interface{}{
|
||||||
|
"type": "object",
|
||||||
|
"description": "Состояние подключения к KeyDB/Redis",
|
||||||
|
"properties": map[string]interface{}{
|
||||||
|
"isConnected": map[string]interface{}{"type": "boolean", "description": "Подключение установлено"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"iniFile": map[string]interface{}{
|
||||||
|
"type": "object",
|
||||||
|
"description": "Информация об ini-файле",
|
||||||
|
"properties": map[string]interface{}{
|
||||||
|
"path": map[string]interface{}{"type": "string", "description": "Путь к playlists.ini"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"StatsResponse": map[string]interface{}{
|
||||||
|
"type": "object",
|
||||||
|
"description": "Агрегированная статистика",
|
||||||
|
"properties": map[string]interface{}{
|
||||||
|
"timestamp": map[string]interface{}{"type": "integer", "description": "UNIX timestamp ответа"},
|
||||||
|
"playlists": map[string]interface{}{
|
||||||
|
"type": "object",
|
||||||
|
"description": "Статистика по плейлистам",
|
||||||
|
"properties": map[string]interface{}{
|
||||||
|
"all": map[string]interface{}{"type": "integer"},
|
||||||
|
"online": map[string]interface{}{"type": "integer"},
|
||||||
|
"offline": map[string]interface{}{"type": "integer"},
|
||||||
|
"unknown": map[string]interface{}{"type": "integer"},
|
||||||
|
"adult": map[string]interface{}{"type": "integer"},
|
||||||
|
"hasCatchup": map[string]interface{}{"type": "integer"},
|
||||||
|
"hasTvg": map[string]interface{}{"type": "integer"},
|
||||||
|
"groupped": map[string]interface{}{"type": "integer"},
|
||||||
|
"latest": map[string]interface{}{
|
||||||
|
"type": "object",
|
||||||
|
"properties": map[string]interface{}{
|
||||||
|
"code": map[string]interface{}{"type": "string"},
|
||||||
|
"time": map[string]interface{}{"type": "integer"},
|
||||||
|
"timeFmt": map[string]interface{}{"type": "string"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"channels": map[string]interface{}{
|
||||||
|
"type": "object",
|
||||||
|
"description": "Статистика по каналам",
|
||||||
|
"properties": map[string]interface{}{
|
||||||
|
"all": map[string]interface{}{"type": "integer"},
|
||||||
|
"online": map[string]interface{}{"type": "integer"},
|
||||||
|
"offline": map[string]interface{}{"type": "integer"},
|
||||||
|
"adult": map[string]interface{}{"type": "integer"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"ErrorResponse": map[string]interface{}{
|
||||||
|
"type": "object",
|
||||||
|
"description": "Ошибка",
|
||||||
|
"properties": map[string]interface{}{
|
||||||
|
"timestamp": map[string]interface{}{"type": "integer", "description": "UNIX timestamp ответа"},
|
||||||
|
"error": map[string]interface{}{
|
||||||
|
"type": "object",
|
||||||
|
"properties": map[string]interface{}{
|
||||||
|
"message": map[string]interface{}{"type": "string", "description": "Текст ошибки"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestOpenAPISpec проверяет, что /api/openapi.json отдаёт валидную спецификацию
|
||||||
|
func TestOpenAPISpec(t *testing.T) {
|
||||||
|
ts, _ := makeTestServer(t)
|
||||||
|
|
||||||
|
resp, err := http.Get(ts.URL + "/api/openapi.json")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GET /api/openapi.json: %s", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
t.Fatalf("expected 200, got %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
if ct := resp.Header.Get("Content-Type"); !strings.HasPrefix(ct, "application/json") {
|
||||||
|
t.Errorf("expected application/json content-type, got %s", ct)
|
||||||
|
}
|
||||||
|
|
||||||
|
var spec map[string]interface{}
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&spec); err != nil {
|
||||||
|
t.Fatalf("cannot decode spec: %s", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// базовые поля OpenAPI 3.0
|
||||||
|
if v, ok := spec["openapi"].(string); !ok || !strings.HasPrefix(v, "3.0") {
|
||||||
|
t.Errorf("expected openapi 3.0.x, got %v", spec["openapi"])
|
||||||
|
}
|
||||||
|
|
||||||
|
info, ok := spec["info"].(map[string]interface{})
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("missing 'info' section")
|
||||||
|
}
|
||||||
|
if _, ok := info["title"].(string); !ok {
|
||||||
|
t.Error("missing info.title")
|
||||||
|
}
|
||||||
|
if _, ok := info["version"].(string); !ok {
|
||||||
|
t.Error("missing info.version")
|
||||||
|
}
|
||||||
|
|
||||||
|
// сервер должен быть указан
|
||||||
|
servers, ok := spec["servers"].([]interface{})
|
||||||
|
if !ok || len(servers) == 0 {
|
||||||
|
t.Error("expected at least one server")
|
||||||
|
}
|
||||||
|
|
||||||
|
// пути
|
||||||
|
paths, ok := spec["paths"].(map[string]interface{})
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("missing 'paths' section")
|
||||||
|
}
|
||||||
|
|
||||||
|
expectedPaths := []string{
|
||||||
|
"/api/playlists",
|
||||||
|
"/api/playlists/{code}",
|
||||||
|
"/api/playlists/{code}/channels",
|
||||||
|
"/api/version",
|
||||||
|
"/api/health",
|
||||||
|
"/api/stats",
|
||||||
|
}
|
||||||
|
for _, p := range expectedPaths {
|
||||||
|
if _, ok := paths[p]; !ok {
|
||||||
|
t.Errorf("missing path %s in spec", p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// компоненты/схемы
|
||||||
|
components, ok := spec["components"].(map[string]interface{})
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("missing 'components' section")
|
||||||
|
}
|
||||||
|
schemas, ok := components["schemas"].(map[string]interface{})
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("missing 'components.schemas' section")
|
||||||
|
}
|
||||||
|
|
||||||
|
expectedSchemas := []string{
|
||||||
|
"PlaylistListResponse",
|
||||||
|
"PlaylistResponse",
|
||||||
|
"Playlist",
|
||||||
|
"ChannelListResponse",
|
||||||
|
"Channel",
|
||||||
|
"Group",
|
||||||
|
"Attribute",
|
||||||
|
"VersionResponse",
|
||||||
|
"HealthResponse",
|
||||||
|
"StatsResponse",
|
||||||
|
"ErrorResponse",
|
||||||
|
}
|
||||||
|
for _, s := range expectedSchemas {
|
||||||
|
if _, ok := schemas[s]; !ok {
|
||||||
|
t.Errorf("missing schema %s in components.schemas", s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSwaggerUI проверяет, что /api/ отдаёт HTML-страницу Swagger UI
|
||||||
|
// и что CSS/JS-ассеты доступны без внешних зависимостей
|
||||||
|
func TestSwaggerUI(t *testing.T) {
|
||||||
|
ts, _ := makeTestServer(t)
|
||||||
|
|
||||||
|
// /api → редирект на /api/
|
||||||
|
noRedirect := &http.Client{CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
||||||
|
return http.ErrUseLastResponse
|
||||||
|
}}
|
||||||
|
resp, err := noRedirect.Get(ts.URL + "/api")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GET /api: %s", err)
|
||||||
|
}
|
||||||
|
resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusMovedPermanently {
|
||||||
|
t.Fatalf("expected 301 redirect, got %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
// /api/ → HTML-страница
|
||||||
|
resp, err = http.Get(ts.URL + "/api/")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GET /api/: %s", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
t.Fatalf("expected 200, got %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
if ct := resp.Header.Get("Content-Type"); !strings.HasPrefix(ct, "text/html") {
|
||||||
|
t.Errorf("expected text/html content-type, got %s", ct)
|
||||||
|
}
|
||||||
|
|
||||||
|
body := fetchBody(t, resp)
|
||||||
|
if !strings.Contains(body, "swagger-ui") {
|
||||||
|
t.Error("Swagger UI page should contain 'swagger-ui' reference")
|
||||||
|
}
|
||||||
|
if !strings.Contains(body, "/api/openapi.json") {
|
||||||
|
t.Error("Swagger UI page should reference /api/openapi.json")
|
||||||
|
}
|
||||||
|
// проверяем, что нет ссылок на CDN
|
||||||
|
if strings.Contains(body, "cdn.jsdelivr.net") || strings.Contains(body, "unpkg.com") {
|
||||||
|
t.Error("Swagger UI page should not reference any CDN")
|
||||||
|
}
|
||||||
|
|
||||||
|
// CSS-ассет
|
||||||
|
respCSS, err := http.Get(ts.URL + "/api/swagger-ui.css")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GET /api/swagger-ui.css: %s", err)
|
||||||
|
}
|
||||||
|
defer respCSS.Body.Close()
|
||||||
|
if respCSS.StatusCode != http.StatusOK {
|
||||||
|
t.Fatalf("expected 200 for CSS, got %d", respCSS.StatusCode)
|
||||||
|
}
|
||||||
|
if ct := respCSS.Header.Get("Content-Type"); !strings.HasPrefix(ct, "text/css") {
|
||||||
|
t.Errorf("expected text/css content-type, got %s", ct)
|
||||||
|
}
|
||||||
|
|
||||||
|
// JS-ассет
|
||||||
|
respJS, err := http.Get(ts.URL + "/api/swagger-ui-bundle.js")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GET /api/swagger-ui-bundle.js: %s", err)
|
||||||
|
}
|
||||||
|
defer respJS.Body.Close()
|
||||||
|
if respJS.StatusCode != http.StatusOK {
|
||||||
|
t.Fatalf("expected 200 for JS, got %d", respJS.StatusCode)
|
||||||
|
}
|
||||||
|
if ct := respJS.Header.Get("Content-Type"); !strings.Contains(ct, "javascript") {
|
||||||
|
t.Errorf("expected javascript content-type, got %s", ct)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,223 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2025-2026, Антон Аксенов
|
||||||
|
* This file is part of iptvc project
|
||||||
|
* MIT License: https://git.axenov.dev/IPTV/iptvc/src/branch/master/LICENSE
|
||||||
|
*/
|
||||||
|
|
||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"axenov/iptv-checker/app/checker"
|
||||||
|
"axenov/iptv-checker/app/config"
|
||||||
|
"axenov/iptv-checker/app/inifile"
|
||||||
|
"axenov/iptv-checker/app/playlist"
|
||||||
|
"axenov/iptv-checker/app/utils"
|
||||||
|
"log"
|
||||||
|
"maps"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"slices"
|
||||||
|
"strconv"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/redis/go-redis/v9"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Server — веб-сервер для отображения плейлистов
|
||||||
|
type Server struct {
|
||||||
|
cfg *config.Config
|
||||||
|
cache *redis.Client
|
||||||
|
templates *TemplateManager
|
||||||
|
iniCache inifile.IniFile
|
||||||
|
iniLoaded time.Time
|
||||||
|
memMu sync.RWMutex
|
||||||
|
memCache map[string]playlist.Playlist
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewServer создаёт новый экземпляр веб-сервера
|
||||||
|
func NewServer(cfg *config.Config, cache *redis.Client) *Server {
|
||||||
|
s := &Server{
|
||||||
|
cfg: cfg,
|
||||||
|
cache: cache,
|
||||||
|
memCache: make(map[string]playlist.Playlist),
|
||||||
|
}
|
||||||
|
|
||||||
|
tm, err := NewTemplateManager(cfg)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Failed to load templates: %s", err)
|
||||||
|
}
|
||||||
|
s.templates = tm
|
||||||
|
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start запускает веб-сервер
|
||||||
|
func (s *Server) Start() {
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
|
||||||
|
// API routes (specific, no conflicts)
|
||||||
|
mux.HandleFunc("GET /api/playlists", s.handleAPIGetAll)
|
||||||
|
mux.HandleFunc("GET /api/playlists/{code}", s.handleAPIGetOne)
|
||||||
|
mux.HandleFunc("GET /api/playlists/{code}/channels", s.handleAPIGetChannels)
|
||||||
|
mux.HandleFunc("GET /api/version", s.handleAPIVersion)
|
||||||
|
mux.HandleFunc("GET /api/health", s.handleAPIHealth)
|
||||||
|
mux.HandleFunc("GET /api/stats", s.handleAPIStats)
|
||||||
|
|
||||||
|
// Static files
|
||||||
|
mux.Handle("GET /static/", http.StripPrefix("/static/", staticFileServer))
|
||||||
|
|
||||||
|
// OpenAPI spec + Swagger UI
|
||||||
|
mux.HandleFunc("GET /api/openapi.json", s.handleOpenAPISpec)
|
||||||
|
mux.HandleFunc("GET /api", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
http.Redirect(w, r, "/api/", http.StatusMovedPermanently)
|
||||||
|
})
|
||||||
|
mux.Handle("GET /api/", http.StripPrefix("/api/", swaggerFileServer))
|
||||||
|
|
||||||
|
// All other routes handled by catch-all to avoid mux pattern conflicts
|
||||||
|
mux.HandleFunc("GET /{$}", s.handleHome)
|
||||||
|
mux.HandleFunc("GET /{path...}", s.handleRoute)
|
||||||
|
|
||||||
|
handler := s.withMiddleware(mux)
|
||||||
|
|
||||||
|
addr := s.cfg.Server.Host + ":" + strconv.FormatUint(uint64(s.cfg.Server.Port), 10)
|
||||||
|
log.Printf("Starting web server on %s", addr)
|
||||||
|
if err := http.ListenAndServe(addr, handler); err != nil {
|
||||||
|
log.Fatalf("Server failed: %s", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// withMiddleware добавляет middleware: логирование, восстановление после паники
|
||||||
|
func (s *Server) withMiddleware(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
start := time.Now()
|
||||||
|
defer func() {
|
||||||
|
if err := recover(); err != nil {
|
||||||
|
log.Printf("PANIC: %v", err)
|
||||||
|
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
log.Printf("%s %s %s %s", r.Method, r.URL.Path, time.Since(start), r.RemoteAddr)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadIni загружает ini-файл (с кешированием на 30 секунд)
|
||||||
|
func (s *Server) loadIni() (inifile.IniFile, error) {
|
||||||
|
// перезагружаем раз в 30 секунд
|
||||||
|
if !s.iniLoaded.IsZero() && time.Since(s.iniLoaded) < 30*time.Second && len(s.iniCache.Lists) > 0 {
|
||||||
|
return s.iniCache, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
ini, err := inifile.Init(s.cfg.App.Playlists)
|
||||||
|
if err != nil {
|
||||||
|
return inifile.IniFile{}, err
|
||||||
|
}
|
||||||
|
s.iniCache = ini
|
||||||
|
s.iniLoaded = time.Now()
|
||||||
|
return ini, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// getUpdatedAt возвращает время последнего изменения ini-файла
|
||||||
|
func (s *Server) getUpdatedAt() string {
|
||||||
|
path, err := utils.ExpandPath(s.cfg.App.Playlists)
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
info, err := os.Stat(path)
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
loc, err := time.LoadLocation(s.cfg.App.Timezone)
|
||||||
|
if err != nil {
|
||||||
|
loc = time.UTC
|
||||||
|
}
|
||||||
|
return info.ModTime().In(loc).Format("02.01.2006 15:04")
|
||||||
|
}
|
||||||
|
|
||||||
|
// getTimezoneLabel возвращает сокращённое обозначение часового пояса для отображения.
|
||||||
|
func (s *Server) getTimezoneLabel() string {
|
||||||
|
return s.cfg.App.Timezone
|
||||||
|
}
|
||||||
|
|
||||||
|
// CheckOptions — параметры фоновой проверки плейлистов
|
||||||
|
type CheckOptions struct {
|
||||||
|
Repeat uint
|
||||||
|
Random uint
|
||||||
|
Files []string
|
||||||
|
Urls []string
|
||||||
|
Codes []string
|
||||||
|
}
|
||||||
|
|
||||||
|
// StartBackgroundChecker запускает фоновую периодическую проверку плейлистов.
|
||||||
|
// Метод блокирует вызывающую горутину, поэтому должен запускаться в отдельной goroutine.
|
||||||
|
func (s *Server) StartBackgroundChecker(opts CheckOptions) {
|
||||||
|
// каждый проверенный плейлист сразу попадает в in-memory кеш
|
||||||
|
checker.OnPlaylistChecked = func(pls playlist.Playlist) {
|
||||||
|
s.memMu.Lock()
|
||||||
|
s.memCache[pls.Code] = pls
|
||||||
|
s.memMu.Unlock()
|
||||||
|
// сбрасываем кеш ini, чтобы веб-часть подхватила свежие данные
|
||||||
|
s.iniLoaded = time.Time{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// AllCooldown хранится в миллисекундах, поэтому переводим в time.Millisecond
|
||||||
|
cooldown := time.Duration(s.cfg.Check.Playlists.AllCooldown.Value()) * time.Millisecond
|
||||||
|
log.Printf("Background checker started: cooldown=%s repeat=%d", cooldown, opts.Repeat)
|
||||||
|
|
||||||
|
iteration := uint(0)
|
||||||
|
for {
|
||||||
|
iteration++
|
||||||
|
log.Printf("@ Background checker iteration=%d repeat=%d", iteration, opts.Repeat)
|
||||||
|
|
||||||
|
s.runCheckerOnce(opts)
|
||||||
|
|
||||||
|
if opts.Repeat > 0 && iteration >= opts.Repeat {
|
||||||
|
log.Println("Background checker finished: reached repeat count")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("Background checker waiting... cooldown=%s", cooldown)
|
||||||
|
// cooldown уже вычислен в миллисекундах выше
|
||||||
|
time.Sleep(cooldown)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// runCheckerOnce выполняет один цикл проверки плейлистов
|
||||||
|
func (s *Server) runCheckerOnce(opts CheckOptions) {
|
||||||
|
defer func() {
|
||||||
|
if err := recover(); err != nil {
|
||||||
|
log.Printf("Background checker panic: %v", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
var lists []playlist.Playlist
|
||||||
|
|
||||||
|
if len(opts.Files) > 0 || len(opts.Urls) > 0 || len(opts.Codes) > 0 {
|
||||||
|
// проверка конкретных файлов/URL/кодов
|
||||||
|
lists = checker.PrepareListsToCheck(opts.Files, opts.Urls, opts.Codes)
|
||||||
|
} else {
|
||||||
|
// проверка всех плейлистов из ini
|
||||||
|
ini, err := inifile.Init(s.cfg.App.Playlists)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Background checker: cannot load ini: %s", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
lists = slices.Collect(maps.Values(ini.Lists))
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(lists) == 0 {
|
||||||
|
log.Println("Background checker: no playlists to check")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("Background checker: %d playlists will be checked", len(lists))
|
||||||
|
startTime := time.Now()
|
||||||
|
onlineCount, offlineCount := checker.CheckPlaylists(lists)
|
||||||
|
log.Printf(
|
||||||
|
"Background checker done! online=%d offline=%d elapsed=%.2fs",
|
||||||
|
onlineCount,
|
||||||
|
offlineCount,
|
||||||
|
time.Since(startTime).Seconds(),
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export{a as addIcons}from"./p-40ae2aa7.js";import"./p-d15ec307.js";
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
import{p as i,b as o}from"./p-d15ec307.js";(()=>{const o=import.meta.url,s={};return""!==o&&(s.resourcesUrl=new URL(".",o).href),i(s)})().then((i=>o([["p-1c0b2c47",[[1,"ion-icon",{mode:[1025],color:[1],ios:[1],md:[1],flipRtl:[4,"flip-rtl"],name:[513],src:[1],icon:[8],size:[1],lazy:[4],sanitize:[4],svgContent:[32],isVisible:[32]}]]]],i)));
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
import{r as o,h as i,H as t,a as n}from"./p-d15ec307.js";import{i as s,b as r,g as e,c,d as a}from"./p-40ae2aa7.js";const l=o=>{if(1===o.nodeType){if("script"===o.nodeName.toLowerCase())return!1;for(let i=0;i<o.attributes.length;i++){const t=o.attributes[i].name;if(s(t)&&0===t.toLowerCase().indexOf("on"))return!1}for(let i=0;i<o.childNodes.length;i++)if(!l(o.childNodes[i]))return!1}return!0},h=new Map,d=new Map;let f;const m=class{constructor(i){o(this,i),this.iconName=null,this.inheritedAttributes={},this.isVisible=!1,this.mode=u(),this.lazy=!1,this.sanitize=!0}componentWillLoad(){this.inheritedAttributes=r(this.el,["aria-label"])}connectedCallback(){this.waitUntilVisible(this.el,"50px",(()=>{this.isVisible=!0,this.loadIcon()}))}disconnectedCallback(){this.io&&(this.io.disconnect(),this.io=void 0)}waitUntilVisible(o,i,t){if(this.lazy&&"undefined"!=typeof window&&window.IntersectionObserver){const n=this.io=new window.IntersectionObserver((o=>{o[0].isIntersecting&&(n.disconnect(),this.io=void 0,t())}),{rootMargin:i});n.observe(o)}else t()}loadIcon(){if(this.isVisible){const o=e(this);o&&(h.has(o)?this.svgContent=h.get(o):((o,i)=>{let t=d.get(o);if(!t){if("undefined"==typeof fetch||"undefined"==typeof document)return h.set(o,""),Promise.resolve();if((o=>o.startsWith("data:image/svg+xml"))(o)&&(o=>-1!==o.indexOf(";utf8,"))(o)){f||(f=new DOMParser);const i=f.parseFromString(o,"text/html").querySelector("svg");return i&&h.set(o,i.outerHTML),Promise.resolve()}t=fetch(o).then((t=>{if(t.ok)return t.text().then((t=>{t&&!1!==i&&(t=(o=>{const i=document.createElement("div");i.innerHTML=o;for(let o=i.childNodes.length-1;o>=0;o--)"svg"!==i.childNodes[o].nodeName.toLowerCase()&&i.removeChild(i.childNodes[o]);const t=i.firstElementChild;if(t&&"svg"===t.nodeName.toLowerCase()){const o=t.getAttribute("class")||"";if(t.setAttribute("class",(o+" s-ion-icon").trim()),l(t))return i.innerHTML}return""})(t)),h.set(o,t||"")}));h.set(o,"")})),d.set(o,t)}return t})(o,this.sanitize).then((()=>this.svgContent=h.get(o))))}this.iconName=c(this.name,this.icon,this.mode,this.ios,this.md)}render(){const{iconName:o,el:n,inheritedAttributes:s}=this,r=this.mode||"md",e=this.flipRtl||o&&(o.indexOf("arrow")>-1||o.indexOf("chevron")>-1)&&!1!==this.flipRtl;return i(t,Object.assign({role:"img",class:Object.assign(Object.assign({[r]:!0},p(this.color)),{[`icon-${this.size}`]:!!this.size,"flip-rtl":!!e&&a(n)})},s),i("div",this.svgContent?{class:"icon-inner",innerHTML:this.svgContent}:{class:"icon-inner"}))}static get assetsDirs(){return["svg"]}get el(){return n(this)}static get watchers(){return{name:["loadIcon"],src:["loadIcon"],icon:["loadIcon"],ios:["loadIcon"],md:["loadIcon"]}}},u=()=>"undefined"!=typeof document&&document.documentElement.getAttribute("mode")||"md",p=o=>o?{"ion-color":!0,[`ion-color-${o}`]:!0}:null;m.style=":host{display:inline-block;width:1em;height:1em;contain:strict;fill:currentColor;-webkit-box-sizing:content-box !important;box-sizing:content-box !important}:host .ionicon{stroke:currentColor}.ionicon-fill-none{fill:none}.ionicon-stroke-width{stroke-width:32px;stroke-width:var(--ionicon-stroke-width, 32px)}.icon-inner,.ionicon,svg{display:block;height:100%;width:100%}:host(.flip-rtl) .icon-inner{-webkit-transform:scaleX(-1);transform:scaleX(-1)}:host(.icon-small){font-size:18px !important}:host(.icon-large){font-size:32px !important}:host(.ion-color){color:var(--ion-color-base) !important}:host(.ion-color-primary){--ion-color-base:var(--ion-color-primary, #3880ff)}:host(.ion-color-secondary){--ion-color-base:var(--ion-color-secondary, #0cd1e8)}:host(.ion-color-tertiary){--ion-color-base:var(--ion-color-tertiary, #f4a942)}:host(.ion-color-success){--ion-color-base:var(--ion-color-success, #10dc60)}:host(.ion-color-warning){--ion-color-base:var(--ion-color-warning, #ffce00)}:host(.ion-color-danger){--ion-color-base:var(--ion-color-danger, #f14141)}:host(.ion-color-light){--ion-color-base:var(--ion-color-light, #f4f5f8)}:host(.ion-color-medium){--ion-color-base:var(--ion-color-medium, #989aa2)}:host(.ion-color-dark){--ion-color-base:var(--ion-color-dark, #222428)}";export{m as ion_icon}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
System.register(["./p-ea7bbed1.system.js","./p-cff0a9de.system.js"],(function(e){"use strict";var o,t,i,n,r,s,a,c,l;return{setters:[function(e){o=e.r;t=e.h;i=e.H;n=e.a},function(e){r=e.i;s=e.b;a=e.g;c=e.c;l=e.d}],execute:function(){var f=function(e){var o=document.createElement("div");o.innerHTML=e;for(var t=o.childNodes.length-1;t>=0;t--){if(o.childNodes[t].nodeName.toLowerCase()!=="svg"){o.removeChild(o.childNodes[t])}}var i=o.firstElementChild;if(i&&i.nodeName.toLowerCase()==="svg"){var n=i.getAttribute("class")||"";i.setAttribute("class",(n+" s-ion-icon").trim());if(u(i)){return o.innerHTML}}return""};var u=function(e){if(e.nodeType===1){if(e.nodeName.toLowerCase()==="script"){return false}for(var o=0;o<e.attributes.length;o++){var t=e.attributes[o].name;if(r(t)&&t.toLowerCase().indexOf("on")===0){return false}}for(var o=0;o<e.childNodes.length;o++){if(!u(e.childNodes[o])){return false}}}return true};var d=function(e){return e.startsWith("data:image/svg+xml")};var h=function(e){return e.indexOf(";utf8,")!==-1};var v=new Map;var m=new Map;var b;var p=function(e,o){var t=m.get(e);if(!t){if(typeof fetch!=="undefined"&&typeof document!=="undefined"){if(d(e)&&h(e)){if(!b){b=new DOMParser}var i=b.parseFromString(e,"text/html");var n=i.querySelector("svg");if(n){v.set(e,n.outerHTML)}return Promise.resolve()}else{t=fetch(e).then((function(t){if(t.ok){return t.text().then((function(t){if(t&&o!==false){t=f(t)}v.set(e,t||"")}))}v.set(e,"")}));m.set(e,t)}}else{v.set(e,"");return Promise.resolve()}}return t};var g=":host{display:inline-block;width:1em;height:1em;contain:strict;fill:currentColor;-webkit-box-sizing:content-box !important;box-sizing:content-box !important}:host .ionicon{stroke:currentColor}.ionicon-fill-none{fill:none}.ionicon-stroke-width{stroke-width:32px;stroke-width:var(--ionicon-stroke-width, 32px)}.icon-inner,.ionicon,svg{display:block;height:100%;width:100%}:host(.flip-rtl) .icon-inner{-webkit-transform:scaleX(-1);transform:scaleX(-1)}:host(.icon-small){font-size:18px !important}:host(.icon-large){font-size:32px !important}:host(.ion-color){color:var(--ion-color-base) !important}:host(.ion-color-primary){--ion-color-base:var(--ion-color-primary, #3880ff)}:host(.ion-color-secondary){--ion-color-base:var(--ion-color-secondary, #0cd1e8)}:host(.ion-color-tertiary){--ion-color-base:var(--ion-color-tertiary, #f4a942)}:host(.ion-color-success){--ion-color-base:var(--ion-color-success, #10dc60)}:host(.ion-color-warning){--ion-color-base:var(--ion-color-warning, #ffce00)}:host(.ion-color-danger){--ion-color-base:var(--ion-color-danger, #f14141)}:host(.ion-color-light){--ion-color-base:var(--ion-color-light, #f4f5f8)}:host(.ion-color-medium){--ion-color-base:var(--ion-color-medium, #989aa2)}:host(.ion-color-dark){--ion-color-base:var(--ion-color-dark, #222428)}";var y=e("ion_icon",function(){function e(e){o(this,e);this.iconName=null;this.inheritedAttributes={};this.isVisible=false;this.mode=w();this.lazy=false;this.sanitize=true}e.prototype.componentWillLoad=function(){this.inheritedAttributes=s(this.el,["aria-label"])};e.prototype.connectedCallback=function(){var e=this;this.waitUntilVisible(this.el,"50px",(function(){e.isVisible=true;e.loadIcon()}))};e.prototype.disconnectedCallback=function(){if(this.io){this.io.disconnect();this.io=undefined}};e.prototype.waitUntilVisible=function(e,o,t){var i=this;if(this.lazy&&typeof window!=="undefined"&&window.IntersectionObserver){var n=this.io=new window.IntersectionObserver((function(e){if(e[0].isIntersecting){n.disconnect();i.io=undefined;t()}}),{rootMargin:o});n.observe(e)}else{t()}};e.prototype.loadIcon=function(){var e=this;if(this.isVisible){var o=a(this);if(o){if(v.has(o)){this.svgContent=v.get(o)}else{p(o,this.sanitize).then((function(){return e.svgContent=v.get(o)}))}}}this.iconName=c(this.name,this.icon,this.mode,this.ios,this.md)};e.prototype.render=function(){var e,o;var n=this,r=n.iconName,s=n.el,a=n.inheritedAttributes;var c=this.mode||"md";var f=this.flipRtl||r&&(r.indexOf("arrow")>-1||r.indexOf("chevron")>-1)&&this.flipRtl!==false;return t(i,Object.assign({role:"img",class:Object.assign(Object.assign((e={},e[c]=true,e),x(this.color)),(o={},o["icon-".concat(this.size)]=!!this.size,o["flip-rtl"]=!!f&&l(s),o))},a),this.svgContent?t("div",{class:"icon-inner",innerHTML:this.svgContent}):t("div",{class:"icon-inner"}))};Object.defineProperty(e,"assetsDirs",{get:function(){return["svg"]},enumerable:false,configurable:true});Object.defineProperty(e.prototype,"el",{get:function(){return n(this)},enumerable:false,configurable:true});Object.defineProperty(e,"watchers",{get:function(){return{name:["loadIcon"],src:["loadIcon"],icon:["loadIcon"],ios:["loadIcon"],md:["loadIcon"]}},enumerable:false,configurable:true});return e}());var w=function(){return typeof document!=="undefined"&&document.documentElement.getAttribute("mode")||"md"};var x=function(e){var o;return e?(o={"ion-color":true},o["ion-color-".concat(e)]=true,o):null};y.style=g}}}));
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
import{g as n}from"./p-d15ec307.js";let t;const e=()=>{if("undefined"==typeof window)return new Map;if(!t){const n=window;n.Ionicons=n.Ionicons||{},t=n.Ionicons.map=n.Ionicons.map||new Map}return t},r=n=>{const t=e();Object.keys(n).forEach((e=>t.set(e,n[e])))},i=n=>{let t=u(n.src);if(t)return t;if(t=s(n.name,n.icon,n.mode,n.ios,n.md),t)return o(t);if(n.icon){if(t=u(n.icon),t)return t;if(t=u(n.icon[n.mode]),t)return t}return null},o=t=>e().get(t)||n(`svg/${t}.svg`),s=(n,t,e,r,i)=>(e="ios"===(e&&a(e))?"ios":"md",r&&"ios"===e?n=a(r):i&&"md"===e?n=a(i):(n||!t||l(t)||(n=t),d(n)&&(n=a(n))),d(n)&&""!==n.trim()?""!==n.replace(/[a-z]|-|\d/gi,"")?null:n:null),u=n=>d(n)&&(n=n.trim(),l(n))?n:null,l=n=>n.length>0&&/(\/|\.)/.test(n),d=n=>"string"==typeof n,a=n=>n.toLowerCase(),f=(n,t=[])=>{const e={};return t.forEach((t=>{n.hasAttribute(t)&&(null!==n.getAttribute(t)&&(e[t]=n.getAttribute(t)),n.removeAttribute(t))})),e},c=n=>n&&""!==n.dir?"rtl"===n.dir.toLowerCase():"rtl"===(null===document||void 0===document?void 0:document.dir.toLowerCase());export{r as a,f as b,s as c,c as d,i as g,d as i}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
System.register(["./p-ea7bbed1.system.js"],(function(e,t){"use strict";var n,r;return{setters:[function(e){n=e.p;r=e.b}],execute:function(){var e=function(){var e=t.meta.url;var r={};if(e!==""){r.resourcesUrl=new URL(".",e).href}return n(r)};e().then((function(e){return r([["p-2b141077.system",[[1,"ion-icon",{mode:[1025],color:[1],ios:[1],md:[1],flipRtl:[4,"flip-rtl"],name:[513],src:[1],icon:[8],size:[1],lazy:[4],sanitize:[4],svgContent:[32],isVisible:[32]}]]]],e)}))}}}));
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
System.register(["./p-ea7bbed1.system.js"],(function(r){"use strict";var n;return{setters:[function(r){n=r.g}],execute:function(){var t;var e=function(){if(typeof window==="undefined"){return new Map}else{if(!t){var r=window;r.Ionicons=r.Ionicons||{};t=r.Ionicons.map=r.Ionicons.map||new Map}return t}};var i=r("a",(function(r){var n=e();Object.keys(r).forEach((function(t){return n.set(t,r[t])}))}));var o=r("g",(function(r){var n=c(r.src);if(n){return n}n=f(r.name,r.icon,r.mode,r.ios,r.md);if(n){return u(n)}if(r.icon){n=c(r.icon);if(n){return n}n=c(r.icon[r.mode]);if(n){return n}}return null}));var u=function(r){var t=e().get(r);if(t){return t}return n("svg/".concat(r,".svg"))};var f=r("c",(function(r,n,t,e,i){t=(t&&v(t))==="ios"?"ios":"md";if(e&&t==="ios"){r=v(e)}else if(i&&t==="md"){r=v(i)}else{if(!r&&n&&!a(n)){r=n}if(s(r)){r=v(r)}}if(!s(r)||r.trim()===""){return null}var o=r.replace(/[a-z]|-|\d/gi,"");if(o!==""){return null}return r}));var c=function(r){if(s(r)){r=r.trim();if(a(r)){return r}}return null};var a=function(r){return r.length>0&&/(\/|\.)/.test(r)};var s=r("i",(function(r){return typeof r==="string"}));var v=function(r){return r.toLowerCase()};var d=r("b",(function(r,n){if(n===void 0){n=[]}var t={};n.forEach((function(n){if(r.hasAttribute(n)){var e=r.getAttribute(n);if(e!==null){t[n]=r.getAttribute(n)}r.removeAttribute(n)}}));return t}));var l=r("d",(function(r){if(r){if(r.dir!==""){return r.dir.toLowerCase()==="rtl"}}return(document===null||document===void 0?void 0:document.dir.toLowerCase())==="rtl"}))}}}));
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
System.register(["./p-cff0a9de.system.js","./p-ea7bbed1.system.js"],(function(e){"use strict";return{setters:[function(t){e("addIcons",t.a)},function(){}],execute:function(){}}}));
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="ionicon" viewBox="0 0 512 512"><circle stroke-linejoin="round" cx="256" cy="56" r="40" class="ionicon-fill-none ionicon-stroke-width"/><path stroke-linejoin="round" d="M204.23 274.44c2.9-18.06 4.2-35.52-.5-47.59-4-10.38-12.7-16.19-23.2-20.15L88 176.76c-12-4-23.21-10.7-24-23.94-1-17 14-28 29-24 0 0 88 31.14 163 31.14s162-31 162-31c18-5 30 9 30 23.79 0 14.21-11 19.21-24 23.94l-88 31.91c-8 3-21 9-26 18.18-6 10.75-5 29.53-2.1 47.59l5.9 29.63 37.41 163.9c2.8 13.15-6.3 25.44-19.4 27.74S308 489 304.12 476.28l-37.56-115.93q-2.71-8.34-4.8-16.87L256 320l-5.3 21.65q-2.52 10.35-5.8 20.48L208 476.18c-4 12.85-14.5 21.75-27.6 19.46s-22.4-15.59-19.46-27.74l37.39-163.83z" class="ionicon-fill-none ionicon-stroke-width"/></svg>
|
||||||
|
After Width: | Height: | Size: 765 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="ionicon" viewBox="0 0 512 512"><path d="M448 112c-66.82 17.92-119.55 32-192 32s-125.18-14.08-192-32l-16 51c48 20.53 96.71 35.16 147.2 53.2L144 496l56.4 16L246 336h20l45.6 176 56.4-16-51.2-279.8C367.26 199.93 416 183.53 464 164zM256 112a56 56 0 1156-56 56.06 56.06 0 01-56 56z"/></svg>
|
||||||
|
After Width: | Height: | Size: 331 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="ionicon" viewBox="0 0 512 512"><path d="M256 112a56 56 0 1156-56 56.06 56.06 0 01-56 56z"/><path d="M432 112.8l-.45.12-.42.13c-1 .28-2 .58-3 .89-18.61 5.46-108.93 30.92-172.56 30.92-59.13 0-141.28-22-167.56-29.47a73.79 73.79 0 00-8-2.58c-19-5-32 14.3-32 31.94 0 17.47 15.7 25.79 31.55 31.76v.28l95.22 29.74c9.73 3.73 12.33 7.54 13.6 10.84 4.13 10.59.83 31.56-.34 38.88l-5.8 45-32.19 176.19q-.15.72-.27 1.47l-.23 1.27c-2.32 16.15 9.54 31.82 32 31.82 19.6 0 28.25-13.53 32-31.94s28-157.57 42-157.57 42.84 157.57 42.84 157.57c3.75 18.41 12.4 31.94 32 31.94 22.52 0 34.38-15.74 32-31.94a57.17 57.17 0 00-.76-4.06L329 301.27l-5.79-45c-4.19-26.21-.82-34.87.32-36.9a1.09 1.09 0 00.08-.15c1.08-2 6-6.48 17.48-10.79l89.28-31.21a16.9 16.9 0 001.62-.52c16-6 32-14.3 32-31.93S451 107.81 432 112.8z"/></svg>
|
||||||
|
After Width: | Height: | Size: 841 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="ionicon" viewBox="0 0 512 512"><path d="M448 256c0-106-86-192-192-192S64 150 64 256s86 192 192 192 192-86 192-192z" stroke-miterlimit="10" class="ionicon-fill-none ionicon-stroke-width"/><path stroke-linecap="round" stroke-linejoin="round" d="M256 176v160M336 256H176" class="ionicon-fill-none ionicon-stroke-width"/></svg>
|
||||||
|
After Width: | Height: | Size: 370 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="ionicon" viewBox="0 0 512 512"><path d="M256 48C141.31 48 48 141.31 48 256s93.31 208 208 208 208-93.31 208-208S370.69 48 256 48zm96 224h-80v80h-32v-80h-80v-32h80v-80h32v80h80z"/></svg>
|
||||||
|
After Width: | Height: | Size: 231 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="ionicon" viewBox="0 0 512 512"><path d="M256 48C141.31 48 48 141.31 48 256s93.31 208 208 208 208-93.31 208-208S370.69 48 256 48zm80 224h-64v64a16 16 0 01-32 0v-64h-64a16 16 0 010-32h64v-64a16 16 0 0132 0v64h64a16 16 0 010 32z"/></svg>
|
||||||
|
After Width: | Height: | Size: 281 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="ionicon" viewBox="0 0 512 512"><path stroke-linecap="round" stroke-linejoin="round" d="M256 112v288M400 256H112" class="ionicon-fill-none ionicon-stroke-width"/></svg>
|
||||||
|
After Width: | Height: | Size: 214 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="ionicon" viewBox="0 0 512 512"><path stroke-linecap="square" stroke-linejoin="round" d="M256 112v288M400 256H112" class="ionicon-fill-none ionicon-stroke-width"/></svg>
|
||||||
|
After Width: | Height: | Size: 215 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="ionicon" viewBox="0 0 512 512"><path stroke-linecap="round" stroke-linejoin="round" d="M256 112v288M400 256H112" class="ionicon-fill-none ionicon-stroke-width"/></svg>
|
||||||
|
After Width: | Height: | Size: 214 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="ionicon" viewBox="0 0 512 512"><path d="M407.72 224c-3.4 0-14.79.1-18 .3l-64.9 1.7a1.83 1.83 0 01-1.69-.9L193.55 67.56a9 9 0 00-6.66-3.56H160l73 161a2.35 2.35 0 01-2.26 3.35l-121.69 1.8a8.06 8.06 0 01-6.6-3.1l-37-45c-3-3.9-8.62-6-13.51-6H33.08c-1.29 0-1.1 1.21-.75 2.43l19.84 71.42a16.3 16.3 0 010 11.9L32.31 333c-.59 1.95-.52 3 1.77 3H52c8.14 0 9.25-1.06 13.41-6.3l37.7-45.7a8.19 8.19 0 016.6-3.1l120.68 2.7a2.7 2.7 0 012.43 3.74L160 448h26.64a9 9 0 006.65-3.55L323.14 287c.39-.6 2-.9 2.69-.9l63.9 1.7c3.3.2 14.59.3 18 .3C452 288.1 480 275.93 480 256s-27.88-32-72.28-32z" stroke-linecap="round" stroke-linejoin="round" class="ionicon-fill-none ionicon-stroke-width"/></svg>
|
||||||
|
After Width: | Height: | Size: 721 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="ionicon" viewBox="0 0 512 512"><path d="M407.72 208c-2.72 0-14.44.08-18.67.31l-57.77 1.52L198.06 48h-62.81l74.59 164.61-97.31 1.44L68.25 160H16.14l20.61 94.18c.15.54.33 1.07.53 1.59a.26.26 0 010 .15 15.42 15.42 0 00-.53 1.58L15.86 352h51.78l45.45-55 96.77 2.17L135.24 464h63l133-161.75 57.77 1.54c4.29.23 16 .31 18.66.31 24.35 0 44.27-3.34 59.21-9.94C492.22 283 496 265.46 496 256c0-30.06-33-48-88.28-48zm-71.29 87.9z"/></svg>
|
||||||
|
After Width: | Height: | Size: 473 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="ionicon" viewBox="0 0 512 512"><path d="M186.62 464H160a16 16 0 01-14.57-22.6l64.46-142.25L113.1 297l-35.3 42.77C71.07 348.23 65.7 352 52 352H34.08a17.66 17.66 0 01-14.7-7.06c-2.38-3.21-4.72-8.65-2.44-16.41l19.82-71c.15-.53.33-1.06.53-1.58a.38.38 0 000-.15 14.82 14.82 0 01-.53-1.59l-19.84-71.45c-2.15-7.61.2-12.93 2.56-16.06a16.83 16.83 0 0113.6-6.7H52c10.23 0 20.16 4.59 26 12l34.57 42.05 97.32-1.44-64.44-142A16 16 0 01160 48h26.91a25 25 0 0119.35 9.8l125.05 152 57.77-1.52c4.23-.23 15.95-.31 18.66-.31C463 208 496 225.94 496 256c0 9.46-3.78 27-29.07 38.16-14.93 6.6-34.85 9.94-59.21 9.94-2.68 0-14.37-.08-18.66-.31l-57.76-1.54-125.36 152a25 25 0 01-19.32 9.75z"/></svg>
|
||||||
|
After Width: | Height: | Size: 720 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="ionicon" viewBox="0 0 512 512"><path d="M416.07 272a160 160 0 10-160 160 160 160 0 00160-160zM142.12 91.21A46.67 46.67 0 00112 80l-2.79.08C83.66 81.62 64 104 64.07 131c0 13.21 4.66 19.37 10.88 27.23a4.55 4.55 0 003.24 1.77h.88a3.23 3.23 0 002.54-1.31L142.38 99a5.38 5.38 0 001.55-4 5.26 5.26 0 00-1.81-3.79zM369.88 91.21A46.67 46.67 0 01400 80l2.79.08C428.34 81.62 448 104 447.93 131c0 13.21-4.66 19.37-10.88 27.23a4.55 4.55 0 01-3.24 1.76h-.88a3.23 3.23 0 01-2.54-1.31L369.62 99a5.38 5.38 0 01-1.55-4 5.26 5.26 0 011.81-3.79z" stroke-miterlimit="10" class="ionicon-fill-none ionicon-stroke-width"/><path stroke-linecap="round" stroke-linejoin="round" d="M256.07 160v112h-80M416.07 432l-40-40M96.07 432l40-40" class="ionicon-fill-none ionicon-stroke-width"/></svg>
|
||||||
|
After Width: | Height: | Size: 811 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="ionicon" viewBox="0 0 512 512"><path d="M403.79 64.11l-3.27-.1H400c-14.73 0-28.55 5.91-40.52 15S344 96 344 96l88 86s11.25-5.71 17.61-13.85 14.28-18.08 14.32-37.11c.07-35.49-26.33-64.89-60.14-66.93zM108.21 64.11l3.27-.1h.52c14.73 0 28.55 5.91 40.52 15S168 96 168 96l-88 86s-11.25-5.71-17.61-13.85S48.11 150.07 48.07 131C48 95.55 74.4 66.15 108.21 64.11z"/><path d="M391.3 384.6a.06.06 0 010-.08C425 344 441 288.24 427 229.23c-13.64-57.52-72.67-115.69-130.34-128.66C182 74.79 80.07 161.71 80.07 272a175.15 175.15 0 0040.78 112.52.06.06 0 010 .08L73 432a.06.06 0 000 .08l23 22.51a.06.06 0 00.08 0l47.43-47.37a.06.06 0 01.08 0 175.64 175.64 0 00225.05 0h.07L416 454.59a.06.06 0 00.08 0L440 432zM272.07 288h-112-.07v-31.9l.05-.05h80V144h32z"/></svg>
|
||||||
|
After Width: | Height: | Size: 791 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="ionicon" viewBox="0 0 512 512"><path d="M153.59 110.46A21.41 21.41 0 00152.48 79 62.67 62.67 0 00112 64l-3.27.09h-.48C74.4 66.15 48 95.55 48.07 131c0 19 8 29.06 14.32 37.11a20.61 20.61 0 0014.7 7.8c.26 0 .7.05 2 .05a19.06 19.06 0 0013.75-5.89zM403.79 64.11l-3.27-.1H400a62.67 62.67 0 00-40.52 15 21.41 21.41 0 00-1.11 31.44l60.77 59.65a19.06 19.06 0 0013.79 5.9c1.28 0 1.72 0 2-.05a20.61 20.61 0 0014.69-7.8c6.36-8.05 14.28-18.08 14.32-37.11.06-35.49-26.34-64.89-60.15-66.93z"/><path d="M256.07 96c-97 0-176 78.95-176 176a175.23 175.23 0 0040.81 112.56l-36.12 36.13a16 16 0 1022.63 22.62l36.12-36.12a175.63 175.63 0 00225.12 0l36.13 36.12a16 16 0 1022.63-22.62l-36.13-36.13A175.17 175.17 0 00432.07 272c0-97-78.95-176-176-176zm16 176a16 16 0 01-16 16h-80a16 16 0 010-32h64v-96a16 16 0 0132 0z"/></svg>
|
||||||
|
After Width: | Height: | Size: 848 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="ionicon" viewBox="0 0 512 512"><rect x="64" y="176" width="384" height="256" rx="28.87" ry="28.87" stroke-linejoin="round" class="ionicon-fill-none ionicon-stroke-width"/><path stroke-linecap="round" stroke-miterlimit="10" d="M144 80h224M112 128h288" class="ionicon-stroke-width"/></svg>
|
||||||
|
After Width: | Height: | Size: 334 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="ionicon" viewBox="0 0 512 512"><path d="M128 64h256v32H128zM96 112h320v32H96zM464 448H48V160h416z"/></svg>
|
||||||
|
After Width: | Height: | Size: 153 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="ionicon" viewBox="0 0 512 512"><path d="M368 96H144a16 16 0 010-32h224a16 16 0 010 32zM400 144H112a16 16 0 010-32h288a16 16 0 010 32zM419.13 448H92.87A44.92 44.92 0 0148 403.13V204.87A44.92 44.92 0 0192.87 160h326.26A44.92 44.92 0 01464 204.87v198.26A44.92 44.92 0 01419.13 448z"/></svg>
|
||||||
|
After Width: | Height: | Size: 334 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="ionicon" viewBox="0 0 512 512"><path d="M448 256c0-106-86-192-192-192S64 150 64 256s86 192 192 192 192-86 192-192z" stroke-miterlimit="10" class="ionicon-fill-none ionicon-stroke-width"/><path d="M250.26 166.05L256 288l5.73-121.95a5.74 5.74 0 00-5.79-6h0a5.74 5.74 0 00-5.68 6z" stroke-linecap="round" stroke-linejoin="round" class="ionicon-fill-none ionicon-stroke-width"/><path d="M256 367.91a20 20 0 1120-20 20 20 0 01-20 20z"/></svg>
|
||||||
|
After Width: | Height: | Size: 484 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="ionicon" viewBox="0 0 512 512"><path d="M240 304h32l6-160h-44l6 160z" class="ionicon-fill-none"/><path d="M256 48C141.31 48 48 141.31 48 256s93.31 208 208 208 208-93.31 208-208S370.69 48 256 48zm20 319.91h-40v-40h40zM272 304h-32l-6-160h44z"/></svg>
|
||||||
|
After Width: | Height: | Size: 295 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="ionicon" viewBox="0 0 512 512"><path d="M256 48C141.31 48 48 141.31 48 256s93.31 208 208 208 208-93.31 208-208S370.69 48 256 48zm0 319.91a20 20 0 1120-20 20 20 0 01-20 20zm21.72-201.15l-5.74 122a16 16 0 01-32 0l-5.74-121.94v-.05a21.74 21.74 0 1143.44 0z"/></svg>
|
||||||
|
After Width: | Height: | Size: 309 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="ionicon" viewBox="0 0 512 512"><path d="M256 80c-8.66 0-16.58 7.36-16 16l8 216a8 8 0 008 8h0a8 8 0 008-8l8-216c.58-8.64-7.34-16-16-16z" stroke-linecap="round" stroke-linejoin="round" class="ionicon-fill-none ionicon-stroke-width"/><circle cx="256" cy="416" r="16" stroke-linecap="round" stroke-linejoin="round" class="ionicon-fill-none ionicon-stroke-width"/></svg>
|
||||||
|
After Width: | Height: | Size: 412 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="ionicon" viewBox="0 0 512 512"><path stroke-linecap="square" stroke-miterlimit="10" d="M240 80l8 240h16l8-240h-32zM240 400h32v32h-32z" class="ionicon-fill-none ionicon-stroke-width"/></svg>
|
||||||
|
After Width: | Height: | Size: 236 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="ionicon" viewBox="0 0 512 512"><path d="M256 80c-8.66 0-16.58 7.36-16 16l8 216a8 8 0 008 8h0a8 8 0 008-8l8-216c.58-8.64-7.34-16-16-16z" stroke-linecap="round" stroke-linejoin="round" class="ionicon-fill-none ionicon-stroke-width"/><circle cx="256" cy="416" r="16" stroke-linecap="round" stroke-linejoin="round" class="ionicon-fill-none ionicon-stroke-width"/></svg>
|
||||||
|
After Width: | Height: | Size: 412 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="ionicon" viewBox="0 0 512 512"><ellipse cx="256" cy="256" rx="267.57" ry="173.44" transform="rotate(-45 256 256.002)" stroke-linecap="round" stroke-linejoin="round" class="ionicon-fill-none ionicon-stroke-width"/><path stroke-linecap="round" stroke-linejoin="round" d="M334.04 177.96L177.96 334.04M278.3 278.3l-44.6-44.6M322.89 233.7l-44.59-44.59M456.68 211.4L300.6 55.32M211.4 456.68L55.32 300.6M233.7 322.89l-44.59-44.59" class="ionicon-fill-none ionicon-stroke-width"/></svg>
|
||||||
|
After Width: | Height: | Size: 525 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="ionicon" viewBox="0 0 512 512"><path d="M120.9 120.51c-44.75 44.56-67.29 101.05-78.64 145.9l202.31 201.44c45.05-11.3 101.78-33.74 146.53-78.3s67.29-101.05 78.64-145.91L267.43 42.21C222.38 53.51 165.65 76 120.9 120.51zm259 257.83zm-45-224.2l22.52 22.42-33.78 33.63 22.52 22.42L323.55 255 301 232.61 278.52 255 301 277.45l-22.51 22.42L256 277.45l-22.52 22.42L256 322.29l-22.52 22.42L211 322.29l-33.78 33.63-22.55-22.42 33.78-33.63-22.52-22.42L188.45 255 211 277.45 233.48 255 211 232.61l22.51-22.42L256 232.61l22.52-22.42L256 187.77l22.52-22.42L301 187.77zM478.48 198.41C485.85 143.65 464 48.05 464 48.05s-96.14-21.88-151.14-14.54c-2.54.33-5.21.72-8 1.14l172.47 171.71c.43-2.76.81-5.42 1.15-7.95zM33.52 311.65C26.15 366.41 48.05 464 48.05 464s60 16 99.86 16a391.92 391.92 0 0051.23-3.45c2.54-.33 5.21-.72 8-1.15L34.67 303.7c-.43 2.76-.81 5.42-1.15 7.95z"/></svg>
|
||||||
|
After Width: | Height: | Size: 907 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="ionicon" viewBox="0 0 512 512"><path d="M122.06 122.06c-44.37 44.37-66.71 100.61-78 145.28l200.6 200.56c44.67-11.25 100.91-33.59 145.28-78s66.71-100.61 78-145.28L267.34 44.1c-44.67 11.25-100.91 33.59-145.28 77.96zm256.73 256.72zM300.65 189L323 166.71A15.78 15.78 0 01345.29 189L323 211.35l11.16 11.17a15.78 15.78 0 01-22.32 22.32l-11.16-11.16L278.32 256l11.16 11.16a15.78 15.78 0 11-22.32 22.32L256 278.32l-22.32 22.33 11.16 11.16a15.78 15.78 0 11-22.32 22.32L211.35 323 189 345.29A15.78 15.78 0 01166.71 323L189 300.65l-11.16-11.17a15.78 15.78 0 0122.32-22.32l11.16 11.16L233.68 256l-11.16-11.16a15.78 15.78 0 1122.32-22.32L256 233.68l22.32-22.33-11.16-11.16a15.78 15.78 0 0122.32-22.32zM476.57 199.63c7.31-54.53 4-120.26-20-144.21s-89.68-27.3-144.21-20c-2.51.34-5.16.72-7.91 1.15l171 171c.4-2.78.78-5.43 1.12-7.94zM35.43 312.37c-7.31 54.53-4 120.26 20 144.21C72.17 473.33 109.34 480 148.84 480a387 387 0 0050.79-3.43c2.51-.34 5.16-.72 7.91-1.15l-171-171c-.39 2.79-.77 5.44-1.11 7.95z"/></svg>
|
||||||
|
After Width: | Height: | Size: 1.0 KiB |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="ionicon" viewBox="0 0 512 512"><path stroke-linecap="round" stroke-linejoin="round" d="M344 280l88-88M232 216l64 64M80 320l104-104" class="ionicon-fill-none ionicon-stroke-width"/><circle cx="456" cy="168" r="24" stroke-linecap="round" stroke-linejoin="round" class="ionicon-fill-none ionicon-stroke-width"/><circle cx="320" cy="304" r="24" stroke-linecap="round" stroke-linejoin="round" class="ionicon-fill-none ionicon-stroke-width"/><circle cx="208" cy="192" r="24" stroke-linecap="round" stroke-linejoin="round" class="ionicon-fill-none ionicon-stroke-width"/><circle cx="56" cy="344" r="24" stroke-linecap="round" stroke-linejoin="round" class="ionicon-fill-none ionicon-stroke-width"/></svg>
|
||||||
|
After Width: | Height: | Size: 744 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="ionicon" viewBox="0 0 512 512"><path d="M450 128a46 46 0 00-44.11 59l-71.37 71.36a45.88 45.88 0 00-29 0l-52.91-52.91a46 46 0 10-89.12 0L75 293.88A46.08 46.08 0 10106.11 325l87.37-87.36a45.85 45.85 0 0029 0l52.92 52.92a46 46 0 1089.12 0L437 218.12A46 46 0 10450 128z"/></svg>
|
||||||
|
After Width: | Height: | Size: 321 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="ionicon" viewBox="0 0 512 512"><path d="M456 128a40 40 0 00-37.23 54.6l-84.17 84.17a39.86 39.86 0 00-29.2 0l-60.17-60.17a40 40 0 10-74.46 0L70.6 306.77a40 40 0 1022.63 22.63L193.4 229.23a39.86 39.86 0 0029.2 0l60.17 60.17a40 40 0 1074.46 0l84.17-84.17A40 40 0 10456 128z"/></svg>
|
||||||
|
After Width: | Height: | Size: 326 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="ionicon" viewBox="0 0 512 512"><path d="M448 256c0-106-86-192-192-192S64 150 64 256s86 192 192 192 192-86 192-192z" stroke-miterlimit="10" class="ionicon-fill-none ionicon-stroke-width"/><path stroke-linecap="round" stroke-linejoin="round" d="M360 94.59V296M443.13 212.87L296 360M417.41 360H216M299.13 443.13l-144-144M152 416V216M68.87 299.13l144-144M94.59 152H288M212.87 68.87L360 216" class="ionicon-fill-none ionicon-stroke-width"/></svg>
|
||||||
|
After Width: | Height: | Size: 488 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="ionicon" viewBox="0 0 512 512"><path d="M216 48l129.49 128.18V48H216zM181.47 58.38L80 134h176l-74.53-75.62zM336 344h128V216L336 344zM454 182L378 80v176l76-74zM48 166v128l128-128H48zM330 454l102-76H256l74 76zM58 330l76 102V256l-76 74zM345.49 222.12l-55.55-55.46h-67.88l-55.55 55.46v67.76l55.62 55.52c.44 0 .88-.06 1.33-.06h66.48l55.55-55.46zM165.98 336.09L166 464h128L165.98 336.09z"/></svg>
|
||||||
|
After Width: | Height: | Size: 437 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="ionicon" viewBox="0 0 512 512"><path d="M250.54 129.17l-67.8-67.8A209.65 209.65 0 0086.32 136h161.4a4 4 0 002.82-6.83zM167.72 168H67.63a207.34 207.34 0 00-16.15 125.9l119.06-119.07a4 4 0 00-2.82-6.83zM344 167.72V67.56a207.82 207.82 0 00-125.89-16.08l119.06 119.06a4 4 0 006.83-2.82zM460.52 218.1L341.46 337.17a4 4 0 002.82 6.83h100.09a207.34 207.34 0 0016.15-125.9zM382.83 250.54l67.83-67.82A209.08 209.08 0 00376 86.2v161.52a4 4 0 006.83 2.82zM221.68 341.77a8 8 0 005.54 2.23h59.66a8 8 0 005.7-2.39l49.18-50a8 8 0 002.3-5.62l-.06-60.81a8 8 0 00-2.38-5.69l-50-49.25a8 8 0 00-5.63-2.3l-60.84.06a8 8 0 00-5.69 2.38l-49.25 50a8 8 0 00-2.3 5.63l.06 60.78a8 8 0 002.45 5.76zM261.46 382.83l67.8 67.8A209.65 209.65 0 00425.68 376h-161.4a4 4 0 00-2.82 6.83zM168 344.28v100.16a207.82 207.82 0 00125.89 16.08L174.83 341.46a4 4 0 00-6.83 2.82zM129.17 261.46l-67.83 67.83A209.1 209.1 0 00136 425.8V264.28a4 4 0 00-6.83-2.82z"/></svg>
|
||||||
|
After Width: | Height: | Size: 968 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="ionicon" viewBox="0 0 512 512"><rect x="64" y="64" width="80" height="80" rx="40" ry="40" stroke-miterlimit="10" class="ionicon-fill-none ionicon-stroke-width"/><rect x="216" y="64" width="80" height="80" rx="40" ry="40" stroke-miterlimit="10" class="ionicon-fill-none ionicon-stroke-width"/><rect x="368" y="64" width="80" height="80" rx="40" ry="40" stroke-miterlimit="10" class="ionicon-fill-none ionicon-stroke-width"/><rect x="64" y="216" width="80" height="80" rx="40" ry="40" stroke-miterlimit="10" class="ionicon-fill-none ionicon-stroke-width"/><rect x="216" y="216" width="80" height="80" rx="40" ry="40" stroke-miterlimit="10" class="ionicon-fill-none ionicon-stroke-width"/><rect x="368" y="216" width="80" height="80" rx="40" ry="40" stroke-miterlimit="10" class="ionicon-fill-none ionicon-stroke-width"/><rect x="64" y="368" width="80" height="80" rx="40" ry="40" stroke-miterlimit="10" class="ionicon-fill-none ionicon-stroke-width"/><rect x="216" y="368" width="80" height="80" rx="40" ry="40" stroke-miterlimit="10" class="ionicon-fill-none ionicon-stroke-width"/><rect x="368" y="368" width="80" height="80" rx="40" ry="40" stroke-miterlimit="10" class="ionicon-fill-none ionicon-stroke-width"/></svg>
|
||||||
|
After Width: | Height: | Size: 1.2 KiB |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="ionicon" viewBox="0 0 512 512"><rect x="48" y="48" width="112" height="112" rx="8" ry="8"/><rect x="200" y="48" width="112" height="112" rx="8" ry="8"/><rect x="352" y="48" width="112" height="112" rx="8" ry="8"/><rect x="48" y="200" width="112" height="112" rx="8" ry="8"/><rect x="200" y="200" width="112" height="112" rx="8" ry="8"/><rect x="352" y="200" width="112" height="112" rx="8" ry="8"/><rect x="48" y="352" width="112" height="112" rx="8" ry="8"/><rect x="200" y="352" width="112" height="112" rx="8" ry="8"/><rect x="352" y="352" width="112" height="112" rx="8" ry="8"/></svg>
|
||||||
|
After Width: | Height: | Size: 636 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="ionicon" viewBox="0 0 512 512"><path d="M104 160a56 56 0 1156-56 56.06 56.06 0 01-56 56zM256 160a56 56 0 1156-56 56.06 56.06 0 01-56 56zM408 160a56 56 0 1156-56 56.06 56.06 0 01-56 56zM104 312a56 56 0 1156-56 56.06 56.06 0 01-56 56zM256 312a56 56 0 1156-56 56.06 56.06 0 01-56 56zM408 312a56 56 0 1156-56 56.06 56.06 0 01-56 56zM104 464a56 56 0 1156-56 56.06 56.06 0 01-56 56zM256 464a56 56 0 1156-56 56.06 56.06 0 01-56 56zM408 464a56 56 0 1156-56 56.06 56.06 0 01-56 56z"/></svg>
|
||||||
|
After Width: | Height: | Size: 528 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="ionicon" viewBox="0 0 512 512"><path d="M80 152v256a40.12 40.12 0 0040 40h272a40.12 40.12 0 0040-40V152" stroke-linecap="round" stroke-linejoin="round" class="ionicon-fill-none ionicon-stroke-width"/><rect x="48" y="64" width="416" height="80" rx="28" ry="28" stroke-linejoin="round" class="ionicon-fill-none ionicon-stroke-width"/><path stroke-linecap="round" stroke-linejoin="round" d="M320 304l-64 64-64-64M256 345.89V224" class="ionicon-fill-none ionicon-stroke-width"/></svg>
|
||||||
|
After Width: | Height: | Size: 527 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="ionicon" viewBox="0 0 512 512"><rect x="32" y="48" width="448" height="80" rx="12" ry="12"/><path d="M64 160v280a24 24 0 0024 24h336a24 24 0 0024-24V160zm192 230.63L169.32 304 192 281.32l48 48.05V208h32v121.37l48.07-48.07 22.61 22.64z"/></svg>
|
||||||
|
After Width: | Height: | Size: 290 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="ionicon" viewBox="0 0 512 512"><path d="M64 164v244a56 56 0 0056 56h272a56 56 0 0056-56V164a4 4 0 00-4-4H68a4 4 0 00-4 4zm267 151.63l-63.69 63.68a16 16 0 01-22.62 0L181 315.63c-6.09-6.09-6.65-16-.85-22.38a16 16 0 0123.16-.56L240 329.37V224.45c0-8.61 6.62-16 15.23-16.43A16 16 0 01272 224v105.37l36.69-36.68a16 16 0 0123.16.56c5.8 6.37 5.24 16.29-.85 22.38z"/><rect x="32" y="48" width="448" height="80" rx="32" ry="32"/></svg>
|
||||||
|
After Width: | Height: | Size: 473 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="ionicon" viewBox="0 0 512 512"><path stroke-linecap="round" stroke-linejoin="round" d="M249.38 336L170 256l79.38-80M181.03 256H342" class="ionicon-fill-none ionicon-stroke-width"/><path d="M448 256c0-106-86-192-192-192S64 150 64 256s86 192 192 192 192-86 192-192z" stroke-miterlimit="10" class="ionicon-fill-none ionicon-stroke-width"/></svg>
|
||||||
|
After Width: | Height: | Size: 389 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="ionicon" viewBox="0 0 512 512"><path d="M48 256c0 114.87 93.13 208 208 208s208-93.13 208-208S370.87 48 256 48 48 141.13 48 256zm224-80.09L208.42 240H358v32H208.42L272 336.09l-22.7 22.54L147.46 256 249.3 153.37z"/></svg>
|
||||||
|
After Width: | Height: | Size: 266 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="ionicon" viewBox="0 0 512 512"><path d="M48 256c0 114.87 93.13 208 208 208s208-93.13 208-208S370.87 48 256 48 48 141.13 48 256zm212.65-91.36a16 16 0 01.09 22.63L208.42 240H342a16 16 0 010 32H208.42l52.32 52.73A16 16 0 11238 347.27l-79.39-80a16 16 0 010-22.54l79.39-80a16 16 0 0122.65-.09z"/></svg>
|
||||||
|
After Width: | Height: | Size: 344 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="ionicon" viewBox="0 0 512 512"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="48" d="M244 400L100 256l144-144M120 256h292" class="ionicon-fill-none"/></svg>
|
||||||
|
After Width: | Height: | Size: 223 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="ionicon" viewBox="0 0 512 512"><path stroke-linecap="square" stroke-miterlimit="10" stroke-width="48" d="M244 400L100 256l144-144M120 256h292" class="ionicon-fill-none"/></svg>
|
||||||
|
After Width: | Height: | Size: 223 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="ionicon" viewBox="0 0 512 512"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="48" d="M244 400L100 256l144-144M120 256h292" class="ionicon-fill-none"/></svg>
|
||||||
|
After Width: | Height: | Size: 223 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="ionicon" viewBox="0 0 512 512"><path stroke-linecap="round" stroke-linejoin="round" d="M176 262.62L256 342l80-79.38M256 330.97V170" class="ionicon-fill-none ionicon-stroke-width"/><path d="M256 64C150 64 64 150 64 256s86 192 192 192 192-86 192-192S362 64 256 64z" stroke-miterlimit="10" class="ionicon-fill-none ionicon-stroke-width"/></svg>
|
||||||
|
After Width: | Height: | Size: 388 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="ionicon" viewBox="0 0 512 512"><path d="M256 464c114.87 0 208-93.13 208-208S370.87 48 256 48 48 141.13 48 256s93.13 208 208 208zm-80.09-224L240 303.58V154h32v149.58L336.09 240l22.54 22.71L256 364.54 153.37 262.7z"/></svg>
|
||||||
|
After Width: | Height: | Size: 268 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="ionicon" viewBox="0 0 512 512"><path d="M256 464c114.87 0 208-93.13 208-208S370.87 48 256 48 48 141.13 48 256s93.13 208 208 208zm-91.36-212.65a16 16 0 0122.63-.09L240 303.58V170a16 16 0 0132 0v133.58l52.73-52.32A16 16 0 11347.27 274l-80 79.39a16 16 0 01-22.54 0l-80-79.39a16 16 0 01-.09-22.65z"/></svg>
|
||||||
|
After Width: | Height: | Size: 349 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="ionicon" viewBox="0 0 512 512"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="48" d="M112 268l144 144 144-144M256 392V100" class="ionicon-fill-none"/></svg>
|
||||||
|
After Width: | Height: | Size: 223 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="ionicon" viewBox="0 0 512 512"><path stroke-linecap="square" stroke-miterlimit="10" stroke-width="48" d="M112 268l144 144 144-144M256 392V100" class="ionicon-fill-none"/></svg>
|
||||||
|
After Width: | Height: | Size: 223 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="ionicon" viewBox="0 0 512 512"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="48" d="M112 268l144 144 144-144M256 392V100" class="ionicon-fill-none"/></svg>
|
||||||
|
After Width: | Height: | Size: 223 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="ionicon" viewBox="0 0 512 512"><path stroke-linecap="round" stroke-linejoin="round" d="M262.62 336L342 256l-79.38-80M330.97 256H170" class="ionicon-fill-none ionicon-stroke-width"/><path d="M256 448c106 0 192-86 192-192S362 64 256 64 64 150 64 256s86 192 192 192z" stroke-miterlimit="10" class="ionicon-fill-none ionicon-stroke-width"/></svg>
|
||||||
|
After Width: | Height: | Size: 389 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="ionicon" viewBox="0 0 512 512"><path d="M464 256c0-114.87-93.13-208-208-208S48 141.13 48 256s93.13 208 208 208 208-93.13 208-208zm-224 80.09L303.58 272H154v-32h149.58L240 175.91l22.71-22.54L364.54 256 262.7 358.63z"/></svg>
|
||||||
|
After Width: | Height: | Size: 270 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="ionicon" viewBox="0 0 512 512"><path d="M464 256c0-114.87-93.13-208-208-208S48 141.13 48 256s93.13 208 208 208 208-93.13 208-208zm-212.65 91.36a16 16 0 01-.09-22.63L303.58 272H170a16 16 0 010-32h133.58l-52.32-52.73A16 16 0 11274 164.73l79.39 80a16 16 0 010 22.54l-79.39 80a16 16 0 01-22.65.09z"/></svg>
|
||||||
|
After Width: | Height: | Size: 349 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="ionicon" viewBox="0 0 512 512"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="48" d="M268 112l144 144-144 144M392 256H100" class="ionicon-fill-none"/></svg>
|
||||||
|
After Width: | Height: | Size: 223 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="ionicon" viewBox="0 0 512 512"><path stroke-linecap="square" stroke-miterlimit="10" stroke-width="48" d="M268 112l144 144-144 144M392 256H100" class="ionicon-fill-none"/></svg>
|
||||||
|
After Width: | Height: | Size: 223 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="ionicon" viewBox="0 0 512 512"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="48" d="M268 112l144 144-144 144M392 256H100" class="ionicon-fill-none"/></svg>
|
||||||
|
After Width: | Height: | Size: 223 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="ionicon" viewBox="0 0 512 512"><path d="M266.91 327.74v-37.32c-57.07 0-84.51 13.47-108.58 38.68-5.4 5.65-15 1.32-14.29-6.43 5.45-61.45 34.14-117.09 122.87-117.09v-37.32a8.32 8.32 0 0114-6L365.42 242a8.2 8.2 0 010 11.94L281 333.71a8.32 8.32 0 01-14.09-5.97z"/><path d="M64 256c0 106 86 192 192 192s192-86 192-192S362 64 256 64 64 150 64 256z" stroke-miterlimit="10" class="ionicon-fill-none ionicon-stroke-width"/></svg>
|
||||||
|
After Width: | Height: | Size: 466 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="ionicon" viewBox="0 0 512 512"><path d="M48 256c0 114.87 93.13 208 208 208s208-93.13 208-208S370.87 48 256 48 48 141.13 48 256zm98 88c0-68.13 22.67-137.14 119.17-137.14V152L366 248l-100.83 96v-54.86c-66.69 0-91.32 19.29-119.17 54.86z"/></svg>
|
||||||
|
After Width: | Height: | Size: 289 B |