Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
1fca796f2c
|
@@ -1,4 +1,10 @@
|
||||
# Config file path (default: config.yml)
|
||||
# CONFIG_PATH=config.yml
|
||||
|
||||
APP_DEBUG=false
|
||||
APP_TITLE=IPTV Checker
|
||||
APP_TIMEZONE=GMT
|
||||
APP_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=
|
||||
PAGE_SIZE=0
|
||||
REPO_URL=https://git.axenov.dev/IPTV
|
||||
|
||||
+8
-7
@@ -1,14 +1,15 @@
|
||||
.idea/
|
||||
.vscode/
|
||||
bin/
|
||||
output/
|
||||
|
||||
/.idea/
|
||||
/bin/
|
||||
/output/
|
||||
/docker/keydb/data/
|
||||
# /docs/
|
||||
.DS_Store
|
||||
.env
|
||||
*.env
|
||||
*.bak
|
||||
*.m3u
|
||||
*.m3u8
|
||||
*.json
|
||||
*.ini
|
||||
iptvc
|
||||
*.rdb
|
||||
|
||||
!/**/*.gitkeep
|
||||
|
||||
Vendored
+14
@@ -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"
|
||||
]
|
||||
}
|
||||
Vendored
+22
@@ -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-флагов.
|
||||
+2
-2
@@ -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 '='
|
||||
|
||||
@@ -5,30 +5,101 @@
|
||||
Консольная программа для проверки 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) |
|
||||
| ОС | Скачать для `amd64` | Скачать для `arm64` |
|
||||
| ------- | ---------------------------------- | ---------------------------------- |
|
||||
| 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
|
||||
```
|
||||
|
||||
### Оптимизации сборки
|
||||
|
||||
Все цели используют следующие флаги:
|
||||
|
||||
- `CGO_ENABLED=0` — статическая сборка без зависимостей от системных библиотек;
|
||||
- `-trimpath` — удаление путей к исходным файлам из бинарника;
|
||||
- `-s -w` — удаление отладочной информации и DWARF-таблиц для уменьшения размера;
|
||||
- `-X` — инъекция версии и коммита из Git в переменные `app.VERSION` и `app.COMMIT`.
|
||||
|
||||
Версия отображается при запуске `./iptvc version`.
|
||||
|
||||
### Запуск из исходников
|
||||
|
||||
Для быстрого запуска без сборки используйте `go run .`.
|
||||
Для запуска тестов и статического анализа:
|
||||
|
||||
```bash
|
||||
go test ./...
|
||||
go vet ./...
|
||||
```
|
||||
|
||||
## Быстрый старт
|
||||
|
||||
@@ -36,7 +107,7 @@
|
||||
|
||||
Выполнить `./iptvc help` для получения краткой справки.
|
||||
|
||||
Если был клонирован репозиторий, то вместо `./iptvc` можно запустить `go run .`
|
||||
Если был клонирован репозиторий, то вместо `./iptvc` можно запустить `go run .` или `make help`.
|
||||
|
||||
Ниже рассмотрены простые примеры использования программы для проверки плейлистов.
|
||||
|
||||
@@ -66,13 +137,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 +168,215 @@
|
||||
> [!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: # опциональный путь к иконке
|
||||
header:
|
||||
title: IPTV Checker
|
||||
navigation:
|
||||
- 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
|
||||
```
|
||||
|
||||
`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_PORT` | `server.port` | `-p`, `serve --port` | Порт веб-сервера |
|
||||
| `SERVER_HOST` | `server.host` | `serve --host` | Хост для привязки |
|
||||
| `APP_URL` | `site.base-url` | — | Базовый URL для ссылок |
|
||||
| `PAGE_SIZE` | `site.page-size` | — | Размер страницы (0 — без пагинации) |
|
||||
| `REPO_URL` | `site.repo-url` | — | Ссылка на репозиторий |
|
||||
| `SITE_FAVICON` | `site.favicon` | — | Путь к иконке сайта |
|
||||
| `APP_TITLE` | `site.header.title` | — | Заголовок сайта |
|
||||
| `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 +398,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 — общая ошибка
|
||||
|
||||
## Лицензия
|
||||
|
||||
|
||||
+59
-13
@@ -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,18 +14,57 @@ import (
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
const VERSION = "1.1.3"
|
||||
// Version информация, заполняется при сборке через ldflags
|
||||
var (
|
||||
VERSION = "dev"
|
||||
COMMIT = "unknown"
|
||||
)
|
||||
|
||||
// Arguments описывает аргументы командной строки
|
||||
type Arguments struct {
|
||||
IniPath string
|
||||
TagsPath string
|
||||
RandomCount uint
|
||||
RepeatCount uint
|
||||
RepeatEverySec uint
|
||||
NeedJson bool
|
||||
NeedQuiet bool
|
||||
Verbose bool
|
||||
IniPath string
|
||||
TagsPath string
|
||||
RandomCount uint
|
||||
RepeatCount 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)
|
||||
}
|
||||
}
|
||||
|
||||
Vendored
+1
-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
|
||||
*/
|
||||
|
||||
+115
-87
@@ -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"
|
||||
@@ -29,8 +28,9 @@ import (
|
||||
)
|
||||
|
||||
var (
|
||||
tagBlocks []tagfile.TagBlock
|
||||
ctx = context.Background()
|
||||
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 {
|
||||
pls := lists[idx]
|
||||
step++
|
||||
wg.Add(1)
|
||||
go func(idx int) {
|
||||
sem <- struct{}{}
|
||||
defer func() {
|
||||
<-sem
|
||||
wg.Done()
|
||||
}()
|
||||
|
||||
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)
|
||||
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()
|
||||
}
|
||||
pls := lists[idx]
|
||||
pls.CheckedAt = time.Now().Unix()
|
||||
|
||||
if err != nil {
|
||||
log.Printf("Cannot read playlist [%s]: %s\n", pls.Url, err)
|
||||
offlineCount++
|
||||
userAgent := pc.UserAgent.Pick()
|
||||
timeout := time.Duration(pc.Timeout) * time.Millisecond
|
||||
|
||||
var err error
|
||||
if pls.Source == "-f" {
|
||||
log.Printf("Playlist from filesystem (%s)\n", pls.Url)
|
||||
err = pls.ReadFromFs()
|
||||
} else {
|
||||
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)
|
||||
pls.IsOnline = false
|
||||
results[idx] = checkResult{idx: idx, pls: pls, online: false}
|
||||
cachePlaylist(pls)
|
||||
if OnPlaylistChecked != nil {
|
||||
OnPlaylistChecked(pls)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
log.Println("Parsing content...")
|
||||
pls.IsOnline = true
|
||||
pls = pls.Parse()
|
||||
|
||||
log.Printf("Parsed, checking channels (%d)...\n", len(pls.Channels))
|
||||
pls = CheckChannels(pls)
|
||||
results[idx] = checkResult{idx: idx, pls: pls, online: true}
|
||||
cachePlaylist(pls)
|
||||
continue
|
||||
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++
|
||||
}
|
||||
|
||||
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
|
||||
cachePlaylist(pls)
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
+591
-39
@@ -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,623 @@
|
||||
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"`
|
||||
}
|
||||
|
||||
// HeaderConfig описывает настройки шапки сайта
|
||||
type HeaderConfig struct {
|
||||
Title string `yaml:"title"`
|
||||
Navigation []Link `yaml:"navigation"`
|
||||
}
|
||||
|
||||
// SiteConfig описывает настройки сайта
|
||||
type SiteConfig struct {
|
||||
BaseUrl string `yaml:"base-url"`
|
||||
RepoUrl string `yaml:"repo-url"`
|
||||
PageSize uint `yaml:"page-size"`
|
||||
Favicon string `yaml:"favicon"`
|
||||
Header HeaderConfig `yaml:"header"`
|
||||
FooterLinks []Link `yaml:"footer-links"`
|
||||
}
|
||||
|
||||
// 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",
|
||||
Header: HeaderConfig{
|
||||
Title: "IPTV Checker",
|
||||
Navigation: []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"},
|
||||
}},
|
||||
},
|
||||
},
|
||||
FooterLinks: []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("APP_URL"); ok && v != "" {
|
||||
cfg.Site.BaseUrl = v
|
||||
}
|
||||
if v, ok := os.LookupEnv("PAGE_SIZE"); ok && v != "" {
|
||||
cfg.Site.PageSize = parseUint(v, cfg.Site.PageSize)
|
||||
}
|
||||
if v, ok := os.LookupEnv("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("APP_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,548 @@
|
||||
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)
|
||||
}
|
||||
if len(cfg.Site.Header.Navigation) == 0 {
|
||||
t.Error("expected non-empty navigation")
|
||||
}
|
||||
}
|
||||
|
||||
// --- 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
|
||||
*/
|
||||
|
||||
+20
-21
@@ -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)
|
||||
}
|
||||
}
|
||||
+8
-5
@@ -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,528 @@
|
||||
/*
|
||||
* 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,
|
||||
Navigation: s.cfg.Site.Header.Navigation,
|
||||
FooterLinks: s.cfg.Site.FooterLinks,
|
||||
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,
|
||||
Navigation: s.cfg.Site.Header.Navigation,
|
||||
FooterLinks: s.cfg.Site.FooterLinks,
|
||||
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,
|
||||
Navigation: s.cfg.Site.Header.Navigation,
|
||||
FooterLinks: s.cfg.Site.FooterLinks,
|
||||
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,432 @@
|
||||
/*
|
||||
* 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
|
||||
|
||||
// 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
|
||||
|
||||
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))
|
||||
}
|
||||
// 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,211 @@
|
||||
/*
|
||||
* 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)
|
||||
|
||||
// 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 ""
|
||||
}
|
||||
return info.ModTime().Format("02.01.2006 15:04")
|
||||
}
|
||||
|
||||
// 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,28 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Swagger UI — IPTV Checker API</title>
|
||||
<link rel="stylesheet" href="./swagger-ui.css">
|
||||
<style>
|
||||
body { margin: 0 }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="swagger-ui"></div>
|
||||
<script src="./swagger-ui-bundle.js"></script>
|
||||
<script src="./swagger-ui-standalone-preset.js"></script>
|
||||
<script>
|
||||
window.onload = function() {
|
||||
window.ui = SwaggerUIBundle({
|
||||
url: window.location.origin + '/api/openapi.json',
|
||||
dom_id: '#swagger-ui',
|
||||
deepLinking: true,
|
||||
presets: [SwaggerUIBundle.presets.apis, SwaggerUIStandalonePreset],
|
||||
layout: 'BaseLayout',
|
||||
});
|
||||
};
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,176 @@
|
||||
/*
|
||||
* 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/config"
|
||||
"bytes"
|
||||
"embed"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"io"
|
||||
"log"
|
||||
"math"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
|
||||
//go:embed views/*.html
|
||||
var viewsFS embed.FS
|
||||
|
||||
// TemplateManager управляет загрузкой и рендерингом шаблонов
|
||||
type TemplateManager struct {
|
||||
templates map[string]*template.Template
|
||||
funcMap template.FuncMap
|
||||
}
|
||||
|
||||
// NewTemplateManager создаёт новый менеджер шаблонов
|
||||
func NewTemplateManager(cfg *config.Config) (*TemplateManager, error) {
|
||||
funcMap := template.FuncMap{
|
||||
"baseUrl": func(path string) string {
|
||||
base := cfg.Site.BaseUrl
|
||||
if path == "" {
|
||||
return base
|
||||
}
|
||||
return base + "/" + path
|
||||
},
|
||||
"formatDate": formatDate,
|
||||
"formatTimeAgo": formatTimeAgo,
|
||||
"statusClass": func(v PlaylistView) string {
|
||||
return statusClass(&v)
|
||||
},
|
||||
"statusBadge": func(v PlaylistView) string {
|
||||
return statusBadge(&v)
|
||||
},
|
||||
"jsonChannels": func(channels map[string]ChannelView) template.JS {
|
||||
arr := make([]ChannelView, 0, len(channels))
|
||||
for _, ch := range channels {
|
||||
arr = append(arr, ch)
|
||||
}
|
||||
b, _ := json.Marshal(arr)
|
||||
return template.JS(b)
|
||||
},
|
||||
"seq": func(start, end int) []int {
|
||||
result := make([]int, 0, end-start+1)
|
||||
for i := start; i <= end; i++ {
|
||||
result = append(result, i)
|
||||
}
|
||||
return result
|
||||
},
|
||||
"sub": func(a, b int) int {
|
||||
return a - b
|
||||
},
|
||||
"add": func(a, b int) int {
|
||||
return a + b
|
||||
},
|
||||
"channelCount": func(v PlaylistView) int {
|
||||
return len(v.Channels)
|
||||
},
|
||||
"groupCount": func(v PlaylistView) int {
|
||||
return len(v.Groups)
|
||||
},
|
||||
"hasTag": containsTag,
|
||||
}
|
||||
|
||||
tm := &TemplateManager{
|
||||
templates: make(map[string]*template.Template),
|
||||
funcMap: funcMap,
|
||||
}
|
||||
|
||||
// parse base template
|
||||
baseContent, err := viewsFS.ReadFile("views/base.html")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot read base.html: %w", err)
|
||||
}
|
||||
|
||||
pages := []string{"list", "details", "notfound"}
|
||||
for _, page := range pages {
|
||||
pageContent, err := viewsFS.ReadFile(fmt.Sprintf("views/%s.html", page))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot read %s.html: %w", page, err)
|
||||
}
|
||||
|
||||
tmpl := template.New("base").Funcs(funcMap)
|
||||
tmpl, err = tmpl.Parse(string(baseContent))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot parse base template: %w", err)
|
||||
}
|
||||
tmpl, err = tmpl.Parse(string(pageContent))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot parse %s template: %w", page, err)
|
||||
}
|
||||
|
||||
tm.templates[page] = tmpl
|
||||
}
|
||||
|
||||
return tm, nil
|
||||
}
|
||||
|
||||
// Render рендерит шаблон страницы
|
||||
func (tm *TemplateManager) Render(w io.Writer, page string, data *PageData) {
|
||||
tmpl, ok := tm.templates[page]
|
||||
if !ok {
|
||||
http.Error(w.(http.ResponseWriter), "Template not found", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
if err := tmpl.ExecuteTemplate(&buf, "base", data); err != nil {
|
||||
log.Printf("Template error (%s): %s", page, err)
|
||||
http.Error(w.(http.ResponseWriter), "Template error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Write(buf.Bytes())
|
||||
}
|
||||
|
||||
// formatTimeAgo возвращает относительное время на русском
|
||||
func formatTimeAgo(ts int64) string {
|
||||
if ts == 0 {
|
||||
return "неизвестно"
|
||||
}
|
||||
diff := time.Now().Unix() - ts
|
||||
if diff < 0 {
|
||||
diff = 0
|
||||
}
|
||||
if diff < 60 {
|
||||
return "только что"
|
||||
}
|
||||
if diff < 3600 {
|
||||
minutes := diff / 60
|
||||
return strconv.FormatInt(minutes, 10) + " " + getNounForm(int(minutes), "минуту", "минуты", "минут") + " назад"
|
||||
}
|
||||
if diff < 86400 {
|
||||
hours := diff / 3600
|
||||
return strconv.FormatInt(hours, 10) + " " + getNounForm(int(hours), "час", "часа", "часов") + " назад"
|
||||
}
|
||||
if diff < 604800 {
|
||||
days := diff / 86400
|
||||
return strconv.FormatInt(days, 10) + " " + getNounForm(int(days), "день", "дня", "дней") + " назад"
|
||||
}
|
||||
return formatDate(ts)
|
||||
}
|
||||
|
||||
// getNounForm возвращает правильную форму существительного для числительного
|
||||
func getNounForm(number int, form1, form2, form5 string) string {
|
||||
abs := int(math.Abs(float64(number)))
|
||||
lastDigit := abs % 10
|
||||
lastTwoDigits := abs % 100
|
||||
|
||||
if lastTwoDigits >= 11 && lastTwoDigits <= 14 {
|
||||
return form5
|
||||
}
|
||||
if lastDigit == 1 {
|
||||
return form1
|
||||
}
|
||||
if lastDigit >= 2 && lastDigit <= 4 {
|
||||
return form2
|
||||
}
|
||||
return form5
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestGetNounForm(t *testing.T) {
|
||||
cases := []struct {
|
||||
n int
|
||||
f1 string
|
||||
f2 string
|
||||
f5 string
|
||||
want string
|
||||
}{
|
||||
{1, "минуту", "минуты", "минут", "минуту"},
|
||||
{2, "минуту", "минуты", "минут", "минуты"},
|
||||
{5, "минуту", "минуты", "минут", "минут"},
|
||||
{11, "минуту", "минуты", "минут", "минут"},
|
||||
{21, "минуту", "минуты", "минут", "минуту"},
|
||||
{22, "минуту", "минуты", "минут", "минуты"},
|
||||
{25, "минуту", "минуты", "минут", "минут"},
|
||||
{101, "минуту", "минуты", "минут", "минуту"},
|
||||
{111, "минуту", "минуты", "минут", "минут"},
|
||||
{0, "минуту", "минуты", "минут", "минут"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
got := getNounForm(c.n, c.f1, c.f2, c.f5)
|
||||
if got != c.want {
|
||||
t.Errorf("getNounForm(%d) = %q, want %q", c.n, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatTimeAgo_Zero(t *testing.T) {
|
||||
if formatTimeAgo(0) != "неизвестно" {
|
||||
t.Errorf("expected 'неизвестно' for ts=0")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatTimeAgo_JustNow(t *testing.T) {
|
||||
now := time.Now().Unix()
|
||||
got := formatTimeAgo(now)
|
||||
if got != "только что" {
|
||||
t.Errorf("expected 'только что', got '%s'", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatTimeAgo_Minutes(t *testing.T) {
|
||||
ts := time.Now().Unix() - 120 // 2 minutes ago
|
||||
got := formatTimeAgo(ts)
|
||||
if got == "неизвестно" || got == "только что" {
|
||||
t.Errorf("expected minutes format, got '%s'", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatTimeAgo_Hours(t *testing.T) {
|
||||
ts := time.Now().Unix() - 7200 // 2 hours ago
|
||||
got := formatTimeAgo(ts)
|
||||
if got == "неизвестно" || got == "только что" {
|
||||
t.Errorf("expected hours format, got '%s'", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatTimeAgo_Days(t *testing.T) {
|
||||
ts := time.Now().Unix() - 172800 // 2 days ago
|
||||
got := formatTimeAgo(ts)
|
||||
if got == "неизвестно" || got == "только что" {
|
||||
t.Errorf("expected days format, got '%s'", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatTimeAgo_Future(t *testing.T) {
|
||||
ts := time.Now().Unix() + 100
|
||||
got := formatTimeAgo(ts)
|
||||
// future → diff clamped to 0 → "только что"
|
||||
if got != "только что" {
|
||||
t.Errorf("expected 'только что' for future, got '%s'", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,322 @@
|
||||
/*
|
||||
* 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/config"
|
||||
"axenov/iptv-checker/app/playlist"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"math"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// PlaylistView — расширенное представление плейлиста для шаблонов
|
||||
type PlaylistView struct {
|
||||
playlist.Playlist
|
||||
IsKnown bool `json:"-"` // true если есть данные в кеше
|
||||
OnlinePercent int `json:"-"`
|
||||
OfflinePercent int `json:"-"`
|
||||
HasCatchup bool `json:"-"`
|
||||
HasTvg bool `json:"-"`
|
||||
HasTokens bool `json:"-"`
|
||||
Tags []string `json:"-"`
|
||||
ViewChannels map[string]ChannelView `json:"-"`
|
||||
}
|
||||
|
||||
// ChannelView — расширенное представление канала
|
||||
type ChannelView struct {
|
||||
playlist.Channel
|
||||
HasToken bool `json:"hasToken"`
|
||||
}
|
||||
|
||||
// PageData — данные для рендеринга шаблонов
|
||||
type PageData struct {
|
||||
Title string
|
||||
BaseUrl string
|
||||
RepoUrl string
|
||||
Version string
|
||||
UpdatedAt string
|
||||
Navigation []config.Link
|
||||
FooterLinks []config.Link
|
||||
Playlists []PlaylistView
|
||||
Playlist *PlaylistView
|
||||
Count int
|
||||
PageCount int
|
||||
PageCurrent int
|
||||
Code string
|
||||
Error string
|
||||
}
|
||||
|
||||
// tokenPatterns — паттерны для определения нестабильных каналов
|
||||
var tokenPatterns = []string{
|
||||
`[?&]token=`,
|
||||
`[?&]drmreq=`,
|
||||
`[?&]u=`,
|
||||
`[?&]user=`,
|
||||
`[?&]username=`,
|
||||
`[?&]p=`,
|
||||
`[?&]pwd=`,
|
||||
`[?&]password=`,
|
||||
}
|
||||
|
||||
// enrichPlaylist подготавливает данные о плейлисте в расширенном формате,
|
||||
// аналогично PHP IniFile::initPlaylist
|
||||
func enrichPlaylist(view *PlaylistView) {
|
||||
// приколы golang: nil maps
|
||||
if view.Attributes == nil {
|
||||
view.Attributes = make(map[string]string)
|
||||
}
|
||||
if view.Groups == nil {
|
||||
view.Groups = make(map[string]playlist.Group)
|
||||
}
|
||||
if view.Channels == nil {
|
||||
view.Channels = make(map[string]playlist.Channel)
|
||||
}
|
||||
|
||||
// проценты онлайн/оффлайн
|
||||
view.OnlinePercent = 0
|
||||
view.OfflinePercent = 0
|
||||
if view.IsKnown && view.IsOnline && len(view.Channels) > 0 {
|
||||
view.OnlinePercent = int(math.Round(float64(view.OnlineCount) / float64(len(view.Channels)) * 100))
|
||||
view.OfflinePercent = int(math.Round(float64(view.OfflineCount) / float64(len(view.Channels)) * 100))
|
||||
}
|
||||
|
||||
// наличие catchup
|
||||
view.HasCatchup = strings.Contains(view.Content, "catchup")
|
||||
|
||||
// наличие TVG
|
||||
view.HasTvg = view.Attributes["url-tvg"] != "" || view.Attributes["x-tvg-url"] != ""
|
||||
|
||||
// наличие токенов в плейлисте
|
||||
view.HasTokens = hasTokensString(view.Url + " " + view.Content)
|
||||
|
||||
// сбор тегов и токенов каналов
|
||||
view.ViewChannels = make(map[string]ChannelView, len(view.Channels))
|
||||
tagSet := make(map[string]bool)
|
||||
for id, ch := range view.Channels {
|
||||
cv := ChannelView{Channel: ch}
|
||||
cv.HasToken = hasTokensString(ch.URL)
|
||||
view.ViewChannels[id] = cv
|
||||
for _, tag := range ch.Tags {
|
||||
tagSet[tag] = true
|
||||
}
|
||||
}
|
||||
view.Tags = make([]string, 0, len(tagSet))
|
||||
for tag := range tagSet {
|
||||
view.Tags = append(view.Tags, tag)
|
||||
}
|
||||
sort.Strings(view.Tags)
|
||||
}
|
||||
|
||||
// hasTokensString проверяет наличие токенов/ключей/логинов в строке
|
||||
func hasTokensString(s string) bool {
|
||||
if s == "" {
|
||||
return false
|
||||
}
|
||||
for _, pattern := range tokenPatterns {
|
||||
if matched, _ := regexp.MatchString(pattern, s); matched {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// getPlaylistFromCache возвращает плейлист из кеша Redis по коду
|
||||
func (s *Server) getPlaylistFromCache(code string) (*PlaylistView, bool) {
|
||||
if s.cache == nil {
|
||||
return nil, false
|
||||
}
|
||||
ctx := context.Background()
|
||||
result, err := s.cache.Get(ctx, code).Result()
|
||||
if err != nil {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
var pls playlist.Playlist
|
||||
if err := json.Unmarshal([]byte(result), &pls); err != nil {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
view := &PlaylistView{Playlist: pls, IsKnown: true}
|
||||
enrichPlaylist(view)
|
||||
return view, true
|
||||
}
|
||||
|
||||
// getPlaylistView возвращает представление плейлиста: из in-memory кеша, Redis или из ini
|
||||
func (s *Server) getPlaylistView(code string) (*PlaylistView, bool) {
|
||||
// сначала пробуем in-memory кеш (результаты фоновой проверки)
|
||||
s.memMu.RLock()
|
||||
if pls, ok := s.memCache[code]; ok {
|
||||
s.memMu.RUnlock()
|
||||
view := &PlaylistView{Playlist: pls, IsKnown: true}
|
||||
enrichPlaylist(view)
|
||||
return view, true
|
||||
}
|
||||
s.memMu.RUnlock()
|
||||
|
||||
// затем пробуем Redis
|
||||
if view, ok := s.getPlaylistFromCache(code); ok {
|
||||
return view, true
|
||||
}
|
||||
|
||||
// если нет в кеше — берём метаданные из ini
|
||||
ini, err := s.loadIni()
|
||||
if err != nil {
|
||||
return nil, false
|
||||
}
|
||||
iniPls, exists := ini.Lists[code]
|
||||
if !exists {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
view := &PlaylistView{
|
||||
Playlist: playlist.Playlist{
|
||||
Code: code,
|
||||
Name: iniPls.Name,
|
||||
Description: iniPls.Description,
|
||||
Url: iniPls.Url,
|
||||
Source: iniPls.Source,
|
||||
IsOnline: false,
|
||||
Attributes: make(map[string]string),
|
||||
Groups: make(map[string]playlist.Group),
|
||||
Channels: make(map[string]playlist.Channel),
|
||||
},
|
||||
IsKnown: false,
|
||||
Tags: []string{},
|
||||
ViewChannels: make(map[string]ChannelView),
|
||||
}
|
||||
return view, true
|
||||
}
|
||||
|
||||
// getPlaylistsView возвращает все плейлисты для списка (отсортированные по коду)
|
||||
func (s *Server) getPlaylistsView() ([]PlaylistView, string, error) {
|
||||
ini, err := s.loadIni()
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
|
||||
updatedAt := s.getUpdatedAt()
|
||||
result := make([]PlaylistView, 0, len(ini.Lists))
|
||||
|
||||
// собираем коды в порядке из ini-файла
|
||||
codes := make([]string, 0, len(ini.Lists))
|
||||
for _, section := range ini.File.Sections() {
|
||||
if section.Name() == "DEFAULT" {
|
||||
continue
|
||||
}
|
||||
if _, ok := ini.Lists[section.Name()]; ok {
|
||||
codes = append(codes, section.Name())
|
||||
}
|
||||
}
|
||||
|
||||
// сначала проверяем in-memory кеш (результаты фоновой проверки)
|
||||
s.memMu.RLock()
|
||||
memHasData := len(s.memCache) > 0
|
||||
s.memMu.RUnlock()
|
||||
|
||||
if memHasData {
|
||||
for _, code := range codes {
|
||||
iniPls := ini.Lists[code]
|
||||
s.memMu.RLock()
|
||||
pls, ok := s.memCache[code]
|
||||
s.memMu.RUnlock()
|
||||
if ok {
|
||||
view := PlaylistView{Playlist: pls, IsKnown: true}
|
||||
enrichPlaylist(&view)
|
||||
result = append(result, view)
|
||||
} else {
|
||||
result = append(result, makeUnknownView(code, iniPls))
|
||||
}
|
||||
}
|
||||
return result, updatedAt, nil
|
||||
}
|
||||
|
||||
// если in-memory пуст — пробуем Redis
|
||||
if s.cache != nil {
|
||||
ctx := context.Background()
|
||||
cached, err := s.cache.MGet(ctx, codes...).Result()
|
||||
if err == nil {
|
||||
for i, code := range codes {
|
||||
iniPls := ini.Lists[code]
|
||||
if i < len(cached) {
|
||||
strVal, ok := cached[i].(string)
|
||||
if ok && strVal != "" {
|
||||
var pls playlist.Playlist
|
||||
if json.Unmarshal([]byte(strVal), &pls) == nil {
|
||||
view := PlaylistView{Playlist: pls, IsKnown: true}
|
||||
enrichPlaylist(&view)
|
||||
result = append(result, view)
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
result = append(result, makeUnknownView(code, iniPls))
|
||||
}
|
||||
return result, updatedAt, nil
|
||||
}
|
||||
}
|
||||
|
||||
// без кеша — все плейлисты unknown
|
||||
for _, code := range codes {
|
||||
result = append(result, makeUnknownView(code, ini.Lists[code]))
|
||||
}
|
||||
return result, updatedAt, nil
|
||||
}
|
||||
|
||||
// makeUnknownView создаёт представление непроверенного плейлиста
|
||||
func makeUnknownView(code string, iniPls playlist.Playlist) PlaylistView {
|
||||
return PlaylistView{
|
||||
Playlist: playlist.Playlist{
|
||||
Code: code,
|
||||
Name: iniPls.Name,
|
||||
Description: iniPls.Description,
|
||||
Url: iniPls.Url,
|
||||
Source: iniPls.Source,
|
||||
IsOnline: false,
|
||||
Attributes: make(map[string]string),
|
||||
Groups: make(map[string]playlist.Group),
|
||||
Channels: make(map[string]playlist.Channel),
|
||||
},
|
||||
IsKnown: false,
|
||||
Tags: []string{},
|
||||
ViewChannels: make(map[string]ChannelView),
|
||||
}
|
||||
}
|
||||
|
||||
// statusClass возвращает CSS-класс для статуса плейлиста
|
||||
func statusClass(view *PlaylistView) string {
|
||||
if !view.IsKnown {
|
||||
return "secondary"
|
||||
}
|
||||
if view.IsOnline {
|
||||
return "success"
|
||||
}
|
||||
return "danger"
|
||||
}
|
||||
|
||||
// statusBadge возвращает текст бейджа статуса
|
||||
func statusBadge(view *PlaylistView) string {
|
||||
if !view.IsKnown {
|
||||
return "unknown"
|
||||
}
|
||||
if view.IsOnline {
|
||||
return "online"
|
||||
}
|
||||
return "offline"
|
||||
}
|
||||
|
||||
// formatDate форматирует unix timestamp в строку
|
||||
func formatDate(ts int64) string {
|
||||
if ts == 0 {
|
||||
return ""
|
||||
}
|
||||
return time.Unix(ts, 0).Format("02.01.2006 15:04:05")
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
{{define "base"}}
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru" class="h-100">
|
||||
<head>
|
||||
<title>{{block "title" .}}{{.Title}}{{end}}</title>
|
||||
<meta charset="utf-8">
|
||||
<meta name="description" content="{{block "metadescription" .}}{{end}}">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="keywords" content="{{block "metakeywords" .}}{{end}}">
|
||||
<meta http-equiv="x-ua-compatible" content="ie=edge">
|
||||
<style>.cursor-pointer{cursor:pointer}.cursor-help{cursor:help}</style>
|
||||
<script async type="module" src="https://unpkg.com/ionicons@7.1.0/dist/ionicons/ionicons.esm.js"></script>
|
||||
<script async nomodule src="https://unpkg.com/ionicons@7.1.0/dist/ionicons/ionicons.js"></script>
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.2.3/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
{{block "head" .}}{{end}}
|
||||
</head>
|
||||
<body class="d-flex flex-column h-100 bg-dark text-light">
|
||||
<header class="sticky-top bg-dark border-bottom border-secondary">
|
||||
<nav class="navbar navbar-expand-lg navbar-dark container px-2">
|
||||
<a class="navbar-brand d-flex align-items-center gap-2" href="/" title="На главную">
|
||||
<span>{{.Title}}</span>
|
||||
</a>
|
||||
<button class="navbar-toggler"
|
||||
type="button"
|
||||
data-bs-toggle="collapse"
|
||||
data-bs-target="#navbarNav"
|
||||
aria-controls="navbarNav"
|
||||
>
|
||||
<span class="navbar-toggler-icon"></span>
|
||||
</button>
|
||||
<div class="collapse navbar-collapse" id="navbarNav">
|
||||
<ul class="navbar-nav ms-auto">
|
||||
{{range .Navigation}}
|
||||
{{if .Children}}
|
||||
<li class="nav-item dropdown">
|
||||
<a class="nav-link dropdown-toggle d-flex align-items-center"
|
||||
href="#"
|
||||
role="button"
|
||||
data-bs-toggle="dropdown"
|
||||
aria-expanded="false"
|
||||
>
|
||||
{{if .Icon}}<ion-icon name="{{.Icon}}" class="me-1"></ion-icon> {{end}}{{.Title}}
|
||||
</a>
|
||||
<ul class="dropdown-menu dropdown-menu-dark">
|
||||
{{range .Children}}
|
||||
<li>
|
||||
<a class="dropdown-item d-flex align-items-center gap-2" target="_blank" href="{{.Url}}">
|
||||
{{if .Icon}}<ion-icon name="{{.Icon}}"></ion-icon> {{end}}{{.Title}}
|
||||
</a>
|
||||
</li>
|
||||
{{end}}
|
||||
</ul>
|
||||
</li>
|
||||
{{else}}
|
||||
<li class="nav-item">
|
||||
<a class="nav-link d-flex align-items-center" target="_blank" href="{{.Url}}">
|
||||
{{if .Icon}}<ion-icon name="{{.Icon}}" class="me-1"></ion-icon> {{end}}{{.Title}}
|
||||
</a>
|
||||
</li>
|
||||
{{end}}
|
||||
{{end}}
|
||||
</ul>
|
||||
</div>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<main class="flex-grow-1 container py-4">
|
||||
{{block "header" .}}{{end}}
|
||||
<div class="content-wrapper">
|
||||
{{block "content" .}}{{end}}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<footer class="bg-dark border-top border-secondary py-4">
|
||||
<div class="container text-center">
|
||||
<div class="d-flex flex-wrap justify-content-center gap-3 mb-3">
|
||||
{{range .FooterLinks}}
|
||||
<a target="_blank" href="{{.Url}}" class="text-light text-decoration-none d-flex align-items-center gap-1">
|
||||
{{if .Icon}}<ion-icon name="{{.Icon}}"></ion-icon> {{end}}{{.Title}}
|
||||
</a>
|
||||
{{end}}
|
||||
</div>
|
||||
<div class="small text-secondary d-inline-flex align-items-center gap-1">
|
||||
<a class="text-secondary" href="{{.RepoUrl}}/releases/tag/v{{.Version}}" target="_blank">
|
||||
<ion-icon name="pricetag-outline"></ion-icon> iptvc v{{.Version}}
|
||||
</a> Антон Аксенов © 2025-2026 MIT License
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<div class="toast-container position-fixed bottom-0 end-0 p-3">
|
||||
<div class="toast align-items-center text-bg-success border-0" role="alert" id="clipboardToast">
|
||||
<div class="d-flex">
|
||||
<div class="toast-body" id="clipboardToastBody"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.2.3/dist/js/bootstrap.bundle.min.js"></script>
|
||||
{{block "footer" .}}{{end}}
|
||||
|
||||
<script>
|
||||
function showToast(message) {
|
||||
const toastEl = document.getElementById('clipboardToast');
|
||||
const toastBodyEl = document.getElementById('clipboardToastBody');
|
||||
toastBodyEl.innerHTML = message;
|
||||
const toast = new bootstrap.Toast(toastEl, {delay: 5000});
|
||||
toast.show();
|
||||
}
|
||||
|
||||
function copyPlaylistUrl(code) {
|
||||
const url = '{{.BaseUrl}}/' + code;
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
navigator.clipboard
|
||||
.writeText(url)
|
||||
.then(() => showToast(`Ссылка на плейлист '${code}' скопирована в буфер обмена`))
|
||||
.catch(err => console.error('Failed to copy:', err));
|
||||
} else {
|
||||
try {
|
||||
const textArea = document.createElement("textarea");
|
||||
textArea.value = url;
|
||||
textArea.style.position = "fixed";
|
||||
document.body.appendChild(textArea);
|
||||
textArea.focus();
|
||||
textArea.select();
|
||||
const successful = document.execCommand('copy');
|
||||
document.body.removeChild(textArea);
|
||||
if (successful) {
|
||||
showToast(`Ссылка на плейлист '${code}' скопирована в буфер обмена`);
|
||||
} else {
|
||||
showToast('Ошибка при копировании ссылки', true);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Fallback copy failed:', err);
|
||||
showToast('Ошибка при копировании ссылки', true);
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
@@ -0,0 +1,493 @@
|
||||
{{define "title"}}[{{.Playlist.Code}}] {{.Playlist.Name}} - {{.Title}}{{end}}
|
||||
|
||||
{{define "metadescription"}}Смотреть бесплатный самообновляемый плейлист {{.Playlist.Name}}, проверить статус, {{.Playlist.Description}}{{end}}
|
||||
|
||||
{{define "head"}}
|
||||
<style>
|
||||
img.tvg-logo{max-width:80px;max-height:80px;padding:2px;border-radius:5px}
|
||||
tr.chrow td{padding:3px}
|
||||
td.chindex{width:1%}
|
||||
td.chlogo{width:100px}
|
||||
div.chlist-table{max-height:550px}
|
||||
textarea.m3u-raw{font-size:.7rem}
|
||||
</style>
|
||||
<script>
|
||||
const DEFAULT_LOGO = 'data:image/svg+xml,' + encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><rect width="100" height="100" fill="#333"/><text x="50" y="55" text-anchor="middle" fill="#999" font-size="14">no logo</text></svg>')
|
||||
|
||||
function setDefaultLogo(imgtag) {
|
||||
imgtag.onerror = null
|
||||
imgtag.src = DEFAULT_LOGO
|
||||
}
|
||||
</script>
|
||||
{{end}}
|
||||
|
||||
{{define "header"}}
|
||||
<h2>О плейлисте: {{.Playlist.Name}}</h2>
|
||||
{{end}}
|
||||
|
||||
{{define "content"}}
|
||||
{{$pls := .Playlist}}
|
||||
<div class="row">
|
||||
<div class="col-lg-7">
|
||||
<ul class="nav nav-tabs">
|
||||
<li class="nav-item small">
|
||||
<a class="nav-link active"
|
||||
type="button"
|
||||
href="#tab-data"
|
||||
data-bs-toggle="tab"
|
||||
data-bs-target="#tab-data"
|
||||
>
|
||||
<ion-icon name="radio-outline"></ion-icon> Основные данные
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item small">
|
||||
<a class="nav-link"
|
||||
type="button"
|
||||
href="#tab-raw"
|
||||
data-bs-toggle="tab"
|
||||
data-bs-target="#tab-raw"
|
||||
>
|
||||
<ion-icon name="document-text-outline"></ion-icon> Исходный текст
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item small">
|
||||
<a class="nav-link"
|
||||
type="button"
|
||||
href="#tab-abuse"
|
||||
data-bs-toggle="tab"
|
||||
data-bs-target="#tab-abuse"
|
||||
>
|
||||
<ion-icon name="wallet-outline"></ion-icon> Правообладателям
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
<div class="tab-content">
|
||||
<div class="tab-pane fade show active" id="tab-data" tabindex="0">
|
||||
<table class="table table-dark table-hover small mb-lg-5">
|
||||
<tbody>
|
||||
<tr>
|
||||
<th class="w-25" scope="row">Код</th>
|
||||
<th class="text-break">
|
||||
<span class="pe-3 font-monospace">{{$pls.Code}}</span>
|
||||
{{if not $pls.IsKnown}}
|
||||
<span class="cursor-help badge small text-dark bg-secondary"
|
||||
title="Не проверялся"
|
||||
>unknown</span>
|
||||
{{else if $pls.IsOnline}}
|
||||
<span class="cursor-help badge small text-dark bg-success"
|
||||
title="Вероятно, работает"
|
||||
>online</span>
|
||||
{{else}}
|
||||
<span class="cursor-help badge small text-dark bg-danger"
|
||||
title="Вероятно, не работает"
|
||||
>offline</span>
|
||||
{{end}}
|
||||
</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">Описание</th>
|
||||
<td class="text-break"><p class="mb-0">{{$pls.Description}}</p></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">Короткая ссылка</th>
|
||||
<td>
|
||||
<span onclick="copyPlaylistUrl('{{$pls.Code}}')"
|
||||
class="cursor-pointer"
|
||||
title="Нажми на ссылку, чтобы скопировать её в буфер обмена"
|
||||
>
|
||||
<b class="cursor-pointer font-monospace text-break">{{baseUrl $pls.Code}}</b>
|
||||
<ion-icon name="copy-outline"></ion-icon>
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">Источник</th>
|
||||
<td class="text-break">{{$pls.Source}}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">Наполнение</th>
|
||||
<td class="text-break">
|
||||
{{if and $pls.IsKnown $pls.IsOnline}}
|
||||
{{if $pls.HasTokens}}
|
||||
<span class="cursor-help badge bg-info text-dark">
|
||||
<ion-icon name="paw"></ion-icon>
|
||||
</span> могут быть нестабильные каналы<br>
|
||||
{{end}}
|
||||
|
||||
{{if hasTag $pls.Tags "adult"}}
|
||||
<span class="cursor-help badge small bg-warning text-dark">18+</span> есть каналы для взрослых<br>
|
||||
{{end}}
|
||||
|
||||
<ion-icon name="folder-open-outline"></ion-icon> группы: {{groupCount $pls}}<br>
|
||||
<ion-icon name="videocam-outline"></ion-icon> каналы:
|
||||
<span class="cursor-help text-success" title="Возможно, рабочие каналы">
|
||||
{{$pls.OnlineCount}} ({{$pls.OnlinePercent}}%)
|
||||
</span>
|
||||
+
|
||||
<span class="cursor-help text-danger" title="Возможно, НЕрабочие каналы">
|
||||
{{$pls.OfflineCount}} ({{$pls.OfflinePercent}}%)
|
||||
</span>
|
||||
= {{channelCount $pls}}
|
||||
{{end}}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">Возможности</th>
|
||||
<td class="text-break">
|
||||
{{if and $pls.IsKnown $pls.IsOnline}}
|
||||
<ion-icon name="newspaper-outline"></ion-icon> Программа передач: {{if $pls.HasTvg}}есть{{else}}нет{{end}}<br>
|
||||
<ion-icon name="play-back"></ion-icon> Перемотка (архив): {{if $pls.HasCatchup}}есть{{else}}нет{{end}}
|
||||
{{end}}
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="text-secondary">
|
||||
<th scope="row">M3U</th>
|
||||
<td class="text-break">{{$pls.Url}}</td>
|
||||
</tr>
|
||||
<tr class="text-secondary">
|
||||
<th class="w-25" scope="row">Проверка плейлиста</th>
|
||||
<td class="text-break">
|
||||
<span title="Фактическая метка времени окончания проверки плейлиста">
|
||||
{{formatDate $pls.CheckedAt}}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
{{if and $pls.IsKnown (not $pls.IsOnline)}}
|
||||
<tr class="text-secondary">
|
||||
<th class="w-25" scope="row">Ошибка проверки</th>
|
||||
<td class="text-break">{{$pls.Content}}</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{{if $pls.Attributes}}
|
||||
<h4>Дополнительные атрибуты</h4>
|
||||
<table class="table table-dark table-hover small font-monospace">
|
||||
<tbody>
|
||||
{{range $attr, $val := $pls.Attributes}}
|
||||
<tr>
|
||||
<th class="w-25" scope="row">{{$attr}}</th>
|
||||
<td class="text-break">{{$val}}</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
{{end}}
|
||||
</div>
|
||||
<div class="tab-pane fade" id="tab-raw" tabindex="1">
|
||||
<button class="btn btn-sm btn-success my-3"
|
||||
id="saveM3UBtn"
|
||||
onclick="savePlaylist()"
|
||||
>
|
||||
<ion-icon name="download-outline"></ion-icon> {{$pls.Code}}.m3u8
|
||||
</button>
|
||||
<textarea class="form-control bg-dark text-light font-monospace mb-3 mb-md-0 m3u-raw"
|
||||
rows="40"
|
||||
id="m3u-raw"
|
||||
readonly
|
||||
>{{$pls.Content}}</textarea>
|
||||
</div>
|
||||
<div class="tab-pane fade" id="tab-abuse" tabindex="2">
|
||||
<p class="my-3">
|
||||
Данные, представленные на данной странице, получены автоматически из открыто доступных в
|
||||
интернете IPTV-плейлистов, опубликованных третьими лицами.
|
||||
При наличии технической возможности, источник плейлиста может быть указан на вкладке "Основные
|
||||
данные".
|
||||
</p>
|
||||
<p class="my-3">
|
||||
Сервис {{baseUrl ""}} не размещает и не транслирует медиаконтент, не создаёт, не призывает
|
||||
использовать и распространять плейлисты третьих лиц, а также не оказывает услуг по ретрансляции
|
||||
телепрограмм.
|
||||
</p>
|
||||
<p class="my-3">
|
||||
Информация о телеканалах (наименования, логотипы, технический статус и другие сведения)
|
||||
формируется исключительно путём обработки содержимого самого плейлиста.
|
||||
Вся информация носит технический и ознакомительный характер, и её достоверность не гарантируется.
|
||||
</p>
|
||||
<p class="my-3">
|
||||
Все права на торговые марки и графические изображения принадлежат их законным владельцам.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-lg-5">
|
||||
<h4>Список каналов: <span id="chcount">{{channelCount $pls}}</span></h4>
|
||||
{{if gt (channelCount $pls) 0}}
|
||||
{{if gt (groupCount $pls) 1}}
|
||||
<div class="row my-3">
|
||||
<div class="col-12">
|
||||
{{if gt (channelCount $pls) 100}}
|
||||
<div class="alert alert-warning small" role="alert" id="chListLoading">
|
||||
<div class="spinner-border text-success spinner-border-sm" role="status"></div>
|
||||
Загрузка...
|
||||
</div>
|
||||
{{end}}
|
||||
<div class="input-group">
|
||||
<select id="groupSelector"
|
||||
class="form-select form-select-sm border-secondary bg-dark text-light"
|
||||
onchange="updateFilter()"
|
||||
>
|
||||
<option selected value="all">Все группы</option>
|
||||
{{range $pls.Groups}}
|
||||
<option value="{{.Id}}">{{.Name}}</option>
|
||||
{{end}}
|
||||
</select>
|
||||
|
||||
<button type="button"
|
||||
onclick="resetGroup()"
|
||||
class="btn btn-sm btn-outline-secondary"
|
||||
title="Сбросить группу"
|
||||
>
|
||||
<ion-icon name="close-outline"></ion-icon>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
<div class="row my-3">
|
||||
<div class="col-12">
|
||||
<div class="input-group">
|
||||
<input type="text"
|
||||
id="search-field"
|
||||
class="cursor-help form-control form-control-sm border-secondary bg-dark text-light fuzzy-search"
|
||||
placeholder="Поиск каналов..."
|
||||
title="Начни вводить название"
|
||||
/>
|
||||
|
||||
<input type="radio"
|
||||
class="btn-check"
|
||||
name="chFilter"
|
||||
id="chfAll"
|
||||
autocomplete="off"
|
||||
onclick="updateFilter()"
|
||||
checked
|
||||
>
|
||||
<label class="btn btn-sm btn-outline-secondary"
|
||||
for="chfAll"
|
||||
title="Выбрать все каналы"
|
||||
>
|
||||
<ion-icon name="radio-button-on-outline"></ion-icon>
|
||||
</label>
|
||||
|
||||
<input type="radio"
|
||||
class="btn-check"
|
||||
name="chFilter"
|
||||
id="chfOnline"
|
||||
autocomplete="off"
|
||||
onclick="updateFilter()"
|
||||
>
|
||||
<label class="btn btn-sm btn-outline-success"
|
||||
for="chfOnline"
|
||||
title="Выбрать только онлайн каналы"
|
||||
>
|
||||
<ion-icon name="radio-button-on-outline"></ion-icon>{{$pls.OnlineCount}}
|
||||
</label>
|
||||
|
||||
<input type="radio"
|
||||
class="btn-check"
|
||||
name="chFilter"
|
||||
id="chfOffline"
|
||||
autocomplete="off"
|
||||
onclick="updateFilter()"
|
||||
>
|
||||
<label class="btn btn-sm btn-outline-danger"
|
||||
for="chfOffline"
|
||||
title="Выбрать только оффлайн каналы"
|
||||
>
|
||||
<ion-icon name="radio-button-on-outline"></ion-icon>{{$pls.OfflineCount}}
|
||||
</label>
|
||||
|
||||
<button type="button"
|
||||
class="btn btn-sm btn-outline-secondary"
|
||||
onclick="resetSearch()"
|
||||
title="Сбросить фильтрацию"
|
||||
>
|
||||
<ion-icon name="close-outline"></ion-icon>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="my-3">
|
||||
{{range $pls.Tags}}
|
||||
<input type="checkbox"
|
||||
class="btn-check"
|
||||
id="btn-tag-{{.}}"
|
||||
data-tag="{{.}}"
|
||||
autocomplete="off"
|
||||
onclick="updateFilter()"
|
||||
>
|
||||
<label class="badge btn btn-sm btn-outline-secondary rounded-pill"
|
||||
for="btn-tag-{{.}}"
|
||||
title="Нажми для фильтрации каналов по тегу, нажми ещё раз чтобы снять фильтр"
|
||||
>#{{.}}</label>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="chlist-table overflow-auto">
|
||||
<table id="chlist" class="table table-dark table-hover small">
|
||||
<tbody class="list">
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{{else if and $pls.IsKnown (not $pls.IsOnline)}}
|
||||
<div class="alert alert-danger small" role="alert">
|
||||
Ошибка плейлиста: {{$pls.Content}}
|
||||
</div>
|
||||
{{else if not $pls.IsKnown}}
|
||||
<div class="alert alert-warning small" role="alert" id="chListLoading">
|
||||
Список каналов сейчас неизвестен: плейлист ещё не проверялся, либо данные о последней проверке потеряли актуальность.
|
||||
<br><br>
|
||||
Вернитесь на эту страницу позже. Каналы будут известны, когда плейлист будет в статусе online.
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{define "footer"}}
|
||||
{{if and .Playlist.IsKnown .Playlist.IsOnline}}
|
||||
<script src="https://cdn.jsdelivr.net/npm/list.js@2.3.1/dist/list.min.js"></script>
|
||||
<script>
|
||||
function getChannelTemplate(channel) {
|
||||
const httpCode = channel.status ?? '(неизвестно)'
|
||||
const errText = !!channel.error
|
||||
? channel.error.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''')
|
||||
: '(нет)'
|
||||
|
||||
const logoUrl = channel.attributes['tvg-logo'] ?? DEFAULT_LOGO
|
||||
const logoHint = !!channel.attributes['tvg-logo']
|
||||
? `Логотип канала '${channel.title}'`
|
||||
: `Нет логотипа для канала '${channel.title}'`
|
||||
|
||||
const statusIconColor = channel.isOnline ? 'text-success' : 'text-danger'
|
||||
const statusIconHint = channel.isOnline
|
||||
? 'Состояние: онлайн (возможно, работает прямо сейчас)"'
|
||||
: 'Состояние: оффлайн (не работал в момент проверки или ошибка проверки)"'
|
||||
|
||||
const adultIcon = channel.tags.indexOf('adult') !== -1
|
||||
? '<span class="badge small bg-warning text-dark" title="Канал для взрослых!">18+</span>'
|
||||
: ''
|
||||
|
||||
const pawIcon = channel.hasToken
|
||||
? '<span class="cursor-help badge small bg-info text-dark" title="Может быть нестабилен"><ion-icon name="paw"></ion-icon></span>'
|
||||
: ''
|
||||
|
||||
const tvgId = !!channel.attributes['tvg-id']
|
||||
? `<div title="Идентификатор канала для телепрограммы (tvg-id)" class="cursor-help"><ion-icon name="star-outline" class="me-1"></ion-icon> ${channel.attributes['tvg-id']}</div>`
|
||||
: ``
|
||||
|
||||
const mimeType = !!channel.contentType
|
||||
? `<div title="Тип контента (mime-type)" class="cursor-help"><ion-icon name="eye-outline" class="me-1"></ion-icon> ${channel.contentType}</div>`
|
||||
: ``
|
||||
|
||||
const tags = channel.tags.length > 0
|
||||
? `<ion-icon name="pricetag-outline" class="cursor-help me-1" title="Теги"></ion-icon> `
|
||||
+ channel.tags.map((tag) => `<span class="chtag">#${tag}</span>`).join(' ')
|
||||
: ``
|
||||
|
||||
return `<tr class="chrow" title="
HTTP: ${httpCode}
Error: ${errText}">
|
||||
<td class="chlogo text-center">
|
||||
<img class="tvg-logo" alt="${logoHint}" title="${logoHint}" src="${logoUrl}" onerror="setDefaultLogo(this)" />
|
||||
</td>
|
||||
<td class="text-break">
|
||||
<ion-icon name="radio-button-on-outline" class="cursor-help me-1 ${statusIconColor}" title="${statusIconHint}"></ion-icon>
|
||||
${adultIcon}
|
||||
${pawIcon}
|
||||
${channel.title}
|
||||
<div class="text-secondary small">
|
||||
${tvgId}
|
||||
${mimeType}
|
||||
${tags}
|
||||
</div>
|
||||
</td>
|
||||
</tr>`
|
||||
}
|
||||
|
||||
const options = {
|
||||
searchColumns: ['title'],
|
||||
item: (channel) => getChannelTemplate(channel),
|
||||
};
|
||||
const values = {{jsonChannels .Playlist.ViewChannels}}
|
||||
|
||||
const list = new List('chlist', options, values)
|
||||
list.on('updated', (data) => document.getElementById('chcount').innerText = data.visibleItems.length)
|
||||
document.getElementById('search-field').addEventListener('keyup', (e) => list.search(e.target.value))
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
const alert = document.getElementById("chListLoading")
|
||||
!!alert && alert.remove()
|
||||
});
|
||||
|
||||
function savePlaylist() {
|
||||
const link = document.createElement("a");
|
||||
const content = document.getElementById("m3u-raw").value
|
||||
const file = new Blob([content], { type: 'text/plain' });
|
||||
link.href = URL.createObjectURL(file);
|
||||
link.download = "{{.Playlist.Code}}.m3u8";
|
||||
link.click();
|
||||
URL.revokeObjectURL(link.href);
|
||||
}
|
||||
|
||||
function resetGroup() {
|
||||
document.getElementById('groupSelector').value = 'all'
|
||||
updateFilter()
|
||||
}
|
||||
|
||||
function resetSearch() {
|
||||
list.search('')
|
||||
document.getElementById('search-field').value = ''
|
||||
document.getElementById('chfAll').checked = true
|
||||
document.querySelectorAll('input[id*="btn-tag-"]:checked').forEach(tag => tag.checked = false)
|
||||
updateFilter()
|
||||
}
|
||||
|
||||
function updateFilter() {
|
||||
const selectedGroupId = document.getElementById('groupSelector')?.value ?? 'all';
|
||||
const tagsElements = document.querySelectorAll('input[id*="btn-tag-"]:checked')
|
||||
const tagsSelected = []
|
||||
tagsElements.forEach(tag => tagsSelected.push(tag.attributes['data-tag'].value));
|
||||
const activeType = document.querySelector('input[name="chFilter"]:checked').id;
|
||||
switch (activeType) {
|
||||
case 'chfAll':
|
||||
list.filter(item => {
|
||||
const chTags = item.values().tags
|
||||
const isGroupValid = item.values().groupId === selectedGroupId || selectedGroupId === 'all';
|
||||
const tagsIntersection = tagsSelected.filter(tagSelected => chTags.includes(tagSelected));
|
||||
const hasValidTags = tagsIntersection.length > 0 || tagsSelected.length === 0;
|
||||
return isGroupValid && hasValidTags;
|
||||
})
|
||||
break
|
||||
|
||||
case 'chfOnline':
|
||||
list.filter(item => {
|
||||
const isOnline = item.values().isOnline
|
||||
const chTags = item.values().tags
|
||||
const isGroupValid = item.values().groupId === selectedGroupId || selectedGroupId === 'all';
|
||||
const tagsIntersection = tagsSelected.filter(tagSelected => chTags.includes(tagSelected));
|
||||
const hasValidTags = tagsIntersection.length > 0 || tagsSelected.length === 0;
|
||||
return isGroupValid && isOnline && hasValidTags
|
||||
})
|
||||
break
|
||||
|
||||
case 'chfOffline':
|
||||
list.filter(item => {
|
||||
const isOffline = !item.values().isOnline
|
||||
const chTags = item.values().tags
|
||||
const isGroupValid = item.values().groupId === selectedGroupId || selectedGroupId === 'all';
|
||||
const tagsIntersection = tagsSelected.filter(tagSelected => chTags.includes(tagSelected));
|
||||
const hasValidTags = tagsIntersection.length > 0 || tagsSelected.length === 0;
|
||||
return isGroupValid && isOffline && hasValidTags
|
||||
})
|
||||
break
|
||||
}
|
||||
}
|
||||
</script>
|
||||
{{end}}
|
||||
{{end}}
|
||||
@@ -0,0 +1,151 @@
|
||||
{{define "title"}}{{.Title}}{{end}}
|
||||
|
||||
{{define "metadescription"}}Самообновляемые бесплатные IPTV-плейлисты для домашнего просмотра по коротким ссылкам, списки каналов, проверка доступности{{end}}
|
||||
|
||||
{{define "metakeywords"}}самообновляемые,бесплатные,iptv-плейлисты,iptv,плейлисты{{end}}
|
||||
|
||||
{{define "head"}}
|
||||
<style>
|
||||
.card {transition: box-shadow .2s, transform .2s}
|
||||
.card.hover-success:hover {transform: translateY(-7px); box-shadow: rgba(var(--bs-success-rgb), 1) 0 5px 20px -5px}
|
||||
.card.hover-danger:hover {transform: translateY(-7px); box-shadow: rgba(var(--bs-danger-rgb), 1) 0 5px 20px -5px}
|
||||
.card.hover-secondary:hover {transform: translateY(-7px); box-shadow: rgba(var(--bs-secondary-rgb), 1) 0 5px 20px -5px}
|
||||
</style>
|
||||
<script>
|
||||
const DEFAULT_LOGO = 'data:image/svg+xml,' + encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><rect width="100" height="100" fill="#333"/><text x="50" y="55" text-anchor="middle" fill="#999" font-size="14">no logo</text></svg>')
|
||||
|
||||
function setDefaultLogo(imgtag) {
|
||||
imgtag.onerror = null
|
||||
imgtag.src = DEFAULT_LOGO
|
||||
}
|
||||
</script>
|
||||
{{end}}
|
||||
|
||||
{{define "header"}}
|
||||
<div class="d-flex flex-wrap justify-content-between align-items-center mb-4">
|
||||
<div class="mb-2">
|
||||
<h2 class="mb-0">Список плейлистов ({{.Count}})</h2>
|
||||
<div class="text-muted small">Изменён {{.UpdatedAt}} МСК</div>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{define "content"}}
|
||||
{{if .Error}}
|
||||
<div class="alert alert-danger" role="alert">
|
||||
<ion-icon name="alert-circle-outline" class="me-1"></ion-icon>{{.Error}}
|
||||
</div>
|
||||
{{else if eq (len .Playlists) 0}}
|
||||
<div class="alert alert-warning" role="alert">
|
||||
<ion-icon name="information-circle-outline" class="me-1"></ion-icon>Список плейлистов пуст.
|
||||
Вернитесь на эту страницу позже — возможно, плейлисты ещё не добавлены или не проверены.
|
||||
</div>
|
||||
{{end}}
|
||||
<div class="row g-4">
|
||||
{{range .Playlists}}
|
||||
{{$sc := statusClass .}}
|
||||
<div class="col-md-6 col-lg-4">
|
||||
<div class="card bg-dark text-light h-100 border border-{{$sc}} hover-{{$sc}} position-relative">
|
||||
<a href="/{{.Code}}/details" class="text-decoration-none">
|
||||
<div class="card-header d-flex align-items-center gap-2">
|
||||
<span class="font-monospace text-{{$sc}}">{{.Code}}</span>
|
||||
|
||||
{{if not .IsKnown}}
|
||||
<span class="cursor-help badge bg-{{$sc}} ms-auto"
|
||||
title="Плейлист ещё не проверялся, придётся подождать"
|
||||
>unknown</span>
|
||||
{{else if .IsOnline}}
|
||||
<span class="cursor-help badge bg-{{$sc}} ms-auto"
|
||||
title="Возможно, этот плейлист рабочий"
|
||||
>online</span>
|
||||
{{else}}
|
||||
<span class="cursor-help badge bg-{{$sc}} ms-auto"
|
||||
title="Этот плейлист нерабочий или его не удалось проверить"
|
||||
>offline</span>
|
||||
{{end}}
|
||||
|
||||
{{if and .IsKnown .IsOnline}}
|
||||
<span class="cursor-help badge border border-success"
|
||||
title="Процент рабочих каналов"
|
||||
>{{.OnlinePercent}}%</span>
|
||||
{{end}}
|
||||
|
||||
{{if hasTag .Tags "adult"}}
|
||||
<span class="cursor-help badge bg-warning text-dark"
|
||||
title="Есть каналы для взрослых!"
|
||||
>18+</span>
|
||||
{{end}}
|
||||
|
||||
{{if .HasTokens}}
|
||||
<span class="cursor-help badge bg-info text-dark"
|
||||
title="В плейлисте есть каналы, которые могут быть нестабильны"
|
||||
><ion-icon name="paw"></ion-icon></span>
|
||||
{{end}}
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<div class="card-body position-relative z-2">
|
||||
<a href="/{{.Code}}/details" class="text-decoration-none">
|
||||
<h5 class="card-title text-light">{{.Name}}</h5>
|
||||
</a>
|
||||
{{if .Description}}
|
||||
<p class="card-text small text-secondary d-none d-md-block">{{.Description}}</p>
|
||||
{{end}}
|
||||
<div class="d-flex flex-wrap gap-2 mb-1">
|
||||
{{if and .IsKnown .IsOnline}}
|
||||
<span class="badge border border-secondary">
|
||||
<ion-icon name="videocam-outline" class="me-1"></ion-icon> {{channelCount .}}<span class="d-none d-xl-inline-block"> каналов</span>
|
||||
</span>
|
||||
{{if gt (groupCount .) 0}}
|
||||
<span class="badge border border-secondary">
|
||||
<ion-icon name="folder-open-outline" class="me-1"></ion-icon> {{groupCount .}}<span class="d-none d-xl-inline-block"> групп</span>
|
||||
</span>
|
||||
{{end}}
|
||||
{{if .HasTvg}}
|
||||
<span class="badge border border-secondary">
|
||||
<ion-icon name="newspaper-outline" class="me-1"></ion-icon><span class="d-none d-xl-inline-block"> ТВ-программа</span>
|
||||
</span>
|
||||
{{end}}
|
||||
{{if .HasCatchup}}
|
||||
<span class="badge border border-secondary">
|
||||
<ion-icon name="play-back-outline" class="me-1"></ion-icon><span class="d-none d-xl-inline-block"> Архив</span>
|
||||
</span>
|
||||
{{end}}
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card-footer cursor-pointer"
|
||||
onclick="copyPlaylistUrl('{{.Code}}')"
|
||||
title="Нажми на ссылку, чтобы скопировать её в буфер обмена"
|
||||
>
|
||||
<div class="d-flex justify-content-between align-items-center small">
|
||||
<span class="font-monospace text-truncate">
|
||||
{{baseUrl .Code}}
|
||||
</span>
|
||||
<ion-icon name="copy-outline"></ion-icon>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
{{if gt .PageCount 1}}
|
||||
<nav class="mt-4">
|
||||
<ul class="pagination justify-content-center">
|
||||
{{range $page := seq 1 .PageCount}}
|
||||
{{if eq $page $.PageCurrent}}
|
||||
<li class="page-item active" aria-current="page">
|
||||
<span class="page-link">{{$page}}</span>
|
||||
</li>
|
||||
{{else}}
|
||||
<li class="page-item">
|
||||
<a class="page-link bg-dark text-light" href="/page/{{$page}}">{{$page}}</a>
|
||||
</li>
|
||||
{{end}}
|
||||
{{end}}
|
||||
</ul>
|
||||
</nav>
|
||||
{{end}}
|
||||
{{end}}
|
||||
@@ -0,0 +1,23 @@
|
||||
{{define "title"}}Плейлист не найден - {{.Title}}{{end}}
|
||||
|
||||
{{define "content"}}
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-md-8 col-lg-6 text-center">
|
||||
<div class="card bg-dark border-secondary">
|
||||
<div class="card-body">
|
||||
<ion-icon name="warning-outline" class="display-1 text-warning mb-3"></ion-icon>
|
||||
<h2 class="card-title">Плейлист <code>{{.Code}}</code> не найден</h2>
|
||||
<p class="card-text">
|
||||
Возможно, его здесь никогда не было, либо он уже был удалён.
|
||||
</p>
|
||||
<p class="text-muted small">
|
||||
Если хочешь, чтобы здесь был плейлист, предложи его к добавлению.
|
||||
</p>
|
||||
<a class="btn btn-outline-light" href="/" title="На главную">
|
||||
<ion-icon name="list-outline" class="me-1"></ion-icon>Перейти к списку плейлистов
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
@@ -0,0 +1,360 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"axenov/iptv-checker/app/playlist"
|
||||
)
|
||||
|
||||
func TestHasTokensString(t *testing.T) {
|
||||
cases := []struct {
|
||||
in string
|
||||
want bool
|
||||
}{
|
||||
{"http://example.com/stream.m3u8?token=abc", true},
|
||||
{"http://example.com/stream?user=test&pwd=secret", true},
|
||||
{"http://example.com/stream?drmreq=xyz", true},
|
||||
{"http://example.com/stream?u=foo", true},
|
||||
{"http://example.com/stream?password=123", true},
|
||||
{"http://example.com/stream.m3u8", false},
|
||||
{"", false},
|
||||
{"plain text without tokens", false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
got := hasTokensString(c.in)
|
||||
if got != c.want {
|
||||
t.Errorf("hasTokensString(%q) = %v, want %v", c.in, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestStatusClass(t *testing.T) {
|
||||
cases := []struct {
|
||||
view *PlaylistView
|
||||
want string
|
||||
}{
|
||||
{&PlaylistView{IsKnown: false}, "secondary"},
|
||||
{&PlaylistView{IsKnown: true, Playlist: playlist.Playlist{IsOnline: true}}, "success"},
|
||||
{&PlaylistView{IsKnown: true, Playlist: playlist.Playlist{IsOnline: false}}, "danger"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
got := statusClass(c.view)
|
||||
if got != c.want {
|
||||
t.Errorf("statusClass() = %s, want %s", got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestStatusBadge(t *testing.T) {
|
||||
cases := []struct {
|
||||
view *PlaylistView
|
||||
want string
|
||||
}{
|
||||
{&PlaylistView{IsKnown: false}, "unknown"},
|
||||
{&PlaylistView{IsKnown: true, Playlist: playlist.Playlist{IsOnline: true}}, "online"},
|
||||
{&PlaylistView{IsKnown: true, Playlist: playlist.Playlist{IsOnline: false}}, "offline"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
got := statusBadge(c.view)
|
||||
if got != c.want {
|
||||
t.Errorf("statusBadge() = %s, want %s", got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatDate(t *testing.T) {
|
||||
if formatDate(0) != "" {
|
||||
t.Error("expected empty string for ts=0")
|
||||
}
|
||||
result := formatDate(1700000000)
|
||||
if result == "" {
|
||||
t.Error("expected non-empty date string")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsAlphanumeric(t *testing.T) {
|
||||
cases := []struct {
|
||||
in string
|
||||
want bool
|
||||
}{
|
||||
{"ABC123", true},
|
||||
{"abc", true},
|
||||
{"123", true},
|
||||
{"", false},
|
||||
{"ABC-123", false},
|
||||
{"ABC_123", false},
|
||||
{"ABC 123", false},
|
||||
{"Привет", false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
got := isAlphanumeric(c.in)
|
||||
if got != c.want {
|
||||
t.Errorf("isAlphanumeric(%q) = %v, want %v", c.in, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseRedisVersion(t *testing.T) {
|
||||
info := "# Server\nredis_version:6.2.6\nredis_mode:standalone\n"
|
||||
got := parseRedisVersion(info)
|
||||
if got != "6.2.6" {
|
||||
t.Errorf("expected '6.2.6', got '%s'", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseRedisVersion_NotFound(t *testing.T) {
|
||||
info := "# Server\nsome_other_field:foo\n"
|
||||
got := parseRedisVersion(info)
|
||||
if got != "unknown" {
|
||||
t.Errorf("expected 'unknown', got '%s'", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestContainsTag(t *testing.T) {
|
||||
tags := []string{"hd", "adult", "russian"}
|
||||
if !containsTag(tags, "adult") {
|
||||
t.Error("expected true for 'adult'")
|
||||
}
|
||||
if containsTag(tags, "movies") {
|
||||
t.Error("expected false for 'movies'")
|
||||
}
|
||||
if containsTag([]string{}, "x") {
|
||||
t.Error("expected false for empty list")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMakeUnknownView(t *testing.T) {
|
||||
iniPls := playlist.Playlist{
|
||||
Name: "Test",
|
||||
Description: "Desc",
|
||||
Url: "http://example.com",
|
||||
Source: "public",
|
||||
}
|
||||
view := makeUnknownView("CODE", iniPls)
|
||||
if view.Code != "CODE" {
|
||||
t.Errorf("expected Code='CODE', got '%s'", view.Code)
|
||||
}
|
||||
if view.IsKnown {
|
||||
t.Error("expected IsKnown=false")
|
||||
}
|
||||
if view.Name != "Test" {
|
||||
t.Errorf("expected Name='Test', got '%s'", view.Name)
|
||||
}
|
||||
if len(view.Tags) != 0 {
|
||||
t.Errorf("expected 0 tags, got %d", len(view.Tags))
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnrichPlaylist_OnlinePercent(t *testing.T) {
|
||||
view := &PlaylistView{
|
||||
Playlist: playlist.Playlist{
|
||||
IsOnline: true,
|
||||
OnlineCount: 8,
|
||||
OfflineCount: 2,
|
||||
Channels: map[string]playlist.Channel{
|
||||
"1": {},
|
||||
"2": {},
|
||||
"3": {},
|
||||
"4": {},
|
||||
"5": {},
|
||||
"6": {},
|
||||
"7": {},
|
||||
"8": {},
|
||||
"9": {},
|
||||
"10": {},
|
||||
},
|
||||
Attributes: map[string]string{},
|
||||
Groups: map[string]playlist.Group{},
|
||||
Content: "#EXTM3U",
|
||||
},
|
||||
IsKnown: true,
|
||||
}
|
||||
enrichPlaylist(view)
|
||||
if view.OnlinePercent != 80 {
|
||||
t.Errorf("expected 80%%, got %d%%", view.OnlinePercent)
|
||||
}
|
||||
if view.OfflinePercent != 20 {
|
||||
t.Errorf("expected 20%%, got %d%%", view.OfflinePercent)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnrichPlaylist_Unknown(t *testing.T) {
|
||||
view := &PlaylistView{
|
||||
Playlist: playlist.Playlist{
|
||||
Channels: map[string]playlist.Channel{"1": {}},
|
||||
Attributes: map[string]string{},
|
||||
Groups: map[string]playlist.Group{},
|
||||
Content: "",
|
||||
},
|
||||
IsKnown: false,
|
||||
}
|
||||
enrichPlaylist(view)
|
||||
if view.OnlinePercent != 0 || view.OfflinePercent != 0 {
|
||||
t.Errorf("expected 0%% for unknown, got online=%d%% offline=%d%%",
|
||||
view.OnlinePercent, view.OfflinePercent)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnrichPlaylist_HasCatchup(t *testing.T) {
|
||||
view := &PlaylistView{
|
||||
Playlist: playlist.Playlist{
|
||||
Content: "#EXTM3U catchup=default",
|
||||
Channels: map[string]playlist.Channel{},
|
||||
Attributes: map[string]string{},
|
||||
Groups: map[string]playlist.Group{},
|
||||
},
|
||||
IsKnown: true,
|
||||
}
|
||||
enrichPlaylist(view)
|
||||
if !view.HasCatchup {
|
||||
t.Error("expected HasCatchup=true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnrichPlaylist_HasTvg(t *testing.T) {
|
||||
view := &PlaylistView{
|
||||
Playlist: playlist.Playlist{
|
||||
Attributes: map[string]string{"url-tvg": "http://example.com/epg.xml"},
|
||||
Channels: map[string]playlist.Channel{},
|
||||
Groups: map[string]playlist.Group{},
|
||||
Content: "",
|
||||
},
|
||||
IsKnown: true,
|
||||
}
|
||||
enrichPlaylist(view)
|
||||
if !view.HasTvg {
|
||||
t.Error("expected HasTvg=true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnrichPlaylist_HasTokens(t *testing.T) {
|
||||
view := &PlaylistView{
|
||||
Playlist: playlist.Playlist{
|
||||
Url: "http://example.com/list.m3u8?token=abc",
|
||||
Content: "#EXTM3U",
|
||||
Channels: map[string]playlist.Channel{},
|
||||
Attributes: map[string]string{},
|
||||
Groups: map[string]playlist.Group{},
|
||||
},
|
||||
IsKnown: true,
|
||||
}
|
||||
enrichPlaylist(view)
|
||||
if !view.HasTokens {
|
||||
t.Error("expected HasTokens=true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnrichPlaylist_TagsCollection(t *testing.T) {
|
||||
view := &PlaylistView{
|
||||
Playlist: playlist.Playlist{
|
||||
Channels: map[string]playlist.Channel{
|
||||
"1": {Tags: []string{"hd", "russian"}},
|
||||
"2": {Tags: []string{"adult", "hd"}},
|
||||
},
|
||||
Attributes: map[string]string{},
|
||||
Groups: map[string]playlist.Group{},
|
||||
Content: "",
|
||||
},
|
||||
IsKnown: true,
|
||||
}
|
||||
enrichPlaylist(view)
|
||||
if len(view.Tags) != 3 {
|
||||
t.Errorf("expected 3 unique tags, got %d: %v", len(view.Tags), view.Tags)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnrichPlaylist_ChannelHasToken(t *testing.T) {
|
||||
view := &PlaylistView{
|
||||
Playlist: playlist.Playlist{
|
||||
Channels: map[string]playlist.Channel{
|
||||
"1": {URL: "http://example.com/stream?token=abc"},
|
||||
},
|
||||
Attributes: map[string]string{},
|
||||
Groups: map[string]playlist.Group{},
|
||||
Content: "",
|
||||
},
|
||||
IsKnown: true,
|
||||
}
|
||||
enrichPlaylist(view)
|
||||
for _, cv := range view.ViewChannels {
|
||||
if !cv.HasToken {
|
||||
t.Error("expected channel HasToken=true")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestComputeStats(t *testing.T) {
|
||||
playlists := []PlaylistView{
|
||||
{
|
||||
Playlist: playlist.Playlist{
|
||||
Code: "RU",
|
||||
IsOnline: true,
|
||||
CheckedAt: 1700000000,
|
||||
Channels: map[string]playlist.Channel{"1": {IsOnline: true}, "2": {IsOnline: false}},
|
||||
Groups: map[string]playlist.Group{"g1": {}},
|
||||
Attributes: map[string]string{"url-tvg": "http://epg.xml"},
|
||||
Content: "#EXTM3U catchup=default",
|
||||
},
|
||||
IsKnown: true,
|
||||
HasCatchup: true,
|
||||
HasTvg: true,
|
||||
Tags: []string{"adult"},
|
||||
},
|
||||
{
|
||||
Playlist: playlist.Playlist{
|
||||
Code: "EN",
|
||||
IsOnline: false,
|
||||
CheckedAt: 1700000100,
|
||||
Channels: map[string]playlist.Channel{"1": {IsOnline: false}},
|
||||
},
|
||||
IsKnown: false,
|
||||
},
|
||||
}
|
||||
stats := computeStats(playlists)
|
||||
plsStats := stats["playlists"].(map[string]interface{})
|
||||
if plsStats["all"].(int) != 2 {
|
||||
t.Errorf("expected 2 playlists, got %d", plsStats["all"])
|
||||
}
|
||||
if plsStats["online"].(int) != 1 {
|
||||
t.Errorf("expected 1 online, got %d", plsStats["online"])
|
||||
}
|
||||
if plsStats["unknown"].(int) != 1 {
|
||||
t.Errorf("expected 1 unknown, got %d", plsStats["unknown"])
|
||||
}
|
||||
if plsStats["adult"].(int) != 1 {
|
||||
t.Errorf("expected 1 adult, got %d", plsStats["adult"])
|
||||
}
|
||||
if plsStats["hasCatchup"].(int) != 1 {
|
||||
t.Errorf("expected 1 catchup, got %d", plsStats["hasCatchup"])
|
||||
}
|
||||
if plsStats["hasTvg"].(int) != 1 {
|
||||
t.Errorf("expected 1 tvg, got %d", plsStats["hasTvg"])
|
||||
}
|
||||
if plsStats["groupped"].(int) != 1 {
|
||||
t.Errorf("expected 1 groupped, got %d", plsStats["groupped"])
|
||||
}
|
||||
|
||||
chStats := stats["channels"].(map[string]interface{})
|
||||
if chStats["all"].(int) != 3 {
|
||||
t.Errorf("expected 3 channels, got %d", chStats["all"])
|
||||
}
|
||||
if chStats["online"].(int) != 1 {
|
||||
t.Errorf("expected 1 online channel, got %d", chStats["online"])
|
||||
}
|
||||
if chStats["offline"].(int) != 2 {
|
||||
t.Errorf("expected 2 offline channels, got %d", chStats["offline"])
|
||||
}
|
||||
|
||||
latest := plsStats["latest"].(map[string]interface{})
|
||||
if latest["code"] != "EN" {
|
||||
t.Errorf("expected latest code 'EN', got '%v'", latest["code"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestComputeStats_Empty(t *testing.T) {
|
||||
stats := computeStats([]PlaylistView{})
|
||||
plsStats := stats["playlists"].(map[string]interface{})
|
||||
if plsStats["all"].(int) != 0 {
|
||||
t.Errorf("expected 0, got %d", plsStats["all"])
|
||||
}
|
||||
}
|
||||
+867
@@ -0,0 +1,867 @@
|
||||
[
|
||||
{"title": "\\W(8[kк])(\\W|$)", "tags": ["8k"]},
|
||||
{"title": "\\W(4[kк])(\\W|$)", "tags": ["4k"]},
|
||||
{"title": "\\W(f?hd)(\\W|$)", "tags": ["hd"]},
|
||||
{"title": "\\W(1080p)(\\W|$)", "tags": ["hd"]},
|
||||
{"title": "\\[not\\W24\\/7](\\W|$)", "tags": ["unstable"]},
|
||||
|
||||
|
||||
|
||||
{"tvg-id": "business", "tags": ["finance"]},
|
||||
{"title": "business", "tags": ["finance"]},
|
||||
|
||||
|
||||
|
||||
{"tvg-id": "fashion", "tags": ["fashion"]},
|
||||
{"title": "fashion", "tags": ["fashion"]},
|
||||
|
||||
|
||||
|
||||
{"tvg-id": "history", "tags": ["history"]},
|
||||
{"title": "history", "tags": ["history"]},
|
||||
|
||||
|
||||
|
||||
{"title": "(crime|detective)", "tags": ["crime"]},
|
||||
{"tvg-id": "(crime|detective)", "tags": ["crime"]},
|
||||
|
||||
|
||||
|
||||
{"tvg-id": "sport", "tags": ["sport"]},
|
||||
{"title": "(sport|спорт)", "tags": ["sport"]},
|
||||
{"tvg-id": "fifa", "tags": ["sport"]},
|
||||
{"title": "fifa", "tags": ["sport"]},
|
||||
{"tvg-id": "fight", "tags": ["sport"]},
|
||||
{"title": "fight", "tags": ["sport"]},
|
||||
{"tvg-id": "ufc", "tags": ["sport"]},
|
||||
{"tvg-id": "(tennis|vol+eyb[oa]l+|golf)", "tags": ["sport"]},
|
||||
{"tvg-id": "(f[ou]tb[oal]l|футбол)", "tags": ["sport"]},
|
||||
{"title": "(f[ou]tb[oal]l|футбол)", "tags": ["sport"]},
|
||||
{"tvg-id": "basketb[oa]ll?", "tags": ["sport"]},
|
||||
{"tvg-id": "[nk][xh]l", "tags": ["sport"]},
|
||||
{"title": "\\W?e?xtreme", "tags": ["sport"]},
|
||||
|
||||
|
||||
|
||||
{"tvg-id": "stand\\W?up", "tags": ["humor"]},
|
||||
{"title": "stand\\W?up", "tags": ["humor"]},
|
||||
{"tvg-id": "sitcom", "tags": ["humor"]},
|
||||
{"title": "sitcom", "tags": ["humor"]},
|
||||
{"title": "юмор\\W?", "tags": ["humor"]},
|
||||
{"title": "^анекдот", "tags": ["humor"]},
|
||||
{"title": "[yh]umor", "tags": ["humor"]},
|
||||
{"title": "pranks?", "tags": ["humor"]},
|
||||
|
||||
{"tvg-id": "(комеди|com[eé]d[iy])", "tags": ["comedy", "film"]},
|
||||
{"title": "(комеди|com[eé]d[iy])", "tags": ["comedy", "film"]},
|
||||
{"tvg-id": "fail\\W?army", "tags": ["fun", "comedy"]},
|
||||
{"title": "fail\\W?army", "tags": ["fun", "comedy"]},
|
||||
{"tvg-id": "fun(ny)?", "tags": ["fun", "humor"]},
|
||||
{"title": "fun(ny)?", "tags": ["fun", "humor"]},
|
||||
{"tvg-id": "^lol", "tags": ["fun", "humor"]},
|
||||
{"title": "^lol\\W?", "tags": ["fun", "humor"]},
|
||||
{"tvg-id": "\\Wlaugh", "tags": ["fun", "humor"]},
|
||||
{"title": "\\Wlaugh", "tags": ["fun", "humor"]},
|
||||
|
||||
|
||||
|
||||
{"tvg-id": "\\W(fmradio)$", "tags": ["radio"]},
|
||||
{"title": "(рад[iи]о|radio|fm|фм)", "tags": ["radio"]},
|
||||
{"title": "trance", "tags": ["radio", "music"]},
|
||||
{"title": "record", "tags": ["radio", "music"]},
|
||||
{"tvg-id": "radiohit", "tags": ["radio", "music"]},
|
||||
{"title": "chill\\W?out", "tags": ["radio", "music"]},
|
||||
|
||||
|
||||
|
||||
{"tvg-id": "\\W?(a[vu]to|cars?\\W)", "tags": ["transport"]},
|
||||
{"title": "\\W?(a[vu]to|car?\\W)", "tags": ["transport"]},
|
||||
|
||||
|
||||
|
||||
{"title": "\\W?(музык|танц)", "tags": ["music"]},
|
||||
{"tvg-id": "(mu[sz]ic|danc[ei]|hits)", "tags": ["music"]},
|
||||
{"title": "(mu[sz]ic|danc[ei]|hits)", "tags": ["music"]},
|
||||
{"tvg-id": "^mtv", "tags": ["music"]},
|
||||
{"title": "^[mм][tт][vв]", "tags": ["music"]},
|
||||
{"tvg-id": "\\Wmuz", "tags": ["music"]},
|
||||
{"title": "\\Wmuz", "tags": ["music"]},
|
||||
{"tvg-id": "\\Wmooz", "tags": ["music"]},
|
||||
{"title": "\\Wmooz", "tags": ["music"]},
|
||||
{"title": "^bridge", "tags": ["music"]},
|
||||
{"title": "^djing", "tags": ["music"]},
|
||||
{"title": "^mcm", "tags": ["music"]},
|
||||
{"title": "^clubbing", "tags": ["music"]},
|
||||
{"title": "^00:", "tags": ["music"]},
|
||||
{"title": "^deejay", "tags": ["music"]},
|
||||
{"title": "^(promo\\W)?dj\\W", "tags": ["music"]},
|
||||
{"tvg-id": "shanson", "tags": ["music"]},
|
||||
{"title": "шансон", "tags": ["music"]},
|
||||
{"title": "^europa\\W?(plus|+)", "tags": ["music"]},
|
||||
|
||||
|
||||
|
||||
{"title": "\\W(19)?[5-9]0\\W?s(\\W|$)", "tags": ["retro"]},
|
||||
{"tvg-id": "retro", "tags": ["retro"]},
|
||||
{"title": "retro", "tags": ["retro"]},
|
||||
{"tvg-id": "nostalg", "tags": ["retro"]},
|
||||
{"title": "nostalg", "tags": ["retro"]},
|
||||
|
||||
|
||||
{"tvg-id": "^[kc]ultur[ae]", "tags": ["culture"]},
|
||||
|
||||
|
||||
|
||||
{"tvg-id": "\\W?(кино|фильм|film|kino|cinema|movie)", "tags": ["film"]},
|
||||
{"title": "\\W?(кино|фильм|film|kino|cinema|movie)", "tags": ["film"]},
|
||||
{"tvg-id": "\\W?hollywood", "tags": ["film"]},
|
||||
{"title": "\\W?hollywood", "tags": ["film"]},
|
||||
|
||||
|
||||
|
||||
{"tvg-id": "сери[яал]", "tags": ["series"]},
|
||||
{"title": "сери[яал]", "tags": ["series"]},
|
||||
|
||||
|
||||
|
||||
{"tvg-id": "horror", "tags": ["film", "series"]},
|
||||
{"title": "horror", "tags": ["film", "series"]},
|
||||
{"title": "netflix", "tags": ["film", "series"]},
|
||||
{"tvg-id": "^sony\\W?", "tags": ["film", "series"]},
|
||||
{"tvg-id": "^viju\\W?", "tags": ["film", "series"]},
|
||||
|
||||
|
||||
|
||||
{"title": "adult", "tags": ["adult"]},
|
||||
{"title": "18\\+", "tags": ["adult"]},
|
||||
{"title": "fuck", "tags": ["adult"]},
|
||||
{"title": "sex", "tags": ["adult"]},
|
||||
{"title": "porn", "tags": ["adult"]},
|
||||
{"tvg-id": "[xх]{3}", "tags": ["adult"]},
|
||||
{"title": "[xх]{3}", "tags": ["adult"]},
|
||||
{"title": "(секс|порн)", "tags": ["adult"]},
|
||||
{"tvg-id": "^ero", "tags": ["adult"]},
|
||||
{"title": "^ero", "tags": ["adult"]},
|
||||
{"tvg-id": "\\Werotic(\\W|$)", "tags": ["adult"]},
|
||||
{"title": "\\W(erotic|эроти[кч])(\\W|$)", "tags": ["adult"]},
|
||||
{"tvg-id": "\\Werotic(\\W|$)", "tags": ["adult"]},
|
||||
{"title": "\\W(erotic|эроти[кч])(\\W|$)", "tags": ["adult"]},
|
||||
{"title": "(\\W|^)cum\\W", "tags": ["adult"]},
|
||||
{"title": "(\\W|^)anal(\\W|$)", "tags": ["adult"]},
|
||||
{"title": "gay", "tags": ["adult"]},
|
||||
{"title": "lesb", "tags": ["adult"]},
|
||||
{"title": "m[iy]lf", "tags": ["adult"]},
|
||||
{"title": "(big)?c[uo]ck", "tags": ["adult"]},
|
||||
{"title": "(big)?dick", "tags": ["adult"]},
|
||||
{"title": "(big)?tits", "tags": ["adult"]},
|
||||
{"title": "(big)?\\Wboob", "tags": ["adult"]},
|
||||
{"title": "(big)?\\W?ass(hole)", "tags": ["adult"]},
|
||||
{"title": "deep\\W?thr", "tags": ["adult"]},
|
||||
{"title": "playboy", "tags": ["adult"]},
|
||||
{"title": "dorcel", "tags": ["adult"]},
|
||||
{"title": "metart", "tags": ["adult"]},
|
||||
{"title": "passion", "tags": ["adult"]},
|
||||
{"title": "femjoy", "tags": ["adult"]},
|
||||
{"title": "fuuu", "tags": ["adult"]},
|
||||
{"title": "penthouse", "tags": ["adult"]},
|
||||
{"title": "vivid", "tags": ["adult"]},
|
||||
{"title": "hustler", "tags": ["adult"]},
|
||||
{"title": "brazz", "tags": ["adult"]},
|
||||
{"title": "private", "tags": ["adult"]},
|
||||
{"title": "redlight", "tags": ["adult"]},
|
||||
{"title": "vixen", "tags": ["adult"]},
|
||||
{"title": "rough", "tags": ["adult"]},
|
||||
{"title": "charm", "tags": ["adult"]},
|
||||
{"title": "exexex", "tags": ["adult"]},
|
||||
{"title": "lethal", "tags": ["adult"]},
|
||||
{"title": "blacked", "tags": ["adult"]},
|
||||
{"title": "tushy", "tags": ["adult"]},
|
||||
{"title": "frenchlover", "tags": ["adult"]},
|
||||
{"title": "bratty", "tags": ["adult"]},
|
||||
{"title": "nubiles", "tags": ["adult"]},
|
||||
{"title": "sinematica", "tags": ["adult"]},
|
||||
{"title": "analized", "tags": ["adult"]},
|
||||
{"title": "woodman", "tags": ["adult"]},
|
||||
{"title": "babe", "tags": ["adult"]},
|
||||
{"title": "blowj", "tags": ["adult"]},
|
||||
{"title": "ex+tasy", "tags": ["adult"]},
|
||||
{"title": "ex+otic", "tags": ["adult"]},
|
||||
{"title": "^hot$", "tags": ["adult"]},
|
||||
{"title": "^lust", "tags": ["adult"]},
|
||||
{"tvg-id": "^leo$", "tags": ["adult"]},
|
||||
{"title": "^leo$", "tags": ["adult"]},
|
||||
{"title": "gags\\W?network", "tags": ["adult"]},
|
||||
{"title": "varely\\W?legal", "tags": ["adult"]},
|
||||
{"title": "^hot\\W?pleasure$", "tags": ["adult"]},
|
||||
{"title": "club\\W?sweethearts", "tags": ["adult"]},
|
||||
{"title": "club\\W?17", "tags": ["adult"]},
|
||||
{"title": "^rk\\W?prime", "tags": ["adult"]},
|
||||
{"title": "^rk\\W?tv", "tags": ["adult"]},
|
||||
{"title": "^rk\\W?hd", "tags": ["adult"]},
|
||||
{"title": "fast\\W?boyz", "tags": ["adult"]},
|
||||
{"title": "girls\\W?on\\W?girls", "tags": ["adult"]},
|
||||
{"title": "^fap\\W?tv", "tags": ["adult"]},
|
||||
{"tvg-id": "^pink\\W", "tags": ["adult"]},
|
||||
{"title": "^pink\\W", "tags": ["adult"]},
|
||||
{"title": "red\\W?lips", "tags": ["adult"]},
|
||||
{"title": "red\\W?tv", "tags": ["adult"]},
|
||||
{"title": "red\\W?traffic", "tags": ["adult"]},
|
||||
{"title": "public\\W?agent", "tags": ["adult"]},
|
||||
{"title": "digital\\W?desire", "tags": ["adult"]},
|
||||
{"title": "digital\\W?playground", "tags": ["adult"]},
|
||||
{"title": "pov\\W?tv", "tags": ["adult"]},
|
||||
{"title": "reality\\W?kings", "tags": ["adult"]},
|
||||
{"title": "fake\\W?taxi", "tags": ["adult"]},
|
||||
{"title": "little\\W?asians", "tags": ["adult"]},
|
||||
{"title": "dirty\\W?masseur", "tags": ["adult"]},
|
||||
{"title": "dirty\\W?flix", "tags": ["adult"]},
|
||||
{"title": "dirty\\W?wives", "tags": ["adult"]},
|
||||
{"title": "super\\W?one", "tags": ["adult"]},
|
||||
{"title": "^xy\\W?m[ia]x", "tags": ["adult"]},
|
||||
{"title": "^xy\\W?plus", "tags": ["adult"]},
|
||||
{"title": "brand\\W?new\\W?amateurs", "tags": ["adult"]},
|
||||
{"title": "true\\W?amateurs", "tags": ["adult"]},
|
||||
{"title": "dane\\W?jones", "tags": ["adult"]},
|
||||
{"title": "tiny\\W?4k", "tags": ["adult"]},
|
||||
{"title": "deep\\W?lush", "tags": ["adult"]},
|
||||
{"title": "^ddf\\W", "tags": ["adult"]},
|
||||
{"title": "beate\\W?uhse", "tags": ["adult"]},
|
||||
{"title": "alex\\W?legend", "tags": ["adult"]},
|
||||
{"title": "(gang)?bang(\\W?POV)", "tags": ["adult"]},
|
||||
{"title": "bang\\W?bus", "tags": ["adult"]},
|
||||
{"title": "o\\W?la\\W?la", "tags": ["adult"]},
|
||||
{"title": "hard\\W?x", "tags": ["adult"]},
|
||||
{"title": "visit\\W?x", "tags": ["adult"]},
|
||||
{"title": "miami\\W?tv", "tags": ["adult"]},
|
||||
{"title": "jasmin\\W?tv", "tags": ["adult"]},
|
||||
{"title": "dark\\W?x", "tags": ["adult"]},
|
||||
{"title": "dad\\W?crush", "tags": ["adult"]},
|
||||
{"title": "^xxl$", "tags": ["adult"]},
|
||||
|
||||
|
||||
|
||||
{"title": "\\Wnews$", "tags": ["news"]},
|
||||
|
||||
|
||||
|
||||
{"title": "\\Wdocumentary$", "tags": ["docs"]},
|
||||
|
||||
|
||||
|
||||
{"tvg-id": "(kid|child|baby)", "tags": ["child"]},
|
||||
{"title": "(kid|child|baby)", "tags": ["child"]},
|
||||
{"tvg-id": "(car)?toon", "tags": ["child"]},
|
||||
{"title": "(car)?toon", "tags": ["child"]},
|
||||
{"tvg-id": "pixar", "tags": ["child"]},
|
||||
{"title": "pixar", "tags": ["child"]},
|
||||
{"title": "disney", "tags": ["child"]},
|
||||
{"tvg-id": "^tiji", "tags": ["child"]},
|
||||
{"title": "^tiji", "tags": ["child"]},
|
||||
{"tvg-id": "^gulli", "tags": ["child"]},
|
||||
{"title": "^gulli", "tags": ["child"]},
|
||||
{"tvg-id": "da-vinci", "tags": ["child"]},
|
||||
{"tvg-id": "^nick(elodeon)?", "tags": ["child"]},
|
||||
{"title": "^nick(elodeon)?", "tags": ["child"]},
|
||||
{"tvg-id": "pixar", "tags": ["child"]},
|
||||
|
||||
|
||||
|
||||
{"title": "leomax", "tags": ["shopping"]},
|
||||
{"tvg-id": "leomax", "tags": ["shopping"]},
|
||||
{"tvg-id": "shopping", "tags": ["shopping"]},
|
||||
|
||||
|
||||
|
||||
{"tvg-id": "doktor", "tags": ["health"]},
|
||||
|
||||
|
||||
|
||||
{"tvg-id": "(\\W|^)(nature|animal|zoo)", "tags": ["nature"]},
|
||||
{"title": "(\\W|^)(nature|animal|zoo)", "tags": ["nature"]},
|
||||
{"tvg-id": "^discovery", "tags": ["nature"]},
|
||||
{"title": "^discovery", "tags": ["nature"]},
|
||||
|
||||
|
||||
|
||||
{"tvg-id": "travel", "tags": ["travel"]},
|
||||
{"title": "travel", "tags": ["travel"]},
|
||||
|
||||
{"tvg-id": "big\\W?planet", "tags": ["travel", "nature"]},
|
||||
{"tvg-id": "^nat(ional)?\\Wgeo(graphic)?", "tags": ["travel", "nature"]},
|
||||
{"title": "^nat(ional)?\\W?geo(graphic)?", "tags": ["travel", "nature"]},
|
||||
|
||||
|
||||
|
||||
{"tvg-id": "o[xh]ota", "tags": ["hunt-fish"]},
|
||||
{"title": "охота", "tags": ["hunt-fish"]},
|
||||
{"tvg-id": "dikaya", "tags": ["hunt-fish"]},
|
||||
{"tvg-id": "rybalk", "tags": ["hunt-fish"]},
|
||||
{"title": "рыбалк", "tags": ["hunt-fish"]},
|
||||
{"tvg-id": "hunt", "tags": ["hunt-fish"]},
|
||||
{"tvg-id": "fish", "tags": ["hunt-fish"]},
|
||||
{"title": "hunt", "tags": ["hunt-fish"]},
|
||||
{"title": "fish", "tags": ["hunt-fish"]},
|
||||
|
||||
|
||||
|
||||
{"tvg-id": "tv1000", "tags": ["film", "series"]},
|
||||
{"tvg-id": "paramount", "tags": ["film", "series"]},
|
||||
|
||||
|
||||
|
||||
{"title": "food", "tags": ["food"]},
|
||||
|
||||
|
||||
|
||||
{"title": "\\W(ru)$", "tags": ["RU"]},
|
||||
{"tvg-id": ".+\\.ru(@.+)?$", "tags": ["RU"]},
|
||||
{"tvg-id": "\\Wrf$", "tags": ["RU"]},
|
||||
{"tvg-id": "\\Wрф$", "tags": ["RU"]},
|
||||
{"tvg-id": "(\\W|^)r[ou]ss", "tags": ["RU"]},
|
||||
{"title": "(\\W|^)r[ou]ss", "tags": ["RU"]},
|
||||
{"title": "р[оу]сс?", "tags": ["RU"]},
|
||||
|
||||
{"title": "истори[яч]", "tags": ["RU", "history"]},
|
||||
|
||||
{"title": "криминал", "tags": ["RU", "crime"]},
|
||||
{"title": "детектив", "tags": ["RU", "crime"]},
|
||||
|
||||
{"tvg-id": "match", "tags": ["RU", "sport"]},
|
||||
{"title": "^матч!?", "tags": ["RU", "sport"]},
|
||||
|
||||
{"tvg-id": "prodvizhenie", "tags": ["RU", "transport"]},
|
||||
{"title": "\\W?авто", "tags": ["RU", "transport"]},
|
||||
{"tvg-id": "^rzh?d\\W?(tv)?", "tags": ["RU", "transport"]},
|
||||
{"title": "^ржд\\W?тв", "tags": ["RU", "transport"]},
|
||||
{"title": "^драйв(\\W|$)", "tags": ["RU", "transport"]},
|
||||
|
||||
{"title": "^европа\\W?(плюс|+)", "tags": ["RU", "music"]},
|
||||
{"tvg-id": "^ru\\W?tv", "tags": ["RU", "music"]},
|
||||
{"title": "^[rр][uу]\\W?[tт][vв]", "tags": ["RU", "music"]},
|
||||
{"tvg-id": "mu[sz]\\W?tv", "tags": ["RU", "music"]},
|
||||
{"title": "(mu[sz]|муз)\\W?[tт][vв]", "tags": ["RU", "music"]},
|
||||
{"title": "^[oо]2\\W?([tт][vв])?", "tags": ["RU", "music"]},
|
||||
{"title": "o2\\W?([tт][vв])?", "tags": ["RU", "music"]},
|
||||
{"title": "жар\\Wптица", "tags": ["RU", "music"]},
|
||||
{"title": "жара(\\W[tт][vв])?", "tags": ["RU", "music"]},
|
||||
|
||||
{"tvg-id": "^teatr", "tags": ["RU", "culture"]},
|
||||
{"title": "^театр", "tags": ["RU", "culture"]},
|
||||
{"tvg-id": "^r[ou]ssia-?k", "tags": ["RU", "culture"]},
|
||||
|
||||
{"title": "доктор", "tags": ["RU", "health"]},
|
||||
|
||||
{"title": "документал", "tags": ["RU", "docs"]},
|
||||
|
||||
{"title": "русская\\W?ночь", "tags": ["RU", "adult"]},
|
||||
|
||||
{"title": "(веб)?-?камер", "tags": ["RU", "webcam"]},
|
||||
|
||||
{"title": "\\W?(новости)\\W?", "tags": ["RU", "news"]},
|
||||
{"title": "\\W?(вести)\\W?", "tags": ["RU", "news"]},
|
||||
{"title": "\\W?(информ)\\W?", "tags": ["RU", "news"]},
|
||||
{"title": "[а-яё]\\W?24\\W?", "tags": ["RU", "news"]},
|
||||
{"tvg-id": "izvestia", "tags": ["RU", "news"]},
|
||||
{"tvg-id": "^360", "tags": ["RU", "news"]},
|
||||
{"title": "^360", "tags": ["RU", "news"]},
|
||||
{"tvg-id": "^ldpr", "tags": ["RU", "politic"]},
|
||||
{"title": "^лдпр", "tags": ["RU", "politic"]},
|
||||
{"title": "^дума", "tags": ["RU", "politic"]},
|
||||
{"tvg-id": "^duma", "tags": ["RU", "politic"]},
|
||||
{"tvg-id": "^otr", "tags": ["RU", "central", "news"]},
|
||||
{"title": "^отр", "tags": ["RU", "central", "news"]},
|
||||
{"tvg-id": "^tvc", "tags": ["RU", "central", "news"]},
|
||||
{"title": "^ТВЦ", "tags": ["RU", "central", "news"]},
|
||||
{"tvg-id": "^ntv", "tags": ["RU", "central", "news"]},
|
||||
{"title": "^нтв", "tags": ["RU", "central", "news"]},
|
||||
{"tvg-id": "^1(tv|hd)", "tags": ["RU", "central", "news", "politic", "docs", "fun"]},
|
||||
{"tvg-id": "^perv[iy][iy]", "tags": ["RU", "central", "news", "politic", "docs", "fun"]},
|
||||
{"title": "^первый\\Wканал", "tags": ["RU", "central", "news", "politic", "docs", "fun"]},
|
||||
{"title": "^(пятый|5)\\Wканал", "tags": ["RU", "central", "news", "politic", "docs", "fun"]},
|
||||
{"title": "[oо][tт][pр]\\W.+", "tags": ["RU", "central", "news"]},
|
||||
{"title": "^россия\\W?[1-9]{1,2}", "tags": ["RU", "central", "news"]},
|
||||
{"title": "в?гтрк\\W", "tags": ["RU", "central", "news"]},
|
||||
{"title": "^соловь[её]в", "tags": ["RU", "politic", "finance"]},
|
||||
{"tvg-id": "zvezda", "tags": ["RU", "news", "politic", "docs", "army"]},
|
||||
{"title": "^звезда", "tags": ["RU", "news", "politic", "docs", "army"]},
|
||||
|
||||
{"tvg-id": "(дет[иск]|mult|мульт)", "tags": ["RU", "child"]},
|
||||
{"title": "(дет[иск]|mult|мульт)", "tags": ["RU", "child"]},
|
||||
{"tvg-id": "(caro?usel|karusel)", "tags": ["RU", "child"]},
|
||||
{"title": "карусель", "tags": ["RU", "child"]},
|
||||
{"tvg-id": "solnce", "tags": ["RU", "child"]},
|
||||
{"title": "^сол?нце", "tags": ["RU", "child"]},
|
||||
{"tvg-id": "^2x2", "tags": ["RU", "child"]},
|
||||
{"title": "^2x2", "tags": ["RU", "child"]},
|
||||
{"title": "^егэ", "tags": ["RU", "sci", "child"]},
|
||||
{"title": "^суббота\\W?", "tags": ["RU", "fun", "child"]},
|
||||
{"title": "^[оo]!?(\\W|$)", "tags": ["RU", "fun"]},
|
||||
{"title": "пиксар", "tags": ["RU", "child"]},
|
||||
{"title": "^рыжий", "tags": ["RU", "child"]},
|
||||
{"title": "^чижик", "tags": ["RU", "child"]},
|
||||
{"title": "^типтоп", "tags": ["RU", "child"]},
|
||||
{"title": "^уникум", "tags": ["RU", "child"]},
|
||||
{"title": "^супергерои", "tags": ["RU", "child"]},
|
||||
{"title": "^сказки", "tags": ["RU", "child"]},
|
||||
{"title": "^л[её]ва", "tags": ["RU", "child"]},
|
||||
{"title": "^ani\\W", "tags": ["RU", "child"]},
|
||||
{"title": "малыш", "tags": ["RU", "child"]},
|
||||
{"title": "смешарики", "tags": ["RU", "child"]},
|
||||
{"title": "^масяня", "tags": ["RU", "child"]},
|
||||
{"title": "том\\Wи\\Wджерри", "tags": ["RU", "child"]},
|
||||
|
||||
{"title": "животн", "tags": ["RU", "nature"]},
|
||||
{"tvg-id": "^lapki", "tags": ["RU", "nature"]},
|
||||
{"title": "^лапки", "tags": ["RU", "nature"]},
|
||||
{"title": "^зоо", "tags": ["RU", "nature"]},
|
||||
{"tvg-id": "путешеств", "tags": ["RU", "travel"]},
|
||||
{"tvg-id": "planeta", "tags": ["RU", "travel", "nature"]},
|
||||
|
||||
{"title": "(советск|[сc][сc][сc][rpр]|ussr)", "tags": ["RU", "retro"]},
|
||||
{"title": "ретро", "tags": ["RU", "retro"]},
|
||||
{"title": "ностальг", "tags": ["RU", "retro"]},
|
||||
|
||||
{"tvg-id": "^mir", "tags": ["RU", "culture", "news", "docs", "film"]},
|
||||
{"title": "^мир\\W?", "tags": ["RU", "culture", "news", "docs", "film"]},
|
||||
|
||||
{"tvg-id": "^amedia", "tags": ["RU", "film", "series"]},
|
||||
{"title": "^amedia", "tags": ["RU", "film", "series"]},
|
||||
{"tvg-id": "^[aа][12](\\W|$)", "tags": ["RU", "film", "series"]},
|
||||
{"title": "^[aа][12](\\W|$)", "tags": ["RU", "film", "series"]},
|
||||
{"title": "^(страх|strah)", "tags": ["RU", "film", "series"]},
|
||||
{"title": "иллюзион", "tags": ["RU", "film", "series"]},
|
||||
{"title": "ужас", "tags": ["RU", "film", "series"]},
|
||||
{"title": "шокирующ", "tags": ["RU", "film", "series"]},
|
||||
{"title": "^\\.sci-fi(\\W|$)", "tags": ["RU", "film", "series"]},
|
||||
{"title": "^\\.red(\\W|$)", "tags": ["RU", "film", "series"]},
|
||||
{"title": "^\\.black(\\W|$)", "tags": ["RU", "film", "series"]},
|
||||
{"title": "^спас", "tags": ["RU", "religion"]},
|
||||
{"tvg-id": "^ti?vi?[kc]om", "tags": ["RU", "local", "news", "film"]},
|
||||
{"title": "тивиком", "tags": ["RU", "local", "news", "film"]},
|
||||
{"title": "^домашний", "tags": ["RU", "central", "film", "series"]},
|
||||
{"title": "^(первый\\W)?космический", "tags": ["RU", "sci", "tech"]},
|
||||
|
||||
{"tvg-id": "^eda", "tags": ["RU", "food"]},
|
||||
{"title": "^еда", "tags": ["RU", "food"]},
|
||||
{"title": "^кулинар", "tags": ["RU", "food"]},
|
||||
{"title": "^аппетит", "tags": ["RU", "food"]},
|
||||
|
||||
{"tvg-id": "^[tт][vв]\\W?3", "tags": ["RU", "central", "film", "series", "mystic"]},
|
||||
{"title": "^[tт][vв]\\W?3", "tags": ["RU", "central", "film", "series", "mystic"]},
|
||||
{"tvg-id": "^rbk", "tags": ["RU", "news", "finance"]},
|
||||
{"title": "^рбк(\\W|$)", "tags": ["RU", "news", "finance"]},
|
||||
{"title": "^эхо(\\W|$)", "tags": ["RU", "news", "finance", "politic"]},
|
||||
{"title": "^rt\\W", "tags": ["news", "politic", "finance", "culture"]},
|
||||
{"tvg-id": "^ntv-?style", "tags": ["RU", "fashion"]},
|
||||
{"tvg-id": "^ntv-?serial", "tags": ["RU", "series"]},
|
||||
|
||||
{"title": "монте-?карло", "tags": ["RU", "music", "radio"]},
|
||||
{"title": "buryad\\Wfm", "tags": ["RU", "news", "local", "music", "radio"]},
|
||||
|
||||
{"title": "^че!?\\W?", "tags": ["RU", "fun", "comedy", "series"]},
|
||||
{"tvg-id": "^che\\W?", "tags": ["RU", "fun", "comedy", "series"]},
|
||||
{"title": "^[tт][nнh][tт]\\W?", "tags": ["RU", "fun", "comedy", "series"]},
|
||||
{"tvg-id": "^[tт][nнh][tт]\\W?", "tags": ["RU", "fun", "comedy", "series"]},
|
||||
{"title": "^[cсs][tт][cсs]\\W?", "tags": ["RU", "fun", "comedy", "series"]},
|
||||
{"tvg-id": "^[cсs][tт][cсs]\\W?", "tags": ["RU", "fun", "comedy", "series"]},
|
||||
{"title": "^пятница\\W?", "tags": ["RU", "fun", "travel"]},
|
||||
{"tvg-id": "(friday|p[yi]atnica)\\W?", "tags": ["RU", "fun", "travel"]},
|
||||
{"title": "^ю!?(\\W|$)", "tags": ["RU", "fun"]},
|
||||
{"tvg-id": "kvn-tv", "tags": ["RU", "fun", "humor"]},
|
||||
{"title": "^КВН\\W?ТВ", "tags": ["RU", "fun", "humor"]},
|
||||
{"tvg-id": "ren\\W?tv", "tags": ["RU", "mystic", "film", "series"]},
|
||||
{"title": "^рен\\W?тв", "tags": ["RU", "mystic", "film", "series"]},
|
||||
|
||||
{"tvg-id": "kulinar", "tags": ["RU", "food"]},
|
||||
{"title": "кулинар", "tags": ["RU", "food"]},
|
||||
|
||||
{"title": "\\W?(триколор|megogo|мегого|окко|okko)\\W?", "tags": ["RU", "film", "series"]},
|
||||
|
||||
{"tvg-id": "глухарь", "tags": ["RU", "series"]},
|
||||
{"tvg-id": "улицы\\sразбитых\\sфонарей", "tags": ["RU", "retro", "series"]},
|
||||
{"title": "улицы\\sразбитых\\sфонарей", "tags": ["RU", "retro", "series"]},
|
||||
|
||||
{"title": "(тюмен[ьск]|тамбов|иркутск|элиста|астрахань|кр[ыи]м|донець?к|лугансь?к|тамбов|ор[её]л)", "tags": ["RU", "local"]},
|
||||
{"title": "(новосиб|нс[ок]|улан-удэ|моско?в|якут|саха|бург|ямал|кызыл|ставроп|орел|камчат|твер)", "tags": ["RU", "local"]},
|
||||
{"title": "(чита|челябинск|югра|севастополь|приволжье|братск|ростов|красно(ярск|дар|каменск))", "tags": ["RU", "local"]},
|
||||
{"title": "(сар(ат)?ов|осетия|дагестан|сибирь|примор(ь|ск)|самара|чеч(ен|ня)|новокузнецк|курск)", "tags": ["RU", "local"]},
|
||||
{"title": "(астрахань|сочи|белорецк|уфа|кавказ|арктика|урал|запад|восток|бурятия|уфа|т?омск|архангел)", "tags": ["RU", "local"]},
|
||||
{"title": "(белгород|нефтекамск|туапсе|кузбасс|магадан|татар|cмоленск|брянск|кабард|ярослав|перм)", "tags": ["RU", "local"]},
|
||||
{"title": "(липецк|рязань|орск|ингушетия|невинномысск|абакан|селенга|обь|волг[оа]|новгород|киров)", "tags": ["RU", "local"]},
|
||||
{"title": "(казань|хакасия|архыз|владимир|воронеж|ижевск|ульяновск|башкорт|башкир|чаваш|чебокс)", "tags": ["RU", "local"]},
|
||||
{"title": "(калинин|мордов|ангар[аск]|черкес|камыш|пенз|когал|тул[аь]|костром|дон|ульяновск|йошкар)", "tags": ["RU", "local"]},
|
||||
{"title": "(нальчик|котлас|салех|калмык|калуга|хабаровск|череповец|ноябрьск|нижневарт|петрозавод)", "tags": ["RU", "local"]},
|
||||
{"title": "(амур|барнаул|сургут|тагил|муравленко|тобол|радужный|муром|колыма|вологда|иванов|arirang)", "tags": ["RU", "local"]},
|
||||
|
||||
{"tvg-id": "chavash", "tags": ["local"]},
|
||||
{"tvg-id": "songtvrussia\\.ru", "tags": ["RU", "music"]},
|
||||
{"tvg-id": "astrahanrutv\\.ru", "tags": ["local"]},
|
||||
{"tvg-id": "enisey\\.ru", "tags": ["local"]},
|
||||
{"tvg-id": "nizhniynovgorod24", "tags": ["local"]},
|
||||
{"tvg-id": "tvrain", "tags": ["politic"]},
|
||||
{"title": "дождь", "tags": ["politic"]},
|
||||
{"tvg-id": "ratnik\\.ru", "tags": ["army"]},
|
||||
{"tvg-id": "nvksakha\\.ru", "tags": ["local"]},
|
||||
{"tvg-id": "belgorod24\\.ru", "tags": ["local"]},
|
||||
{"tvg-id": "tsentrkrasnoyarsk\\.ru", "tags": ["local"]},
|
||||
{"tvg-id": "govoritmoskva\\.ru", "tags": ["local"]},
|
||||
{"tvg-id": "astrahan24\\.ru", "tags": ["local"]},
|
||||
{"tvg-id": "^sochi", "tags": ["local"]},
|
||||
{"tvg-id": "kinopremyerahd\\.ru", "tags": ["film"]},
|
||||
{"tvg-id": "tvoyetvyumor\\.ru", "tags": ["humor"]},
|
||||
{"tvg-id": "kino24\\.ru", "tags": ["film"]},
|
||||
{"tvg-id": "86\\.ru", "tags": ["local"]},
|
||||
{"tvg-id": "volgograd24\\.ru", "tags": ["local"]},
|
||||
{"tvg-id": "muzsoyuz\\.ru", "tags": ["music"]},
|
||||
{"tvg-id": "surgut24\\.ru", "tags": ["local"]},
|
||||
{"tvg-id": "aisttv\\.ru", "tags": ["local", "news"]},
|
||||
{"tvg-id": "12-omsk", "tags": ["local"]},
|
||||
{"tvg-id": "che-pl4", "tags": ["fun"]},
|
||||
{"tvg-id": "pervy-pl8", "tags": ["central", "news", "docs"]},
|
||||
{"tvg-id": "song-tv-ru", "tags": ["music"]},
|
||||
{"tvg-id": "muzyka-pervogo", "tags": ["music"]},
|
||||
{"tvg-id": "rts-abakan", "tags": ["local"]},
|
||||
{"tvg-id": "tatarstan24", "tags": ["local"]},
|
||||
{"tvg-id": "tomskoe-vremia", "tags": ["local"]},
|
||||
{"tvg-id": "udmurtia", "tags": ["local"]},
|
||||
{"tvg-id": "kuban-24", "tags": ["local"]},
|
||||
{"tvg-id": "pobeda", "tags": ["film", "series"]},
|
||||
{"tvg-id": "deviatka", "tags": ["local"]},
|
||||
{"tvg-id": "don-24", "tags": ["local"]},
|
||||
{"tvg-id": "tolyatti24", "tags": ["local"]},
|
||||
{"tvg-id": "общественная служба новостей", "tags": ["news"]},
|
||||
{"tvg-id": "8kanal-novosibirsk", "tags": ["local"]},
|
||||
{"tvg-id": "kion-xit", "tags": ["film", "series"]},
|
||||
{"tvg-id": "7kanal-krasnoyarsk", "tags": ["local"]},
|
||||
{"tvg-id": "pervy-tulsky", "tags": ["local"]},
|
||||
{"tvg-id": "kuxnia-tv", "tags": ["food"]},
|
||||
{"tvg-id": "dozhd", "tags": ["politic"]},
|
||||
{"tvg-id": "russky-detektiv", "tags": ["film"]},
|
||||
{"tvg-id": "lipeckoe-vremia", "tags": ["local"]},
|
||||
{"tvg-id": "dzenzhinsk-tv", "tags": ["local"]},
|
||||
{"tvg-id": "^rt-news", "tags": ["central", "news", "docs"]},
|
||||
{"tvg-id": "russia\\stoday", "tags": ["central", "news", "docs"]},
|
||||
{"tvg-id": "mordovia-24", "tags": ["local"]},
|
||||
{"tvg-id": "russky-bestseller", "tags": ["film", "series"]},
|
||||
{"tvg-id": "russky-roman", "tags": ["film", "series"]},
|
||||
{"tvg-id": "saxa", "tags": ["news", "local"]},
|
||||
{"tvg-id": "fox", "tags": ["film", "series"]},
|
||||
{"tvg-id": "samara", "tags": ["local"]},
|
||||
{"tvg-id": "start", "tags": ["film"]},
|
||||
{"tvg-id": "bbc", "tags": ["GB", "news"]},
|
||||
{"tvg-id": "nts-irkutsk", "tags": ["local"]},
|
||||
{"tvg-id": "rezonans", "tags": ["fun"]},
|
||||
{"tvg-id": "pervy-oblastnoy-orel", "tags": ["local"]},
|
||||
{"tvg-id": "8kanal-krasnoyarsk", "tags": ["RU", "local"]},
|
||||
{"tvg-id": "zdorovoe-tv", "tags": ["RU", "health"]},
|
||||
{"tvg-id": "seym-kursk", "tags": ["RU", "local"]},
|
||||
{"tvg-id": "8vladik", "tags": ["local"]},
|
||||
{"tvg-id": "vremia-arktiki", "tags": ["local"]},
|
||||
{"tvg-id": "ots", "tags": ["local"]},
|
||||
{"tvg-id": "krasnodar", "tags": ["RU", "local"]},
|
||||
{"tvg-id": "krym24", "tags": ["RU", "local"]},
|
||||
{"tvg-id": "smotlor-nv", "tags": ["local"]},
|
||||
{"tvg-id": "sankt-peterburg", "tags": ["local"]},
|
||||
{"tvg-id": "^5([kc]anal|chann?el)", "tags": ["RU", "central", "news"]},
|
||||
{"tvg-id": "rtg-tv", "tags": ["sci"]},
|
||||
{"tvg-id": "kuban-24", "tags": ["local"]},
|
||||
{"tvg-id": "h2", "tags": ["history"]},
|
||||
{"tvg-id": "rimtv", "tags": ["local"]},
|
||||
{"tvg-id": "atv-media", "tags": ["local"]},
|
||||
{"tvg-id": "enisey-region", "tags": ["local"]},
|
||||
{"tvg-id": "gubernia-33", "tags": ["local"]},
|
||||
{"tvg-id": "otv-saxalin", "tags": ["RU", "news", "local"]},
|
||||
{"tvg-id": "tvk-krasnoyarsk", "tags": ["RU", "news", "local"]},
|
||||
{"tvg-id": "volgograd-24", "tags": ["RU", "local"]},
|
||||
{"tvg-id": "brianskaya-gubernia", "tags": ["local"]},
|
||||
{"tvg-id": "premialnoe-hd", "tags": ["film"]},
|
||||
{"tvg-id": "nts-sevastopol", "tags": ["local"]},
|
||||
{"tvg-id": "rostov-papa", "tags": ["local"]},
|
||||
{"tvg-id": "muztv", "tags": ["music"]},
|
||||
{"tvg-id": "domashny(-int)?", "tags": ["RU", "series", "film"]},
|
||||
{"tvg-id": "cnn", "tags": ["news", "US"]},
|
||||
{"tvg-id": "^rentv", "tags": ["RU", "series", "fun", "mystic"]},
|
||||
{"tvg-id": "ужасы hd", "tags": ["film"]},
|
||||
{"tvg-id": "kuban-24-orbita", "tags": ["local"]},
|
||||
{"tvg-id": "yu", "tags": ["fun", "series"]},
|
||||
{"tvg-id": "ivanovo-ot", "tags": ["local"]},
|
||||
{"tvg-id": "yakutia24", "tags": ["local"]},
|
||||
{"tvg-id": "patriot", "tags": ["army"]},
|
||||
{"tvg-id": "novgorodskoe-obl-tv", "tags": ["local"]},
|
||||
{"tvg-id": "kabardino-balkaria", "tags": ["local"]},
|
||||
{"tvg-id": "belgorod24", "tags": ["local"]},
|
||||
{"tvg-id": "360-news", "tags": ["news"]},
|
||||
{"tvg-id": "moskovsky-obrazovatelny", "tags": ["local", "sci"]},
|
||||
{"tvg-id": "10kanal-novokuzneck", "tags": ["local"]},
|
||||
{"tvg-id": "arig-us-24", "tags": ["RU", "film", "news", "docs"]},
|
||||
{"tvg-id": "selenga-tv", "tags": ["RU", "film", "news", "docs"]},
|
||||
{"tvg-id": "bst-bratsk", "tags": ["local"]},
|
||||
{"tvg-id": "takt", "tags": ["local"]},
|
||||
{"tvg-id": "voprosy-i-otvety", "tags": ["sci"]},
|
||||
{"tvg-id": "yamal-region", "tags": ["local"]},
|
||||
{"tvg-id": "ulianovskaya-pravda", "tags": ["film"]},
|
||||
{"tvg-id": "muravlenko24", "tags": ["local"]},
|
||||
{"tvg-id": "yugra", "tags": ["local"]},
|
||||
{"tvg-id": "my-i-gorod", "tags": ["local"]},
|
||||
{"tvg-id": "glazami-turista", "tags": ["travel"]},
|
||||
{"tvg-id": "neizvestnaya-rosia", "tags": ["RU"]},
|
||||
{"tvg-id": "zhar-ptica", "tags": ["music"]},
|
||||
{"tvg-id": "kto-est-kto", "tags": ["sci"]},
|
||||
{"tvg-id": "31-kanal-chelyabinsk", "tags": ["local"]},
|
||||
{"tvg-id": "sampo-tv", "tags": ["local"]},
|
||||
{"tvg-id": "osetia", "tags": ["RU", "local"]},
|
||||
{"tvg-id": "nasha-sibir", "tags": ["RU", "local"]},
|
||||
{"tvg-id": "gubernia-samara", "tags": ["RU", "local"]},
|
||||
{"tvg-id": "rtd-ru", "tags": ["RU", "news", "docs"]},
|
||||
{"tvg-id": "pervy-krymsky", "tags": ["RU", "local"]},
|
||||
{"tvg-id": "volgograd-1", "tags": ["RU", "local"]},
|
||||
{"tvg-id": "tumenskoe-vremia", "tags": ["RU", "local"]},
|
||||
|
||||
|
||||
|
||||
{"title": "\\W(by)$", "tags": ["BY"]},
|
||||
{"tvg-id": ".+[\\.-]by$", "tags": ["BY"]},
|
||||
{"tvg-id": "bel(arus|ros)", "tags": ["BY"]},
|
||||
{"title": "бел(арус|рос)", "tags": ["BY"]},
|
||||
{"tvg-id": "pervy-info-by", "tags": ["BY"]},
|
||||
{"tvg-id": "ont-by", "tags": ["BY"]},
|
||||
{"tvg-id": "^tro$", "tags": ["BY"]},
|
||||
|
||||
|
||||
|
||||
{"title": "\\W(kz)$", "tags": ["KZ"]},
|
||||
{"tvg-id": ".+\\.kz(@.+)?$", "tags": ["KZ"]},
|
||||
{"title": "казахстан", "tags": ["KZ"]},
|
||||
{"title": "^qazaqstan", "tags": ["KZ"]},
|
||||
{"tvg-id": "^qazaqstan", "tags": ["KZ"]},
|
||||
{"tvg-id": "setanta-kz", "tags": ["KZ"]},
|
||||
|
||||
|
||||
|
||||
{"title": "\\W(uz)$", "tags": ["UZ"]},
|
||||
{"title": "^zor(\\stv)?$", "tags": ["UZ"]},
|
||||
{"title": "b?iz$", "tags": ["UZ"]},
|
||||
{"title": "^futbol\\stv?", "tags": ["UZ"]},
|
||||
{"title": "^navo", "tags": ["UZ"]},
|
||||
{"title": "^uzbekistan", "tags": ["UZ"]},
|
||||
{"title": "^yoshlar", "tags": ["UZ"]},
|
||||
{"title": "^kinoteatr", "tags": ["UZ"]},
|
||||
{"title": "^bolajon", "tags": ["UZ"]},
|
||||
{"title": "^toshkent", "tags": ["UZ"]},
|
||||
{"title": "^dasturxon(\\stv)?$", "tags": ["UZ"]},
|
||||
{"title": "^nurafshon(\\stv)?$", "tags": ["UZ"]},
|
||||
{"title": "^dunyo\\sboylab", "tags": ["UZ"]},
|
||||
{"title": "^backus(\\stv)?$", "tags": ["UZ"]},
|
||||
{"title": "^uzbekistan(\\s24)?", "tags": ["UZ"]},
|
||||
{"title": "^biz(\\stv)?$", "tags": ["UZ"]},
|
||||
{"title": "^renessans(\\stv)?$", "tags": ["UZ"]},
|
||||
{"title": "^sevimli(\\stv)?$", "tags": ["UZ"]},
|
||||
{"title": "^milliy(\\stv)?$", "tags": ["UZ"]},
|
||||
{"title": "^sevimli(\\stv)?$", "tags": ["UZ"]},
|
||||
{"title": "^uzreport(\\stv)?$", "tags": ["UZ"]},
|
||||
{"title": "^milliy(\\stv)?$", "tags": ["UZ"]},
|
||||
{"title": "^zo\\'rtv", "tags": ["UZ"]},
|
||||
{"title": "^my\\s5", "tags": ["UZ"]},
|
||||
|
||||
|
||||
|
||||
{"title": "\\W(az)$", "tags": ["AZ"]},
|
||||
{"tvg-id": ".+\\.az(@.+)?$", "tags": ["AZ"]},
|
||||
{"title": "^arb\\sgunes", "tags": ["AZ"]},
|
||||
{"title": "^el(\\stv)?$", "tags": ["AZ"]},
|
||||
{"title": "^vip(\\stv)?$", "tags": ["AZ"]},
|
||||
{"title": "^xezer(\\stv)?$", "tags": ["AZ"]},
|
||||
{"title": "^anewz", "tags": ["AZ"]},
|
||||
{"title": "^idman(\\stv)?$", "tags": ["AZ"]},
|
||||
{"title": "^mtv\\stv", "tags": ["AZ"]},
|
||||
{"title": "^tmb(\\stv)?$", "tags": ["AZ"]},
|
||||
{"title": "^mcj(\\stv)?$", "tags": ["AZ"]},
|
||||
{"title": "^space(\\stv)?$", "tags": ["AZ"]},
|
||||
{"title": "^alvin channel(\\stv)?$", "tags": ["AZ"]},
|
||||
{"title": "^arb(\\s24)?", "tags": ["AZ"]},
|
||||
{"title": "^azad(\\stv)?$", "tags": ["AZ"]},
|
||||
{"title": "^dunya(\\stv)?$", "tags": ["AZ"]},
|
||||
{"title": "^kanal\\ss(\\stv)?$", "tags": ["AZ"]},
|
||||
{"title": "^az(\\stv)?$", "tags": ["AZ"]},
|
||||
{"title": "^azstar(\\stv)?$", "tags": ["AZ"]},
|
||||
{"title": "^baku(\\stv)?$", "tags": ["AZ"]},
|
||||
{"title": "^cbc\\ssport", "tags": ["AZ"]},
|
||||
{"title": "^ictimai(\\stv)?$", "tags": ["AZ"]},
|
||||
{"title": "^medeniyyet(\\stv)?$", "tags": ["AZ"]},
|
||||
|
||||
|
||||
|
||||
{"title": "\\W(cn)$", "tags": ["CN"]},
|
||||
{"tvg-id": ".+\\.cn(@.+)?$", "tags": ["CN"]},
|
||||
{"title": "chinese.+", "tags": ["CN"]},
|
||||
{"tvg-id": "cctv.+", "tags": ["CN", "central"]},
|
||||
{"title": "cctv.+", "tags": ["CN", "central"]},
|
||||
|
||||
|
||||
|
||||
{"title": "\\W(am)$", "tags": ["AM"]},
|
||||
{"tvg-id": ".+\\.am(@.+)?$", "tags": ["AM"]},
|
||||
{"title": "арм[ея]н", "tags": ["AM"]},
|
||||
|
||||
|
||||
|
||||
{"title": "\\W(de)$", "tags": ["DE"]},
|
||||
{"tvg-id": ".+\\.de(@.+)?$", "tags": ["DE"]},
|
||||
{"title": "^dw\\W?", "tags": ["DE"]},
|
||||
{"title": "(german|deutsch)", "tags": ["DE"]},
|
||||
{"title": "^deutsche(\\Wwelle)?", "tags": ["DE", "news"]},
|
||||
|
||||
|
||||
|
||||
{"title": "\\W(us)$", "tags": ["US"]},
|
||||
{"tvg-id": ".+\\.us(@.+)?$", "tags": ["US"]},
|
||||
{"title": "\\W?(сша|америк|americ)\\W?", "tags": ["US"]},
|
||||
|
||||
|
||||
|
||||
{"tvg-id": ".+\\.(uk|gb)(@.+)?$", "tags": ["GB"]},
|
||||
|
||||
|
||||
|
||||
{"title": "\\Wenglish$", "tags": ["GB", "US"]},
|
||||
|
||||
|
||||
|
||||
{"title": "\\Wespañol$", "tags": ["ES"]},
|
||||
|
||||
|
||||
|
||||
{"title": "\\W(ua)$", "tags": ["UA"]},
|
||||
{"tvg-id": ".+\\.ua(@.+)?$", "tags": ["UA"]},
|
||||
{"title": "укра[иї]н", "tags": ["UA"]},
|
||||
{"title": "ки[еї]в", "tags": ["UA"]},
|
||||
{"title": "iнтер", "tags": ["UA"]},
|
||||
|
||||
|
||||
|
||||
{"title": "\\W(lv)$", "tags": ["LV"]},
|
||||
{"title": "латвия", "tags": ["LV"]},
|
||||
|
||||
|
||||
|
||||
{"title": "\\W(il)$", "tags": ["IL"]},
|
||||
{"title": "израиль", "tags": ["IL"]},
|
||||
|
||||
|
||||
|
||||
{"title": "\\W(lt)$", "tags": ["LT"]},
|
||||
{"title": "литва", "tags": ["LT"]},
|
||||
|
||||
|
||||
|
||||
{"title": "\\W(md)$", "tags": ["MD"]},
|
||||
{"title": "молдова", "tags": ["MD"]},
|
||||
|
||||
|
||||
|
||||
{"title": "\\W(ro)$", "tags": ["RO"]},
|
||||
{"tvg-id": ".+\\.ro(@.+)?$", "tags": ["RO"]},
|
||||
|
||||
|
||||
|
||||
{"title": "\\W(se)$", "tags": ["SE"]},
|
||||
{"tvg-id": ".+\\.se(@.+)?$", "tags": ["SE"]},
|
||||
|
||||
|
||||
|
||||
{"title": "\\W(tr)$", "tags": ["TR"]},
|
||||
{"tvg-id": ".+\\.tr(@.+)?$", "tags": ["TR"]},
|
||||
|
||||
|
||||
|
||||
{"title": "\\W(es)$", "tags": ["ES"]},
|
||||
{"tvg-id": ".+\\.es(@.+)?$", "tags": ["ES"]},
|
||||
|
||||
|
||||
|
||||
{"title": "\\W(pl)$", "tags": ["PL"]},
|
||||
{"tvg-id": ".+\\.pl(@.+)?$", "tags": ["PL"]},
|
||||
|
||||
|
||||
|
||||
{"title": "\\W(ge)$", "tags": ["GE"]},
|
||||
{"tvg-id": ".+\\.ge(@.+)?$", "tags": ["GE"]},
|
||||
|
||||
|
||||
|
||||
{"title": "\\W(fr)$", "tags": ["FR"]},
|
||||
{"tvg-id": ".+\\.fr(@.+)?$", "tags": ["FR"]},
|
||||
{"title": "\\W(paris)$", "tags": ["FR"]},
|
||||
{"tvg-id": ".+\\.paris(@.+)?$", "tags": ["FR"]},
|
||||
|
||||
|
||||
|
||||
{"title": "\\W(hr)$", "tags": ["HR"]},
|
||||
{"tvg-id": ".+\\.hr(@.+)?$", "tags": ["HR"]},
|
||||
|
||||
|
||||
|
||||
{"title": "\\W(ca)$", "tags": ["CA"]},
|
||||
{"tvg-id": ".+\\.ca(@.+)?$", "tags": ["CA"]},
|
||||
|
||||
|
||||
|
||||
{"title": "\\W(ee)$", "tags": ["EE"]},
|
||||
{"tvg-id": ".+\\.ee(@.+)?$", "tags": ["EE"]},
|
||||
|
||||
|
||||
|
||||
{"title": "\\W(in)$", "tags": ["IN"]},
|
||||
{"tvg-id": ".+\\.in(@.+)?$", "tags": ["IN"]},
|
||||
{"tvg-id": "(india|индия)", "tags": ["IN"]},
|
||||
{"title": "(india|индия)", "tags": ["IN"]},
|
||||
{"tvg-id": "\\W?bollywood", "tags": ["film", "series", "IN"]},
|
||||
{"title": "\\W?bollywood", "tags": ["film", "series", "IN"]},
|
||||
|
||||
|
||||
|
||||
{"title": "\\W(it)$", "tags": ["IT"]},
|
||||
{"tvg-id": ".+\\.it(@.+)?$", "tags": ["IT"]},
|
||||
{"tvg-id": "italy", "tags": ["IT"]},
|
||||
{"title": "italy", "tags": ["IT"]},
|
||||
|
||||
|
||||
|
||||
{"title": "\\W(ae)$", "tags": ["AE"]},
|
||||
{"tvg-id": ".+\\.ae(@.+)?$", "tags": ["AE"]},
|
||||
|
||||
|
||||
|
||||
{"title": "\\W(gr)$", "tags": ["GR"]},
|
||||
{"tvg-id": ".+\\.gr(@.+)?$", "tags": ["GR"]},
|
||||
|
||||
|
||||
|
||||
{"title": "\\W(za)$", "tags": ["ZA"]},
|
||||
{"tvg-id": ".+\\.za(@.+)?$", "tags": ["ZA"]},
|
||||
|
||||
|
||||
|
||||
{"title": "\\W(sa)$", "tags": ["SA"]},
|
||||
{"tvg-id": ".+\\.sa(@.+)?$", "tags": ["SA"]},
|
||||
|
||||
|
||||
|
||||
{"title": "\\W(ar)$", "tags": ["AR"]},
|
||||
{"tvg-id": ".+\\.ar(@.+)?$", "tags": ["AR"]},
|
||||
|
||||
|
||||
|
||||
{"title": "\\W(cl)$", "tags": ["CL"]},
|
||||
{"tvg-id": ".+\\.cl(@.+)?$", "tags": ["CL"]},
|
||||
|
||||
|
||||
|
||||
{"title": "\\W(br)$", "tags": ["BR"]},
|
||||
{"tvg-id": ".+\\.br(@.+)?$", "tags": ["BR"]},
|
||||
|
||||
|
||||
|
||||
{"title": "\\W(cz)$", "tags": ["CZ"]},
|
||||
{"tvg-id": ".+\\.cz(@.+)?$", "tags": ["CZ"]}
|
||||
]
|
||||
+14
-25
@@ -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
|
||||
*/
|
||||
@@ -24,15 +24,10 @@ var checkCmd = &cobra.Command{
|
||||
Short: "Check playlists",
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
app.Init()
|
||||
|
||||
files, _ := cmd.Flags().GetStringSlice("file")
|
||||
urls, _ := cmd.Flags().GetStringSlice("url")
|
||||
codes, _ := cmd.Flags().GetStringSlice("code")
|
||||
|
||||
waitSeconds := app.Args.RepeatEverySec
|
||||
if waitSeconds <= 0 {
|
||||
waitSeconds = 5
|
||||
}
|
||||
applyAppOverrides(cmd)
|
||||
applyCacheOverrides(cmd)
|
||||
applyCheckOverrides(cmd)
|
||||
app.InitCache()
|
||||
|
||||
currentIteration := 1
|
||||
for {
|
||||
@@ -45,11 +40,11 @@ var checkCmd = &cobra.Command{
|
||||
}
|
||||
|
||||
var lists []playlist.Playlist
|
||||
if len(files) == 0 && len(urls) == 0 && len(codes) == 0 {
|
||||
lists = checker.PrepareListsToCheck(files, urls, codes)
|
||||
if len(app.Args.Files) == 0 && len(app.Args.Urls) == 0 && len(app.Args.Codes) == 0 {
|
||||
lists = checker.PrepareListsToCheck(app.Args.Files, app.Args.Urls, app.Args.Codes)
|
||||
} else {
|
||||
if currentIteration == 1 {
|
||||
lists = checker.PrepareListsToCheck(files, urls, codes)
|
||||
lists = checker.PrepareListsToCheck(app.Args.Files, app.Args.Urls, app.Args.Codes)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -79,22 +74,16 @@ var checkCmd = &cobra.Command{
|
||||
}
|
||||
currentIteration++
|
||||
}
|
||||
log.Printf("Waiting for new iteration... seconds=%d\n", app.Args.RepeatEverySec)
|
||||
time.Sleep(time.Duration(app.Args.RepeatEverySec) * time.Second)
|
||||
// AllCooldown хранится в миллисекундах, поэтому переводим в time.Millisecond
|
||||
cooldown := time.Duration(app.Config.Check.Playlists.AllCooldown.Value()) * time.Millisecond
|
||||
log.Printf("Waiting for new iteration... cooldown=%s\n", cooldown)
|
||||
time.Sleep(cooldown)
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
func init() {
|
||||
checkCmd.Flags().StringVarP(&app.Args.TagsPath, "tags", "t", "./channels.json", "path to a local tagfile")
|
||||
checkCmd.Flags().StringVarP(&app.Args.IniPath, "ini", "i", "./playlists.ini", "path to a local ini-file")
|
||||
checkCmd.Flags().UintVarP(&app.Args.RandomCount, "random", "r", 0, "take this count of random playlists to check from ini-file")
|
||||
checkCmd.Flags().UintVarP(&app.Args.RepeatCount, "repeat", "", 1, "repeat same check X times")
|
||||
checkCmd.Flags().UintVarP(&app.Args.RepeatEverySec, "every", "", 5, "wait N seconds after every check")
|
||||
checkCmd.Flags().BoolVarP(&app.Args.NeedJson, "json", "j", false, "print results in JSON format in the end")
|
||||
checkCmd.Flags().BoolVarP(&app.Args.NeedQuiet, "quiet", "q", false, "suppress logs (does not affect on -j)")
|
||||
checkCmd.Flags().StringSliceP("file", "f", []string{}, "path to a local playlist file (m3u/m3u8)")
|
||||
checkCmd.Flags().StringSliceP("url", "u", []string{}, "URL to a remote playlist (http/https)")
|
||||
checkCmd.Flags().StringSliceP("code", "c", []string{}, "code of playlist from ini-file")
|
||||
addCommonCheckFlags(checkCmd, 1)
|
||||
addCheckOnlyFlags(checkCmd)
|
||||
rootCmd.AddCommand(checkCmd)
|
||||
}
|
||||
|
||||
+154
@@ -0,0 +1,154 @@
|
||||
/*
|
||||
* Copyright (c) 2025-2026, Антон Аксенов
|
||||
* This file is part of iptvc project
|
||||
* MIT License: https://git.axenov.dev/IPTV/iptvc/src/branch/master/LICENSE
|
||||
*/
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"axenov/iptv-checker/app"
|
||||
"axenov/iptv-checker/app/config"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// addCommonCheckFlags добавляет общие флаги, используемые как командой check,
|
||||
// так и serve. Флаги используют zero-value defaults; переопределение config.yml
|
||||
// происходит через cmd.Flags().Changed() в applyCacheOverrides() и applyCheckOverrides().
|
||||
func addCommonCheckFlags(cmd *cobra.Command, repeatDefault uint) {
|
||||
// paths
|
||||
cmd.Flags().StringVarP(&app.Args.IniPath, "ini", "i", "", "path to a local ini-file (overrides config.yml)")
|
||||
cmd.Flags().StringVarP(&app.Args.TagsPath, "tags", "t", "", "path to a local tagfile (overrides config.yml)")
|
||||
|
||||
// iteration
|
||||
cmd.Flags().UintVarP(&app.Args.RandomCount, "random", "r", 0, "take this count of random playlists to check from ini-file")
|
||||
cmd.Flags().UintVarP(&app.Args.RepeatCount, "repeat", "", repeatDefault, "repeat same check X times (0 = infinite)")
|
||||
|
||||
// check.playlists
|
||||
cmd.Flags().IntVar(&app.Args.PlTimeout, "playlists-timeout", 0, "playlist request timeout in seconds (overrides config.yml)")
|
||||
cmd.Flags().IntVar(&app.Args.PlAllCooldown, "playlists-all-cooldown", 0, "cooldown after all playlists in seconds (overrides config.yml)")
|
||||
cmd.Flags().IntVar(&app.Args.PlOneCooldown, "playlists-one-cooldown", 0, "cooldown after each playlist in seconds (overrides config.yml)")
|
||||
cmd.Flags().IntVar(&app.Args.PlMaxRoutines, "playlists-max-routines", 0, "max concurrent playlist checks (overrides config.yml)")
|
||||
cmd.Flags().IntVar(&app.Args.PlPerRoutine, "playlists-per-routine", 0, "playlists per routine (overrides config.yml)")
|
||||
cmd.Flags().StringSliceVar(&app.Args.PlUserAgent, "playlists-user-agent", nil, "user-agent for playlist requests (overrides config.yml)")
|
||||
|
||||
// check.channels
|
||||
cmd.Flags().IntVar(&app.Args.ChTimeout, "channels-timeout", 0, "channel request timeout in seconds (overrides config.yml)")
|
||||
cmd.Flags().IntVar(&app.Args.ChByteRange, "channels-byte-range", 0, "bytes to fetch from server per channel (overrides config.yml)")
|
||||
cmd.Flags().IntVar(&app.Args.ChCooldown, "channels-cooldown", 0, "cooldown after each channel in seconds (overrides config.yml)")
|
||||
cmd.Flags().IntVar(&app.Args.ChMaxRoutines, "channels-max-routines", 0, "max concurrent channel checks (overrides config.yml)")
|
||||
cmd.Flags().IntVar(&app.Args.ChPerRoutine, "channels-per-routine", 0, "channels per routine (overrides config.yml)")
|
||||
cmd.Flags().StringSliceVar(&app.Args.ChUserAgent, "channels-user-agent", nil, "user-agent for channel requests (overrides config.yml)")
|
||||
|
||||
// cache
|
||||
cmd.Flags().BoolVar(&app.Args.CacheEnabled, "cache-enabled", false, "enable cache (overrides config.yml)")
|
||||
cmd.Flags().StringVar(&app.Args.CacheHost, "cache-host", "", "cache host (overrides config.yml)")
|
||||
cmd.Flags().UintVar(&app.Args.CachePort, "cache-port", 0, "cache port (overrides config.yml)")
|
||||
cmd.Flags().StringVar(&app.Args.CacheUsername, "cache-username", "", "cache username (overrides config.yml)")
|
||||
cmd.Flags().StringVar(&app.Args.CachePassword, "cache-password", "", "cache password (overrides config.yml)")
|
||||
cmd.Flags().UintVar(&app.Args.CacheDb, "cache-db", 0, "cache database number (overrides config.yml)")
|
||||
cmd.Flags().UintVar(&app.Args.CacheTtl, "cache-ttl", 0, "cache TTL in seconds (overrides config.yml)")
|
||||
}
|
||||
|
||||
// addCheckOnlyFlags добавляет флаги, доступные только в команде check.
|
||||
func addCheckOnlyFlags(cmd *cobra.Command) {
|
||||
cmd.Flags().BoolVarP(&app.Args.NeedJson, "json", "j", false, "print results in JSON format in the end")
|
||||
cmd.Flags().BoolVarP(&app.Args.NeedQuiet, "quiet", "q", false, "suppress logs (does not affect on -j)")
|
||||
cmd.Flags().StringSliceVarP(&app.Args.Files, "file", "f", []string{}, "path to a local playlist file (m3u/m3u8)")
|
||||
cmd.Flags().StringSliceVarP(&app.Args.Urls, "url", "u", []string{}, "URL to a remote playlist (http/https)")
|
||||
cmd.Flags().StringSliceVarP(&app.Args.Codes, "code", "c", []string{}, "code of playlist from ini-file")
|
||||
}
|
||||
|
||||
// applyAppOverrides применяет CLI-флаги для app.* поверх конфигурации.
|
||||
// Использует Changed() — переопределение срабатывает только при явной передаче флага.
|
||||
func applyAppOverrides(cmd *cobra.Command) {
|
||||
if cmd.Flags().Changed("debug") {
|
||||
app.Config.App.Debug = app.Args.Debug
|
||||
}
|
||||
if cmd.Flags().Changed("log-level") {
|
||||
app.Config.App.LogLevel = app.Args.LogLevel
|
||||
}
|
||||
if cmd.Flags().Changed("ini") {
|
||||
app.Config.App.Playlists = app.Args.IniPath
|
||||
}
|
||||
if cmd.Flags().Changed("tags") {
|
||||
app.Config.App.Tags = app.Args.TagsPath
|
||||
}
|
||||
}
|
||||
|
||||
// applyCacheOverrides применяет CLI-флаги для cache.* поверх конфигурации.
|
||||
// Использует Changed() — переопределение срабатывает только при явной передаче флага.
|
||||
// Вызывается между app.Init() и app.InitCache().
|
||||
func applyCacheOverrides(cmd *cobra.Command) {
|
||||
c := &app.Config.Cache
|
||||
if cmd.Flags().Changed("cache-enabled") {
|
||||
c.Enabled = app.Args.CacheEnabled
|
||||
}
|
||||
if cmd.Flags().Changed("cache-host") {
|
||||
c.Host = app.Args.CacheHost
|
||||
}
|
||||
if cmd.Flags().Changed("cache-port") {
|
||||
c.Port = app.Args.CachePort
|
||||
}
|
||||
if cmd.Flags().Changed("cache-username") {
|
||||
c.Username = app.Args.CacheUsername
|
||||
}
|
||||
if cmd.Flags().Changed("cache-password") {
|
||||
c.Password = app.Args.CachePassword
|
||||
}
|
||||
if cmd.Flags().Changed("cache-db") {
|
||||
c.Db = app.Args.CacheDb
|
||||
}
|
||||
if cmd.Flags().Changed("cache-ttl") {
|
||||
c.Ttl = app.Args.CacheTtl
|
||||
}
|
||||
}
|
||||
|
||||
// applyCheckOverrides применяет CLI-флаги для check.* поверх конфигурации.
|
||||
// Значения времени передаются в секундах и переводятся в миллисекунды.
|
||||
// Вызывается в обработчиках check и serve после app.Init().
|
||||
func applyCheckOverrides(cmd *cobra.Command) {
|
||||
pp := &app.Config.Check.Playlists
|
||||
if cmd.Flags().Changed("playlists-timeout") {
|
||||
pp.Timeout = config.DurationSeconds(app.Args.PlTimeout * 1000)
|
||||
}
|
||||
if cmd.Flags().Changed("playlists-all-cooldown") {
|
||||
pp.AllCooldown.Min = app.Args.PlAllCooldown * 1000
|
||||
pp.AllCooldown.Max = app.Args.PlAllCooldown * 1000
|
||||
}
|
||||
if cmd.Flags().Changed("playlists-one-cooldown") {
|
||||
pp.OneCooldown.Min = app.Args.PlOneCooldown * 1000
|
||||
pp.OneCooldown.Max = app.Args.PlOneCooldown * 1000
|
||||
}
|
||||
if cmd.Flags().Changed("playlists-max-routines") {
|
||||
pp.MaxRoutines = app.Args.PlMaxRoutines
|
||||
}
|
||||
if cmd.Flags().Changed("playlists-per-routine") {
|
||||
pp.PerRoutine = app.Args.PlPerRoutine
|
||||
}
|
||||
if cmd.Flags().Changed("playlists-user-agent") {
|
||||
pp.UserAgent = app.Args.PlUserAgent
|
||||
}
|
||||
|
||||
cc := &app.Config.Check.Channels
|
||||
if cmd.Flags().Changed("channels-timeout") {
|
||||
cc.Timeout = config.DurationSeconds(app.Args.ChTimeout * 1000)
|
||||
}
|
||||
if cmd.Flags().Changed("channels-byte-range") {
|
||||
cc.ByteRange = app.Args.ChByteRange
|
||||
}
|
||||
if cmd.Flags().Changed("channels-cooldown") {
|
||||
cc.Cooldown.Min = app.Args.ChCooldown * 1000
|
||||
cc.Cooldown.Max = app.Args.ChCooldown * 1000
|
||||
}
|
||||
if cmd.Flags().Changed("channels-max-routines") {
|
||||
cc.MaxRoutines = app.Args.ChMaxRoutines
|
||||
}
|
||||
if cmd.Flags().Changed("channels-per-routine") {
|
||||
cc.PerRoutine = app.Args.ChPerRoutine
|
||||
}
|
||||
if cmd.Flags().Changed("channels-user-agent") {
|
||||
cc.UserAgent = app.Args.ChUserAgent
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,296 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"axenov/iptv-checker/app"
|
||||
"axenov/iptv-checker/app/config"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func newCmdWithFlags() *cobra.Command {
|
||||
cmd := &cobra.Command{Use: "test"}
|
||||
// persistent flags normally set on rootCmd
|
||||
cmd.PersistentFlags().BoolVar(&app.Args.Debug, "debug", false, "")
|
||||
cmd.PersistentFlags().StringVar(&app.Args.LogLevel, "log-level", "", "")
|
||||
addCommonCheckFlags(cmd, 0)
|
||||
return cmd
|
||||
}
|
||||
|
||||
func resetArgs() {
|
||||
app.Args = app.Arguments{}
|
||||
}
|
||||
|
||||
func resetConfig() {
|
||||
app.Config = &config.Config{
|
||||
App: config.AppConfig{
|
||||
Debug: false,
|
||||
LogLevel: "info",
|
||||
Playlists: "./playlists.ini",
|
||||
Tags: "./channels.json",
|
||||
},
|
||||
Check: config.CheckConfig{
|
||||
Playlists: config.CheckPlaylistsConfig{
|
||||
Timeout: 10000,
|
||||
AllCooldown: config.IntRange{Min: 0, Max: 0},
|
||||
OneCooldown: config.IntRange{Min: 0, Max: 0},
|
||||
MaxRoutines: 5,
|
||||
PerRoutine: 1,
|
||||
UserAgent: config.UserAgents{"default"},
|
||||
},
|
||||
Channels: config.CheckChannelsConfig{
|
||||
Timeout: 10000,
|
||||
ByteRange: 512,
|
||||
Cooldown: config.IntRange{Min: 0, Max: 0},
|
||||
MaxRoutines: 50,
|
||||
PerRoutine: 10,
|
||||
UserAgent: config.UserAgents{"default"},
|
||||
},
|
||||
},
|
||||
Cache: config.CacheConfig{
|
||||
Enabled: false,
|
||||
Host: "localhost",
|
||||
Port: 6379,
|
||||
Ttl: 1800,
|
||||
},
|
||||
Server: config.ServerConfig{Port: 8800},
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyAppOverrides_NothingChanged(t *testing.T) {
|
||||
resetArgs()
|
||||
resetConfig()
|
||||
cmd := newCmdWithFlags()
|
||||
cmd.ParseFlags([]string{})
|
||||
applyAppOverrides(cmd)
|
||||
if app.Config.App.Debug {
|
||||
t.Error("debug should remain false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyAppOverrides_Debug(t *testing.T) {
|
||||
resetArgs()
|
||||
resetConfig()
|
||||
cmd := newCmdWithFlags()
|
||||
cmd.ParseFlags([]string{"--debug"})
|
||||
applyAppOverrides(cmd)
|
||||
if !app.Config.App.Debug {
|
||||
t.Error("expected debug=true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyAppOverrides_LogLevel(t *testing.T) {
|
||||
resetArgs()
|
||||
resetConfig()
|
||||
cmd := newCmdWithFlags()
|
||||
cmd.ParseFlags([]string{"--log-level", "error"})
|
||||
applyAppOverrides(cmd)
|
||||
if app.Config.App.LogLevel != "error" {
|
||||
t.Errorf("expected 'error', got '%s'", app.Config.App.LogLevel)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyAppOverrides_Ini(t *testing.T) {
|
||||
resetArgs()
|
||||
resetConfig()
|
||||
cmd := newCmdWithFlags()
|
||||
cmd.ParseFlags([]string{"-i", "/custom/path.ini"})
|
||||
applyAppOverrides(cmd)
|
||||
if app.Config.App.Playlists != "/custom/path.ini" {
|
||||
t.Errorf("expected '/custom/path.ini', got '%s'", app.Config.App.Playlists)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyAppOverrides_Tags(t *testing.T) {
|
||||
resetArgs()
|
||||
resetConfig()
|
||||
cmd := newCmdWithFlags()
|
||||
cmd.ParseFlags([]string{"-t", "/custom/tags.json"})
|
||||
applyAppOverrides(cmd)
|
||||
if app.Config.App.Tags != "/custom/tags.json" {
|
||||
t.Errorf("expected '/custom/tags.json', got '%s'", app.Config.App.Tags)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyCacheOverrides_NothingChanged(t *testing.T) {
|
||||
resetArgs()
|
||||
resetConfig()
|
||||
cmd := newCmdWithFlags()
|
||||
cmd.ParseFlags([]string{})
|
||||
applyCacheOverrides(cmd)
|
||||
if app.Config.Cache.Enabled {
|
||||
t.Error("cache should remain disabled")
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyCacheOverrides_Enable(t *testing.T) {
|
||||
resetArgs()
|
||||
resetConfig()
|
||||
cmd := newCmdWithFlags()
|
||||
cmd.ParseFlags([]string{"--cache-enabled"})
|
||||
applyCacheOverrides(cmd)
|
||||
if !app.Config.Cache.Enabled {
|
||||
t.Error("expected cache enabled")
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyCacheOverrides_DisableWhenAlreadyEnabled(t *testing.T) {
|
||||
resetArgs()
|
||||
resetConfig()
|
||||
app.Config.Cache.Enabled = true
|
||||
cmd := newCmdWithFlags()
|
||||
cmd.ParseFlags([]string{"--cache-enabled=false"})
|
||||
applyCacheOverrides(cmd)
|
||||
if app.Config.Cache.Enabled {
|
||||
t.Error("expected cache disabled")
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyCacheOverrides_Host(t *testing.T) {
|
||||
resetArgs()
|
||||
resetConfig()
|
||||
cmd := newCmdWithFlags()
|
||||
cmd.ParseFlags([]string{"--cache-host", "db.local"})
|
||||
applyCacheOverrides(cmd)
|
||||
if app.Config.Cache.Host != "db.local" {
|
||||
t.Errorf("expected 'db.local', got '%s'", app.Config.Cache.Host)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyCacheOverrides_Port(t *testing.T) {
|
||||
resetArgs()
|
||||
resetConfig()
|
||||
cmd := newCmdWithFlags()
|
||||
cmd.ParseFlags([]string{"--cache-port", "6380"})
|
||||
applyCacheOverrides(cmd)
|
||||
if app.Config.Cache.Port != 6380 {
|
||||
t.Errorf("expected 6380, got %d", app.Config.Cache.Port)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyCacheOverrides_Ttl(t *testing.T) {
|
||||
resetArgs()
|
||||
resetConfig()
|
||||
cmd := newCmdWithFlags()
|
||||
cmd.ParseFlags([]string{"--cache-ttl", "3600"})
|
||||
applyCacheOverrides(cmd)
|
||||
if app.Config.Cache.Ttl != 3600 {
|
||||
t.Errorf("expected 3600, got %d", app.Config.Cache.Ttl)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyCheckOverrides_NothingChanged(t *testing.T) {
|
||||
resetArgs()
|
||||
resetConfig()
|
||||
cmd := newCmdWithFlags()
|
||||
cmd.ParseFlags([]string{})
|
||||
applyCheckOverrides(cmd)
|
||||
if app.Config.Check.Playlists.Timeout != 10000 {
|
||||
t.Errorf("expected 10000, got %d", app.Config.Check.Playlists.Timeout)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyCheckOverrides_PlaylistsTimeout(t *testing.T) {
|
||||
resetArgs()
|
||||
resetConfig()
|
||||
cmd := newCmdWithFlags()
|
||||
cmd.ParseFlags([]string{"--playlists-timeout", "5"})
|
||||
applyCheckOverrides(cmd)
|
||||
if app.Config.Check.Playlists.Timeout != 5000 {
|
||||
t.Errorf("expected 5000 ms from 5 s, got %d", app.Config.Check.Playlists.Timeout)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyCheckOverrides_PlaylistsAllCooldown(t *testing.T) {
|
||||
resetArgs()
|
||||
resetConfig()
|
||||
cmd := newCmdWithFlags()
|
||||
cmd.ParseFlags([]string{"--playlists-all-cooldown", "3"})
|
||||
applyCheckOverrides(cmd)
|
||||
if app.Config.Check.Playlists.AllCooldown.Min != 3000 || app.Config.Check.Playlists.AllCooldown.Max != 3000 {
|
||||
t.Errorf("expected [3000,3000] ms from 3 s, got [%d,%d]",
|
||||
app.Config.Check.Playlists.AllCooldown.Min, app.Config.Check.Playlists.AllCooldown.Max)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyCheckOverrides_PlaylistsMaxRoutines(t *testing.T) {
|
||||
resetArgs()
|
||||
resetConfig()
|
||||
cmd := newCmdWithFlags()
|
||||
cmd.ParseFlags([]string{"--playlists-max-routines", "10"})
|
||||
applyCheckOverrides(cmd)
|
||||
if app.Config.Check.Playlists.MaxRoutines != 10 {
|
||||
t.Errorf("expected 10, got %d", app.Config.Check.Playlists.MaxRoutines)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyCheckOverrides_PlaylistsUserAgent(t *testing.T) {
|
||||
resetArgs()
|
||||
resetConfig()
|
||||
cmd := newCmdWithFlags()
|
||||
cmd.ParseFlags([]string{"--playlists-user-agent", "UA1,UA2"})
|
||||
applyCheckOverrides(cmd)
|
||||
if len(app.Config.Check.Playlists.UserAgent) != 2 {
|
||||
t.Fatalf("expected 2 user-agents, got %d", len(app.Config.Check.Playlists.UserAgent))
|
||||
}
|
||||
if app.Config.Check.Playlists.UserAgent[0] != "UA1" || app.Config.Check.Playlists.UserAgent[1] != "UA2" {
|
||||
t.Errorf("unexpected user-agents: %v", app.Config.Check.Playlists.UserAgent)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyCheckOverrides_ChannelsTimeout(t *testing.T) {
|
||||
resetArgs()
|
||||
resetConfig()
|
||||
cmd := newCmdWithFlags()
|
||||
cmd.ParseFlags([]string{"--channels-timeout", "8"})
|
||||
applyCheckOverrides(cmd)
|
||||
if app.Config.Check.Channels.Timeout != 8000 {
|
||||
t.Errorf("expected 8000 ms from 8 s, got %d", app.Config.Check.Channels.Timeout)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyCheckOverrides_ChannelsByteRange(t *testing.T) {
|
||||
resetArgs()
|
||||
resetConfig()
|
||||
cmd := newCmdWithFlags()
|
||||
cmd.ParseFlags([]string{"--channels-byte-range", "1024"})
|
||||
applyCheckOverrides(cmd)
|
||||
if app.Config.Check.Channels.ByteRange != 1024 {
|
||||
t.Errorf("expected 1024, got %d", app.Config.Check.Channels.ByteRange)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyCheckOverrides_ChannelsCooldown(t *testing.T) {
|
||||
resetArgs()
|
||||
resetConfig()
|
||||
cmd := newCmdWithFlags()
|
||||
cmd.ParseFlags([]string{"--channels-cooldown", "1"})
|
||||
applyCheckOverrides(cmd)
|
||||
if app.Config.Check.Channels.Cooldown.Min != 1000 || app.Config.Check.Channels.Cooldown.Max != 1000 {
|
||||
t.Errorf("expected [1000,1000] ms from 1 s, got [%d,%d]",
|
||||
app.Config.Check.Channels.Cooldown.Min, app.Config.Check.Channels.Cooldown.Max)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyCheckOverrides_ChannelsMaxRoutines(t *testing.T) {
|
||||
resetArgs()
|
||||
resetConfig()
|
||||
cmd := newCmdWithFlags()
|
||||
cmd.ParseFlags([]string{"--channels-max-routines", "100"})
|
||||
applyCheckOverrides(cmd)
|
||||
if app.Config.Check.Channels.MaxRoutines != 100 {
|
||||
t.Errorf("expected 100, got %d", app.Config.Check.Channels.MaxRoutines)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyCheckOverrides_ChannelsUserAgent(t *testing.T) {
|
||||
resetArgs()
|
||||
resetConfig()
|
||||
cmd := newCmdWithFlags()
|
||||
cmd.ParseFlags([]string{"--channels-user-agent", "VLC"})
|
||||
applyCheckOverrides(cmd)
|
||||
if len(app.Config.Check.Channels.UserAgent) != 1 || app.Config.Check.Channels.UserAgent[0] != "VLC" {
|
||||
t.Errorf("expected [VLC], got %v", app.Config.Check.Channels.UserAgent)
|
||||
}
|
||||
}
|
||||
+4
-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
|
||||
*/
|
||||
@@ -35,4 +35,7 @@ func Execute() {
|
||||
|
||||
func init() {
|
||||
rootCmd.PersistentFlags().BoolVarP(&app.Args.Verbose, "verbose", "v", false, "enable additional output")
|
||||
rootCmd.PersistentFlags().StringVar(&app.Args.ConfigPath, "config", "config.yml", "path to config file")
|
||||
rootCmd.PersistentFlags().BoolVar(&app.Args.Debug, "debug", false, "enable debug mode (overrides config.yml)")
|
||||
rootCmd.PersistentFlags().StringVar(&app.Args.LogLevel, "log-level", "", "log level: debug, info, warn, error (overrides config.yml)")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
/*
|
||||
* Copyright (c) 2025-2026, Антон Аксенов
|
||||
* This file is part of iptvc project
|
||||
* MIT License: https://git.axenov.dev/IPTV/iptvc/src/branch/master/LICENSE
|
||||
*/
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"axenov/iptv-checker/app"
|
||||
"axenov/iptv-checker/app/web"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// serveCmd represents the serve command
|
||||
var serveCmd = &cobra.Command{
|
||||
Use: "serve",
|
||||
Short: "Start web interface",
|
||||
Long: `Start web interface for browsing IPTV playlists.
|
||||
Use --check to enable background playlist checking.
|
||||
Copyright (c) 2025, Антон Аксенов, MIT license.`,
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
app.Init()
|
||||
applyAppOverrides(cmd)
|
||||
applyCacheOverrides(cmd)
|
||||
applyCheckOverrides(cmd)
|
||||
app.InitCache()
|
||||
|
||||
// CLI-флаги переопределяют конфиг, только если заданы явно.
|
||||
if cmd.Flags().Changed("port") {
|
||||
app.Config.Server.Port = app.Args.ServerPort
|
||||
}
|
||||
if cmd.Flags().Changed("host") {
|
||||
app.Config.Server.Host = app.Args.ServerHost
|
||||
}
|
||||
|
||||
server := web.NewServer(app.Config, app.Cache)
|
||||
|
||||
// запускаем фоновую проверку, если передан --check или включён check.start-on-serve в конфиге
|
||||
if app.Args.NeedCheck || app.Config.Check.StartOnServe {
|
||||
opts := web.CheckOptions{
|
||||
Repeat: app.Args.RepeatCount,
|
||||
Random: app.Args.RandomCount,
|
||||
}
|
||||
go server.StartBackgroundChecker(opts)
|
||||
}
|
||||
|
||||
server.Start()
|
||||
},
|
||||
}
|
||||
|
||||
func init() {
|
||||
serveCmd.Flags().UintVarP(&app.Args.ServerPort, "port", "p", 0, "port for web interface (overrides config.yml and env)")
|
||||
serveCmd.Flags().StringVar(&app.Args.ServerHost, "host", "", "host to bind web interface (overrides config.yml and env)")
|
||||
serveCmd.Flags().BoolVar(&app.Args.NeedCheck, "check", false, "enable background playlist checking")
|
||||
addCommonCheckFlags(serveCmd, 0)
|
||||
rootCmd.AddCommand(serveCmd)
|
||||
}
|
||||
+1
-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
|
||||
*/
|
||||
|
||||
+60
@@ -0,0 +1,60 @@
|
||||
name: iptv
|
||||
|
||||
networks:
|
||||
iptvc-network:
|
||||
driver: bridge
|
||||
|
||||
x-common-attributes: &common-attributes
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- /etc/localtime:/etc/localtime:ro
|
||||
- /etc/timezone:/etc/timezone:ro
|
||||
extra_hosts: [host.docker.internal:host-gateway]
|
||||
networks: [iptvc-network]
|
||||
logging:
|
||||
driver: "json-file"
|
||||
options:
|
||||
max-file: 5
|
||||
max-size: 50m
|
||||
|
||||
services:
|
||||
iptvc:
|
||||
<<: *common-attributes
|
||||
container_name: iptvc
|
||||
image: git.axenov.dev/iptv/iptvc:latest
|
||||
build:
|
||||
context: ./iptvc
|
||||
dockerfile: Dockerfile
|
||||
env_file: [.env]
|
||||
command: ["serve", "--check", "--repeat", "0"]
|
||||
ports: [8800:8800]
|
||||
volumes:
|
||||
- /etc/localtime:/etc/localtime:ro
|
||||
- /etc/timezone:/etc/timezone:ro
|
||||
- ./playlists.ini:/app/playlists.ini:ro
|
||||
- ./channels.json:/app/channels.json:ro
|
||||
- ./config.yml:/app/config.yml:ro
|
||||
depends_on: [keydb]
|
||||
|
||||
docs:
|
||||
<<: *common-attributes
|
||||
container_name: iptv-docs
|
||||
image: git.axenov.dev/iptv/iptv-docs:latest
|
||||
build:
|
||||
context: ./docs
|
||||
dockerfile: Dockerfile
|
||||
ports: [8801:8801]
|
||||
|
||||
keydb:
|
||||
<<: *common-attributes
|
||||
container_name: iptv-keydb
|
||||
image: eqalpha/keydb:latest
|
||||
pull_policy: always
|
||||
user: ${KEYDB_UID}:${KEYDB_GID}
|
||||
entrypoint: ["sh", "/entrypoint.sh"]
|
||||
volumes:
|
||||
- ./docker/keydb/entrypoint.sh:/entrypoint.sh
|
||||
- ./docker/keydb/keydb.conf:/etc/keydb/keydb.conf
|
||||
- ./docker/keydb/data/:/data:rw
|
||||
ports: [${KEYDB_PORT:-6379}:6379]
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
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: # опциональный путь к иконке
|
||||
header:
|
||||
title: IPTV Checker
|
||||
navigation:
|
||||
- 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
|
||||
@@ -0,0 +1,4 @@
|
||||
#!/bin/sh
|
||||
trap 'echo "Received SIGTERM, saving KeyDB data..."; keydb-cli SAVE; echo "Data saved. Exiting."; exit 0' TERM
|
||||
echo "[entrypoint] Starting KeyDB..."
|
||||
exec keydb-server /etc/keydb/keydb.conf "$@"
|
||||
File diff suppressed because it is too large
Load Diff
-114
@@ -1,114 +0,0 @@
|
||||
# Результаты в формате JSON
|
||||
|
||||
Результаты проверки могут быть выведены в формате JSON.
|
||||
Для этого необходимо указать аргумент `-j` перед вызовом программы.
|
||||
|
||||
Структуры описаны файлах:
|
||||
- [Канал](../app/playlist/channel.go)
|
||||
- [Группа](../app/playlist/group.go)
|
||||
- [Плейлист](../app/playlist/playlist.go)
|
||||
|
||||
Ниже описан пример результата проверки плейлиста с четырьмя каналами:
|
||||
1. `01df21bb720e46fa8b3a1c064effa41f` доступен для просмотра, сервер отдаёт m3u с разными потоками;
|
||||
2. `36a32a9741e869d182c21beb38dce0c9` доступен для просмотра, сервер отдаёт сразу один поток с бинарными данными, в ответе они скрыты строкой `"binary"`;
|
||||
3. `2dab47162d78814e2a55b65c292d91e7` не доступен для просмотра, сервер отдаёт ошибку 403 и html страницы-заглушки;
|
||||
4. `d5e815a6cb24221b317afc3eb436b537` не доступен для просмотра, произошла ошибка при проверке.
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"code": "ru",
|
||||
"name": "[iptv-org] Российские",
|
||||
"description": "",
|
||||
"url": "https://raw.githubusercontent.com/iptv-org/iptv/master/streams/ru.m3u",
|
||||
"source": "https://github.com/iptv-org/iptv",
|
||||
"content": "#EXTM3U\n#EXTINF:-1 tvg-id=\"vijuPlusComedy.ru\",viju+ Comedy (1080p)\nhttp://77.235.1.17/vip_comedy/index.m3u8\n#EXTINF:-1 tvg-id=\"NanoHD.ru\",Нано ТВ HD\nhttp://s1.tv-nano.com/Nano_rec/index.m3u8\n#EXTINF:-1 tvg-id=\"\",TRK 555 (720p)\nhttp://trk555.tv:8888/live\n#EXTINF:-1 tvg-id=\"NizhniyNovgorod24.ru\",Нижний Новгород 24 (720p) [Not 24/7]\nhttps://live-vestinn.cdnvideo.ru/vestinn/nn24-khl/playlist.m3u8\n",
|
||||
"isOnline": true,
|
||||
"attributes": null,
|
||||
"groups": {},
|
||||
"channels": {
|
||||
"01df21bb720e46fa8b3a1c064effa41f": {
|
||||
"id": "01df21bb720e46fa8b3a1c064effa41f",
|
||||
"title": "Нано ТВ HD",
|
||||
"url": "http://s1.tv-nano.com/Nano_rec/index.m3u8",
|
||||
"groupId": "",
|
||||
"attributes": {
|
||||
"tvg-id": "NanoHD.ru"
|
||||
},
|
||||
"status": 200,
|
||||
"isOnline": true,
|
||||
"error": "",
|
||||
"content": "#EXTM3U\n#EXT-X-STREAM-INF:AVERAGE-BANDWIDTH=670000,BANDWIDTH=840000,RESOLUTION=480x270,FRAME-RATE=25.000,CODECS=\"avc1.4d400d,mp4a.40.2\",CLOSED-CAPTIONS=NONE\ntracks-v3a1/mono.ts.m3u8\n#EXT-X-STREAM-INF:AVERAGE-BANDWIDTH=1320000,BANDWIDTH=1650000,RESOLUTION=640x360,FRAME-RATE=25.000,CODECS=\"avc1.4d4015,mp4a.40.2\",CLOSED-CAPTIONS=NONE\ntracks-v2a1/mono.ts.m3u8\n#EXT-X-STREAM-INF:AVERAGE-BANDWIDTH=2380000,BANDWIDTH=2980000,RESOLUTION=1024x576,FRAME-RATE=25.000,CODECS=\"avc1.4d401e,mp4a.40.2\",CLOSED-CAPTIONS=NONE\ntracks-v1a1/mono.ts.m3u8\n",
|
||||
"contentType": "application/vnd.apple.mpegurl",
|
||||
"tags": [
|
||||
"untagged"
|
||||
],
|
||||
"checkedAt": 1234567890
|
||||
},
|
||||
"36a32a9741e869d182c21beb38dce0c9": {
|
||||
"id": "36a32a9741e869d182c21beb38dce0c9",
|
||||
"title": "TRK 555 (720p)",
|
||||
"url": "http://trk555.tv:8888/live",
|
||||
"groupId": "",
|
||||
"attributes": {
|
||||
"tvg-id": ""
|
||||
},
|
||||
"status": 200,
|
||||
"isOnline": true,
|
||||
"error": "",
|
||||
"content": "\u003cbinary\u003e",
|
||||
"contentType": "application/octet-stream",
|
||||
"tags": [
|
||||
"untagged"
|
||||
],
|
||||
"checkedAt": 1234567890
|
||||
},
|
||||
"2dab47162d78814e2a55b65c292d91e7": {
|
||||
"id": "2dab47162d78814e2a55b65c292d91e7",
|
||||
"title": "Нижний Новгород 24 (720p) [Not 24/7]",
|
||||
"url": "https://live-vestinn.cdnvideo.ru/vestinn/nn24-khl/playlist.m3u8",
|
||||
"groupId": "",
|
||||
"attributes": {
|
||||
"tvg-id": "NizhniyNovgorod24.ru"
|
||||
},
|
||||
"status": 403,
|
||||
"isOnline": false,
|
||||
"error": "\u003chtml\u003e\r\n\u003chead\u003e\u003ctitle\u003e403 Forbidden\u003c/title\u003e\u003c/head\u003e\r\n\u003cbody\u003e\r\n\u003ccenter\u003e\u003ch1\u003e403 Forbidden\u003c/h1\u003e\u003c/center\u003e\r\n\u003chr\u003e\u003ccenter\u003enginx\u003c/center\u003e\r\n\u003c/body\u003e\r\n\u003c/html\u003e\r\n",
|
||||
"content": "",
|
||||
"contentType": "text/html",
|
||||
"tags": [
|
||||
"RU",
|
||||
"local",
|
||||
"news"
|
||||
],
|
||||
"checkedAt": 1234567890
|
||||
},
|
||||
"d5e815a6cb24221b317afc3eb436b537": {
|
||||
"id": "d5e815a6cb24221b317afc3eb436b537",
|
||||
"title": "viju+ Comedy (1080p)",
|
||||
"url": "http://77.235.1.17/vip_comedy/index.m3u8",
|
||||
"groupId": "",
|
||||
"attributes": {
|
||||
"tvg-id": "vijuPlusComedy.ru"
|
||||
},
|
||||
"status": 0,
|
||||
"isOnline": false,
|
||||
"error": "",
|
||||
"content": "",
|
||||
"contentType": "",
|
||||
"tags": [
|
||||
"untagged"
|
||||
],
|
||||
"checkedAt": 1234567890
|
||||
}
|
||||
},
|
||||
"onlineCount": 2,
|
||||
"offlineCount": 2,
|
||||
"checkedAt": 1234567890
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
На этом примере видно, что:
|
||||
- `channels` не массив, а объект, в котором ключи равны идентификаторам каналов `id`;
|
||||
- `attributes` не массив, а объект, в котором ключи есть названия атрибутов.
|
||||
@@ -7,6 +7,7 @@ require (
|
||||
github.com/redis/go-redis/v9 v9.7.3
|
||||
github.com/spf13/cobra v1.9.1
|
||||
gopkg.in/ini.v1 v1.67.0
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
)
|
||||
|
||||
require (
|
||||
|
||||
@@ -26,6 +26,7 @@ github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA=
|
||||
gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
|
||||
|
||||
@@ -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,14 @@
|
||||
; Шаблон файла с плейлистами
|
||||
; Подробности: https://m3u.su/docs
|
||||
|
||||
[test1] ; короткий код
|
||||
name = Test Playlist 1 ; Название
|
||||
desc = A test playlist ; Короткое описание
|
||||
pls = http://example.com/test1.m3u ; Прямая ссылка на плейлист
|
||||
src = http://example.com ; Где найдено
|
||||
|
||||
[test2]
|
||||
name = Test Playlist 2
|
||||
desc = Another test playlist
|
||||
pls = http://example.com/test2.m3u8
|
||||
src = http://example.com
|
||||
Reference in New Issue
Block a user