Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
f0fe5409fa
|
@@ -1,4 +1,10 @@
|
||||
# Config file path (default: config.yml)
|
||||
# CONFIG_PATH=config.yml
|
||||
|
||||
APP_DEBUG=false
|
||||
SITE_TITLE=IPTV Checker
|
||||
APP_TIMEZONE=GMT
|
||||
SITE_BASE_URL=http://localhost:8800
|
||||
|
||||
CACHE_ENABLED=false
|
||||
CACHE_HOST=localhost
|
||||
@@ -7,3 +13,8 @@ CACHE_USERNAME=
|
||||
CACHE_PASSWORD=
|
||||
CACHE_DB=1
|
||||
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
|
||||
go-version: '>=1.24.2'
|
||||
|
||||
- name: Build release files (amd64)
|
||||
run: make release ARCH=amd64
|
||||
|
||||
- name: Build release files (arm64)
|
||||
run: make release ARCH=arm64
|
||||
- name: Build release files
|
||||
run: make release
|
||||
|
||||
- name: Create new release
|
||||
id: use-go-action
|
||||
@@ -40,7 +37,6 @@ jobs:
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
with: # https://github.com/docker/setup-buildx-action
|
||||
build-args: "IPTVC_VERSION=${{ github.ref_name }}"
|
||||
buildkitd-config-inline: |
|
||||
# https://github.com/moby/buildkit/blob/master/docs/buildkitd.toml.md
|
||||
[ registry."docker.io" ]
|
||||
@@ -60,6 +56,8 @@ jobs:
|
||||
with: # https://github.com/docker/build-push-action
|
||||
context: .
|
||||
push: true
|
||||
build-args: |
|
||||
IPTVC_VERSION=${{ github.ref_name }}
|
||||
tags: |
|
||||
git.axenov.dev/iptv/iptvc:${{ github.ref_name }}
|
||||
git.axenov.dev/iptv/iptvc:latest
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
.idea/
|
||||
.vscode/
|
||||
bin/
|
||||
output/
|
||||
|
||||
/.idea/
|
||||
/bin/
|
||||
/output/
|
||||
/docker/keydb/data/
|
||||
# /docs/
|
||||
.DS_Store
|
||||
.env
|
||||
*.env
|
||||
*.bak
|
||||
*.m3u
|
||||
*.m3u8
|
||||
*.json
|
||||
*.ini
|
||||
*.rdb
|
||||
iptvc
|
||||
|
||||
!/**/*.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 GOARCH
|
||||
ARG IPTVC_VERSION
|
||||
@@ -14,7 +14,7 @@ RUN go build \
|
||||
-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>"
|
||||
WORKDIR /app
|
||||
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
|
||||
.PHONY: clear linux win darwin release image image-push up down help
|
||||
|
||||
BINARY_NAME := iptvc
|
||||
GOARCH ?= amd64
|
||||
IMAGE_TAG ?= latest
|
||||
GOARCH ?= $(shell go env GOARCH)
|
||||
GOOS ?= $(shell go env GOOS)
|
||||
|
||||
## clean: Remove all compiled binaries
|
||||
clean:
|
||||
VERSION := $(shell git describe --tags --always --dirty 2>/dev/null | sed 's/^v//' || echo "dev")
|
||||
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
|
||||
@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:
|
||||
@rm -rf bin/linux_$(GOARCH)
|
||||
@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))"
|
||||
@$(MAKE) internal-build GOOS=linux GOARCH=$(GOARCH) OUTPUT=bin/linux_$(GOARCH)/$(BINARY_NAME)
|
||||
|
||||
## win: Build new binaries for windows
|
||||
## win = Build new binaries for windows
|
||||
win:
|
||||
@rm -rf bin/windows_$(GOARCH)
|
||||
@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))"
|
||||
@$(MAKE) internal-build GOOS=windows GOARCH=$(GOARCH) OUTPUT=bin/windows_$(GOARCH)/$(BINARY_NAME).exe
|
||||
|
||||
## darwin: Build new binaries for darwin
|
||||
## darwin = Build new binaries for darwin
|
||||
darwin:
|
||||
@rm -rf bin/darwin_$(GOARCH)
|
||||
@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))"
|
||||
@$(MAKE) internal-build GOOS=darwin GOARCH=$(GOARCH) OUTPUT=bin/darwin_$(GOARCH)/$(BINARY_NAME)
|
||||
|
||||
## release: Build all binaries and zip them
|
||||
release: clean
|
||||
@make linux GOARCH=amd64
|
||||
@make linux GOARCH=arm64
|
||||
@make win GOARCH=amd64
|
||||
@make win GOARCH=arm64
|
||||
@make darwin GOARCH=amd64
|
||||
@make darwin GOARCH=arm64
|
||||
## release = Build all binaries and zip them
|
||||
release: clear
|
||||
@$(MAKE) -j2 linux GOARCH=amd64
|
||||
@$(MAKE) -j2 linux GOARCH=arm64
|
||||
@$(MAKE) -j2 win GOARCH=amd64
|
||||
@$(MAKE) -j2 win GOARCH=arm64
|
||||
@$(MAKE) -j2 darwin GOARCH=amd64
|
||||
@$(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
|
||||
@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)
|
||||
|
||||
[](https://git.axenov.dev/IPTV/iptvc/releases/latest)
|
||||
[](https://git.axenov.dev/IPTV/iptvc/releases/latest)
|
||||
|
||||
Консольная программа для проверки IPTV-плейлистов в формате m3u или m3u8.
|
||||
|
||||
> **Веб-сайт:** [m3u.su](https://m3u.su)
|
||||
> **Документация:** [m3u.su/docs](https://m3u.su/docs)
|
||||
> Исходный код: [git.axenov.dev/IPTV](https://git.axenov.dev/IPTV)
|
||||
> Документация: [m3u.su/docs](https://m3u.su/docs)
|
||||
> Telegram-канал: [@iptv_aggregator](https://t.me/iptv_aggregator)
|
||||
> Обсуждение: [@iptv_aggregator_chat](https://t.me/iptv_aggregator_chat)
|
||||
> Бот: [@iptv_aggregator_bot](https://t.me/iptv_aggregator_bot)
|
||||
> Исходный код: [git.axenov.dev/IPTV](https://git.axenov.dev/IPTV)
|
||||
|
||||
## Установка
|
||||
|
||||
Достаточно скачать и распаковать архив с подходящим исполняемым файлом [со страницы последнего релиза](https://git.axenov.dev/IPTV/iptvc/releases/latest):
|
||||
|
||||
| ОС | Скачать для `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) |
|
||||
| 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) |
|
||||
| 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) |
|
||||
| ------- | ---------------------------------- | ---------------------------------- |
|
||||
| Linux | [linux_amd64.zip][linux_amd64] | [linux_arm64.zip][linux_arm64] |
|
||||
| MacOS | [darwin_amd64.zip][darwin_amd64] | [darwin_arm64.zip][darwin_arm64] |
|
||||
| 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. Склонировать репозиторий
|
||||
2. Находясь в корне репозитория, следует выполнить `make` или `make help` для получения справки.
|
||||
3. Другой способ — выполнить `go run .` для быстрого запуска.
|
||||
```bash
|
||||
git clone https://git.axenov.dev/IPTV/iptvc.git
|
||||
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` можно запустить `go run .`
|
||||
Если был клонирован репозиторий, то вместо `./iptvc` можно запустить `go run .` или `make help`.
|
||||
|
||||
Ниже рассмотрены простые примеры использования программы для проверки плейлистов.
|
||||
|
||||
@@ -66,13 +126,27 @@
|
||||
|
||||
Аргумент `-i` можно указывать только однажды, но его можно комбинировать с `-f` и `-u`.
|
||||
|
||||
### Другие возможности команды `check`
|
||||
### Флаги команды `check`
|
||||
|
||||
* `--random|-r X` — проверить X случайных плейлистов из ini-файла
|
||||
* `--json|-j` — вывести результаты проверки в формате JSON
|
||||
* `--quiet|-q` — полностью подавить вывод лога (включая отладочную информацию)
|
||||
* `--verbose|-v` — добавить в лог более подробную отладочную информацию (значительно увеличит количество строк!)
|
||||
* `--tags|-t` — файл с перечислением тегов (подробности см. [здесь](https://git.axenov.dev/IPTV/playlists#файл-channelsjson))
|
||||
Источники плейлистов:
|
||||
|
||||
* `--file|-f PATH` — локальный файл плейлиста M3U/M3U8; флаг можно указывать несколько раз;
|
||||
* `--url|-u URL` — удалённый плейлист по HTTP/HTTPS; флаг можно указывать несколько раз;
|
||||
* `--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` и, отфильтровав результат, вывести названия оффлайн каналов:
|
||||
|
||||
@@ -83,6 +157,251 @@
|
||||
> [!NOTE]
|
||||
> Набери `./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 Loaded 2 playlists
|
||||
2025/01/02 12:34:00 [001/001] Playlist [first]
|
||||
2025/01/02 12:34:00 Fetching... (https://example.com/list1.m3u)
|
||||
2025/01/02 12:34:00 1 playlists will be checked (max-routines=1)
|
||||
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 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 Done! count=1 online=1 offline=0 elapsedTime=12.39s
|
||||
```
|
||||
|
||||
Разберём построчно:
|
||||
1. Загрузка ini-файла
|
||||
2. Файл загружен, в нём 2 плейлиста
|
||||
3. Начало обработки плейлиста `first`
|
||||
4. Скачивание плейлиста `first` по ссылке `https://example.com/list1.m3u` в оперативную память
|
||||
5. Плейлист скачан, начало разбора плейлиста
|
||||
3. Начало проверки 1 плейлиста (выбран кодом `first`)
|
||||
4. Скачивание плейлиста `first` по ссылке `https://example.com/list1.m3u`
|
||||
5. Плейлист скачан, начало разбора
|
||||
6. Плейлист разобран, начало проверки каналов (114 штук)
|
||||
7. Определены параметры проверки (подробности ниже)
|
||||
8. (спустя время) Проверка успешно завершена: онлайн каналов 101 (88.60% от всех), оффлайн каналов 13 (11.40% от всех), затрачено 12 секунд.
|
||||
|
||||
### Параметры проверки
|
||||
|
||||
Выше в п.7 видно некоторые служебные данные:
|
||||
* `timeout` — таймаут каждого запроса в секундах (макс. время ожидания ответа канала);
|
||||
* `routines` — количество одновременных проверок.
|
||||
|
||||
Эти параметры рассчитываются динамически для каждого плейлиста в отдельности, исходя из количества каналов в каждом (`count`).
|
||||
См. [app/checker/checker.go](app/checker/checker.go) для подробностей.
|
||||
|
||||
Идея в том, чтобы найти баланс между скоростью проверки и качеством:
|
||||
* чем ниже коэффициент `k`, тем больше горутин, меньше таймаут, быстрее проверка, хуже результаты;
|
||||
* чем выше коэффициент `k`, тем меньше горутин, больше таймаут, медленнее проверка, лучше результаты.
|
||||
|
||||
На скорость проверки влияют:
|
||||
* количество горутин (чем выше, тем больше каналов проверяется параллельно);
|
||||
* таймаут (тем ниже, тем быстрее закончится проверка канала);
|
||||
* количество каналов (чем больше, тем дольше общий процесс);
|
||||
* скорость ответа сервера (чем выше, тем хуже) и количество данных, которые он отдаёт (чем больше, тем хуже).
|
||||
|
||||
На качество проверки влияет таймаут:
|
||||
* чем выше, тем выше вероятность получить успешный ответ от сервера, транслирующего поток;
|
||||
* чем ниже, тем выше вероятность не дождаться успешного ответа и засчитать канал нерабочим.
|
||||
|
||||
> [!NOTE]
|
||||
> Логика балансирования будет уточняться и корректироваться по мере развития проекта, исходя из реального применения.
|
||||
7. Параметры проверки каналов (таймаут, объём данных, число горутин)
|
||||
8. (спустя время) Проверка каналов завершена
|
||||
9. Проверка плейлиста завершена
|
||||
|
||||
### Коды возврата
|
||||
|
||||
* 0 — успех
|
||||
* 1 — общая ошибка, см. вывод
|
||||
* 2 — команде `check` не переданы параметры `--file`, `--url` и `--code`
|
||||
* 1 — общая ошибка
|
||||
|
||||
## Лицензия
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Copyright (c) 2025, Антон Аксенов
|
||||
* Copyright (c) 2025-2026, Антон Аксенов
|
||||
* This file is part of iptvc project
|
||||
* MIT License: https://git.axenov.dev/IPTV/iptvc/src/branch/master/LICENSE
|
||||
*/
|
||||
@@ -14,7 +14,11 @@ import (
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
const VERSION = "1.1.3"
|
||||
// Version информация, заполняется при сборке через ldflags
|
||||
var (
|
||||
VERSION = "dev"
|
||||
COMMIT = "unknown"
|
||||
)
|
||||
|
||||
// Arguments описывает аргументы командной строки
|
||||
type Arguments struct {
|
||||
@@ -22,10 +26,45 @@ type Arguments struct {
|
||||
TagsPath string
|
||||
RandomCount uint
|
||||
RepeatCount uint
|
||||
RepeatEverySec uint
|
||||
NeedJson bool
|
||||
NeedQuiet 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 (
|
||||
@@ -34,11 +73,18 @@ var (
|
||||
Config *config.Config
|
||||
)
|
||||
|
||||
// Init инициализирует конфигурацию и подключение к keydb
|
||||
// Init загружает конфигурацию и инициализирует логгер.
|
||||
// CLI-override применяются через applyAppOverrides(), applyCacheOverrides()
|
||||
// и applyCheckOverrides() в обработчиках команд после Init().
|
||||
func Init() {
|
||||
Config = config.Init()
|
||||
Config = config.Init(Args.ConfigPath)
|
||||
logger.Init(Args.NeedQuiet)
|
||||
if Config.Cache.IsEnabled {
|
||||
}
|
||||
|
||||
// InitCache инициализирует подключение к KeyDB/Redis, если кеш включён.
|
||||
// Вызывайте после применения всех cache-override.
|
||||
func InitCache() {
|
||||
if Config.Cache.Enabled {
|
||||
Cache = cache.Init(&Config.Cache)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Copyright (c) 2025, Антон Аксенов
|
||||
* Copyright (c) 2025-2026, Антон Аксенов
|
||||
* This file is part of iptvc project
|
||||
* 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
|
||||
* MIT License: https://git.axenov.dev/IPTV/iptvc/src/branch/master/LICENSE
|
||||
*/
|
||||
@@ -15,13 +15,12 @@ import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"maps"
|
||||
"math"
|
||||
"math/rand"
|
||||
"net/http"
|
||||
"os"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -31,6 +30,7 @@ import (
|
||||
var (
|
||||
tagBlocks []tagfile.TagBlock
|
||||
ctx = context.Background()
|
||||
OnPlaylistChecked func(playlist.Playlist) // вызывается после проверки каждого плейлиста
|
||||
)
|
||||
|
||||
// PrepareListsToCheck готовит список плейлистов для проверки
|
||||
@@ -57,7 +57,7 @@ func PrepareListsToCheck(files []string, urls []string, codes []string) []playli
|
||||
}
|
||||
|
||||
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 {
|
||||
log.Printf("Warning: %s, all --code flags will be ignored\n", err)
|
||||
return lists
|
||||
@@ -109,56 +109,100 @@ func getCachedPlaylists() map[string]playlist.Playlist {
|
||||
return result
|
||||
}
|
||||
|
||||
// CheckPlaylists проверяет плейлисты и возвращает их же с результатами проверки
|
||||
// CheckPlaylists проверяет плейлисты и возвращает их же с результатами проверки.
|
||||
// Параметры (user-agent, timeout, cooldown, per-routine) берутся из config.Check.Playlists.
|
||||
func CheckPlaylists(lists []playlist.Playlist) (int, int) {
|
||||
count := len(lists)
|
||||
if count == 0 {
|
||||
log.Println("There are no playlists to check")
|
||||
os.Exit(0)
|
||||
return 0, 0
|
||||
}
|
||||
|
||||
log.Printf("%d playlists will be checked\n", len(lists))
|
||||
step, onlineCount, offlineCount := 0, 0, 0
|
||||
tagBlocks = tagfile.Init(app.Args.TagsPath)
|
||||
pc := app.Config.Check.Playlists
|
||||
maxRoutines := pc.MaxRoutines
|
||||
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 {
|
||||
wg.Add(1)
|
||||
go func(idx int) {
|
||||
sem <- struct{}{}
|
||||
defer func() {
|
||||
<-sem
|
||||
wg.Done()
|
||||
}()
|
||||
|
||||
pls := lists[idx]
|
||||
step++
|
||||
pls.CheckedAt = time.Now().Unix()
|
||||
|
||||
userAgent := pc.UserAgent.Pick()
|
||||
timeout := time.Duration(pc.Timeout) * time.Millisecond
|
||||
|
||||
var err error
|
||||
if pls.Source == "-f" {
|
||||
// direct m3u path
|
||||
log.Printf("[%.3d/%.3d] Playlist from filesystem\n", step, count)
|
||||
log.Printf("Reading file... (%s)\n", pls.Url)
|
||||
log.Printf("Playlist from filesystem (%s)\n", pls.Url)
|
||||
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 {
|
||||
// from ini
|
||||
log.Printf("[%.3d/%.3d] Playlist [%s]\n", step, count, pls.Code)
|
||||
log.Printf("Fetching... (%s)\n", pls.Url)
|
||||
err = pls.Download()
|
||||
log.Printf("Fetching playlist [%s] (%s)\n", pls.Code, pls.Url)
|
||||
err = pls.Download(userAgent, timeout)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
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)
|
||||
continue
|
||||
if OnPlaylistChecked != nil {
|
||||
OnPlaylistChecked(pls)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
log.Println("Parsing content...")
|
||||
pls.IsOnline = true
|
||||
onlineCount++
|
||||
pls = pls.Parse()
|
||||
|
||||
log.Printf("Parsed, checking channels (%d)...\n", len(pls.Channels))
|
||||
pls = CheckChannels(pls)
|
||||
lists[idx] = pls
|
||||
results[idx] = checkResult{idx: idx, pls: pls, online: true}
|
||||
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
|
||||
@@ -183,7 +227,8 @@ func cachePlaylist(pls playlist.Playlist) {
|
||||
log.Println("Cached sucessfully")
|
||||
}
|
||||
|
||||
// CheckChannels проверяет каналы и возвращает их же с результатами проверки
|
||||
// CheckChannels проверяет каналы и возвращает их же с результатами проверки.
|
||||
// Параметры (user-agent, timeout, byte-range, cooldown, per-routine) берутся из config.Check.Channels.
|
||||
func CheckChannels(pls playlist.Playlist) playlist.Playlist {
|
||||
type errorData struct {
|
||||
tvChannel playlist.Channel
|
||||
@@ -199,14 +244,27 @@ func CheckChannels(pls playlist.Playlist) playlist.Playlist {
|
||||
pls.OnlineCount = 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}
|
||||
chSemaphores := make(chan struct{}, routines)
|
||||
chSemaphores := make(chan struct{}, maxRoutines)
|
||||
chOnline := make(chan playlist.Channel, len(pls.Channels))
|
||||
chOffline := make(chan playlist.Channel, len(pls.Channels))
|
||||
chError := make(chan errorData, len(pls.Channels))
|
||||
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()
|
||||
for _, tvChannel := range pls.Channels {
|
||||
wg.Add(1)
|
||||
@@ -227,8 +285,8 @@ func CheckChannels(pls playlist.Playlist) playlist.Playlist {
|
||||
return
|
||||
}
|
||||
|
||||
req.Header.Set("User-Agent", "Mozilla/5.0 WINK/1.31.1 (AndroidTV/9) HlsWinkPlayer")
|
||||
req.Header.Set("Range", "bytes=0-511") // 512 B, but sometimes servers ignore it
|
||||
req.Header.Set("User-Agent", cc.UserAgent.Pick())
|
||||
req.Header.Set("Range", fmt.Sprintf("bytes=0-%d", byteRange-1))
|
||||
resp, err := httpClient.Do(req)
|
||||
tvChannel.CheckedAt = time.Now().Unix()
|
||||
if err != nil {
|
||||
@@ -240,7 +298,7 @@ func CheckChannels(pls playlist.Playlist) playlist.Playlist {
|
||||
tvChannel.Status = resp.StatusCode
|
||||
tvChannel.IsOnline = tvChannel.Status < http.StatusBadRequest
|
||||
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)
|
||||
bodyString := string(bodyBytes)
|
||||
_ = resp.Body.Close()
|
||||
@@ -263,7 +321,13 @@ func CheckChannels(pls playlist.Playlist) playlist.Playlist {
|
||||
}
|
||||
|
||||
chOnline <- tvChannel
|
||||
return
|
||||
|
||||
// cooldown: задержка после проверки каждого канала
|
||||
// cc.Cooldown хранится в миллисекундах, поэтому переводим в time.Millisecond
|
||||
cd := time.Duration(cc.Cooldown.Value()) * time.Millisecond
|
||||
if cd > 0 {
|
||||
time.Sleep(cd)
|
||||
}
|
||||
}(tvChannel)
|
||||
}
|
||||
|
||||
@@ -323,42 +387,6 @@ func CheckChannels(pls playlist.Playlist) playlist.Playlist {
|
||||
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 ищет и возвращает теги для канала
|
||||
func getTagsForChannel(tvChannel playlist.Channel) []string {
|
||||
var foundTags []string
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Copyright (c) 2025, Антон Аксенов
|
||||
* Copyright (c) 2025-2026, Антон Аксенов
|
||||
* This file is part of iptvc project
|
||||
* MIT License: https://git.axenov.dev/IPTV/iptvc/src/branch/master/LICENSE
|
||||
*/
|
||||
@@ -7,71 +7,664 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"github.com/joho/godotenv"
|
||||
"fmt"
|
||||
"log"
|
||||
"math/rand"
|
||||
"os"
|
||||
"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 описывает конфигурацию
|
||||
type Config struct {
|
||||
DebugMode bool
|
||||
Cache CacheConfig
|
||||
App AppConfig `yaml:"app"`
|
||||
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 {
|
||||
IsEnabled bool
|
||||
Host string
|
||||
Port uint
|
||||
Username string
|
||||
Password string
|
||||
Db uint
|
||||
Ttl uint
|
||||
IsActive bool
|
||||
Enabled bool `yaml:"enabled"`
|
||||
Host string `yaml:"host"`
|
||||
Port uint `yaml:"port"`
|
||||
Username string `yaml:"username"`
|
||||
Password string `yaml:"password"`
|
||||
Db uint `yaml:"db"`
|
||||
Ttl uint `yaml:"ttl"`
|
||||
IsActive bool `yaml:"-"`
|
||||
}
|
||||
|
||||
// Init инициализирует объект конфигурации из переменных окружения
|
||||
func Init() *Config {
|
||||
_ = godotenv.Load(".env")
|
||||
// CheckConfig описывает параметры проверки плейлистов и каналов
|
||||
type CheckConfig struct {
|
||||
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{
|
||||
//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{
|
||||
IsEnabled: readEnvBoolean("CACHE_ENABLED", false),
|
||||
Host: readEnv("CACHE_HOST", "localhost"),
|
||||
Port: readEnvInteger("CACHE_PORT", 6379),
|
||||
Username: readEnv("CACHE_USERNAME", ""),
|
||||
Password: readEnv("CACHE_PASSWORD", ""),
|
||||
Db: readEnvInteger("CACHE_DB", 0),
|
||||
Ttl: readEnvInteger("CACHE_TTL", 1800),
|
||||
Enabled: false,
|
||||
Host: "localhost",
|
||||
Port: 6379,
|
||||
Username: "",
|
||||
Password: "",
|
||||
Db: 0,
|
||||
Ttl: defaultCacheTtlSeconds,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// readEnv считывает строковую переменную окружения с заданным именем или возвращает значение по умолчанию
|
||||
func readEnv(key string, defaultValue string) string {
|
||||
value, exists := os.LookupEnv(key)
|
||||
if exists {
|
||||
return value
|
||||
// Init инициализирует объект конфигурации.
|
||||
// Порядок приоритета (от низшего к высшему):
|
||||
// 1. Значения по умолчанию
|
||||
// 2. config.yml (путь можно задать через configPath)
|
||||
// 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)
|
||||
}
|
||||
|
||||
// переопределяем значениями из переменных окружения
|
||||
applyEnv(cfg)
|
||||
|
||||
// валидируем и исправляем
|
||||
validate(cfg)
|
||||
|
||||
return cfg
|
||||
}
|
||||
|
||||
// readEnvBoolean считывает булеву переменную окружения с заданным именем или возвращает значение по умолчанию
|
||||
func readEnvBoolean(name string, defaultValue bool) bool {
|
||||
valStr := readEnv(name, "")
|
||||
val, err := strconv.ParseBool(valStr)
|
||||
// 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 {
|
||||
return val
|
||||
}
|
||||
return defaultValue
|
||||
}
|
||||
|
||||
// readEnvInteger считывает целочисленную переменную окружения с заданным именем или возвращает значение по умолчанию
|
||||
func readEnvInteger(name string, defaultValue uint) uint {
|
||||
valueStr := readEnv(name, "")
|
||||
value, err := strconv.Atoi(valueStr)
|
||||
// parseUint разбирает строку в uint, при ошибке возвращает значение по умолчанию
|
||||
func parseUint(s string, defaultValue uint) uint {
|
||||
val, err := strconv.Atoi(s)
|
||||
if err == nil {
|
||||
return uint(value)
|
||||
return uint(val)
|
||||
}
|
||||
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
|
||||
* MIT License: https://git.axenov.dev/IPTV/iptvc/src/branch/master/LICENSE
|
||||
*/
|
||||
@@ -9,10 +9,11 @@ package inifile
|
||||
import (
|
||||
"axenov/iptv-checker/app/playlist"
|
||||
"axenov/iptv-checker/app/utils"
|
||||
"gopkg.in/ini.v1"
|
||||
"log"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"gopkg.in/ini.v1"
|
||||
)
|
||||
|
||||
// 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
|
||||
* 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
|
||||
* 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
|
||||
}
|
||||
|
||||
// tmpChannel хранит временные данные о канале, который обрабатывается в Parse
|
||||
var tmpChannel = Channel{}
|
||||
|
||||
// MakeFromFile создаёт экземпляр плейлиста из файла
|
||||
func MakeFromFile(filepath string) (Playlist, error) {
|
||||
expandedPath, err := utils.ExpandPath(filepath)
|
||||
@@ -131,8 +128,10 @@ func parseTitle(line string) string {
|
||||
}
|
||||
|
||||
// Download загружает плейлист по URL-адресу
|
||||
func (pls *Playlist) Download() error {
|
||||
content, err := utils.Fetch(pls.Url)
|
||||
// Download скачивает плейлист по 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 {
|
||||
pls.Content = err.Error()
|
||||
pls.CheckedAt = time.Now().Unix()
|
||||
@@ -159,6 +158,7 @@ func (pls *Playlist) ReadFromFs() error {
|
||||
// Parse разбирает плейлист
|
||||
func (pls *Playlist) Parse() Playlist {
|
||||
isChannel := false
|
||||
var ch Channel
|
||||
pls.Attributes = make(map[string]string)
|
||||
pls.Channels = make(map[string]Channel)
|
||||
pls.Groups = make(map[string]Group)
|
||||
@@ -180,20 +180,20 @@ func (pls *Playlist) Parse() Playlist {
|
||||
|
||||
if strings.HasPrefix(line, "#EXTINF") {
|
||||
isChannel = true
|
||||
tmpChannel.Attributes = parseAttributes(line)
|
||||
tmpChannel.Title = parseTitle(line)
|
||||
ch = Channel{Attributes: parseAttributes(line)}
|
||||
ch.Title = parseTitle(line)
|
||||
|
||||
if tmpChannel.Title == "" {
|
||||
if tvgid, ok := tmpChannel.Attributes["tvg-id"]; ok {
|
||||
tmpChannel.Title = "(канал без названия, tvg-id=" + tvgid + ")"
|
||||
if ch.Title == "" {
|
||||
if tvgid, ok := ch.Attributes["tvg-id"]; ok {
|
||||
ch.Title = "(канал без названия, tvg-id=" + tvgid + ")"
|
||||
} 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)
|
||||
tmpChannel.GroupId = id
|
||||
ch.GroupId = id
|
||||
pls.Groups[id] = Group{
|
||||
Id: id,
|
||||
Name: groupName,
|
||||
@@ -207,7 +207,7 @@ func (pls *Playlist) Parse() Playlist {
|
||||
parts := strings.Split(line, ":")
|
||||
groupName := strings.Trim(parts[1], " ")
|
||||
id := utils.Md5str(groupName)
|
||||
tmpChannel.GroupId = id
|
||||
ch.GroupId = id
|
||||
pls.Groups[id] = Group{
|
||||
Id: id,
|
||||
Name: groupName,
|
||||
@@ -217,13 +217,12 @@ func (pls *Playlist) Parse() Playlist {
|
||||
}
|
||||
|
||||
if isChannel && strings.HasPrefix(line, "http") {
|
||||
tmpChannel.URL = strings.Trim(line, " ")
|
||||
tmpChannel.Id = utils.Md5str(tmpChannel.URL)
|
||||
if tmpChannel.Id != "" {
|
||||
pls.Channels[tmpChannel.Id] = tmpChannel
|
||||
ch.URL = strings.Trim(line, " ")
|
||||
ch.Id = utils.Md5str(ch.URL)
|
||||
if ch.Id != "" {
|
||||
pls.Channels[ch.Id] = ch
|
||||
isChannel = false
|
||||
tmpChannel = Channel{}
|
||||
tmpChannel.Attributes = make(map[string]string)
|
||||
ch = Channel{}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
* 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
|
||||
* MIT License: https://git.axenov.dev/IPTV/iptvc/src/branch/master/LICENSE
|
||||
*/
|
||||
@@ -44,16 +44,19 @@ func ArrayUnique(arr []string) []string {
|
||||
return result
|
||||
}
|
||||
|
||||
// Fetch выполняет GET запрос и возвращает результат в виде массива байт
|
||||
func Fetch(url string) ([]byte, error) {
|
||||
// Fetch выполняет GET запрос и возвращает результат в виде массива байт.
|
||||
// userAgent — значение заголовка User-Agent, timeout — таймаут запроса.
|
||||
func Fetch(url string, userAgent string, timeout time.Duration) ([]byte, error) {
|
||||
req, err := http.NewRequest("GET", url, nil)
|
||||
if err != nil {
|
||||
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}
|
||||
httpClient := http.Client{Timeout: 10 * time.Second}
|
||||
httpClient := http.Client{Timeout: timeout}
|
||||
resp, err := httpClient.Do(req)
|
||||
if err != nil {
|
||||
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 |