1 Commits

Author SHA1 Message Date
anthony f0fe5409fa wip7 2026-07-20 12:33:32 +08:00
1409 changed files with 11369 additions and 413 deletions
+11
View File
@@ -1,4 +1,10 @@
# Config file path (default: config.yml)
# CONFIG_PATH=config.yml
APP_DEBUG=false
SITE_TITLE=IPTV Checker
APP_TIMEZONE=GMT
SITE_BASE_URL=http://localhost:8800
CACHE_ENABLED=false
CACHE_HOST=localhost
@@ -7,3 +13,8 @@ CACHE_USERNAME=
CACHE_PASSWORD=
CACHE_DB=1
CACHE_TTL=1800
SERVER_PORT=8800
SERVER_HOST=
SITE_PAGE_SIZE=0
SITE_REPO_URL=https://git.axenov.dev/IPTV
+4 -6
View File
@@ -22,11 +22,8 @@ jobs:
with: # https://github.com/actions/setup-go
go-version: '>=1.24.2'
- name: Build release files (amd64)
run: make release ARCH=amd64
- name: Build release files (arm64)
run: make release ARCH=arm64
- name: Build release files
run: make release
- name: Create new release
id: use-go-action
@@ -40,7 +37,6 @@ jobs:
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
with: # https://github.com/docker/setup-buildx-action
build-args: "IPTVC_VERSION=${{ github.ref_name }}"
buildkitd-config-inline: |
# https://github.com/moby/buildkit/blob/master/docs/buildkitd.toml.md
[ registry."docker.io" ]
@@ -60,6 +56,8 @@ jobs:
with: # https://github.com/docker/build-push-action
context: .
push: true
build-args: |
IPTVC_VERSION=${{ github.ref_name }}
tags: |
git.axenov.dev/iptv/iptvc:${{ github.ref_name }}
git.axenov.dev/iptv/iptvc:latest
+8 -7
View File
@@ -1,14 +1,15 @@
.idea/
.vscode/
bin/
output/
/.idea/
/bin/
/output/
/docker/keydb/data/
# /docs/
.DS_Store
.env
*.env
*.bak
*.m3u
*.m3u8
*.json
*.ini
*.rdb
iptvc
!/**/*.gitkeep
+14
View File
@@ -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"
]
}
+22
View File
@@ -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"
}
]
}
+206
View File
@@ -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
View File
@@ -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
+21
View File
@@ -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.
+44 -28
View File
@@ -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 '='
+352 -58
View File
@@ -1,34 +1,94 @@
# IPTV Checker (iptvc)
[![Последний релиз](https://img.shields.io/gitea/v/release/IPTV/iptvc?gitea_url=https%3A%2F%2Fgit.axenov.dev&display_name=release&color=green&cacheSeconds=600)](https://git.axenov.dev/IPTV/iptvc/releases/latest)
[![Статус последнего релиза](https://img.shields.io/gitea/v/release/IPTV/iptvc?gitea_url=https%3A%2F%2Fgit.axenov.dev&display_name=release&color=green&cacheSeconds=600)](https://git.axenov.dev/IPTV/iptvc/releases/latest)
Консольная программа для проверки IPTV-плейлистов в формате m3u или m3u8.
> **Веб-сайт:** [m3u.su](https://m3u.su)
> **Документация:** [m3u.su/docs](https://m3u.su/docs)
> Исходный код: [git.axenov.dev/IPTV](https://git.axenov.dev/IPTV)
> Документация: [m3u.su/docs](https://m3u.su/docs)
> Telegram-канал: [@iptv_aggregator](https://t.me/iptv_aggregator)
> Обсуждение: [@iptv_aggregator_chat](https://t.me/iptv_aggregator_chat)
> Бот: [@iptv_aggregator_bot](https://t.me/iptv_aggregator_bot)
> Исходный код: [git.axenov.dev/IPTV](https://git.axenov.dev/IPTV)
## Установка
Достаточно скачать и распаковать архив с подходящим исполняемым файлом [со страницы последнего релиза](https://git.axenov.dev/IPTV/iptvc/releases/latest):
| ОС | Скачать для `amd64` | Скачать для `arm64` |
| ------- | ------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------- |
| Linux | [linux_amd64.zip](https://git.axenov.dev/IPTV/iptvc/releases/download/latest/linux_amd64.zip) | [linux_arm64.zip](https://git.axenov.dev/IPTV/iptvc/releases/download/latest/linux_arm64.zip) |
| MacOS | [darwin_amd64.zip](https://git.axenov.dev/IPTV/iptvc/releases/download/latest/darwin_amd64.zip) | [darwin_arm64.zip](https://git.axenov.dev/IPTV/iptvc/releases/download/latest/darwin_arm64.zip) |
| Windows | [windows_amd64.zip](https://git.axenov.dev/IPTV/iptvc/releases/download/latest/windows_amd64.zip) | [windows_arm64.zip](https://git.axenov.dev/IPTV/iptvc/releases/download/latest/windows_arm64.zip) |
| ОС | Скачать для `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
```
### Запуск из исходников
Для быстрого запуска без сборки используйте `go run .`.
Для запуска тестов и статического анализа:
```bash
go test ./...
go vet ./...
```
## Быстрый старт
@@ -36,7 +96,7 @@
Выполнить `./iptvc help` для получения краткой справки.
Если был клонирован репозиторий, то вместо `./iptvc` можно запустить `go run .`
Если был клонирован репозиторий, то вместо `./iptvc` можно запустить `go run .` или `make help`.
Ниже рассмотрены простые примеры использования программы для проверки плейлистов.
@@ -66,13 +126,27 @@
Аргумент `-i` можно указывать только однажды, но его можно комбинировать с `-f` и `-u`.
### Другие возможности команды `check`
### Флаги команды `check`
* `--random|-r X` — проверить X случайных плейлистов из ini-файла
* `--json|-j` — вывести результаты проверки в формате JSON
* `--quiet|-q` — полностью подавить вывод лога (включая отладочную информацию)
* `--verbose|-v` — добавить в лог более подробную отладочную информацию (значительно увеличит количество строк!)
* `--tags|-t` — файл с перечислением тегов (подробности см. [здесь](https://git.axenov.dev/IPTV/playlists#файл-channelsjson))
Источники плейлистов:
* `--file|-f PATH` — локальный файл плейлиста M3U/M3U8; флаг можно указывать несколько раз;
* `--url|-u URL` — удалённый плейлист по HTTP/HTTPS; флаг можно указывать несколько раз;
* `--code|-c CODE` — код плейлиста из ini-файла; флаг можно указывать несколько раз;
* `--ini|-i PATH` — путь к ini-файлу;
* `--tags|-t PATH` — путь к файлу тегов каналов.
Управление выводом и повторными проверками:
* `--json|-j` — вывести результаты проверки в формате JSON;
* `--quiet|-q` — подавить журналы, не отключая вывод JSON;
* `--verbose|-v` — включить подробный журнал;
* `--random|-r N` — выбрать N случайных плейлистов из ini-файла;
* `--repeat N` — повторить проверку N раз, значение `0` означает бесконечный цикл;
* `--playlists-all-cooldown N` — ждать N секунд между полными циклами проверки; значение также можно задать через `check.playlists.all-cooldown`.
Параметры проверки и кеша также можно переопределить CLI-флагами.
Полный список доступен через `./iptvc check --help`.
Например, можно получить только json с результатами, передать его в `jq` и, отфильтровав результат, вывести названия оффлайн каналов:
@@ -83,6 +157,251 @@
> [!NOTE]
> Набери `./iptvc help` для получения помощи.
## Веб-интерфейс
Программа включает встроенный веб-сервер для просмотра плейлистов и результатов их проверки.
### Запуск
```
./iptvc serve -i playlists.ini -p 8800
```
### Параметры команды `serve`
* `-p, --port` — порт для веб-сервера (переопределяет `config.yml` и `SERVER_PORT`);
* `--host` — хост для привязки (переопределяет `config.yml` и `SERVER_HOST`);
* `--check` — включить фоновую проверку плейлистов (по умолчанию выключена).
При указании `--check` доступны флаги проверки:
* `-i, --ini` — путь к ini-файлу (по умолчанию `./playlists.ini`);
* `-t, --tags` — путь к файлу тегов (по умолчанию `./channels.json`);
* `--playlists-all-cooldown N` — пауза между полными циклами проверки в секундах; если флаг не указан, используется `check.playlists.all-cooldown` из конфигурации;
* `--repeat` — количество циклов проверки (по умолчанию `0` — бесконечно).
### Конфигурация
Приоритет настроек (от низшего к высшему):
1. **Значения по умолчанию** — встроены в код;
2. **`config.yml`** — YAML-файл в корне проекта (путь можно задать через флаг `--config`);
3. **Переменные окружения** — переопределяют значения из `config.yml` (если заданы);
4. **CLI-флаги** — переопределяют значения из окружения и `config.yml` (если заданы явно).
Флаги `--port` и `--host` переопределяют конфигурацию только если переданы явно.
Если флаг не указан, используется значение из переменной окружения, затем из `config.yml`,
затем значение по умолчанию.
Файл `.env` загружается автоматически, переменные из него применяются как переменные окружения.
#### `config.yml`
```yaml
app:
timezone: GMT
debug: false
log_level: info
playlists: ./playlists.ini
tags: ./channels.json
server:
host: ""
port: 8800
site:
base-url: http://localhost:8800
repo-url: https://git.axenov.dev/IPTV
page-size: 0
favicon: # опциональный путь к иконке
title: IPTV Checker
meta:
description: Самообновляемые бесплатные IPTV-плейлисты для домашнего просмотра по коротким ссылкам, списки каналов, проверка доступности
keywords: самообновляемые,бесплатные,iptv-плейлисты,iptv,плейлисты
tabs:
raw:
visible: true
legal:
visible: true
text: |
Данные, представленные на данной странице, получены автоматически из открыто доступных в интернете IPTV-плейлистов, опубликованных третьими лицами.
Сервис не размещает и не транслирует медиаконтент, не создаёт, не призывает использовать и распространять плейлисты третьих лиц, а также не оказывает услуг по ретрансляции телепрограмм.
Информация о телеканалах формируется исключительно путём обработки содержимого самого плейлиста.
header:
menu:
- title: Помощь
icon: help-circle-outline
children:
- title: Документация
url: https://m3u.su/docs
icon: document-text-outline
- title: Исходники
url: https://git.axenov.dev/IPTV
icon: code-slash-outline
- title: "@iptv_aggregator"
url: https://t.me/iptv_aggregator
icon: paper-plane-outline
footer:
links:
- title: Исходники
url: https://git.axenov.dev/IPTV
icon: code-slash-outline
- title: axenov.dev
url: https://axenov.dev
icon: person-outline
- title: "@iptv_aggregator"
url: https://t.me/iptv_aggregator
icon: megaphone-outline
check:
start-on-serve: false
playlists:
user-agent:
- Mozilla/5.0 WINK/1.31.1 (AndroidTV/9) HlsWinkPlayer
timeout: 10
all-cooldown: 1800
one-cooldown: 2
max-routines: 1
per-routine: 1
channels:
user-agent: Mozilla/5.0 WINK/1.31.1 (AndroidTV/9) HlsWinkPlayer
timeout: 10
byte-range: 512
cooldown: 0
max-routines: 50
per-routine: 10
cache:
enabled: false
host: localhost
port: 6379
username:
password:
db: 0
ttl: 30
```
### Часовой пояс
Параметр `app.timezone` задаёт часовой пояс, в котором отображается время последнего изменения плейлистов на сайте.
Поддерживаются значения из базы IANA (`Europe/Moscow`, `UTC`, `GMT` и т.д.) и фиксированные смещения (`UTC+3`, `UTC+03:00`).
Если указано некорректное значение, используется `UTC`.
### SEO-метаданные
Секция `site.meta` позволяет задать описание и ключевые слова для главной страницы и других страниц, где мета-данные не переопределяются шаблоном:
```yaml
site:
meta:
description: Самообновляемые бесплатные IPTV-плейлисты...
keywords: самообновляемые,бесплатные,iptv-плейлисты,iptv,плейлисты
```
Те же значения можно переопределить через переменные окружения `SITE_META_DESCRIPTION` и `SITE_META_KEYWORDS`.
`check.playlists.all-cooldown` задаёт паузу между полными циклами проверки.
Она используется командами `check` и `serve` и больше не применяется дополнительно внутри одного цикла. `check.playlists.one-cooldown` задаёт паузу между отдельными плейлистами, а `check.channels.cooldown` — между каналами одного плейлиста.
Параметры задержек (`timeout`, `all-cooldown`, `one-cooldown`, `cooldown`) и переменные окружения `CHECK_*_TIMEOUT`, `CHECK_*_COOLDOWN` задаются в секундах.
Они могут быть заданы как число или диапазон `[min, max]` в секундах.
При диапазоне для каждой операции выбирается случайное значение.
#### Переменные окружения
| Переменная | Соответствует в `config.yml` | CLI-флаг | Описание |
| ----------------------------------- | ------------------------------ | -------------------------- | ----------------------------------- |
| `APP_DEBUG` | `app.debug` | `--debug` | Режим отладки |
| `APP_LOG_LEVEL` | `app.log_level` | `--log-level` | Уровень журналирования |
| `APP_TIMEZONE` | `app.timezone` | — | Часовой пояс |
| `APP_PLAYLISTS` | `app.playlists` | `-i`, `--ini` | Путь к `playlists.ini` |
| `APP_TAGS` | `app.tags` | `-t`, `--tags` | Путь к `channels.json` |
| `SERVER_HOST` | `server.host` | `serve --host` | Хост для привязки |
| `SERVER_PORT` | `server.port` | `-p`, `serve --port` | Порт веб-сервера |
| `SITE_BASE_URL` | `site.base-url` | — | Базовый URL для ссылок |
| `SITE_PAGE_SIZE` | `site.page-size` | — | Размер страницы (0 — без пагинации) |
| `SITE_REPO_URL` | `site.repo-url` | — | Ссылка на репозиторий |
| `SITE_FAVICON` | `site.favicon` | — | Путь к иконке сайта |
| `SITE_TITLE` | `site.title` | — | Заголовок сайта |
| `SITE_META_DESCRIPTION` | `site.meta.description` | — | Описание сайта (meta) |
| `SITE_META_KEYWORDS` | `site.meta.keywords` | — | Ключевые слова сайта (meta) |
| `CHECK_START_ON_SERVE` | `check.start-on-serve` | `serve --check` | Запускать проверку вместе с `serve` |
| `CHECK_PLAYLISTS_TIMEOUT` | `check.playlists.timeout` | `--playlists-timeout` | Таймаут запроса плейлиста (с) |
| `CHECK_PLAYLISTS_ALL_COOLDOWN` | `check.playlists.all-cooldown` | `--playlists-all-cooldown` | Пауза между циклами проверки (с) |
| `CHECK_PLAYLISTS_ONE_COOLDOWN` | `check.playlists.one-cooldown` | `--playlists-one-cooldown` | Пауза между плейлистами (с) |
| `CHECK_PLAYLISTS_MAX_ROUTINES` | `check.playlists.max-routines` | `--playlists-max-routines` | Параллельных проверок плейлистов |
| `CHECK_PLAYLISTS_PER_ROUTINE` | `check.playlists.per-routine` | `--playlists-per-routine` | Плейлистов на одну процедуру |
| `CHECK_PLAYLISTS_USER_AGENT_[1..*]` | `check.playlists.user-agent` | `--playlists-user-agent` | User-Agent для проверки плейлистов |
| `CHECK_CHANNELS_TIMEOUT` | `check.channels.timeout` | `--channels-timeout` | Таймаут запроса канала (с) |
| `CHECK_CHANNELS_BYTE_RANGE` | `check.channels.byte-range` | `--channels-byte-range` | Объём данных для получения (байт) |
| `CHECK_CHANNELS_COOLDOWN` | `check.channels.cooldown` | `--channels-cooldown` | Пауза между каналами (с) |
| `CHECK_CHANNELS_MAX_ROUTINES` | `check.channels.max-routines` | `--channels-max-routines` | Параллельных проверок каналов |
| `CHECK_CHANNELS_PER_ROUTINE` | `check.channels.per-routine` | `--channels-per-routine` | Каналов на одну процедуру |
| `CHECK_CHANNELS_USER_AGENT_[1..*]` | `check.channels.user-agent` | `--channels-user-agent` | User-Agent для проверки каналов |
| `CACHE_ENABLED` | `cache.enabled` | `--cache-enabled` | Включить кеш (KeyDB/Redis) |
| `CACHE_HOST` | `cache.host` | `--cache-host` | Хост KeyDB/Redis |
| `CACHE_PORT` | `cache.port` | `--cache-port` | Порт KeyDB/Redis |
| `CACHE_USERNAME` | `cache.username` | `--cache-username` | Имя пользователя KeyDB/Redis |
| `CACHE_PASSWORD` | `cache.password` | `--cache-password` | Пароль KeyDB/Redis |
| `CACHE_DB` | `cache.db` | `--cache-db` | Номер БД KeyDB/Redis |
| `CACHE_TTL` | `cache.ttl` | `--cache-ttl` | TTL записей в кеше (сек) |
### Маршруты
| Метод | Путь | Описание |
| ----- | -------------------------------- | ---------------------------------------- |
| GET | `/` | Главная страница со списком плейлистов |
| GET | `/page/{N}` | Страница N списка плейлистов |
| GET | `/{code}` | Редирект на прямую ссылку плейлиста |
| GET | `/{code}.m3u` | Редирект на прямую ссылку плейлиста |
| GET | `/{code}.m3u8` | Редирект на прямую ссылку плейлиста |
| GET | `/{code}/details` | Страница с описанием плейлиста |
| GET | `/api/playlists` | JSON: список плейлистов |
| GET | `/api/playlists/{code}` | JSON: информация о плейлисте |
| GET | `/api/playlists/{code}/channels` | JSON: каналы плейлиста |
| GET | `/api/version` | JSON: версии компонентов |
| GET | `/api/health` | JSON: состояние сервиса |
| GET | `/api/stats` | JSON: статистика по плейлистам и каналам |
| GET | `/api` | Swagger UI документация API |
### Связь с проверкой
Веб-сервер отображает данные из кеша KeyDB/Redis, который заполняется командой `check`.
Если кеш недоступен, все плейлисты отображаются со статусом `unknown`.
Для обновления данных в фоне запустите `serve` с флагом `--check`:
```
./iptvc serve -i playlists.ini -p 8800 --check
```
Результаты проверок появляются на веб-страницах немедленно после проверки каждого
плейлиста, не дожидаясь завершения полного цикла.
После завершения цикла приложение
ждёт значение `check.playlists.all-cooldown` и затем начинает следующий цикл.
Можно запускать `serve` и `check` отдельными процессами:
```
./iptvc check --repeat 0 --playlists-all-cooldown 60 &
./iptvc serve
```
Или используйте Docker Compose. Перед запуском подготовьте `.env`, `config.yml`, `playlists.ini` и `channels.json` в рабочем каталоге:
```bash
cp .env.example .env
cp config.yml.example config.yml
docker compose up -d --build
docker compose logs -f iptvc
docker compose down
```
Compose запускает сервисы `iptvc`, `keydb` и `docs`.
В текущем `compose.yml` контексты сборки указаны как `./iptvc` и `./docs`, поэтому запуск из одного только каталога этого репозитория может потребовать внешней структуры проекта или корректировки контекстов.
## Результаты проверки
Программа логирует процесс и результаты своей работы.
@@ -104,55 +423,30 @@ pls='https://example.com/list2.m3u'
```
2025/01/02 12:34:00 Loading playlists from ini-file: /home/user/playlists.ini
2025/01/02 12:34:00 Loaded 2 playlists
2025/01/02 12:34:00 [001/001] Playlist [first]
2025/01/02 12:34:00 Fetching... (https://example.com/list1.m3u)
2025/01/02 12:34:00 1 playlists will be checked (max-routines=1)
2025/01/02 12:34:00 Fetching playlist [first] (https://example.com/list1.m3u)
2025/01/02 12:34:00 Parsing content...
2025/01/02 12:34:00 Parsed, checking channels (114)...
2025/01/02 12:34:00 Check parameters calculated: count=114 timeout=8.00s routines=12
2025/01/02 12:34:00 Check channels parameters: timeout=10.00s byte-range=512 max-routines=50
2025/01/02 12:34:12 Checked successfully! online=101 onlinePercent=88.60% offline=13 offlinePercent=11.40% elapsedTime=12.39s
2025/01/02 12:34:12 Done! count=1 online=1 offline=0 elapsedTime=12.39s
```
Разберём построчно:
1. Загрузка ini-файла
2. Файл загружен, в нём 2 плейлиста
3. Начало обработки плейлиста `first`
4. Скачивание плейлиста `first` по ссылке `https://example.com/list1.m3u` в оперативную память
5. Плейлист скачан, начало разбора плейлиста
3. Начало проверки 1 плейлиста (выбран кодом `first`)
4. Скачивание плейлиста `first` по ссылке `https://example.com/list1.m3u`
5. Плейлист скачан, начало разбора
6. Плейлист разобран, начало проверки каналов (114 штук)
7. Определены параметры проверки (подробности ниже)
8. (спустя время) Проверка успешно завершена: онлайн каналов 101 (88.60% от всех), оффлайн каналов 13 (11.40% от всех), затрачено 12 секунд.
### Параметры проверки
Выше в п.7 видно некоторые служебные данные:
* `timeout` — таймаут каждого запроса в секундах (макс. время ожидания ответа канала);
* `routines` — количество одновременных проверок.
Эти параметры рассчитываются динамически для каждого плейлиста в отдельности, исходя из количества каналов в каждом (`count`).
См. [app/checker/checker.go](app/checker/checker.go) для подробностей.
Идея в том, чтобы найти баланс между скоростью проверки и качеством:
* чем ниже коэффициент `k`, тем больше горутин, меньше таймаут, быстрее проверка, хуже результаты;
* чем выше коэффициент `k`, тем меньше горутин, больше таймаут, медленнее проверка, лучше результаты.
На скорость проверки влияют:
* количество горутин (чем выше, тем больше каналов проверяется параллельно);
* таймаут (тем ниже, тем быстрее закончится проверка канала);
* количество каналов (чем больше, тем дольше общий процесс);
* скорость ответа сервера (чем выше, тем хуже) и количество данных, которые он отдаёт (чем больше, тем хуже).
На качество проверки влияет таймаут:
* чем выше, тем выше вероятность получить успешный ответ от сервера, транслирующего поток;
* чем ниже, тем выше вероятность не дождаться успешного ответа и засчитать канал нерабочим.
> [!NOTE]
> Логика балансирования будет уточняться и корректироваться по мере развития проекта, исходя из реального применения.
7. Параметры проверки каналов (таймаут, объём данных, число горутин)
8. (спустя время) Проверка каналов завершена
9. Проверка плейлиста завершена
### Коды возврата
* 0 — успех
* 1 — общая ошибка, см. вывод
* 2 — команде `check` не переданы параметры `--file`, `--url` и `--code`
* 1 — общая ошибка
## Лицензия
+59 -13
View File
@@ -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)
}
}
+1 -1
View File
@@ -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
View File
@@ -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
+632 -39
View File
@@ -1,5 +1,5 @@
/*
* Copyright (c) 2025, Антон Аксенов
* Copyright (c) 2025-2026, Антон Аксенов
* This file is part of iptvc project
* MIT License: https://git.axenov.dev/IPTV/iptvc/src/branch/master/LICENSE
*/
@@ -7,71 +7,664 @@
package config
import (
"github.com/joho/godotenv"
"fmt"
"log"
"math/rand"
"os"
"strconv"
"strings"
"github.com/joho/godotenv"
"gopkg.in/yaml.v3"
)
// Константы значений по умолчанию для временных параметров.
// Внешний интерфейс (YAML, env, CLI) задаёт их в секундах,
// внутреннее представление — в миллисекундах.
const (
defaultTimeoutSeconds = 10
defaultAllCooldownSeconds = 1800 // 30 минут
defaultOneCooldownSeconds = 2
defaultChannelsCooldownSeconds = 0
defaultCacheTtlSeconds = 30
)
// Config описывает конфигурацию
type Config struct {
DebugMode bool
Cache CacheConfig
App AppConfig `yaml:"app"`
Server ServerConfig `yaml:"server"`
Site SiteConfig `yaml:"site"`
Check CheckConfig `yaml:"check"`
Cache CacheConfig `yaml:"cache"`
}
// CacheConfig описывает конфигурацию подключения к keydb
// AppConfig описывает общие настройки приложения
type AppConfig struct {
Timezone string `yaml:"timezone"`
Debug bool `yaml:"debug"`
LogLevel string `yaml:"log_level"`
Playlists string `yaml:"playlists"`
Tags string `yaml:"tags"`
}
// ServerConfig описывает конфигурацию веб-сервера
type ServerConfig struct {
Port uint `yaml:"port"`
Host string `yaml:"host"`
}
// Link описывает ссылку в навигации или подвале сайта.
// Если заданы Children, рендерится как выпадающее меню.
type Link struct {
Title string `yaml:"title"`
Url string `yaml:"url"`
Icon string `yaml:"icon"`
Children []Link `yaml:"children"`
}
// TabConfig описывает настройку одной вкладки на странице плейлиста.
type TabConfig struct {
Visible bool `yaml:"visible"`
Text string `yaml:"text"`
}
// TabsConfig описывает группу вкладок на странице плейлиста.
type TabsConfig struct {
Raw TabConfig `yaml:"raw"`
Legal TabConfig `yaml:"legal"`
}
// HeaderConfig описывает настройки шапки сайта
type HeaderConfig struct {
Title string `yaml:"title"`
Menu []Link `yaml:"menu"`
}
// FooterConfig описывает настройки подвала сайта
type FooterConfig struct {
Links []Link `yaml:"links"`
}
// SiteConfig описывает настройки сайта
type SiteConfig struct {
BaseUrl string `yaml:"base-url"`
RepoUrl string `yaml:"repo-url"`
PageSize uint `yaml:"page-size"`
Favicon string `yaml:"favicon"`
Meta MetaConfig `yaml:"meta"`
Tabs TabsConfig `yaml:"tabs"`
Header HeaderConfig `yaml:"header"`
Footer FooterConfig `yaml:"footer"`
}
// MetaConfig описывает SEO-метаданные сайта.
type MetaConfig struct {
Description string `yaml:"description"`
Keywords string `yaml:"keywords"`
}
// CacheConfig описывает конфигурацию подключения к keydb.
// Ttl задаётся в секундах.
type CacheConfig struct {
IsEnabled bool
Host string
Port uint
Username string
Password string
Db uint
Ttl uint
IsActive bool
Enabled bool `yaml:"enabled"`
Host string `yaml:"host"`
Port uint `yaml:"port"`
Username string `yaml:"username"`
Password string `yaml:"password"`
Db uint `yaml:"db"`
Ttl uint `yaml:"ttl"`
IsActive bool `yaml:"-"`
}
// Init инициализирует объект конфигурации из переменных окружения
func Init() *Config {
_ = godotenv.Load(".env")
// CheckConfig описывает параметры проверки плейлистов и каналов
type CheckConfig struct {
StartOnServe bool `yaml:"start-on-serve"`
Playlists CheckPlaylistsConfig `yaml:"playlists"`
Channels CheckChannelsConfig `yaml:"channels"`
}
// DurationSeconds — тип для временных интервалов, задаваемых в секундах,
// но хранящихся внутри в миллисекундах.
type DurationSeconds int
// UnmarshalYAML декодирует значение из секунд в миллисекунды.
func (d *DurationSeconds) UnmarshalYAML(value *yaml.Node) error {
var v float64
if err := value.Decode(&v); err != nil {
return err
}
*d = DurationSeconds(v * 1000)
return nil
}
// CheckPlaylistsConfig — параметры проверки плейлистов.
// Значения Timeout, AllCooldown, OneCooldown задаются в секундах
// в YAML/CLI/env, но хранятся в миллисекундах для использования в коде.
type CheckPlaylistsConfig struct {
UserAgent UserAgents `yaml:"user-agent"`
Timeout DurationSeconds `yaml:"timeout"` // ms internally
AllCooldown IntRange `yaml:"all-cooldown"` // ms internally
OneCooldown IntRange `yaml:"one-cooldown"` // ms internally
MaxRoutines int `yaml:"max-routines"` // count
PerRoutine int `yaml:"per-routine"` // count
}
// CheckChannelsConfig — параметры проверки каналов.
// Значения Timeout и Cooldown задаются в секундах
// в YAML/CLI/env, но хранятся в миллисекундах для использования в коде.
type CheckChannelsConfig struct {
UserAgent UserAgents `yaml:"user-agent"`
Timeout DurationSeconds `yaml:"timeout"` // ms internally
ByteRange int `yaml:"byte-range"` // bytes
Cooldown IntRange `yaml:"cooldown"` // ms internally
MaxRoutines int `yaml:"max-routines"` // count
PerRoutine int `yaml:"per-routine"` // count
}
// IntRange — значение, которое может быть скаляром или [min, max].
// Если скаляр: Min == Max. Если [min, max]: Min <= Max.
type IntRange struct {
Min int
Max int
}
// UnmarshalYAML реализует кастомный разбор IntRange из YAML.
// В YAML значение задаётся в секундах, результат сохраняется в миллисекундах.
// Поддерживает скаляр (int/float) или массив из двух значений [min, max].
func (r *IntRange) UnmarshalYAML(value *yaml.Node) error {
decodeNode := func(n *yaml.Node) (int, error) {
var f float64
if err := n.Decode(&f); err != nil {
return 0, err
}
return int(f * 1000), nil
}
if value.Kind == yaml.ScalarNode {
v, err := decodeNode(value)
if err != nil {
return err
}
r.Min = v
r.Max = v
return nil
}
if value.Kind == yaml.SequenceNode {
if len(value.Content) == 0 {
r.Min, r.Max = 0, 0
return nil
}
min, err := decodeNode(value.Content[0])
if err != nil {
return err
}
r.Min = min
if len(value.Content) >= 2 {
max, err := decodeNode(value.Content[1])
if err != nil {
return err
}
r.Max = max
} else {
r.Max = min
}
return nil
}
return nil
}
// Value возвращает значение: если Min == Max — константу, иначе случайное в [Min, Max].
func (r IntRange) Value() int {
if r.Min == r.Max {
return r.Min
}
return r.Min + rand.Intn(r.Max-r.Min+1)
}
// UserAgents — список User-Agent строк. Может быть задан как скаляр или массив.
type UserAgents []string
// UnmarshalYAML реализует кастомный разбор UserAgents из YAML.
func (u *UserAgents) UnmarshalYAML(value *yaml.Node) error {
if value.Kind == yaml.ScalarNode {
var s string
if err := value.Decode(&s); err != nil {
return err
}
*u = []string{s}
return nil
}
if value.Kind == yaml.SequenceNode {
var arr []string
if err := value.Decode(&arr); err != nil {
return err
}
*u = arr
return nil
}
return nil
}
// Pick возвращает случайный User-Agent или пустую строку если список пуст.
func (u UserAgents) Pick() string {
if len(u) == 0 {
return ""
}
if len(u) == 1 {
return u[0]
}
return u[rand.Intn(len(u))]
}
// defaults возвращает конфигурацию со значениями по умолчанию.
// Временные параметры хранятся в миллисекундах.
func defaults() *Config {
return &Config{
//DebugMode: readEnvBoolean("APP_DEBUG", false),
App: AppConfig{
Timezone: "GMT",
Debug: false,
LogLevel: "info",
Playlists: "./playlists.ini",
Tags: "./channels.json",
},
Server: ServerConfig{
Port: 8800,
Host: "",
},
Site: SiteConfig{
BaseUrl: "http://localhost:8800",
PageSize: 0,
RepoUrl: "https://git.axenov.dev/IPTV",
Meta: MetaConfig{
Description: "Самообновляемые бесплатные IPTV-плейлисты для домашнего просмотра по коротким ссылкам, списки каналов, проверка доступности",
Keywords: "самообновляемые,бесплатные,iptv-плейлисты,iptv,плейлисты",
},
Tabs: TabsConfig{
Raw: TabConfig{Visible: true},
Legal: TabConfig{Visible: true},
},
Header: HeaderConfig{
Title: "IPTV Checker",
Menu: []Link{
{Title: "Помощь", Icon: "help-circle-outline", Children: []Link{
{Title: "Документация", Url: "https://m3u.su/docs", Icon: "document-text-outline"},
{Title: "Исходники", Url: "https://git.axenov.dev/IPTV", Icon: "code-slash-outline"},
{Title: "@iptv_aggregator", Url: "https://t.me/iptv_aggregator", Icon: "paper-plane-outline"},
}},
},
},
Footer: FooterConfig{
Links: []Link{
{Title: "Исходники", Url: "https://git.axenov.dev/IPTV", Icon: "code-slash-outline"},
{Title: "axenov.dev", Url: "https://axenov.dev", Icon: "person-outline"},
{Title: "@iptv_aggregator", Url: "https://t.me/iptv_aggregator", Icon: "megaphone-outline"},
},
},
},
Check: CheckConfig{
StartOnServe: false,
Playlists: CheckPlaylistsConfig{
UserAgent: UserAgents{"Mozilla/5.0 WINK/1.31.1 (AndroidTV/9) HlsWinkPlayer"},
Timeout: defaultTimeoutSeconds * 1000,
AllCooldown: IntRange{Min: defaultAllCooldownSeconds * 1000, Max: defaultAllCooldownSeconds * 1000},
OneCooldown: IntRange{Min: defaultOneCooldownSeconds * 1000, Max: defaultOneCooldownSeconds * 1000},
MaxRoutines: 1,
PerRoutine: 1,
},
Channels: CheckChannelsConfig{
UserAgent: UserAgents{"Mozilla/5.0 WINK/1.31.1 (AndroidTV/9) HlsWinkPlayer"},
Timeout: defaultTimeoutSeconds * 1000,
ByteRange: 512,
Cooldown: IntRange{Min: defaultChannelsCooldownSeconds * 1000, Max: defaultChannelsCooldownSeconds * 1000},
MaxRoutines: 50,
PerRoutine: 10,
},
},
Cache: CacheConfig{
IsEnabled: readEnvBoolean("CACHE_ENABLED", false),
Host: readEnv("CACHE_HOST", "localhost"),
Port: readEnvInteger("CACHE_PORT", 6379),
Username: readEnv("CACHE_USERNAME", ""),
Password: readEnv("CACHE_PASSWORD", ""),
Db: readEnvInteger("CACHE_DB", 0),
Ttl: readEnvInteger("CACHE_TTL", 1800),
Enabled: false,
Host: "localhost",
Port: 6379,
Username: "",
Password: "",
Db: 0,
Ttl: defaultCacheTtlSeconds,
},
}
}
// readEnv считывает строковую переменную окружения с заданным именем или возвращает значение по умолчанию
func readEnv(key string, defaultValue string) string {
value, exists := os.LookupEnv(key)
if exists {
return value
// Init инициализирует объект конфигурации.
// Порядок приоритета (от низшего к высшему):
// 1. Значения по умолчанию
// 2. config.yml (путь можно задать через configPath)
// 3. Переменные окружения (.env загружается автоматически)
func Init(configPath string) *Config {
_ = godotenv.Load(".env")
cfg := defaults()
// загружаем config.yml
if configPath == "" {
configPath = "config.yml"
}
return defaultValue
if data, err := os.ReadFile(configPath); err == nil {
if err := yaml.Unmarshal(data, cfg); err != nil {
log.Printf("Warning: cannot parse %s: %s", configPath, err)
}
} else if !os.IsNotExist(err) {
log.Printf("Warning: cannot read %s: %s", configPath, err)
}
// переопределяем значениями из переменных окружения
applyEnv(cfg)
// валидируем и исправляем
validate(cfg)
return cfg
}
// readEnvBoolean считывает булеву переменную окружения с заданным именем или возвращает значение по умолчанию
func readEnvBoolean(name string, defaultValue bool) bool {
valStr := readEnv(name, "")
val, err := strconv.ParseBool(valStr)
// applyEnv переопределяет поля конфигурации значениями из переменных окружения.
func applyEnv(cfg *Config) {
// app
if v, ok := os.LookupEnv("APP_DEBUG"); ok {
cfg.App.Debug = parseBool(v, cfg.App.Debug)
}
if v, ok := os.LookupEnv("APP_LOG_LEVEL"); ok && v != "" {
cfg.App.LogLevel = v
}
if v, ok := os.LookupEnv("APP_TIMEZONE"); ok && v != "" {
cfg.App.Timezone = v
}
if v, ok := os.LookupEnv("APP_PLAYLISTS"); ok && v != "" {
cfg.App.Playlists = v
}
if v, ok := os.LookupEnv("APP_TAGS"); ok && v != "" {
cfg.App.Tags = v
}
// server
if v, ok := os.LookupEnv("SERVER_PORT"); ok && v != "" {
cfg.Server.Port = parseUint(v, cfg.Server.Port)
}
if v, ok := os.LookupEnv("SERVER_HOST"); ok {
cfg.Server.Host = v
}
// site
if v, ok := os.LookupEnv("SITE_BASE_URL"); ok && v != "" {
cfg.Site.BaseUrl = v
}
if v, ok := os.LookupEnv("SITE_PAGE_SIZE"); ok && v != "" {
cfg.Site.PageSize = parseUint(v, cfg.Site.PageSize)
}
if v, ok := os.LookupEnv("SITE_REPO_URL"); ok && v != "" {
cfg.Site.RepoUrl = v
}
if v, ok := os.LookupEnv("SITE_FAVICON"); ok {
cfg.Site.Favicon = v
}
if v, ok := os.LookupEnv("SITE_META_DESCRIPTION"); ok && v != "" {
cfg.Site.Meta.Description = v
}
if v, ok := os.LookupEnv("SITE_META_KEYWORDS"); ok && v != "" {
cfg.Site.Meta.Keywords = v
}
if v, ok := os.LookupEnv("SITE_TITLE"); ok && v != "" {
cfg.Site.Header.Title = v
}
// check
if v, ok := os.LookupEnv("CHECK_START_ON_SERVE"); ok {
cfg.Check.StartOnServe = parseBool(v, cfg.Check.StartOnServe)
}
// check.playlists
pp := &cfg.Check.Playlists
if v, ok := os.LookupEnv("CHECK_PLAYLISTS_TIMEOUT"); ok && v != "" {
pp.Timeout = DurationSeconds(parseSecondsToMillis(v, int(pp.Timeout)))
}
applyEnvIntRange(&pp.AllCooldown, "CHECK_PLAYLISTS_ALL_COOLDOWN")
applyEnvIntRange(&pp.OneCooldown, "CHECK_PLAYLISTS_ONE_COOLDOWN")
if v, ok := os.LookupEnv("CHECK_PLAYLISTS_MAX_ROUTINES"); ok && v != "" {
pp.MaxRoutines = parseInt(v, pp.MaxRoutines)
}
if v, ok := os.LookupEnv("CHECK_PLAYLISTS_PER_ROUTINE"); ok && v != "" {
pp.PerRoutine = parseInt(v, pp.PerRoutine)
}
if uas := applyEnvStringSlice("CHECK_PLAYLISTS_USER_AGENT"); uas != nil {
pp.UserAgent = UserAgents(uas)
}
// check.channels
cc := &cfg.Check.Channels
if v, ok := os.LookupEnv("CHECK_CHANNELS_TIMEOUT"); ok && v != "" {
cc.Timeout = DurationSeconds(parseSecondsToMillis(v, int(cc.Timeout)))
}
if v, ok := os.LookupEnv("CHECK_CHANNELS_BYTE_RANGE"); ok && v != "" {
cc.ByteRange = parseInt(v, cc.ByteRange)
}
applyEnvIntRange(&cc.Cooldown, "CHECK_CHANNELS_COOLDOWN")
if v, ok := os.LookupEnv("CHECK_CHANNELS_MAX_ROUTINES"); ok && v != "" {
cc.MaxRoutines = parseInt(v, cc.MaxRoutines)
}
if v, ok := os.LookupEnv("CHECK_CHANNELS_PER_ROUTINE"); ok && v != "" {
cc.PerRoutine = parseInt(v, cc.PerRoutine)
}
if uas := applyEnvStringSlice("CHECK_CHANNELS_USER_AGENT"); uas != nil {
cc.UserAgent = UserAgents(uas)
}
// cache
if v, ok := os.LookupEnv("CACHE_ENABLED"); ok {
cfg.Cache.Enabled = parseBool(v, cfg.Cache.Enabled)
}
if v, ok := os.LookupEnv("CACHE_HOST"); ok && v != "" {
cfg.Cache.Host = v
}
if v, ok := os.LookupEnv("CACHE_PORT"); ok && v != "" {
cfg.Cache.Port = parseUint(v, cfg.Cache.Port)
}
if v, ok := os.LookupEnv("CACHE_USERNAME"); ok {
cfg.Cache.Username = v
}
if v, ok := os.LookupEnv("CACHE_PASSWORD"); ok {
cfg.Cache.Password = v
}
if v, ok := os.LookupEnv("CACHE_DB"); ok && v != "" {
cfg.Cache.Db = parseUint(v, cfg.Cache.Db)
}
if v, ok := os.LookupEnv("CACHE_TTL"); ok && v != "" {
cfg.Cache.Ttl = parseUint(v, cfg.Cache.Ttl)
}
}
// parseBool разбирает строку в bool, при ошибке возвращает значение по умолчанию
func parseBool(s string, defaultValue bool) bool {
val, err := strconv.ParseBool(s)
if err == nil {
return val
}
return defaultValue
}
// readEnvInteger считывает целочисленную переменную окружения с заданным именем или возвращает значение по умолчанию
func readEnvInteger(name string, defaultValue uint) uint {
valueStr := readEnv(name, "")
value, err := strconv.Atoi(valueStr)
// parseUint разбирает строку в uint, при ошибке возвращает значение по умолчанию
func parseUint(s string, defaultValue uint) uint {
val, err := strconv.Atoi(s)
if err == nil {
return uint(value)
return uint(val)
}
return defaultValue
}
// parseInt разбирает строку в int, при ошибке возвращает значение по умолчанию
func parseInt(s string, defaultValue int) int {
val, err := strconv.Atoi(s)
if err == nil {
return val
}
return defaultValue
}
// parseSecondsToMillis разбирает строку с секундами и возвращает миллисекунды.
// При ошибке возвращает значение по умолчанию.
func parseSecondsToMillis(s string, defaultValue int) int {
val, err := strconv.ParseFloat(s, 64)
if err == nil && val >= 0 {
return int(val * 1000)
}
return defaultValue
}
// applyEnvIntRange читает одно env-значение (в секундах) для IntRange,
// умножая результат на 1000, так как внутри значения хранятся в миллисекундах.
// Одно число задаёт фиксированное значение, два числа через запятую — диапазон.
func applyEnvIntRange(r *IntRange, name string) {
value, ok := os.LookupEnv(name)
if !ok || strings.TrimSpace(value) == "" {
return
}
parse := func(s string) (int, bool) {
s = strings.TrimSpace(s)
v, err := strconv.ParseFloat(s, 64)
if err != nil || v < 0 {
return 0, false
}
return int(v * 1000), true
}
parts := strings.Split(value, ",")
if len(parts) == 1 {
n, ok := parse(parts[0])
if !ok {
return
}
r.Min = n
r.Max = n
return
}
if len(parts) != 2 {
return
}
min, minOk := parse(parts[0])
max, maxOk := parse(parts[1])
if !minOk || !maxOk {
return
}
r.Min = min
r.Max = max
}
// applyEnvStringSlice читает индексированные env-переменные PREFIX_1, PREFIX_2, ...
// Возвращает nil, если ни одна не задана.
func applyEnvStringSlice(prefix string) []string {
var result []string
for i := 1; ; i++ {
key := fmt.Sprintf("%s_%d", prefix, i)
v, ok := os.LookupEnv(key)
if !ok {
break
}
if v != "" {
result = append(result, v)
}
}
return result
}
// validate проверяет значения конфигурации и исправляет некорректные,
// логируя предупреждения для каждого исправления.
func validate(cfg *Config) {
// server
if cfg.Server.Port == 0 || cfg.Server.Port > 65535 {
log.Println("Config: server.port is not in the 0..65535 range, using default 8800")
cfg.Server.Port = 8800
}
if cfg.Site.BaseUrl == "" {
log.Println("Config: site.base-url is empty, using default")
cfg.Site.BaseUrl = fmt.Sprintf("http://localhost:%d", cfg.Server.Port)
}
// cache
if cfg.Cache.Enabled {
if cfg.Cache.Host == "" {
log.Println("Config: cache.host is empty, using default 'localhost'")
cfg.Cache.Host = "localhost"
}
if cfg.Cache.Port == 0 {
log.Println("Config: cache.port is 0, using default 6379")
cfg.Cache.Port = 6379
}
if cfg.Cache.Ttl == 0 {
log.Printf("Config: cache.ttl is 0, using default %d s", defaultCacheTtlSeconds)
cfg.Cache.Ttl = defaultCacheTtlSeconds
}
}
// check.playlists
pp := &cfg.Check.Playlists
if pp.Timeout <= 0 {
log.Printf("Config: check.playlists.timeout <= 0, using default %d s", defaultTimeoutSeconds)
pp.Timeout = defaultTimeoutSeconds * 1000
}
validateIntRange(&pp.AllCooldown, "check.playlists.all-cooldown", 0, 3600000)
validateIntRange(&pp.OneCooldown, "check.playlists.one-cooldown", 0, 3600000)
if pp.MaxRoutines < 1 {
log.Println("Config: check.playlists.max-routines < 1, using default 5")
pp.MaxRoutines = 5
}
if pp.PerRoutine < 1 {
log.Println("Config: check.playlists.per-routine < 1, using default 1")
pp.PerRoutine = 1
}
if len(pp.UserAgent) == 0 {
log.Println("Config: check.playlists.user-agent is empty, using default")
pp.UserAgent = UserAgents{"Mozilla/5.0 WINK/1.31.1 (AndroidTV/9) HlsWinkPlayer"}
}
// check.channels
cc := &cfg.Check.Channels
if cc.Timeout <= 0 {
log.Printf("Config: check.channels.timeout <= 0, using default %d s", defaultTimeoutSeconds)
cc.Timeout = defaultTimeoutSeconds * 1000
}
validateIntRange(&cc.Cooldown, "check.channels.cooldown", 0, 3600000)
if cc.MaxRoutines < 1 {
log.Println("Config: check.channels.max-routines < 1, using default 50")
cc.MaxRoutines = 50
}
if cc.PerRoutine < 1 {
log.Println("Config: check.channels.per-routine < 1, using default 10")
cc.PerRoutine = 10
}
if cc.ByteRange <= 0 {
log.Println("Config: check.channels.byte-range <= 0, using default 512")
cc.ByteRange = 512
}
if len(cc.UserAgent) == 0 {
log.Println("Config: check.channels.user-agent is empty, using default")
cc.UserAgent = UserAgents{"Mozilla/5.0 WINK/1.31.1 (AndroidTV/9) HlsWinkPlayer"}
}
}
// validateIntRange проверяет IntRange: Min <= Max, оба в пределах [minVal, maxVal].
// Некорректные значения исправляются с логированием предупреждения.
func validateIntRange(r *IntRange, name string, minVal, maxVal int) {
if r.Min > r.Max {
log.Printf("Config: %s min=%d > max=%d, swapping", name, r.Min, r.Max)
r.Min, r.Max = r.Max, r.Min
}
if r.Min < minVal {
log.Printf("Config: %s min=%d < %d, clamped", name, r.Min, minVal)
r.Min = minVal
}
if r.Max > maxVal {
log.Printf("Config: %s max=%d > %d, clamped", name, r.Max, maxVal)
r.Max = maxVal
}
if r.Min > r.Max {
r.Min = minVal
r.Max = minVal
}
}
+545
View File
@@ -0,0 +1,545 @@
package config
import (
"os"
"testing"
"gopkg.in/yaml.v3"
)
// --- IntRange ---
func TestIntRangeValue_Scalar(t *testing.T) {
r := IntRange{Min: 5, Max: 5}
if v := r.Value(); v != 5 {
t.Errorf("expected 5, got %d", v)
}
}
func TestIntRangeValue_Range(t *testing.T) {
r := IntRange{Min: 10, Max: 20}
for i := 0; i < 100; i++ {
v := r.Value()
if v < 10 || v > 20 {
t.Errorf("value %d out of [10, 20]", v)
}
}
}
func TestIntRangeUnmarshalYAML_Scalar(t *testing.T) {
yamlData := []byte("timeout: 5000\n")
type wrapper struct {
Timeout int `yaml:"timeout"`
}
var w wrapper
if err := yaml.Unmarshal(yamlData, &w); err != nil {
t.Fatal(err)
}
if w.Timeout != 5000 {
t.Errorf("expected 5000, got %d", w.Timeout)
}
}
func TestIntRangeUnmarshalYAML_FromFullConfig(t *testing.T) {
yamlData := []byte(`
check:
playlists:
all-cooldown: 1
one-cooldown: [2, 3]
channels:
cooldown: [0, 1]
`)
cfg := defaults()
if err := yaml.Unmarshal(yamlData, cfg); err != nil {
t.Fatal(err)
}
if cfg.Check.Playlists.AllCooldown.Min != 1000 || cfg.Check.Playlists.AllCooldown.Max != 1000 {
t.Errorf("scalar all-cooldown: expected Min=Max=1000 ms from 1 s, got Min=%d Max=%d",
cfg.Check.Playlists.AllCooldown.Min, cfg.Check.Playlists.AllCooldown.Max)
}
if cfg.Check.Playlists.OneCooldown.Min != 2000 || cfg.Check.Playlists.OneCooldown.Max != 3000 {
t.Errorf("range one-cooldown: expected [2000,3000] ms from [2,3] s, got [%d,%d]",
cfg.Check.Playlists.OneCooldown.Min, cfg.Check.Playlists.OneCooldown.Max)
}
if cfg.Check.Channels.Cooldown.Min != 0 || cfg.Check.Channels.Cooldown.Max != 1000 {
t.Errorf("range cooldown: expected [0,1000] ms from [0,1] s, got [%d,%d]",
cfg.Check.Channels.Cooldown.Min, cfg.Check.Channels.Cooldown.Max)
}
}
// --- UserAgents ---
func TestUserAgentsPick_Empty(t *testing.T) {
ua := UserAgents{}
if ua.Pick() != "" {
t.Error("expected empty string for empty UserAgents")
}
}
func TestUserAgentsPick_Single(t *testing.T) {
ua := UserAgents{"Mozilla"}
if ua.Pick() != "Mozilla" {
t.Errorf("expected 'Mozilla', got '%s'", ua.Pick())
}
}
func TestUserAgentsPick_Multiple(t *testing.T) {
ua := UserAgents{"A", "B", "C"}
for i := 0; i < 100; i++ {
v := ua.Pick()
found := false
for _, s := range ua {
if v == s {
found = true
break
}
}
if !found {
t.Errorf("Pick returned '%s' which is not in list", v)
}
}
}
func TestUserAgentsUnmarshalYAML_Scalar(t *testing.T) {
yamlData := []byte(`
check:
channels:
user-agent: Mozilla/5.0
`)
cfg := defaults()
if err := yaml.Unmarshal(yamlData, cfg); err != nil {
t.Fatal(err)
}
if len(cfg.Check.Channels.UserAgent) != 1 || cfg.Check.Channels.UserAgent[0] != "Mozilla/5.0" {
t.Errorf("expected single 'Mozilla/5.0', got %v", cfg.Check.Channels.UserAgent)
}
}
func TestUserAgentsUnmarshalYAML_Array(t *testing.T) {
yamlData := []byte(`
check:
channels:
user-agent:
- Mozilla/5.0
- curl/8.0
`)
cfg := defaults()
if err := yaml.Unmarshal(yamlData, cfg); err != nil {
t.Fatal(err)
}
if len(cfg.Check.Channels.UserAgent) != 2 {
t.Errorf("expected 2 user-agents, got %d", len(cfg.Check.Channels.UserAgent))
}
}
// --- parseBool / parseUint / parseInt ---
func TestParseBool(t *testing.T) {
cases := []struct {
in string
def bool
want bool
}{
{"true", false, true},
{"1", false, true},
{"false", true, false},
{"0", true, false},
{"yes", false, false},
{"", true, true},
}
for _, c := range cases {
got := parseBool(c.in, c.def)
if got != c.want {
t.Errorf("parseBool(%q, %v) = %v, want %v", c.in, c.def, got, c.want)
}
}
}
func TestParseUint(t *testing.T) {
cases := []struct {
in string
def uint
want uint
}{
{"8800", 0, 8800},
{"0", 999, 0},
{"abc", 999, 999},
{"", 999, 999},
}
for _, c := range cases {
got := parseUint(c.in, c.def)
if got != c.want {
t.Errorf("parseUint(%q, %d) = %d, want %d", c.in, c.def, got, c.want)
}
}
}
func TestParseInt(t *testing.T) {
cases := []struct {
in string
def int
want int
}{
{"100", 0, 100},
{"-5", 0, -5},
{"abc", 999, 999},
{"", 999, 999},
}
for _, c := range cases {
got := parseInt(c.in, c.def)
if got != c.want {
t.Errorf("parseInt(%q, %d) = %d, want %d", c.in, c.def, got, c.want)
}
}
}
// --- applyEnvIntRange ---
func TestApplyEnvIntRange_Scalar(t *testing.T) {
t.Setenv("TEST_COOLDOWN", "1.5")
r := IntRange{Min: 0, Max: 0}
applyEnvIntRange(&r, "TEST_COOLDOWN")
if r.Min != 1500 || r.Max != 1500 {
t.Errorf("expected Min=Max=1500, got Min=%d Max=%d", r.Min, r.Max)
}
}
func TestApplyEnvIntRange_Range(t *testing.T) {
t.Setenv("TEST_COOLDOWN", "1,2")
r := IntRange{Min: 0, Max: 0}
applyEnvIntRange(&r, "TEST_COOLDOWN")
if r.Min != 1000 || r.Max != 2000 {
t.Errorf("expected [1000,2000], got [%d,%d]", r.Min, r.Max)
}
}
func TestApplyEnvIntRange_Invalid(t *testing.T) {
t.Setenv("TEST_COOLDOWN", "1,invalid")
r := IntRange{Min: 5, Max: 5}
applyEnvIntRange(&r, "TEST_COOLDOWN")
if r.Min != 5 || r.Max != 5 {
t.Errorf("expected unchanged [5,5], got [%d,%d]", r.Min, r.Max)
}
}
func TestApplyEnvIntRange_None(t *testing.T) {
r := IntRange{Min: 7, Max: 7}
applyEnvIntRange(&r, "TEST_COOLDOWN")
if r.Min != 7 || r.Max != 7 {
t.Errorf("expected unchanged [7,7], got [%d,%d]", r.Min, r.Max)
}
}
// --- applyEnvStringSlice ---
func TestApplyEnvStringSlice_Multiple(t *testing.T) {
t.Setenv("MY_PREFIX_1", "foo")
t.Setenv("MY_PREFIX_2", "bar")
t.Setenv("MY_PREFIX_3", "baz")
result := applyEnvStringSlice("MY_PREFIX")
if len(result) != 3 {
t.Fatalf("expected 3 items, got %d", len(result))
}
if result[0] != "foo" || result[1] != "bar" || result[2] != "baz" {
t.Errorf("unexpected result: %v", result)
}
}
func TestApplyEnvStringSlice_EmptyValue(t *testing.T) {
t.Setenv("MY_PREFIX_1", "foo")
t.Setenv("MY_PREFIX_2", "")
t.Setenv("MY_PREFIX_3", "baz")
result := applyEnvStringSlice("MY_PREFIX")
if len(result) != 2 {
t.Fatalf("expected 2 items (empty skipped), got %d: %v", len(result), result)
}
if result[0] != "foo" || result[1] != "baz" {
t.Errorf("unexpected result: %v", result)
}
}
func TestApplyEnvStringSlice_None(t *testing.T) {
result := applyEnvStringSlice("NONEXISTENT_PREFIX")
if result != nil {
t.Errorf("expected nil, got %v", result)
}
}
// --- validate ---
func TestValidate_ServerPort(t *testing.T) {
cfg := defaults()
cfg.Server.Port = 0
validate(cfg)
if cfg.Server.Port != 8800 {
t.Errorf("expected 8800, got %d", cfg.Server.Port)
}
}
func TestValidate_ServerPortTooHigh(t *testing.T) {
cfg := defaults()
cfg.Server.Port = 99999
validate(cfg)
if cfg.Server.Port != 8800 {
t.Errorf("expected 8800, got %d", cfg.Server.Port)
}
}
func TestValidate_BaseUrlEmpty(t *testing.T) {
cfg := defaults()
cfg.Site.BaseUrl = ""
cfg.Server.Port = 3000
validate(cfg)
if cfg.Site.BaseUrl != "http://localhost:3000" {
t.Errorf("expected http://localhost:3000, got %s", cfg.Site.BaseUrl)
}
}
func TestValidate_CacheEnabled(t *testing.T) {
cfg := defaults()
cfg.Cache.Enabled = true
cfg.Cache.Host = ""
cfg.Cache.Port = 0
cfg.Cache.Ttl = 0
validate(cfg)
if cfg.Cache.Host != "localhost" {
t.Errorf("expected localhost, got %s", cfg.Cache.Host)
}
if cfg.Cache.Port != 6379 {
t.Errorf("expected 6379, got %d", cfg.Cache.Port)
}
if cfg.Cache.Ttl != 30 {
t.Errorf("expected 30, got %d", cfg.Cache.Ttl)
}
}
func TestValidate_PlaylistsTimeout(t *testing.T) {
cfg := defaults()
cfg.Check.Playlists.Timeout = 0
validate(cfg)
if cfg.Check.Playlists.Timeout != 10000 {
t.Errorf("expected 10000, got %d", cfg.Check.Playlists.Timeout)
}
}
func TestValidate_ChannelsTimeout(t *testing.T) {
cfg := defaults()
cfg.Check.Channels.Timeout = -5
validate(cfg)
if cfg.Check.Channels.Timeout != 10000 {
t.Errorf("expected 10000, got %d", cfg.Check.Channels.Timeout)
}
}
func TestValidate_PlaylistsMaxRoutines(t *testing.T) {
cfg := defaults()
cfg.Check.Playlists.MaxRoutines = 0
validate(cfg)
if cfg.Check.Playlists.MaxRoutines != 5 {
t.Errorf("expected 5, got %d", cfg.Check.Playlists.MaxRoutines)
}
}
func TestValidate_ChannelsMaxRoutines(t *testing.T) {
cfg := defaults()
cfg.Check.Channels.MaxRoutines = 0
validate(cfg)
if cfg.Check.Channels.MaxRoutines != 50 {
t.Errorf("expected 50, got %d", cfg.Check.Channels.MaxRoutines)
}
}
func TestValidate_ChannelsByteRange(t *testing.T) {
cfg := defaults()
cfg.Check.Channels.ByteRange = 0
validate(cfg)
if cfg.Check.Channels.ByteRange != 512 {
t.Errorf("expected 512, got %d", cfg.Check.Channels.ByteRange)
}
}
func TestValidate_UserAgentEmpty(t *testing.T) {
cfg := defaults()
cfg.Check.Playlists.UserAgent = nil
cfg.Check.Channels.UserAgent = nil
validate(cfg)
if len(cfg.Check.Playlists.UserAgent) == 0 {
t.Error("expected non-empty playlists user-agent")
}
if len(cfg.Check.Channels.UserAgent) == 0 {
t.Error("expected non-empty channels user-agent")
}
}
// --- validateIntRange ---
func TestValidateIntRange_Swap(t *testing.T) {
r := IntRange{Min: 100, Max: 10}
validateIntRange(&r, "test", 0, 1000)
if r.Min != 10 || r.Max != 100 {
t.Errorf("expected [10,100] after swap, got [%d,%d]", r.Min, r.Max)
}
}
func TestValidateIntRange_ClampMin(t *testing.T) {
r := IntRange{Min: -5, Max: 10}
validateIntRange(&r, "test", 0, 1000)
if r.Min != 0 {
t.Errorf("expected Min=0 after clamp, got %d", r.Min)
}
}
func TestValidateIntRange_ClampMax(t *testing.T) {
r := IntRange{Min: 0, Max: 999999}
validateIntRange(&r, "test", 0, 1000)
if r.Max != 1000 {
t.Errorf("expected Max=1000 after clamp, got %d", r.Max)
}
}
func TestValidateIntRange_SwapThenClamp(t *testing.T) {
r := IntRange{Min: 2000, Max: -10}
validateIntRange(&r, "test", 0, 1000)
// swap → [10, 2000], clamp → [0, 1000]
if r.Min != 0 || r.Max != 1000 {
t.Errorf("expected [0,1000], got [%d,%d]", r.Min, r.Max)
}
}
// --- defaults ---
func TestDefaults(t *testing.T) {
cfg := defaults()
if cfg.App.Timezone != "GMT" {
t.Errorf("expected GMT, got %s", cfg.App.Timezone)
}
if cfg.Server.Port != 8800 {
t.Errorf("expected 8800, got %d", cfg.Server.Port)
}
if cfg.Check.Playlists.Timeout != 10000 {
t.Errorf("expected 10000 ms, got %d", cfg.Check.Playlists.Timeout)
}
if cfg.Check.Channels.Timeout != 10000 {
t.Errorf("expected 10000 ms, got %d", cfg.Check.Channels.Timeout)
}
if cfg.Cache.Port != 6379 {
t.Errorf("expected 6379, got %d", cfg.Cache.Port)
}
}
// --- Init (integration) ---
func TestInit_NoConfigFile(t *testing.T) {
cfg := Init("/nonexistent/config.yml")
if cfg == nil {
t.Fatal("expected non-nil config")
}
if cfg.Server.Port != 8800 {
t.Errorf("expected default port 8800, got %d", cfg.Server.Port)
}
}
func TestInit_WithConfigFile(t *testing.T) {
tmp := t.TempDir() + "/config.yml"
data := []byte(`
app:
timezone: UTC+3
debug: true
server:
port: 3000
host: 0.0.0.0
check:
playlists:
timeout: 5
all-cooldown: [1, 2]
channels:
byte-range: 1024
cache:
enabled: true
host: db.local
port: 6380
`)
if err := os.WriteFile(tmp, data, 0644); err != nil {
t.Fatal(err)
}
cfg := Init(tmp)
if cfg.App.Timezone != "UTC+3" {
t.Errorf("expected UTC+3, got %s", cfg.App.Timezone)
}
if !cfg.App.Debug {
t.Error("expected debug=true")
}
if cfg.Server.Port != 3000 {
t.Errorf("expected 3000, got %d", cfg.Server.Port)
}
if cfg.Server.Host != "0.0.0.0" {
t.Errorf("expected 0.0.0.0, got %s", cfg.Server.Host)
}
if cfg.Check.Playlists.Timeout != 5000 {
t.Errorf("expected 5000 ms from 5 s, got %d", cfg.Check.Playlists.Timeout)
}
if cfg.Check.Playlists.AllCooldown.Min != 1000 || cfg.Check.Playlists.AllCooldown.Max != 2000 {
t.Errorf("expected [1000,2000] ms from [1,2] s, got [%d,%d]",
cfg.Check.Playlists.AllCooldown.Min, cfg.Check.Playlists.AllCooldown.Max)
}
if cfg.Check.Channels.ByteRange != 1024 {
t.Errorf("expected 1024, got %d", cfg.Check.Channels.ByteRange)
}
if !cfg.Cache.Enabled {
t.Error("expected cache enabled")
}
if cfg.Cache.Host != "db.local" {
t.Errorf("expected db.local, got %s", cfg.Cache.Host)
}
if cfg.Cache.Port != 6380 {
t.Errorf("expected 6380, got %d", cfg.Cache.Port)
}
}
func TestInit_EnvOverridesConfig(t *testing.T) {
tmp := t.TempDir() + "/config.yml"
data := []byte("server:\n port: 3000\n")
if err := os.WriteFile(tmp, data, 0644); err != nil {
t.Fatal(err)
}
t.Setenv("SERVER_PORT", "9090")
t.Setenv("APP_DEBUG", "true")
t.Setenv("CHECK_PLAYLISTS_TIMEOUT", "7")
t.Setenv("CHECK_PLAYLISTS_USER_AGENT_1", "CustomUA")
t.Setenv("CHECK_PLAYLISTS_ALL_COOLDOWN", "1.5,2.5")
cfg := Init(tmp)
if cfg.Server.Port != 9090 {
t.Errorf("expected 9090 from env, got %d", cfg.Server.Port)
}
if !cfg.App.Debug {
t.Error("expected debug=true from env")
}
if cfg.Check.Playlists.Timeout != 7000 {
t.Errorf("expected 7000 ms from 7 s env, got %d", cfg.Check.Playlists.Timeout)
}
if len(cfg.Check.Playlists.UserAgent) != 1 || cfg.Check.Playlists.UserAgent[0] != "CustomUA" {
t.Errorf("expected [CustomUA], got %v", cfg.Check.Playlists.UserAgent)
}
if cfg.Check.Playlists.AllCooldown.Min != 1500 || cfg.Check.Playlists.AllCooldown.Max != 2500 {
t.Errorf("expected [1500,2500] ms from [1.5,2.5] s env, got [%d,%d]",
cfg.Check.Playlists.AllCooldown.Min, cfg.Check.Playlists.AllCooldown.Max)
}
}
func TestInit_EnvScalarCooldownOverridesRange(t *testing.T) {
tmp := t.TempDir() + "/config.yml"
data := []byte("check:\n playlists:\n all-cooldown: [1, 2]\n")
if err := os.WriteFile(tmp, data, 0644); err != nil {
t.Fatal(err)
}
t.Setenv("CHECK_PLAYLISTS_ALL_COOLDOWN", "2.5")
cfg := Init(tmp)
if cfg.Check.Playlists.AllCooldown.Min != 2500 || cfg.Check.Playlists.AllCooldown.Max != 2500 {
t.Errorf("expected scalar [2500,2500] ms from 2.5 s env, got [%d,%d]",
cfg.Check.Playlists.AllCooldown.Min, cfg.Check.Playlists.AllCooldown.Max)
}
}
// helper removed — using yaml.Unmarshal directly
+3 -2
View File
@@ -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 плейлистами
+91
View File
@@ -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 -1
View File
@@ -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
View File
@@ -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{}
}
}
}
+266
View File
@@ -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 -1
View File
@@ -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
*/
+163
View File
@@ -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
View File
@@ -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
+155
View File
@@ -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")
}
}
+255
View File
@@ -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()
}
+535
View File
@@ -0,0 +1,535 @@
/*
* Copyright (c) 2025-2026, Антон Аксенов
* This file is part of iptvc project
* MIT License: https://git.axenov.dev/IPTV/iptvc/src/branch/master/LICENSE
*/
package web
import (
"axenov/iptv-checker/app"
"axenov/iptv-checker/app/playlist"
"encoding/json"
"fmt"
"log"
"math"
"net/http"
"strconv"
"strings"
"time"
)
// handleHome — главная страница со списком плейлистов
func (s *Server) handleHome(w http.ResponseWriter, r *http.Request) {
playlists, updatedAt, err := s.getPlaylistsView()
errorMsg := ""
if err != nil {
log.Printf("Error loading playlists: %s", err)
errorMsg = "Не удалось загрузить список плейлистов. Проверьте наличие файла playlists.ini."
playlists = []PlaylistView{}
updatedAt = ""
}
count := len(playlists)
pageSize := int(s.cfg.Site.PageSize)
pageCurrent := 1
pageCount := 1
// pagination
if pageSize > 0 && count > pageSize {
pageCount = int(math.Ceil(float64(count) / float64(pageSize)))
if pageArg := r.PathValue("page"); pageArg != "" {
if p, err := strconv.Atoi(pageArg); err == nil && p >= 1 && p <= pageCount {
pageCurrent = p
}
}
offset := (pageCurrent - 1) * pageSize
if offset >= count {
offset = 0
pageCurrent = 1
}
end := offset + pageSize
if end > count {
end = count
}
playlists = playlists[offset:end]
}
data := &PageData{
Title: s.cfg.Site.Header.Title,
BaseUrl: s.cfg.Site.BaseUrl,
RepoUrl: s.cfg.Site.RepoUrl,
Version: app.VERSION,
UpdatedAt: updatedAt,
UpdatedAtTZ: s.getTimezoneLabel(),
Meta: s.cfg.Site.Meta,
Menu: s.cfg.Site.Header.Menu,
FooterLinks: s.cfg.Site.Footer.Links,
Tabs: s.cfg.Site.Tabs,
Playlists: playlists,
Count: count,
PageCount: pageCount,
PageCurrent: pageCurrent,
Error: errorMsg,
}
s.templates.Render(w, "list", data)
}
// handleDetails — страница с описанием плейлиста
func (s *Server) handleDetails(w http.ResponseWriter, r *http.Request) {
code := r.PathValue("code")
if code == "" {
s.renderNotFound(w, r, "")
return
}
view, ok := s.getPlaylistView(code)
if !ok {
s.renderNotFound(w, r, code)
return
}
data := &PageData{
Title: s.cfg.Site.Header.Title,
BaseUrl: s.cfg.Site.BaseUrl,
RepoUrl: s.cfg.Site.RepoUrl,
Version: app.VERSION,
Meta: s.cfg.Site.Meta,
Menu: s.cfg.Site.Header.Menu,
FooterLinks: s.cfg.Site.Footer.Links,
Tabs: s.cfg.Site.Tabs,
Playlist: view,
}
s.templates.Render(w, "details", data)
}
// handleRoute — единый обработчик для не-API маршрутов
// Разбирает путь и направляет на нужный обработчик:
// /page/N → главная с пагинацией
// /CODE/details → страница плейлиста
// /CODE или /CODE.m3u[8] → редирект на URL плейлиста
// остальное → 404
func (s *Server) handleRoute(w http.ResponseWriter, r *http.Request) {
path := r.PathValue("path")
if path == "" {
s.handleHome(w, r)
return
}
segments := strings.Split(path, "/")
switch len(segments) {
case 1:
// /page/N → пагинация
if segments[0] == "page" {
s.renderNotFound(w, r, "")
return
}
if strings.HasPrefix(segments[0], "page/") {
// shouldn't happen with single segment, but just in case
pageStr := strings.TrimPrefix(segments[0], "page/")
if _, err := strconv.Atoi(pageStr); err == nil {
r.SetPathValue("page", pageStr)
s.handleHome(w, r)
return
}
}
// /CODE or /CODE.m3u[8] → redirect
s.handleRedirect(w, r, segments[0])
case 2:
// /page/N → пагинация
if segments[0] == "page" {
if _, err := strconv.Atoi(segments[1]); err == nil {
r.SetPathValue("page", segments[1])
s.handleHome(w, r)
return
}
s.renderNotFound(w, r, "")
return
}
// /CODE/details → страница плейлиста
if segments[1] == "details" {
r.SetPathValue("code", segments[0])
s.handleDetails(w, r)
return
}
s.renderNotFound(w, r, segments[0])
default:
s.renderNotFound(w, r, "")
}
}
// handleRedirect — перенаправляет на URL плейлиста по коду
func (s *Server) handleRedirect(w http.ResponseWriter, r *http.Request, path string) {
// strip .m3u / .m3u8 suffix
code := path
code = strings.TrimSuffix(code, ".m3u8")
code = strings.TrimSuffix(code, ".m3u")
// validate: alphanumeric
if !isAlphanumeric(code) {
s.renderNotFound(w, r, code)
return
}
// look up in ini
ini, err := s.loadIni()
if err != nil {
s.renderNotFound(w, r, code)
return
}
pls, exists := ini.Lists[code]
if !exists || pls.Url == "" {
s.renderNotFound(w, r, code)
return
}
http.Redirect(w, r, pls.Url, http.StatusFound)
}
// handleAPIGetAll — API: список плейлистов
func (s *Server) handleAPIGetAll(w http.ResponseWriter, r *http.Request) {
playlists, _, err := s.getPlaylistsView()
if err != nil {
s.jsonError(w, http.StatusInternalServerError, err)
return
}
items := make([]map[string]interface{}, 0, len(playlists))
for _, view := range playlists {
items = append(items, buildAPIPlaylist(view))
}
s.jsonResponse(w, http.StatusOK, map[string]interface{}{
"items": items,
})
}
// handleAPIGetOne — API: информация о плейлисте
func (s *Server) handleAPIGetOne(w http.ResponseWriter, r *http.Request) {
code := r.PathValue("code")
if code == "" {
s.jsonError(w, http.StatusBadRequest, fmt.Errorf("code is required"))
return
}
view, ok := s.getPlaylistView(code)
if !ok {
s.jsonError(w, http.StatusNotFound, fmt.Errorf("playlist '%s' not found", code))
return
}
s.jsonResponse(w, http.StatusOK, buildAPIPlaylist(*view))
}
// handleAPIGetChannels — API: каналы плейлиста
func (s *Server) handleAPIGetChannels(w http.ResponseWriter, r *http.Request) {
code := r.PathValue("code")
if code == "" {
s.jsonError(w, http.StatusBadRequest, fmt.Errorf("code is required"))
return
}
view, ok := s.getPlaylistView(code)
if !ok {
s.jsonError(w, http.StatusNotFound, fmt.Errorf("playlist '%s' not found", code))
return
}
channels := make([]map[string]interface{}, 0, len(view.Channels))
for _, ch := range view.ViewChannels {
channels = append(channels, map[string]interface{}{
"id": ch.Id,
"title": ch.Title,
"url": ch.URL,
"groupId": ch.GroupId,
"attributes": mapToAttributeArray(ch.Attributes),
"status": ch.Status,
"isOnline": ch.IsOnline,
"error": ch.Error,
"contentType": ch.ContentType,
"tags": ch.Tags,
"checkedAt": ch.CheckedAt,
"hasToken": ch.HasToken,
})
}
s.jsonResponse(w, http.StatusOK, map[string]interface{}{
"items": channels,
})
}
// buildAPIPlaylist формирует JSON-представление плейлиста для API
func buildAPIPlaylist(view PlaylistView) map[string]interface{} {
resp := map[string]interface{}{
"code": view.Code,
"name": view.Name,
"description": view.Description,
"url": view.Url,
"source": view.Source,
"isOnline": nil,
"onlineCount": view.OnlineCount,
"offlineCount": view.OfflineCount,
"checkedAt": view.CheckedAt,
"attributes": mapToAttributeArray(view.Attributes),
"groups": groupsToArray(view.Groups),
"tags": view.Tags,
}
if view.IsKnown {
resp["isOnline"] = view.IsOnline
if view.IsOnline {
delete(resp, "content")
} else {
resp["content"] = view.Content
}
}
return resp
}
// mapToAttributeArray преобразует map атрибутов в массив {name, value}
func mapToAttributeArray(attrs map[string]string) []map[string]string {
result := make([]map[string]string, 0, len(attrs))
for name, value := range attrs {
result = append(result, map[string]string{
"name": name,
"value": value,
})
}
return result
}
// groupsToArray преобразует map групп в массив объектов
func groupsToArray(groups map[string]playlist.Group) []map[string]interface{} {
result := make([]map[string]interface{}, 0, len(groups))
for _, g := range groups {
result = append(result, map[string]interface{}{
"id": g.Id,
"name": g.Name,
"attributes": mapToAttributeArray(g.Attributes),
})
}
return result
}
// handleAPIVersion — API: версия
func (s *Server) handleAPIVersion(w http.ResponseWriter, r *http.Request) {
resp := map[string]interface{}{
"iptvc": app.VERSION,
}
if s.cache != nil {
ctx := r.Context()
info, err := s.cache.Info(ctx, "server").Result()
if err == nil {
resp["keydb"] = parseRedisVersion(info)
}
}
s.jsonResponse(w, http.StatusOK, resp)
}
// handleAPIHealth — API: состояние
func (s *Server) handleAPIHealth(w http.ResponseWriter, r *http.Request) {
health := map[string]interface{}{
"timestamp": time.Now().Unix(),
}
if s.cache != nil {
ctx := r.Context()
health["redis"] = map[string]interface{}{
"isConnected": s.cache.Ping(ctx).Err() == nil,
}
} else {
health["redis"] = map[string]interface{}{
"isConnected": false,
}
}
health["iniFile"] = map[string]interface{}{
"path": s.cfg.App.Playlists,
}
s.jsonResponse(w, http.StatusOK, health)
}
// handleAPIStats — API: статистика
func (s *Server) handleAPIStats(w http.ResponseWriter, r *http.Request) {
playlists, _, err := s.getPlaylistsView()
if err != nil {
s.jsonError(w, http.StatusInternalServerError, err)
return
}
stats := computeStats(playlists)
s.jsonResponse(w, http.StatusOK, stats)
}
// renderNotFound — отображает страницу 404
func (s *Server) renderNotFound(w http.ResponseWriter, r *http.Request, code string) {
w.WriteHeader(http.StatusNotFound)
data := &PageData{
Title: s.cfg.Site.Header.Title,
BaseUrl: s.cfg.Site.BaseUrl,
RepoUrl: s.cfg.Site.RepoUrl,
Version: app.VERSION,
Meta: s.cfg.Site.Meta,
Menu: s.cfg.Site.Header.Menu,
FooterLinks: s.cfg.Site.Footer.Links,
Tabs: s.cfg.Site.Tabs,
Code: code,
}
s.templates.Render(w, "notfound", data)
}
// jsonResponse отправляет JSON-ответ
func (s *Server) jsonResponse(w http.ResponseWriter, status int, data interface{}) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
resp := map[string]interface{}{
"timestamp": time.Now().Unix(),
}
if m, ok := data.(map[string]interface{}); ok {
for k, v := range m {
resp[k] = v
}
} else {
resp["data"] = data
}
json.NewEncoder(w).Encode(resp)
}
// jsonError отправляет JSON-ошибку
func (s *Server) jsonError(w http.ResponseWriter, status int, err error) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(map[string]interface{}{
"timestamp": time.Now().Unix(),
"error": map[string]string{
"message": err.Error(),
},
})
}
// isAlphanumeric проверяет, состоит ли строка только из букв и цифр
func isAlphanumeric(s string) bool {
for _, c := range s {
if !((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9')) {
return false
}
}
return len(s) > 0
}
// parseRedisVersion извлекает версию Redis из INFO-ответа
func parseRedisVersion(info string) string {
for _, line := range strings.Split(info, "\n") {
line = strings.TrimSpace(line)
if strings.HasPrefix(line, "redis_version:") {
return strings.TrimPrefix(line, "redis_version:")
}
}
return "unknown"
}
// computeStats вычисляет статистику по плейлистам и каналам
func computeStats(playlists []PlaylistView) map[string]interface{} {
plsOnline := 0
plsOffline := 0
plsUnknown := 0
plsAdult := 0
plsCatchup := 0
plsTvg := 0
plsGroupped := 0
channelsAll := 0
channelsOnline := 0
channelsOffline := 0
channelsAdult := 0
var latestCode string
var latestTime int64
for _, pls := range playlists {
if !pls.IsKnown {
plsUnknown++
} else if pls.IsOnline {
plsOnline++
} else {
plsOffline++
}
if containsTag(pls.Tags, "adult") {
plsAdult++
}
if pls.HasCatchup {
plsCatchup++
}
if pls.HasTvg {
plsTvg++
}
if len(pls.Groups) > 0 {
plsGroupped++
}
if pls.CheckedAt > latestTime {
latestTime = pls.CheckedAt
latestCode = pls.Code
}
for _, ch := range pls.Channels {
channelsAll++
if ch.IsOnline {
channelsOnline++
} else {
channelsOffline++
}
if containsTag(ch.Tags, "adult") {
channelsAdult++
}
}
}
latest := map[string]interface{}{
"code": latestCode,
"time": latestTime,
}
if latestTime > 0 {
latest["timeFmt"] = time.Unix(latestTime, 0).Format("15:04:05 02.01.2006")
}
return map[string]interface{}{
"playlists": map[string]interface{}{
"all": len(playlists),
"online": plsOnline,
"offline": plsOffline,
"unknown": plsUnknown,
"adult": plsAdult,
"hasCatchup": plsCatchup,
"hasTvg": plsTvg,
"groupped": plsGroupped,
"latest": latest,
},
"channels": map[string]interface{}{
"all": channelsAll,
"online": channelsOnline,
"offline": channelsOffline,
"adult": channelsAdult,
},
}
}
// containsTag проверяет наличие тега в списке
func containsTag(tags []string, tag string) bool {
for _, t := range tags {
if t == tag {
return true
}
}
return false
}
+444
View File
@@ -0,0 +1,444 @@
/*
* Copyright (c) 2025-2026, Антон Аксенов
* This file is part of iptvc project
* MIT License: https://git.axenov.dev/IPTV/iptvc/src/branch/master/LICENSE
*/
package web
import (
"axenov/iptv-checker/app"
"embed"
"encoding/json"
"io/fs"
"net/http"
)
//go:embed swagger
var swaggerFS embed.FS
//go:embed static
var staticFS embed.FS
// handleOpenAPISpec — отдаёт динамически сгенерированную OpenAPI 3.0 спецификацию
func (s *Server) handleOpenAPISpec(w http.ResponseWriter, r *http.Request) {
spec := s.generateOpenAPISpec()
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(spec)
}
// swaggerFileServer раздаёт встроенные файлы Swagger UI (CSS, JS, HTML)
var swaggerFileServer http.Handler
// staticFileServer раздаёт встроенные статические файлы (JS, CSS)
var staticFileServer http.Handler
func init() {
sub, err := fs.Sub(swaggerFS, "swagger")
if err != nil {
panic("cannot create sub-FS for swagger: " + err.Error())
}
swaggerFileServer = http.FileServer(http.FS(sub))
staticSub, err := fs.Sub(staticFS, "static")
if err != nil {
panic("cannot create sub-FS for static: " + err.Error())
}
staticFileServer = http.FileServer(http.FS(staticSub))
}
// generateOpenAPISpec строит OpenAPI 3.0.3 спецификацию динамически
func (s *Server) generateOpenAPISpec() map[string]interface{} {
return map[string]interface{}{
"openapi": "3.0.3",
"info": map[string]interface{}{
"title": s.cfg.Site.Header.Title + " API",
"version": app.VERSION,
"description": "API для проверки IPTV-плейлистов и доступности каналов.",
"license": map[string]interface{}{
"name": "MIT",
"url": "https://git.axenov.dev/IPTV/iptvc/src/branch/master/LICENSE",
},
},
"servers": []map[string]interface{}{
{
"url": s.cfg.Site.BaseUrl,
"description": "Текущий сервер",
},
},
"paths": map[string]interface{}{
"/api/playlists": map[string]interface{}{
"get": map[string]interface{}{
"tags": []string{"Playlists"},
"summary": "Список плейлистов",
"description": "Возвращает список всех плейлистов из ini-файла с результатами проверки.",
"responses": map[string]interface{}{
"200": map[string]interface{}{
"description": "Список плейлистов",
"content": map[string]interface{}{
"application/json": map[string]interface{}{
"schema": map[string]interface{}{"$ref": "#/components/schemas/PlaylistListResponse"},
},
},
},
"500": map[string]interface{}{
"description": "Ошибка загрузки плейлистов",
"content": map[string]interface{}{
"application/json": map[string]interface{}{
"schema": map[string]interface{}{"$ref": "#/components/schemas/ErrorResponse"},
},
},
},
},
},
},
"/api/playlists/{code}": map[string]interface{}{
"get": map[string]interface{}{
"tags": []string{"Playlists"},
"summary": "Информация о плейлисте",
"description": "Возвращает информацию о плейлисте по его коду, включая группы, атрибуты и результаты проверки.",
"parameters": []map[string]interface{}{
{
"name": "code",
"in": "path",
"required": true,
"description": "Код плейлиста (из ini-файла)",
"schema": map[string]interface{}{"type": "string"},
},
},
"responses": map[string]interface{}{
"200": map[string]interface{}{
"description": "Информация о плейлисте",
"content": map[string]interface{}{
"application/json": map[string]interface{}{
"schema": map[string]interface{}{"$ref": "#/components/schemas/PlaylistResponse"},
},
},
},
"400": map[string]interface{}{
"description": "Код плейлиста не передан",
"content": map[string]interface{}{
"application/json": map[string]interface{}{
"schema": map[string]interface{}{"$ref": "#/components/schemas/ErrorResponse"},
},
},
},
"404": map[string]interface{}{
"description": "Плейлист не найден",
"content": map[string]interface{}{
"application/json": map[string]interface{}{
"schema": map[string]interface{}{"$ref": "#/components/schemas/ErrorResponse"},
},
},
},
},
},
},
"/api/playlists/{code}/channels": map[string]interface{}{
"get": map[string]interface{}{
"tags": []string{"Playlists"},
"summary": "Каналы плейлиста",
"description": "Возвращает каналы плейлиста по его коду.",
"parameters": []map[string]interface{}{
{
"name": "code",
"in": "path",
"required": true,
"description": "Код плейлиста (из ini-файла)",
"schema": map[string]interface{}{"type": "string"},
},
},
"responses": map[string]interface{}{
"200": map[string]interface{}{
"description": "Каналы плейлиста",
"content": map[string]interface{}{
"application/json": map[string]interface{}{
"schema": map[string]interface{}{"$ref": "#/components/schemas/ChannelListResponse"},
},
},
},
"400": map[string]interface{}{
"description": "Код плейлиста не передан",
"content": map[string]interface{}{
"application/json": map[string]interface{}{
"schema": map[string]interface{}{"$ref": "#/components/schemas/ErrorResponse"},
},
},
},
"404": map[string]interface{}{
"description": "Плейлист не найден",
"content": map[string]interface{}{
"application/json": map[string]interface{}{
"schema": map[string]interface{}{"$ref": "#/components/schemas/ErrorResponse"},
},
},
},
},
},
},
"/api/version": map[string]interface{}{
"get": map[string]interface{}{
"tags": []string{"System"},
"summary": "Версии компонентов",
"description": "Возвращает версию iptvc и (при наличии) версию KeyDB/Redis.",
"responses": map[string]interface{}{
"200": map[string]interface{}{
"description": "Информация о версиях",
"content": map[string]interface{}{
"application/json": map[string]interface{}{
"schema": map[string]interface{}{"$ref": "#/components/schemas/VersionResponse"},
},
},
},
},
},
},
"/api/health": map[string]interface{}{
"get": map[string]interface{}{
"tags": []string{"System"},
"summary": "Состояние сервиса",
"description": "Возвращает состояние подключения к KeyDB/Redis и путь к ini-файлу.",
"responses": map[string]interface{}{
"200": map[string]interface{}{
"description": "Состояние сервиса",
"content": map[string]interface{}{
"application/json": map[string]interface{}{
"schema": map[string]interface{}{"$ref": "#/components/schemas/HealthResponse"},
},
},
},
},
},
},
"/api/stats": map[string]interface{}{
"get": map[string]interface{}{
"tags": []string{"Statistics"},
"summary": "Статистика",
"description": "Возвращает агрегированную статистику по плейлистам и каналам.",
"responses": map[string]interface{}{
"200": map[string]interface{}{
"description": "Статистика",
"content": map[string]interface{}{
"application/json": map[string]interface{}{
"schema": map[string]interface{}{"$ref": "#/components/schemas/StatsResponse"},
},
},
},
"500": map[string]interface{}{
"description": "Ошибка загрузки плейлистов",
"content": map[string]interface{}{
"application/json": map[string]interface{}{
"schema": map[string]interface{}{"$ref": "#/components/schemas/ErrorResponse"},
},
},
},
},
},
},
},
"components": map[string]interface{}{
"schemas": map[string]interface{}{
"PlaylistListResponse": map[string]interface{}{
"type": "object",
"description": "Список плейлистов",
"properties": map[string]interface{}{
"timestamp": map[string]interface{}{"type": "integer", "description": "UNIX timestamp ответа"},
"items": map[string]interface{}{
"type": "array",
"items": map[string]interface{}{"$ref": "#/components/schemas/Playlist"},
"description": "Массив плейлистов",
},
},
},
"PlaylistResponse": map[string]interface{}{
"type": "object",
"description": "Информация о плейлисте с результатами проверки",
"allOf": []map[string]interface{}{
{"$ref": "#/components/schemas/Playlist"},
},
},
"Playlist": map[string]interface{}{
"type": "object",
"description": "Информация о плейлисте",
"properties": map[string]interface{}{
"code": map[string]interface{}{"type": "string", "description": "Код плейлиста"},
"name": map[string]interface{}{"type": "string", "description": "Название плейлиста"},
"description": map[string]interface{}{"type": "string", "description": "Описание плейлиста"},
"url": map[string]interface{}{"type": "string", "description": "URL плейлиста"},
"source": map[string]interface{}{"type": "string", "description": "Источник плейлиста"},
"isOnline": map[string]interface{}{
"type": "boolean",
"nullable": true,
"description": "Доступность плейлиста (null если не проверялся)",
},
"onlineCount": map[string]interface{}{"type": "integer", "description": "Количество рабочих каналов"},
"offlineCount": map[string]interface{}{"type": "integer", "description": "Количество нерабочих каналов"},
"checkedAt": map[string]interface{}{"type": "integer", "description": "UNIX timestamp последней проверки"},
"content": map[string]interface{}{
"type": "string",
"description": "Содержимое плейлиста (только для оффлайн-плейлистов)",
},
"attributes": map[string]interface{}{
"type": "array",
"items": map[string]interface{}{"$ref": "#/components/schemas/Attribute"},
"description": "Атрибуты тега #EXTM3U",
},
"groups": map[string]interface{}{
"type": "array",
"items": map[string]interface{}{"$ref": "#/components/schemas/Group"},
"description": "Группы каналов",
},
"tags": map[string]interface{}{
"type": "array",
"items": map[string]interface{}{"type": "string"},
"description": "Теги плейлиста",
},
},
},
"ChannelListResponse": map[string]interface{}{
"type": "object",
"description": "Список каналов плейлиста",
"properties": map[string]interface{}{
"timestamp": map[string]interface{}{"type": "integer", "description": "UNIX timestamp ответа"},
"items": map[string]interface{}{
"type": "array",
"items": map[string]interface{}{"$ref": "#/components/schemas/Channel"},
"description": "Массив каналов",
},
},
},
"Channel": map[string]interface{}{
"type": "object",
"description": "Информация о канале и статусе его проверки",
"properties": map[string]interface{}{
"id": map[string]interface{}{"type": "string", "description": "MD5-хэш URL канала"},
"title": map[string]interface{}{"type": "string", "description": "Название канала"},
"url": map[string]interface{}{"type": "string", "description": "URL потока канала"},
"groupId": map[string]interface{}{"type": "string", "description": "MD5-хэш названия группы"},
"hasToken": map[string]interface{}{"type": "boolean", "description": "Признак наличия токена/ключа в URL"},
"attributes": map[string]interface{}{
"type": "array",
"items": map[string]interface{}{"$ref": "#/components/schemas/Attribute"},
"description": "Атрибуты тега #EXTINF",
},
"status": map[string]interface{}{"type": "integer", "description": "HTTP-код статуса"},
"isOnline": map[string]interface{}{"type": "boolean", "description": "Доступность канала (status < 400)"},
"error": map[string]interface{}{"type": "string", "description": "Текст ошибки"},
"contentType": map[string]interface{}{"type": "string", "description": "MIME-тип ответа"},
"tags": map[string]interface{}{
"type": "array",
"items": map[string]interface{}{"type": "string"},
"description": "Теги канала",
},
"checkedAt": map[string]interface{}{"type": "integer", "description": "UNIX timestamp проверки"},
},
},
"Group": map[string]interface{}{
"type": "object",
"description": "Группа каналов",
"properties": map[string]interface{}{
"id": map[string]interface{}{"type": "string", "description": "MD5-хэш названия группы"},
"name": map[string]interface{}{"type": "string", "description": "Название группы"},
"attributes": map[string]interface{}{
"type": "array",
"items": map[string]interface{}{"$ref": "#/components/schemas/Attribute"},
"description": "Атрибуты тега #EXTGRP",
},
},
},
"Attribute": map[string]interface{}{
"type": "object",
"description": "Именованный атрибут тега",
"properties": map[string]interface{}{
"name": map[string]interface{}{"type": "string", "description": "Имя атрибута"},
"value": map[string]interface{}{"type": "string", "description": "Значение атрибута"},
},
},
"VersionResponse": map[string]interface{}{
"type": "object",
"description": "Информация о версиях компонентов",
"properties": map[string]interface{}{
"timestamp": map[string]interface{}{"type": "integer", "description": "UNIX timestamp ответа"},
"iptvc": map[string]interface{}{"type": "string", "description": "Версия iptvc"},
"keydb": map[string]interface{}{
"type": "string",
"description": "Версия KeyDB/Redis (если подключён)",
},
},
},
"HealthResponse": map[string]interface{}{
"type": "object",
"description": "Состояние сервиса",
"properties": map[string]interface{}{
"timestamp": map[string]interface{}{"type": "integer", "description": "UNIX timestamp ответа"},
"redis": map[string]interface{}{
"type": "object",
"description": "Состояние подключения к KeyDB/Redis",
"properties": map[string]interface{}{
"isConnected": map[string]interface{}{"type": "boolean", "description": "Подключение установлено"},
},
},
"iniFile": map[string]interface{}{
"type": "object",
"description": "Информация об ini-файле",
"properties": map[string]interface{}{
"path": map[string]interface{}{"type": "string", "description": "Путь к playlists.ini"},
},
},
},
},
"StatsResponse": map[string]interface{}{
"type": "object",
"description": "Агрегированная статистика",
"properties": map[string]interface{}{
"timestamp": map[string]interface{}{"type": "integer", "description": "UNIX timestamp ответа"},
"playlists": map[string]interface{}{
"type": "object",
"description": "Статистика по плейлистам",
"properties": map[string]interface{}{
"all": map[string]interface{}{"type": "integer"},
"online": map[string]interface{}{"type": "integer"},
"offline": map[string]interface{}{"type": "integer"},
"unknown": map[string]interface{}{"type": "integer"},
"adult": map[string]interface{}{"type": "integer"},
"hasCatchup": map[string]interface{}{"type": "integer"},
"hasTvg": map[string]interface{}{"type": "integer"},
"groupped": map[string]interface{}{"type": "integer"},
"latest": map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"code": map[string]interface{}{"type": "string"},
"time": map[string]interface{}{"type": "integer"},
"timeFmt": map[string]interface{}{"type": "string"},
},
},
},
},
"channels": map[string]interface{}{
"type": "object",
"description": "Статистика по каналам",
"properties": map[string]interface{}{
"all": map[string]interface{}{"type": "integer"},
"online": map[string]interface{}{"type": "integer"},
"offline": map[string]interface{}{"type": "integer"},
"adult": map[string]interface{}{"type": "integer"},
},
},
},
},
"ErrorResponse": map[string]interface{}{
"type": "object",
"description": "Ошибка",
"properties": map[string]interface{}{
"timestamp": map[string]interface{}{"type": "integer", "description": "UNIX timestamp ответа"},
"error": map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"message": map[string]interface{}{"type": "string", "description": "Текст ошибки"},
},
},
},
},
},
},
}
}
+171
View File
@@ -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)
}
}
+223
View File
@@ -0,0 +1,223 @@
/*
* Copyright (c) 2025-2026, Антон Аксенов
* This file is part of iptvc project
* MIT License: https://git.axenov.dev/IPTV/iptvc/src/branch/master/LICENSE
*/
package web
import (
"axenov/iptv-checker/app/checker"
"axenov/iptv-checker/app/config"
"axenov/iptv-checker/app/inifile"
"axenov/iptv-checker/app/playlist"
"axenov/iptv-checker/app/utils"
"log"
"maps"
"net/http"
"os"
"slices"
"strconv"
"sync"
"time"
"github.com/redis/go-redis/v9"
)
// Server — веб-сервер для отображения плейлистов
type Server struct {
cfg *config.Config
cache *redis.Client
templates *TemplateManager
iniCache inifile.IniFile
iniLoaded time.Time
memMu sync.RWMutex
memCache map[string]playlist.Playlist
}
// NewServer создаёт новый экземпляр веб-сервера
func NewServer(cfg *config.Config, cache *redis.Client) *Server {
s := &Server{
cfg: cfg,
cache: cache,
memCache: make(map[string]playlist.Playlist),
}
tm, err := NewTemplateManager(cfg)
if err != nil {
log.Fatalf("Failed to load templates: %s", err)
}
s.templates = tm
return s
}
// Start запускает веб-сервер
func (s *Server) Start() {
mux := http.NewServeMux()
// API routes (specific, no conflicts)
mux.HandleFunc("GET /api/playlists", s.handleAPIGetAll)
mux.HandleFunc("GET /api/playlists/{code}", s.handleAPIGetOne)
mux.HandleFunc("GET /api/playlists/{code}/channels", s.handleAPIGetChannels)
mux.HandleFunc("GET /api/version", s.handleAPIVersion)
mux.HandleFunc("GET /api/health", s.handleAPIHealth)
mux.HandleFunc("GET /api/stats", s.handleAPIStats)
// Static files
mux.Handle("GET /static/", http.StripPrefix("/static/", staticFileServer))
// OpenAPI spec + Swagger UI
mux.HandleFunc("GET /api/openapi.json", s.handleOpenAPISpec)
mux.HandleFunc("GET /api", func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/api/", http.StatusMovedPermanently)
})
mux.Handle("GET /api/", http.StripPrefix("/api/", swaggerFileServer))
// All other routes handled by catch-all to avoid mux pattern conflicts
mux.HandleFunc("GET /{$}", s.handleHome)
mux.HandleFunc("GET /{path...}", s.handleRoute)
handler := s.withMiddleware(mux)
addr := s.cfg.Server.Host + ":" + strconv.FormatUint(uint64(s.cfg.Server.Port), 10)
log.Printf("Starting web server on %s", addr)
if err := http.ListenAndServe(addr, handler); err != nil {
log.Fatalf("Server failed: %s", err)
}
}
// withMiddleware добавляет middleware: логирование, восстановление после паники
func (s *Server) withMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
defer func() {
if err := recover(); err != nil {
log.Printf("PANIC: %v", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
}
}()
next.ServeHTTP(w, r)
log.Printf("%s %s %s %s", r.Method, r.URL.Path, time.Since(start), r.RemoteAddr)
})
}
// loadIni загружает ini-файл (с кешированием на 30 секунд)
func (s *Server) loadIni() (inifile.IniFile, error) {
// перезагружаем раз в 30 секунд
if !s.iniLoaded.IsZero() && time.Since(s.iniLoaded) < 30*time.Second && len(s.iniCache.Lists) > 0 {
return s.iniCache, nil
}
ini, err := inifile.Init(s.cfg.App.Playlists)
if err != nil {
return inifile.IniFile{}, err
}
s.iniCache = ini
s.iniLoaded = time.Now()
return ini, nil
}
// getUpdatedAt возвращает время последнего изменения ini-файла
func (s *Server) getUpdatedAt() string {
path, err := utils.ExpandPath(s.cfg.App.Playlists)
if err != nil {
return ""
}
info, err := os.Stat(path)
if err != nil {
return ""
}
loc, err := time.LoadLocation(s.cfg.App.Timezone)
if err != nil {
loc = time.UTC
}
return info.ModTime().In(loc).Format("02.01.2006 15:04")
}
// getTimezoneLabel возвращает сокращённое обозначение часового пояса для отображения.
func (s *Server) getTimezoneLabel() string {
return s.cfg.App.Timezone
}
// CheckOptions — параметры фоновой проверки плейлистов
type CheckOptions struct {
Repeat uint
Random uint
Files []string
Urls []string
Codes []string
}
// StartBackgroundChecker запускает фоновую периодическую проверку плейлистов.
// Метод блокирует вызывающую горутину, поэтому должен запускаться в отдельной goroutine.
func (s *Server) StartBackgroundChecker(opts CheckOptions) {
// каждый проверенный плейлист сразу попадает в in-memory кеш
checker.OnPlaylistChecked = func(pls playlist.Playlist) {
s.memMu.Lock()
s.memCache[pls.Code] = pls
s.memMu.Unlock()
// сбрасываем кеш ini, чтобы веб-часть подхватила свежие данные
s.iniLoaded = time.Time{}
}
// AllCooldown хранится в миллисекундах, поэтому переводим в time.Millisecond
cooldown := time.Duration(s.cfg.Check.Playlists.AllCooldown.Value()) * time.Millisecond
log.Printf("Background checker started: cooldown=%s repeat=%d", cooldown, opts.Repeat)
iteration := uint(0)
for {
iteration++
log.Printf("@ Background checker iteration=%d repeat=%d", iteration, opts.Repeat)
s.runCheckerOnce(opts)
if opts.Repeat > 0 && iteration >= opts.Repeat {
log.Println("Background checker finished: reached repeat count")
return
}
log.Printf("Background checker waiting... cooldown=%s", cooldown)
// cooldown уже вычислен в миллисекундах выше
time.Sleep(cooldown)
}
}
// runCheckerOnce выполняет один цикл проверки плейлистов
func (s *Server) runCheckerOnce(opts CheckOptions) {
defer func() {
if err := recover(); err != nil {
log.Printf("Background checker panic: %v", err)
}
}()
var lists []playlist.Playlist
if len(opts.Files) > 0 || len(opts.Urls) > 0 || len(opts.Codes) > 0 {
// проверка конкретных файлов/URL/кодов
lists = checker.PrepareListsToCheck(opts.Files, opts.Urls, opts.Codes)
} else {
// проверка всех плейлистов из ini
ini, err := inifile.Init(s.cfg.App.Playlists)
if err != nil {
log.Printf("Background checker: cannot load ini: %s", err)
return
}
lists = slices.Collect(maps.Values(ini.Lists))
}
if len(lists) == 0 {
log.Println("Background checker: no playlists to check")
return
}
log.Printf("Background checker: %d playlists will be checked", len(lists))
startTime := time.Now()
onlineCount, offlineCount := checker.CheckPlaylists(lists)
log.Printf(
"Background checker done! online=%d offline=%d elapsed=%.2fs",
onlineCount,
offlineCount,
time.Since(startTime).Seconds(),
)
}
File diff suppressed because one or more lines are too long
+1
View File
@@ -0,0 +1 @@
export{a as addIcons}from"./p-40ae2aa7.js";import"./p-d15ec307.js";
+1
View File
@@ -0,0 +1 @@
import{p as i,b as o}from"./p-d15ec307.js";(()=>{const o=import.meta.url,s={};return""!==o&&(s.resourcesUrl=new URL(".",o).href),i(s)})().then((i=>o([["p-1c0b2c47",[[1,"ion-icon",{mode:[1025],color:[1],ios:[1],md:[1],flipRtl:[4,"flip-rtl"],name:[513],src:[1],icon:[8],size:[1],lazy:[4],sanitize:[4],svgContent:[32],isVisible:[32]}]]]],i)));
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
import{r as o,h as i,H as t,a as n}from"./p-d15ec307.js";import{i as s,b as r,g as e,c,d as a}from"./p-40ae2aa7.js";const l=o=>{if(1===o.nodeType){if("script"===o.nodeName.toLowerCase())return!1;for(let i=0;i<o.attributes.length;i++){const t=o.attributes[i].name;if(s(t)&&0===t.toLowerCase().indexOf("on"))return!1}for(let i=0;i<o.childNodes.length;i++)if(!l(o.childNodes[i]))return!1}return!0},h=new Map,d=new Map;let f;const m=class{constructor(i){o(this,i),this.iconName=null,this.inheritedAttributes={},this.isVisible=!1,this.mode=u(),this.lazy=!1,this.sanitize=!0}componentWillLoad(){this.inheritedAttributes=r(this.el,["aria-label"])}connectedCallback(){this.waitUntilVisible(this.el,"50px",(()=>{this.isVisible=!0,this.loadIcon()}))}disconnectedCallback(){this.io&&(this.io.disconnect(),this.io=void 0)}waitUntilVisible(o,i,t){if(this.lazy&&"undefined"!=typeof window&&window.IntersectionObserver){const n=this.io=new window.IntersectionObserver((o=>{o[0].isIntersecting&&(n.disconnect(),this.io=void 0,t())}),{rootMargin:i});n.observe(o)}else t()}loadIcon(){if(this.isVisible){const o=e(this);o&&(h.has(o)?this.svgContent=h.get(o):((o,i)=>{let t=d.get(o);if(!t){if("undefined"==typeof fetch||"undefined"==typeof document)return h.set(o,""),Promise.resolve();if((o=>o.startsWith("data:image/svg+xml"))(o)&&(o=>-1!==o.indexOf(";utf8,"))(o)){f||(f=new DOMParser);const i=f.parseFromString(o,"text/html").querySelector("svg");return i&&h.set(o,i.outerHTML),Promise.resolve()}t=fetch(o).then((t=>{if(t.ok)return t.text().then((t=>{t&&!1!==i&&(t=(o=>{const i=document.createElement("div");i.innerHTML=o;for(let o=i.childNodes.length-1;o>=0;o--)"svg"!==i.childNodes[o].nodeName.toLowerCase()&&i.removeChild(i.childNodes[o]);const t=i.firstElementChild;if(t&&"svg"===t.nodeName.toLowerCase()){const o=t.getAttribute("class")||"";if(t.setAttribute("class",(o+" s-ion-icon").trim()),l(t))return i.innerHTML}return""})(t)),h.set(o,t||"")}));h.set(o,"")})),d.set(o,t)}return t})(o,this.sanitize).then((()=>this.svgContent=h.get(o))))}this.iconName=c(this.name,this.icon,this.mode,this.ios,this.md)}render(){const{iconName:o,el:n,inheritedAttributes:s}=this,r=this.mode||"md",e=this.flipRtl||o&&(o.indexOf("arrow")>-1||o.indexOf("chevron")>-1)&&!1!==this.flipRtl;return i(t,Object.assign({role:"img",class:Object.assign(Object.assign({[r]:!0},p(this.color)),{[`icon-${this.size}`]:!!this.size,"flip-rtl":!!e&&a(n)})},s),i("div",this.svgContent?{class:"icon-inner",innerHTML:this.svgContent}:{class:"icon-inner"}))}static get assetsDirs(){return["svg"]}get el(){return n(this)}static get watchers(){return{name:["loadIcon"],src:["loadIcon"],icon:["loadIcon"],ios:["loadIcon"],md:["loadIcon"]}}},u=()=>"undefined"!=typeof document&&document.documentElement.getAttribute("mode")||"md",p=o=>o?{"ion-color":!0,[`ion-color-${o}`]:!0}:null;m.style=":host{display:inline-block;width:1em;height:1em;contain:strict;fill:currentColor;-webkit-box-sizing:content-box !important;box-sizing:content-box !important}:host .ionicon{stroke:currentColor}.ionicon-fill-none{fill:none}.ionicon-stroke-width{stroke-width:32px;stroke-width:var(--ionicon-stroke-width, 32px)}.icon-inner,.ionicon,svg{display:block;height:100%;width:100%}:host(.flip-rtl) .icon-inner{-webkit-transform:scaleX(-1);transform:scaleX(-1)}:host(.icon-small){font-size:18px !important}:host(.icon-large){font-size:32px !important}:host(.ion-color){color:var(--ion-color-base) !important}:host(.ion-color-primary){--ion-color-base:var(--ion-color-primary, #3880ff)}:host(.ion-color-secondary){--ion-color-base:var(--ion-color-secondary, #0cd1e8)}:host(.ion-color-tertiary){--ion-color-base:var(--ion-color-tertiary, #f4a942)}:host(.ion-color-success){--ion-color-base:var(--ion-color-success, #10dc60)}:host(.ion-color-warning){--ion-color-base:var(--ion-color-warning, #ffce00)}:host(.ion-color-danger){--ion-color-base:var(--ion-color-danger, #f14141)}:host(.ion-color-light){--ion-color-base:var(--ion-color-light, #f4f5f8)}:host(.ion-color-medium){--ion-color-base:var(--ion-color-medium, #989aa2)}:host(.ion-color-dark){--ion-color-base:var(--ion-color-dark, #222428)}";export{m as ion_icon}
@@ -0,0 +1 @@
System.register(["./p-ea7bbed1.system.js","./p-cff0a9de.system.js"],(function(e){"use strict";var o,t,i,n,r,s,a,c,l;return{setters:[function(e){o=e.r;t=e.h;i=e.H;n=e.a},function(e){r=e.i;s=e.b;a=e.g;c=e.c;l=e.d}],execute:function(){var f=function(e){var o=document.createElement("div");o.innerHTML=e;for(var t=o.childNodes.length-1;t>=0;t--){if(o.childNodes[t].nodeName.toLowerCase()!=="svg"){o.removeChild(o.childNodes[t])}}var i=o.firstElementChild;if(i&&i.nodeName.toLowerCase()==="svg"){var n=i.getAttribute("class")||"";i.setAttribute("class",(n+" s-ion-icon").trim());if(u(i)){return o.innerHTML}}return""};var u=function(e){if(e.nodeType===1){if(e.nodeName.toLowerCase()==="script"){return false}for(var o=0;o<e.attributes.length;o++){var t=e.attributes[o].name;if(r(t)&&t.toLowerCase().indexOf("on")===0){return false}}for(var o=0;o<e.childNodes.length;o++){if(!u(e.childNodes[o])){return false}}}return true};var d=function(e){return e.startsWith("data:image/svg+xml")};var h=function(e){return e.indexOf(";utf8,")!==-1};var v=new Map;var m=new Map;var b;var p=function(e,o){var t=m.get(e);if(!t){if(typeof fetch!=="undefined"&&typeof document!=="undefined"){if(d(e)&&h(e)){if(!b){b=new DOMParser}var i=b.parseFromString(e,"text/html");var n=i.querySelector("svg");if(n){v.set(e,n.outerHTML)}return Promise.resolve()}else{t=fetch(e).then((function(t){if(t.ok){return t.text().then((function(t){if(t&&o!==false){t=f(t)}v.set(e,t||"")}))}v.set(e,"")}));m.set(e,t)}}else{v.set(e,"");return Promise.resolve()}}return t};var g=":host{display:inline-block;width:1em;height:1em;contain:strict;fill:currentColor;-webkit-box-sizing:content-box !important;box-sizing:content-box !important}:host .ionicon{stroke:currentColor}.ionicon-fill-none{fill:none}.ionicon-stroke-width{stroke-width:32px;stroke-width:var(--ionicon-stroke-width, 32px)}.icon-inner,.ionicon,svg{display:block;height:100%;width:100%}:host(.flip-rtl) .icon-inner{-webkit-transform:scaleX(-1);transform:scaleX(-1)}:host(.icon-small){font-size:18px !important}:host(.icon-large){font-size:32px !important}:host(.ion-color){color:var(--ion-color-base) !important}:host(.ion-color-primary){--ion-color-base:var(--ion-color-primary, #3880ff)}:host(.ion-color-secondary){--ion-color-base:var(--ion-color-secondary, #0cd1e8)}:host(.ion-color-tertiary){--ion-color-base:var(--ion-color-tertiary, #f4a942)}:host(.ion-color-success){--ion-color-base:var(--ion-color-success, #10dc60)}:host(.ion-color-warning){--ion-color-base:var(--ion-color-warning, #ffce00)}:host(.ion-color-danger){--ion-color-base:var(--ion-color-danger, #f14141)}:host(.ion-color-light){--ion-color-base:var(--ion-color-light, #f4f5f8)}:host(.ion-color-medium){--ion-color-base:var(--ion-color-medium, #989aa2)}:host(.ion-color-dark){--ion-color-base:var(--ion-color-dark, #222428)}";var y=e("ion_icon",function(){function e(e){o(this,e);this.iconName=null;this.inheritedAttributes={};this.isVisible=false;this.mode=w();this.lazy=false;this.sanitize=true}e.prototype.componentWillLoad=function(){this.inheritedAttributes=s(this.el,["aria-label"])};e.prototype.connectedCallback=function(){var e=this;this.waitUntilVisible(this.el,"50px",(function(){e.isVisible=true;e.loadIcon()}))};e.prototype.disconnectedCallback=function(){if(this.io){this.io.disconnect();this.io=undefined}};e.prototype.waitUntilVisible=function(e,o,t){var i=this;if(this.lazy&&typeof window!=="undefined"&&window.IntersectionObserver){var n=this.io=new window.IntersectionObserver((function(e){if(e[0].isIntersecting){n.disconnect();i.io=undefined;t()}}),{rootMargin:o});n.observe(e)}else{t()}};e.prototype.loadIcon=function(){var e=this;if(this.isVisible){var o=a(this);if(o){if(v.has(o)){this.svgContent=v.get(o)}else{p(o,this.sanitize).then((function(){return e.svgContent=v.get(o)}))}}}this.iconName=c(this.name,this.icon,this.mode,this.ios,this.md)};e.prototype.render=function(){var e,o;var n=this,r=n.iconName,s=n.el,a=n.inheritedAttributes;var c=this.mode||"md";var f=this.flipRtl||r&&(r.indexOf("arrow")>-1||r.indexOf("chevron")>-1)&&this.flipRtl!==false;return t(i,Object.assign({role:"img",class:Object.assign(Object.assign((e={},e[c]=true,e),x(this.color)),(o={},o["icon-".concat(this.size)]=!!this.size,o["flip-rtl"]=!!f&&l(s),o))},a),this.svgContent?t("div",{class:"icon-inner",innerHTML:this.svgContent}):t("div",{class:"icon-inner"}))};Object.defineProperty(e,"assetsDirs",{get:function(){return["svg"]},enumerable:false,configurable:true});Object.defineProperty(e.prototype,"el",{get:function(){return n(this)},enumerable:false,configurable:true});Object.defineProperty(e,"watchers",{get:function(){return{name:["loadIcon"],src:["loadIcon"],icon:["loadIcon"],ios:["loadIcon"],md:["loadIcon"]}},enumerable:false,configurable:true});return e}());var w=function(){return typeof document!=="undefined"&&document.documentElement.getAttribute("mode")||"md"};var x=function(e){var o;return e?(o={"ion-color":true},o["ion-color-".concat(e)]=true,o):null};y.style=g}}}));
+1
View File
@@ -0,0 +1 @@
import{g as n}from"./p-d15ec307.js";let t;const e=()=>{if("undefined"==typeof window)return new Map;if(!t){const n=window;n.Ionicons=n.Ionicons||{},t=n.Ionicons.map=n.Ionicons.map||new Map}return t},r=n=>{const t=e();Object.keys(n).forEach((e=>t.set(e,n[e])))},i=n=>{let t=u(n.src);if(t)return t;if(t=s(n.name,n.icon,n.mode,n.ios,n.md),t)return o(t);if(n.icon){if(t=u(n.icon),t)return t;if(t=u(n.icon[n.mode]),t)return t}return null},o=t=>e().get(t)||n(`svg/${t}.svg`),s=(n,t,e,r,i)=>(e="ios"===(e&&a(e))?"ios":"md",r&&"ios"===e?n=a(r):i&&"md"===e?n=a(i):(n||!t||l(t)||(n=t),d(n)&&(n=a(n))),d(n)&&""!==n.trim()?""!==n.replace(/[a-z]|-|\d/gi,"")?null:n:null),u=n=>d(n)&&(n=n.trim(),l(n))?n:null,l=n=>n.length>0&&/(\/|\.)/.test(n),d=n=>"string"==typeof n,a=n=>n.toLowerCase(),f=(n,t=[])=>{const e={};return t.forEach((t=>{n.hasAttribute(t)&&(null!==n.getAttribute(t)&&(e[t]=n.getAttribute(t)),n.removeAttribute(t))})),e},c=n=>n&&""!==n.dir?"rtl"===n.dir.toLowerCase():"rtl"===(null===document||void 0===document?void 0:document.dir.toLowerCase());export{r as a,f as b,s as c,c as d,i as g,d as i}
@@ -0,0 +1 @@
System.register(["./p-ea7bbed1.system.js"],(function(e,t){"use strict";var n,r;return{setters:[function(e){n=e.p;r=e.b}],execute:function(){var e=function(){var e=t.meta.url;var r={};if(e!==""){r.resourcesUrl=new URL(".",e).href}return n(r)};e().then((function(e){return r([["p-2b141077.system",[[1,"ion-icon",{mode:[1025],color:[1],ios:[1],md:[1],flipRtl:[4,"flip-rtl"],name:[513],src:[1],icon:[8],size:[1],lazy:[4],sanitize:[4],svgContent:[32],isVisible:[32]}]]]],e)}))}}}));
@@ -0,0 +1 @@
System.register(["./p-ea7bbed1.system.js"],(function(r){"use strict";var n;return{setters:[function(r){n=r.g}],execute:function(){var t;var e=function(){if(typeof window==="undefined"){return new Map}else{if(!t){var r=window;r.Ionicons=r.Ionicons||{};t=r.Ionicons.map=r.Ionicons.map||new Map}return t}};var i=r("a",(function(r){var n=e();Object.keys(r).forEach((function(t){return n.set(t,r[t])}))}));var o=r("g",(function(r){var n=c(r.src);if(n){return n}n=f(r.name,r.icon,r.mode,r.ios,r.md);if(n){return u(n)}if(r.icon){n=c(r.icon);if(n){return n}n=c(r.icon[r.mode]);if(n){return n}}return null}));var u=function(r){var t=e().get(r);if(t){return t}return n("svg/".concat(r,".svg"))};var f=r("c",(function(r,n,t,e,i){t=(t&&v(t))==="ios"?"ios":"md";if(e&&t==="ios"){r=v(e)}else if(i&&t==="md"){r=v(i)}else{if(!r&&n&&!a(n)){r=n}if(s(r)){r=v(r)}}if(!s(r)||r.trim()===""){return null}var o=r.replace(/[a-z]|-|\d/gi,"");if(o!==""){return null}return r}));var c=function(r){if(s(r)){r=r.trim();if(a(r)){return r}}return null};var a=function(r){return r.length>0&&/(\/|\.)/.test(r)};var s=r("i",(function(r){return typeof r==="string"}));var v=function(r){return r.toLowerCase()};var d=r("b",(function(r,n){if(n===void 0){n=[]}var t={};n.forEach((function(n){if(r.hasAttribute(n)){var e=r.getAttribute(n);if(e!==null){t[n]=r.getAttribute(n)}r.removeAttribute(n)}}));return t}));var l=r("d",(function(r){if(r){if(r.dir!==""){return r.dir.toLowerCase()==="rtl"}}return(document===null||document===void 0?void 0:document.dir.toLowerCase())==="rtl"}))}}}));
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 @@
System.register(["./p-cff0a9de.system.js","./p-ea7bbed1.system.js"],(function(e){"use strict";return{setters:[function(t){e("addIcons",t.a)},function(){}],execute:function(){}}}));
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" class="ionicon" viewBox="0 0 512 512"><circle stroke-linejoin="round" cx="256" cy="56" r="40" class="ionicon-fill-none ionicon-stroke-width"/><path stroke-linejoin="round" d="M204.23 274.44c2.9-18.06 4.2-35.52-.5-47.59-4-10.38-12.7-16.19-23.2-20.15L88 176.76c-12-4-23.21-10.7-24-23.94-1-17 14-28 29-24 0 0 88 31.14 163 31.14s162-31 162-31c18-5 30 9 30 23.79 0 14.21-11 19.21-24 23.94l-88 31.91c-8 3-21 9-26 18.18-6 10.75-5 29.53-2.1 47.59l5.9 29.63 37.41 163.9c2.8 13.15-6.3 25.44-19.4 27.74S308 489 304.12 476.28l-37.56-115.93q-2.71-8.34-4.8-16.87L256 320l-5.3 21.65q-2.52 10.35-5.8 20.48L208 476.18c-4 12.85-14.5 21.75-27.6 19.46s-22.4-15.59-19.46-27.74l37.39-163.83z" class="ionicon-fill-none ionicon-stroke-width"/></svg>

After

Width:  |  Height:  |  Size: 765 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" class="ionicon" viewBox="0 0 512 512"><path d="M448 112c-66.82 17.92-119.55 32-192 32s-125.18-14.08-192-32l-16 51c48 20.53 96.71 35.16 147.2 53.2L144 496l56.4 16L246 336h20l45.6 176 56.4-16-51.2-279.8C367.26 199.93 416 183.53 464 164zM256 112a56 56 0 1156-56 56.06 56.06 0 01-56 56z"/></svg>

After

Width:  |  Height:  |  Size: 331 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" class="ionicon" viewBox="0 0 512 512"><path d="M256 112a56 56 0 1156-56 56.06 56.06 0 01-56 56z"/><path d="M432 112.8l-.45.12-.42.13c-1 .28-2 .58-3 .89-18.61 5.46-108.93 30.92-172.56 30.92-59.13 0-141.28-22-167.56-29.47a73.79 73.79 0 00-8-2.58c-19-5-32 14.3-32 31.94 0 17.47 15.7 25.79 31.55 31.76v.28l95.22 29.74c9.73 3.73 12.33 7.54 13.6 10.84 4.13 10.59.83 31.56-.34 38.88l-5.8 45-32.19 176.19q-.15.72-.27 1.47l-.23 1.27c-2.32 16.15 9.54 31.82 32 31.82 19.6 0 28.25-13.53 32-31.94s28-157.57 42-157.57 42.84 157.57 42.84 157.57c3.75 18.41 12.4 31.94 32 31.94 22.52 0 34.38-15.74 32-31.94a57.17 57.17 0 00-.76-4.06L329 301.27l-5.79-45c-4.19-26.21-.82-34.87.32-36.9a1.09 1.09 0 00.08-.15c1.08-2 6-6.48 17.48-10.79l89.28-31.21a16.9 16.9 0 001.62-.52c16-6 32-14.3 32-31.93S451 107.81 432 112.8z"/></svg>

After

Width:  |  Height:  |  Size: 841 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" class="ionicon" viewBox="0 0 512 512"><path d="M448 256c0-106-86-192-192-192S64 150 64 256s86 192 192 192 192-86 192-192z" stroke-miterlimit="10" class="ionicon-fill-none ionicon-stroke-width"/><path stroke-linecap="round" stroke-linejoin="round" d="M256 176v160M336 256H176" class="ionicon-fill-none ionicon-stroke-width"/></svg>

After

Width:  |  Height:  |  Size: 370 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" class="ionicon" viewBox="0 0 512 512"><path d="M256 48C141.31 48 48 141.31 48 256s93.31 208 208 208 208-93.31 208-208S370.69 48 256 48zm96 224h-80v80h-32v-80h-80v-32h80v-80h32v80h80z"/></svg>

After

Width:  |  Height:  |  Size: 231 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" class="ionicon" viewBox="0 0 512 512"><path d="M256 48C141.31 48 48 141.31 48 256s93.31 208 208 208 208-93.31 208-208S370.69 48 256 48zm80 224h-64v64a16 16 0 01-32 0v-64h-64a16 16 0 010-32h64v-64a16 16 0 0132 0v64h64a16 16 0 010 32z"/></svg>

After

Width:  |  Height:  |  Size: 281 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" class="ionicon" viewBox="0 0 512 512"><path stroke-linecap="round" stroke-linejoin="round" d="M256 112v288M400 256H112" class="ionicon-fill-none ionicon-stroke-width"/></svg>

After

Width:  |  Height:  |  Size: 214 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" class="ionicon" viewBox="0 0 512 512"><path stroke-linecap="square" stroke-linejoin="round" d="M256 112v288M400 256H112" class="ionicon-fill-none ionicon-stroke-width"/></svg>

After

Width:  |  Height:  |  Size: 215 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" class="ionicon" viewBox="0 0 512 512"><path stroke-linecap="round" stroke-linejoin="round" d="M256 112v288M400 256H112" class="ionicon-fill-none ionicon-stroke-width"/></svg>

After

Width:  |  Height:  |  Size: 214 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" class="ionicon" viewBox="0 0 512 512"><path d="M407.72 224c-3.4 0-14.79.1-18 .3l-64.9 1.7a1.83 1.83 0 01-1.69-.9L193.55 67.56a9 9 0 00-6.66-3.56H160l73 161a2.35 2.35 0 01-2.26 3.35l-121.69 1.8a8.06 8.06 0 01-6.6-3.1l-37-45c-3-3.9-8.62-6-13.51-6H33.08c-1.29 0-1.1 1.21-.75 2.43l19.84 71.42a16.3 16.3 0 010 11.9L32.31 333c-.59 1.95-.52 3 1.77 3H52c8.14 0 9.25-1.06 13.41-6.3l37.7-45.7a8.19 8.19 0 016.6-3.1l120.68 2.7a2.7 2.7 0 012.43 3.74L160 448h26.64a9 9 0 006.65-3.55L323.14 287c.39-.6 2-.9 2.69-.9l63.9 1.7c3.3.2 14.59.3 18 .3C452 288.1 480 275.93 480 256s-27.88-32-72.28-32z" stroke-linecap="round" stroke-linejoin="round" class="ionicon-fill-none ionicon-stroke-width"/></svg>

After

Width:  |  Height:  |  Size: 721 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" class="ionicon" viewBox="0 0 512 512"><path d="M407.72 208c-2.72 0-14.44.08-18.67.31l-57.77 1.52L198.06 48h-62.81l74.59 164.61-97.31 1.44L68.25 160H16.14l20.61 94.18c.15.54.33 1.07.53 1.59a.26.26 0 010 .15 15.42 15.42 0 00-.53 1.58L15.86 352h51.78l45.45-55 96.77 2.17L135.24 464h63l133-161.75 57.77 1.54c4.29.23 16 .31 18.66.31 24.35 0 44.27-3.34 59.21-9.94C492.22 283 496 265.46 496 256c0-30.06-33-48-88.28-48zm-71.29 87.9z"/></svg>

After

Width:  |  Height:  |  Size: 473 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" class="ionicon" viewBox="0 0 512 512"><path d="M186.62 464H160a16 16 0 01-14.57-22.6l64.46-142.25L113.1 297l-35.3 42.77C71.07 348.23 65.7 352 52 352H34.08a17.66 17.66 0 01-14.7-7.06c-2.38-3.21-4.72-8.65-2.44-16.41l19.82-71c.15-.53.33-1.06.53-1.58a.38.38 0 000-.15 14.82 14.82 0 01-.53-1.59l-19.84-71.45c-2.15-7.61.2-12.93 2.56-16.06a16.83 16.83 0 0113.6-6.7H52c10.23 0 20.16 4.59 26 12l34.57 42.05 97.32-1.44-64.44-142A16 16 0 01160 48h26.91a25 25 0 0119.35 9.8l125.05 152 57.77-1.52c4.23-.23 15.95-.31 18.66-.31C463 208 496 225.94 496 256c0 9.46-3.78 27-29.07 38.16-14.93 6.6-34.85 9.94-59.21 9.94-2.68 0-14.37-.08-18.66-.31l-57.76-1.54-125.36 152a25 25 0 01-19.32 9.75z"/></svg>

After

Width:  |  Height:  |  Size: 720 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" class="ionicon" viewBox="0 0 512 512"><path d="M416.07 272a160 160 0 10-160 160 160 160 0 00160-160zM142.12 91.21A46.67 46.67 0 00112 80l-2.79.08C83.66 81.62 64 104 64.07 131c0 13.21 4.66 19.37 10.88 27.23a4.55 4.55 0 003.24 1.77h.88a3.23 3.23 0 002.54-1.31L142.38 99a5.38 5.38 0 001.55-4 5.26 5.26 0 00-1.81-3.79zM369.88 91.21A46.67 46.67 0 01400 80l2.79.08C428.34 81.62 448 104 447.93 131c0 13.21-4.66 19.37-10.88 27.23a4.55 4.55 0 01-3.24 1.76h-.88a3.23 3.23 0 01-2.54-1.31L369.62 99a5.38 5.38 0 01-1.55-4 5.26 5.26 0 011.81-3.79z" stroke-miterlimit="10" class="ionicon-fill-none ionicon-stroke-width"/><path stroke-linecap="round" stroke-linejoin="round" d="M256.07 160v112h-80M416.07 432l-40-40M96.07 432l40-40" class="ionicon-fill-none ionicon-stroke-width"/></svg>

After

Width:  |  Height:  |  Size: 811 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" class="ionicon" viewBox="0 0 512 512"><path d="M403.79 64.11l-3.27-.1H400c-14.73 0-28.55 5.91-40.52 15S344 96 344 96l88 86s11.25-5.71 17.61-13.85 14.28-18.08 14.32-37.11c.07-35.49-26.33-64.89-60.14-66.93zM108.21 64.11l3.27-.1h.52c14.73 0 28.55 5.91 40.52 15S168 96 168 96l-88 86s-11.25-5.71-17.61-13.85S48.11 150.07 48.07 131C48 95.55 74.4 66.15 108.21 64.11z"/><path d="M391.3 384.6a.06.06 0 010-.08C425 344 441 288.24 427 229.23c-13.64-57.52-72.67-115.69-130.34-128.66C182 74.79 80.07 161.71 80.07 272a175.15 175.15 0 0040.78 112.52.06.06 0 010 .08L73 432a.06.06 0 000 .08l23 22.51a.06.06 0 00.08 0l47.43-47.37a.06.06 0 01.08 0 175.64 175.64 0 00225.05 0h.07L416 454.59a.06.06 0 00.08 0L440 432zM272.07 288h-112-.07v-31.9l.05-.05h80V144h32z"/></svg>

After

Width:  |  Height:  |  Size: 791 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" class="ionicon" viewBox="0 0 512 512"><path d="M153.59 110.46A21.41 21.41 0 00152.48 79 62.67 62.67 0 00112 64l-3.27.09h-.48C74.4 66.15 48 95.55 48.07 131c0 19 8 29.06 14.32 37.11a20.61 20.61 0 0014.7 7.8c.26 0 .7.05 2 .05a19.06 19.06 0 0013.75-5.89zM403.79 64.11l-3.27-.1H400a62.67 62.67 0 00-40.52 15 21.41 21.41 0 00-1.11 31.44l60.77 59.65a19.06 19.06 0 0013.79 5.9c1.28 0 1.72 0 2-.05a20.61 20.61 0 0014.69-7.8c6.36-8.05 14.28-18.08 14.32-37.11.06-35.49-26.34-64.89-60.15-66.93z"/><path d="M256.07 96c-97 0-176 78.95-176 176a175.23 175.23 0 0040.81 112.56l-36.12 36.13a16 16 0 1022.63 22.62l36.12-36.12a175.63 175.63 0 00225.12 0l36.13 36.12a16 16 0 1022.63-22.62l-36.13-36.13A175.17 175.17 0 00432.07 272c0-97-78.95-176-176-176zm16 176a16 16 0 01-16 16h-80a16 16 0 010-32h64v-96a16 16 0 0132 0z"/></svg>

After

Width:  |  Height:  |  Size: 848 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" class="ionicon" viewBox="0 0 512 512"><rect x="64" y="176" width="384" height="256" rx="28.87" ry="28.87" stroke-linejoin="round" class="ionicon-fill-none ionicon-stroke-width"/><path stroke-linecap="round" stroke-miterlimit="10" d="M144 80h224M112 128h288" class="ionicon-stroke-width"/></svg>

After

Width:  |  Height:  |  Size: 334 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" class="ionicon" viewBox="0 0 512 512"><path d="M128 64h256v32H128zM96 112h320v32H96zM464 448H48V160h416z"/></svg>

After

Width:  |  Height:  |  Size: 153 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" class="ionicon" viewBox="0 0 512 512"><path d="M368 96H144a16 16 0 010-32h224a16 16 0 010 32zM400 144H112a16 16 0 010-32h288a16 16 0 010 32zM419.13 448H92.87A44.92 44.92 0 0148 403.13V204.87A44.92 44.92 0 0192.87 160h326.26A44.92 44.92 0 01464 204.87v198.26A44.92 44.92 0 01419.13 448z"/></svg>

After

Width:  |  Height:  |  Size: 334 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" class="ionicon" viewBox="0 0 512 512"><path d="M448 256c0-106-86-192-192-192S64 150 64 256s86 192 192 192 192-86 192-192z" stroke-miterlimit="10" class="ionicon-fill-none ionicon-stroke-width"/><path d="M250.26 166.05L256 288l5.73-121.95a5.74 5.74 0 00-5.79-6h0a5.74 5.74 0 00-5.68 6z" stroke-linecap="round" stroke-linejoin="round" class="ionicon-fill-none ionicon-stroke-width"/><path d="M256 367.91a20 20 0 1120-20 20 20 0 01-20 20z"/></svg>

After

Width:  |  Height:  |  Size: 484 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" class="ionicon" viewBox="0 0 512 512"><path d="M240 304h32l6-160h-44l6 160z" class="ionicon-fill-none"/><path d="M256 48C141.31 48 48 141.31 48 256s93.31 208 208 208 208-93.31 208-208S370.69 48 256 48zm20 319.91h-40v-40h40zM272 304h-32l-6-160h44z"/></svg>

After

Width:  |  Height:  |  Size: 295 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" class="ionicon" viewBox="0 0 512 512"><path d="M256 48C141.31 48 48 141.31 48 256s93.31 208 208 208 208-93.31 208-208S370.69 48 256 48zm0 319.91a20 20 0 1120-20 20 20 0 01-20 20zm21.72-201.15l-5.74 122a16 16 0 01-32 0l-5.74-121.94v-.05a21.74 21.74 0 1143.44 0z"/></svg>

After

Width:  |  Height:  |  Size: 309 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" class="ionicon" viewBox="0 0 512 512"><path d="M256 80c-8.66 0-16.58 7.36-16 16l8 216a8 8 0 008 8h0a8 8 0 008-8l8-216c.58-8.64-7.34-16-16-16z" stroke-linecap="round" stroke-linejoin="round" class="ionicon-fill-none ionicon-stroke-width"/><circle cx="256" cy="416" r="16" stroke-linecap="round" stroke-linejoin="round" class="ionicon-fill-none ionicon-stroke-width"/></svg>

After

Width:  |  Height:  |  Size: 412 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" class="ionicon" viewBox="0 0 512 512"><path stroke-linecap="square" stroke-miterlimit="10" d="M240 80l8 240h16l8-240h-32zM240 400h32v32h-32z" class="ionicon-fill-none ionicon-stroke-width"/></svg>

After

Width:  |  Height:  |  Size: 236 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" class="ionicon" viewBox="0 0 512 512"><path d="M256 80c-8.66 0-16.58 7.36-16 16l8 216a8 8 0 008 8h0a8 8 0 008-8l8-216c.58-8.64-7.34-16-16-16z" stroke-linecap="round" stroke-linejoin="round" class="ionicon-fill-none ionicon-stroke-width"/><circle cx="256" cy="416" r="16" stroke-linecap="round" stroke-linejoin="round" class="ionicon-fill-none ionicon-stroke-width"/></svg>

After

Width:  |  Height:  |  Size: 412 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" class="ionicon" viewBox="0 0 512 512"><ellipse cx="256" cy="256" rx="267.57" ry="173.44" transform="rotate(-45 256 256.002)" stroke-linecap="round" stroke-linejoin="round" class="ionicon-fill-none ionicon-stroke-width"/><path stroke-linecap="round" stroke-linejoin="round" d="M334.04 177.96L177.96 334.04M278.3 278.3l-44.6-44.6M322.89 233.7l-44.59-44.59M456.68 211.4L300.6 55.32M211.4 456.68L55.32 300.6M233.7 322.89l-44.59-44.59" class="ionicon-fill-none ionicon-stroke-width"/></svg>

After

Width:  |  Height:  |  Size: 525 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" class="ionicon" viewBox="0 0 512 512"><path d="M120.9 120.51c-44.75 44.56-67.29 101.05-78.64 145.9l202.31 201.44c45.05-11.3 101.78-33.74 146.53-78.3s67.29-101.05 78.64-145.91L267.43 42.21C222.38 53.51 165.65 76 120.9 120.51zm259 257.83zm-45-224.2l22.52 22.42-33.78 33.63 22.52 22.42L323.55 255 301 232.61 278.52 255 301 277.45l-22.51 22.42L256 277.45l-22.52 22.42L256 322.29l-22.52 22.42L211 322.29l-33.78 33.63-22.55-22.42 33.78-33.63-22.52-22.42L188.45 255 211 277.45 233.48 255 211 232.61l22.51-22.42L256 232.61l22.52-22.42L256 187.77l22.52-22.42L301 187.77zM478.48 198.41C485.85 143.65 464 48.05 464 48.05s-96.14-21.88-151.14-14.54c-2.54.33-5.21.72-8 1.14l172.47 171.71c.43-2.76.81-5.42 1.15-7.95zM33.52 311.65C26.15 366.41 48.05 464 48.05 464s60 16 99.86 16a391.92 391.92 0 0051.23-3.45c2.54-.33 5.21-.72 8-1.15L34.67 303.7c-.43 2.76-.81 5.42-1.15 7.95z"/></svg>

After

Width:  |  Height:  |  Size: 907 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" class="ionicon" viewBox="0 0 512 512"><path d="M122.06 122.06c-44.37 44.37-66.71 100.61-78 145.28l200.6 200.56c44.67-11.25 100.91-33.59 145.28-78s66.71-100.61 78-145.28L267.34 44.1c-44.67 11.25-100.91 33.59-145.28 77.96zm256.73 256.72zM300.65 189L323 166.71A15.78 15.78 0 01345.29 189L323 211.35l11.16 11.17a15.78 15.78 0 01-22.32 22.32l-11.16-11.16L278.32 256l11.16 11.16a15.78 15.78 0 11-22.32 22.32L256 278.32l-22.32 22.33 11.16 11.16a15.78 15.78 0 11-22.32 22.32L211.35 323 189 345.29A15.78 15.78 0 01166.71 323L189 300.65l-11.16-11.17a15.78 15.78 0 0122.32-22.32l11.16 11.16L233.68 256l-11.16-11.16a15.78 15.78 0 1122.32-22.32L256 233.68l22.32-22.33-11.16-11.16a15.78 15.78 0 0122.32-22.32zM476.57 199.63c7.31-54.53 4-120.26-20-144.21s-89.68-27.3-144.21-20c-2.51.34-5.16.72-7.91 1.15l171 171c.4-2.78.78-5.43 1.12-7.94zM35.43 312.37c-7.31 54.53-4 120.26 20 144.21C72.17 473.33 109.34 480 148.84 480a387 387 0 0050.79-3.43c2.51-.34 5.16-.72 7.91-1.15l-171-171c-.39 2.79-.77 5.44-1.11 7.95z"/></svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" class="ionicon" viewBox="0 0 512 512"><path stroke-linecap="round" stroke-linejoin="round" d="M344 280l88-88M232 216l64 64M80 320l104-104" class="ionicon-fill-none ionicon-stroke-width"/><circle cx="456" cy="168" r="24" stroke-linecap="round" stroke-linejoin="round" class="ionicon-fill-none ionicon-stroke-width"/><circle cx="320" cy="304" r="24" stroke-linecap="round" stroke-linejoin="round" class="ionicon-fill-none ionicon-stroke-width"/><circle cx="208" cy="192" r="24" stroke-linecap="round" stroke-linejoin="round" class="ionicon-fill-none ionicon-stroke-width"/><circle cx="56" cy="344" r="24" stroke-linecap="round" stroke-linejoin="round" class="ionicon-fill-none ionicon-stroke-width"/></svg>

After

Width:  |  Height:  |  Size: 744 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" class="ionicon" viewBox="0 0 512 512"><path d="M450 128a46 46 0 00-44.11 59l-71.37 71.36a45.88 45.88 0 00-29 0l-52.91-52.91a46 46 0 10-89.12 0L75 293.88A46.08 46.08 0 10106.11 325l87.37-87.36a45.85 45.85 0 0029 0l52.92 52.92a46 46 0 1089.12 0L437 218.12A46 46 0 10450 128z"/></svg>

After

Width:  |  Height:  |  Size: 321 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" class="ionicon" viewBox="0 0 512 512"><path d="M456 128a40 40 0 00-37.23 54.6l-84.17 84.17a39.86 39.86 0 00-29.2 0l-60.17-60.17a40 40 0 10-74.46 0L70.6 306.77a40 40 0 1022.63 22.63L193.4 229.23a39.86 39.86 0 0029.2 0l60.17 60.17a40 40 0 1074.46 0l84.17-84.17A40 40 0 10456 128z"/></svg>

After

Width:  |  Height:  |  Size: 326 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" class="ionicon" viewBox="0 0 512 512"><path d="M448 256c0-106-86-192-192-192S64 150 64 256s86 192 192 192 192-86 192-192z" stroke-miterlimit="10" class="ionicon-fill-none ionicon-stroke-width"/><path stroke-linecap="round" stroke-linejoin="round" d="M360 94.59V296M443.13 212.87L296 360M417.41 360H216M299.13 443.13l-144-144M152 416V216M68.87 299.13l144-144M94.59 152H288M212.87 68.87L360 216" class="ionicon-fill-none ionicon-stroke-width"/></svg>

After

Width:  |  Height:  |  Size: 488 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" class="ionicon" viewBox="0 0 512 512"><path d="M216 48l129.49 128.18V48H216zM181.47 58.38L80 134h176l-74.53-75.62zM336 344h128V216L336 344zM454 182L378 80v176l76-74zM48 166v128l128-128H48zM330 454l102-76H256l74 76zM58 330l76 102V256l-76 74zM345.49 222.12l-55.55-55.46h-67.88l-55.55 55.46v67.76l55.62 55.52c.44 0 .88-.06 1.33-.06h66.48l55.55-55.46zM165.98 336.09L166 464h128L165.98 336.09z"/></svg>

After

Width:  |  Height:  |  Size: 437 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" class="ionicon" viewBox="0 0 512 512"><path d="M250.54 129.17l-67.8-67.8A209.65 209.65 0 0086.32 136h161.4a4 4 0 002.82-6.83zM167.72 168H67.63a207.34 207.34 0 00-16.15 125.9l119.06-119.07a4 4 0 00-2.82-6.83zM344 167.72V67.56a207.82 207.82 0 00-125.89-16.08l119.06 119.06a4 4 0 006.83-2.82zM460.52 218.1L341.46 337.17a4 4 0 002.82 6.83h100.09a207.34 207.34 0 0016.15-125.9zM382.83 250.54l67.83-67.82A209.08 209.08 0 00376 86.2v161.52a4 4 0 006.83 2.82zM221.68 341.77a8 8 0 005.54 2.23h59.66a8 8 0 005.7-2.39l49.18-50a8 8 0 002.3-5.62l-.06-60.81a8 8 0 00-2.38-5.69l-50-49.25a8 8 0 00-5.63-2.3l-60.84.06a8 8 0 00-5.69 2.38l-49.25 50a8 8 0 00-2.3 5.63l.06 60.78a8 8 0 002.45 5.76zM261.46 382.83l67.8 67.8A209.65 209.65 0 00425.68 376h-161.4a4 4 0 00-2.82 6.83zM168 344.28v100.16a207.82 207.82 0 00125.89 16.08L174.83 341.46a4 4 0 00-6.83 2.82zM129.17 261.46l-67.83 67.83A209.1 209.1 0 00136 425.8V264.28a4 4 0 00-6.83-2.82z"/></svg>

After

Width:  |  Height:  |  Size: 968 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" class="ionicon" viewBox="0 0 512 512"><rect x="64" y="64" width="80" height="80" rx="40" ry="40" stroke-miterlimit="10" class="ionicon-fill-none ionicon-stroke-width"/><rect x="216" y="64" width="80" height="80" rx="40" ry="40" stroke-miterlimit="10" class="ionicon-fill-none ionicon-stroke-width"/><rect x="368" y="64" width="80" height="80" rx="40" ry="40" stroke-miterlimit="10" class="ionicon-fill-none ionicon-stroke-width"/><rect x="64" y="216" width="80" height="80" rx="40" ry="40" stroke-miterlimit="10" class="ionicon-fill-none ionicon-stroke-width"/><rect x="216" y="216" width="80" height="80" rx="40" ry="40" stroke-miterlimit="10" class="ionicon-fill-none ionicon-stroke-width"/><rect x="368" y="216" width="80" height="80" rx="40" ry="40" stroke-miterlimit="10" class="ionicon-fill-none ionicon-stroke-width"/><rect x="64" y="368" width="80" height="80" rx="40" ry="40" stroke-miterlimit="10" class="ionicon-fill-none ionicon-stroke-width"/><rect x="216" y="368" width="80" height="80" rx="40" ry="40" stroke-miterlimit="10" class="ionicon-fill-none ionicon-stroke-width"/><rect x="368" y="368" width="80" height="80" rx="40" ry="40" stroke-miterlimit="10" class="ionicon-fill-none ionicon-stroke-width"/></svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" class="ionicon" viewBox="0 0 512 512"><rect x="48" y="48" width="112" height="112" rx="8" ry="8"/><rect x="200" y="48" width="112" height="112" rx="8" ry="8"/><rect x="352" y="48" width="112" height="112" rx="8" ry="8"/><rect x="48" y="200" width="112" height="112" rx="8" ry="8"/><rect x="200" y="200" width="112" height="112" rx="8" ry="8"/><rect x="352" y="200" width="112" height="112" rx="8" ry="8"/><rect x="48" y="352" width="112" height="112" rx="8" ry="8"/><rect x="200" y="352" width="112" height="112" rx="8" ry="8"/><rect x="352" y="352" width="112" height="112" rx="8" ry="8"/></svg>

After

Width:  |  Height:  |  Size: 636 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" class="ionicon" viewBox="0 0 512 512"><path d="M104 160a56 56 0 1156-56 56.06 56.06 0 01-56 56zM256 160a56 56 0 1156-56 56.06 56.06 0 01-56 56zM408 160a56 56 0 1156-56 56.06 56.06 0 01-56 56zM104 312a56 56 0 1156-56 56.06 56.06 0 01-56 56zM256 312a56 56 0 1156-56 56.06 56.06 0 01-56 56zM408 312a56 56 0 1156-56 56.06 56.06 0 01-56 56zM104 464a56 56 0 1156-56 56.06 56.06 0 01-56 56zM256 464a56 56 0 1156-56 56.06 56.06 0 01-56 56zM408 464a56 56 0 1156-56 56.06 56.06 0 01-56 56z"/></svg>

After

Width:  |  Height:  |  Size: 528 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" class="ionicon" viewBox="0 0 512 512"><path d="M80 152v256a40.12 40.12 0 0040 40h272a40.12 40.12 0 0040-40V152" stroke-linecap="round" stroke-linejoin="round" class="ionicon-fill-none ionicon-stroke-width"/><rect x="48" y="64" width="416" height="80" rx="28" ry="28" stroke-linejoin="round" class="ionicon-fill-none ionicon-stroke-width"/><path stroke-linecap="round" stroke-linejoin="round" d="M320 304l-64 64-64-64M256 345.89V224" class="ionicon-fill-none ionicon-stroke-width"/></svg>

After

Width:  |  Height:  |  Size: 527 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" class="ionicon" viewBox="0 0 512 512"><rect x="32" y="48" width="448" height="80" rx="12" ry="12"/><path d="M64 160v280a24 24 0 0024 24h336a24 24 0 0024-24V160zm192 230.63L169.32 304 192 281.32l48 48.05V208h32v121.37l48.07-48.07 22.61 22.64z"/></svg>

After

Width:  |  Height:  |  Size: 290 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" class="ionicon" viewBox="0 0 512 512"><path d="M64 164v244a56 56 0 0056 56h272a56 56 0 0056-56V164a4 4 0 00-4-4H68a4 4 0 00-4 4zm267 151.63l-63.69 63.68a16 16 0 01-22.62 0L181 315.63c-6.09-6.09-6.65-16-.85-22.38a16 16 0 0123.16-.56L240 329.37V224.45c0-8.61 6.62-16 15.23-16.43A16 16 0 01272 224v105.37l36.69-36.68a16 16 0 0123.16.56c5.8 6.37 5.24 16.29-.85 22.38z"/><rect x="32" y="48" width="448" height="80" rx="32" ry="32"/></svg>

After

Width:  |  Height:  |  Size: 473 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" class="ionicon" viewBox="0 0 512 512"><path stroke-linecap="round" stroke-linejoin="round" d="M249.38 336L170 256l79.38-80M181.03 256H342" class="ionicon-fill-none ionicon-stroke-width"/><path d="M448 256c0-106-86-192-192-192S64 150 64 256s86 192 192 192 192-86 192-192z" stroke-miterlimit="10" class="ionicon-fill-none ionicon-stroke-width"/></svg>

After

Width:  |  Height:  |  Size: 389 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" class="ionicon" viewBox="0 0 512 512"><path d="M48 256c0 114.87 93.13 208 208 208s208-93.13 208-208S370.87 48 256 48 48 141.13 48 256zm224-80.09L208.42 240H358v32H208.42L272 336.09l-22.7 22.54L147.46 256 249.3 153.37z"/></svg>

After

Width:  |  Height:  |  Size: 266 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" class="ionicon" viewBox="0 0 512 512"><path d="M48 256c0 114.87 93.13 208 208 208s208-93.13 208-208S370.87 48 256 48 48 141.13 48 256zm212.65-91.36a16 16 0 01.09 22.63L208.42 240H342a16 16 0 010 32H208.42l52.32 52.73A16 16 0 11238 347.27l-79.39-80a16 16 0 010-22.54l79.39-80a16 16 0 0122.65-.09z"/></svg>

After

Width:  |  Height:  |  Size: 344 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" class="ionicon" viewBox="0 0 512 512"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="48" d="M244 400L100 256l144-144M120 256h292" class="ionicon-fill-none"/></svg>

After

Width:  |  Height:  |  Size: 223 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" class="ionicon" viewBox="0 0 512 512"><path stroke-linecap="square" stroke-miterlimit="10" stroke-width="48" d="M244 400L100 256l144-144M120 256h292" class="ionicon-fill-none"/></svg>

After

Width:  |  Height:  |  Size: 223 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" class="ionicon" viewBox="0 0 512 512"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="48" d="M244 400L100 256l144-144M120 256h292" class="ionicon-fill-none"/></svg>

After

Width:  |  Height:  |  Size: 223 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" class="ionicon" viewBox="0 0 512 512"><path stroke-linecap="round" stroke-linejoin="round" d="M176 262.62L256 342l80-79.38M256 330.97V170" class="ionicon-fill-none ionicon-stroke-width"/><path d="M256 64C150 64 64 150 64 256s86 192 192 192 192-86 192-192S362 64 256 64z" stroke-miterlimit="10" class="ionicon-fill-none ionicon-stroke-width"/></svg>

After

Width:  |  Height:  |  Size: 388 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" class="ionicon" viewBox="0 0 512 512"><path d="M256 464c114.87 0 208-93.13 208-208S370.87 48 256 48 48 141.13 48 256s93.13 208 208 208zm-80.09-224L240 303.58V154h32v149.58L336.09 240l22.54 22.71L256 364.54 153.37 262.7z"/></svg>

After

Width:  |  Height:  |  Size: 268 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" class="ionicon" viewBox="0 0 512 512"><path d="M256 464c114.87 0 208-93.13 208-208S370.87 48 256 48 48 141.13 48 256s93.13 208 208 208zm-91.36-212.65a16 16 0 0122.63-.09L240 303.58V170a16 16 0 0132 0v133.58l52.73-52.32A16 16 0 11347.27 274l-80 79.39a16 16 0 01-22.54 0l-80-79.39a16 16 0 01-.09-22.65z"/></svg>

After

Width:  |  Height:  |  Size: 349 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" class="ionicon" viewBox="0 0 512 512"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="48" d="M112 268l144 144 144-144M256 392V100" class="ionicon-fill-none"/></svg>

After

Width:  |  Height:  |  Size: 223 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" class="ionicon" viewBox="0 0 512 512"><path stroke-linecap="square" stroke-miterlimit="10" stroke-width="48" d="M112 268l144 144 144-144M256 392V100" class="ionicon-fill-none"/></svg>

After

Width:  |  Height:  |  Size: 223 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" class="ionicon" viewBox="0 0 512 512"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="48" d="M112 268l144 144 144-144M256 392V100" class="ionicon-fill-none"/></svg>

After

Width:  |  Height:  |  Size: 223 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" class="ionicon" viewBox="0 0 512 512"><path stroke-linecap="round" stroke-linejoin="round" d="M262.62 336L342 256l-79.38-80M330.97 256H170" class="ionicon-fill-none ionicon-stroke-width"/><path d="M256 448c106 0 192-86 192-192S362 64 256 64 64 150 64 256s86 192 192 192z" stroke-miterlimit="10" class="ionicon-fill-none ionicon-stroke-width"/></svg>

After

Width:  |  Height:  |  Size: 389 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" class="ionicon" viewBox="0 0 512 512"><path d="M464 256c0-114.87-93.13-208-208-208S48 141.13 48 256s93.13 208 208 208 208-93.13 208-208zm-224 80.09L303.58 272H154v-32h149.58L240 175.91l22.71-22.54L364.54 256 262.7 358.63z"/></svg>

After

Width:  |  Height:  |  Size: 270 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" class="ionicon" viewBox="0 0 512 512"><path d="M464 256c0-114.87-93.13-208-208-208S48 141.13 48 256s93.13 208 208 208 208-93.13 208-208zm-212.65 91.36a16 16 0 01-.09-22.63L303.58 272H170a16 16 0 010-32h133.58l-52.32-52.73A16 16 0 11274 164.73l79.39 80a16 16 0 010 22.54l-79.39 80a16 16 0 01-22.65.09z"/></svg>

After

Width:  |  Height:  |  Size: 349 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" class="ionicon" viewBox="0 0 512 512"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="48" d="M268 112l144 144-144 144M392 256H100" class="ionicon-fill-none"/></svg>

After

Width:  |  Height:  |  Size: 223 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" class="ionicon" viewBox="0 0 512 512"><path stroke-linecap="square" stroke-miterlimit="10" stroke-width="48" d="M268 112l144 144-144 144M392 256H100" class="ionicon-fill-none"/></svg>

After

Width:  |  Height:  |  Size: 223 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" class="ionicon" viewBox="0 0 512 512"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="48" d="M268 112l144 144-144 144M392 256H100" class="ionicon-fill-none"/></svg>

After

Width:  |  Height:  |  Size: 223 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" class="ionicon" viewBox="0 0 512 512"><path d="M266.91 327.74v-37.32c-57.07 0-84.51 13.47-108.58 38.68-5.4 5.65-15 1.32-14.29-6.43 5.45-61.45 34.14-117.09 122.87-117.09v-37.32a8.32 8.32 0 0114-6L365.42 242a8.2 8.2 0 010 11.94L281 333.71a8.32 8.32 0 01-14.09-5.97z"/><path d="M64 256c0 106 86 192 192 192s192-86 192-192S362 64 256 64 64 150 64 256z" stroke-miterlimit="10" class="ionicon-fill-none ionicon-stroke-width"/></svg>

After

Width:  |  Height:  |  Size: 466 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" class="ionicon" viewBox="0 0 512 512"><path d="M48 256c0 114.87 93.13 208 208 208s208-93.13 208-208S370.87 48 256 48 48 141.13 48 256zm98 88c0-68.13 22.67-137.14 119.17-137.14V152L366 248l-100.83 96v-54.86c-66.69 0-91.32 19.29-119.17 54.86z"/></svg>

After

Width:  |  Height:  |  Size: 289 B

Some files were not shown because too many files have changed in this diff Show More