wip1
This commit is contained in:
@@ -1,4 +1,10 @@
|
||||
# Config file path (default: config.yml)
|
||||
# CONFIG_PATH=config.yml
|
||||
|
||||
APP_DEBUG=false
|
||||
APP_TITLE=IPTV Checker
|
||||
APP_TIMEZONE=GMT
|
||||
APP_URL=http://localhost:8080
|
||||
|
||||
CACHE_ENABLED=false
|
||||
CACHE_HOST=localhost
|
||||
@@ -7,3 +13,8 @@ CACHE_USERNAME=
|
||||
CACHE_PASSWORD=
|
||||
CACHE_DB=1
|
||||
CACHE_TTL=1800
|
||||
|
||||
WEB_PORT=8080
|
||||
WEB_HOST=
|
||||
PAGE_SIZE=0
|
||||
REPO_URL=https://git.axenov.dev/IPTV
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
bin/
|
||||
output/
|
||||
|
||||
.DS_Store
|
||||
.env
|
||||
*.bak
|
||||
*.m3u
|
||||
|
||||
@@ -5,21 +5,26 @@
|
||||
Консольная программа для проверки 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
|
||||
|
||||
## Компиляция
|
||||
|
||||
@@ -83,6 +88,146 @@
|
||||
> [!NOTE]
|
||||
> Набери `./iptvc help` для получения помощи.
|
||||
|
||||
## Веб-интерфейс
|
||||
|
||||
Программа включает встроенный веб-сервер для просмотра плейлистов и результатов их проверки.
|
||||
|
||||
### Запуск
|
||||
|
||||
```
|
||||
./iptvc serve -i playlists.ini -p 8080
|
||||
```
|
||||
|
||||
### Параметры команды `serve`
|
||||
|
||||
* `-p, --port` — порт для веб-сервера (переопределяет `config.yml` и `WEB_PORT`);
|
||||
* `--host` — хост для привязки (переопределяет `config.yml` и `WEB_HOST`);
|
||||
* `--check` — включить фоновую проверку плейлистов (по умолчанию выключена).
|
||||
|
||||
При указании `--check` доступны флаги проверки:
|
||||
|
||||
* `-i, --ini` — путь к ini-файлу (по умолчанию `./playlists.ini`);
|
||||
* `-t, --tags` — путь к файлу тегов (по умолчанию `./channels.json`);
|
||||
* `-r, --random` — проверить N случайных плейлистов из ini-файла;
|
||||
* `--every` — интервал между проверками в секундах (по умолчанию `60`);
|
||||
* `--repeat` — количество циклов проверки (по умолчанию `0` — бесконечно).
|
||||
|
||||
### Конфигурация
|
||||
|
||||
Приоритет настроек (от низшего к высшему):
|
||||
|
||||
1. **Значения по умолчанию** — встроены в код;
|
||||
2. **`config.yml`** — YAML-файл в корне проекта (путь можно задать через флаг `--config`);
|
||||
3. **Переменные окружения** — переопределяют значения из `config.yml` (если заданы);
|
||||
4. **CLI-флаги** — переопределяют значения из окружения и `config.yml` (если заданы явно).
|
||||
|
||||
Флаги `--port` и `--host` переопределяют конфигурацию только если переданы явно.
|
||||
Если флаг не указан, используется значение из переменной окружения, затем из `config.yml`,
|
||||
затем значение по умолчанию.
|
||||
|
||||
Файл `.env` загружается автоматически, переменные из него применяются как переменные окружения.
|
||||
|
||||
#### `config.yml`
|
||||
|
||||
```yaml
|
||||
app:
|
||||
title: IPTV Checker
|
||||
timezone: GMT
|
||||
debug: false
|
||||
log_level: info
|
||||
|
||||
server:
|
||||
host: localhost
|
||||
port: 8080
|
||||
base_url: http://localhost:8080
|
||||
page_size: 0
|
||||
repo_url: https://git.axenov.dev/IPTV
|
||||
|
||||
check:
|
||||
playlists:
|
||||
user-agent:
|
||||
- Mozilla/5.0 WINK/1.31.1 (AndroidTV/9) HlsWinkPlayer
|
||||
timeout: [0, 10000] # ms, макс время ожидания ответа при проверке каждого плейлиста
|
||||
all-cooldown: [0, 10000] # ms, задержка между проверками всех плейлистов
|
||||
one-cooldown: [0, 10000] # ms, задержка между проверками каждого плейлиста
|
||||
per-routine: [0, 10000] # количество одновременно проверяемых плейлистов
|
||||
channels:
|
||||
user-agent: Mozilla/5.0 WINK/1.31.1 (AndroidTV/9) HlsWinkPlayer
|
||||
timeout: 10 # sec, макс время ожидания ответа при проверке каждого канала
|
||||
byte-range: 512 # bytes, количество байт для получения от сервера
|
||||
all-cooldown: 0 # ms, задержка между проверками всех каналов плейлиста
|
||||
one-cooldown: 0 # ms, задержка между проверками каждого канала плейлиста
|
||||
per-routine: 10 # количество одновременно проверяемых каналов
|
||||
|
||||
cache:
|
||||
enabled: false
|
||||
host: localhost
|
||||
port: 6379
|
||||
username:
|
||||
password:
|
||||
db: 1
|
||||
ttl: 1800
|
||||
```
|
||||
|
||||
Параметры `check.playlists` и `check.channels` (кроме `byte-range`) могут быть заданы как скаляр или массив `[min, max]`. При массиве каждый раз выбирается случайное значение в диапазоне. `user-agent` может быть строкой или массивом строк (случайный выбор). `byte-range` — всегда целое число.
|
||||
|
||||
#### Переменные окружения
|
||||
|
||||
| Переменная | Соответствует в `config.yml` | Описание |
|
||||
| ---------------- | ---------------------------- | ----------------------------------- |
|
||||
| `APP_DEBUG` | `app.debug` | Режим отладки |
|
||||
| `APP_TITLE` | `app.title` | Заголовок сайта |
|
||||
| `APP_TIMEZONE` | `app.timezone` | Часовой пояс |
|
||||
| `APP_URL` | `server.base_url` | Базовый URL для ссылок |
|
||||
| `CACHE_ENABLED` | `cache.enabled` | Включить кеш (KeyDB/Redis) |
|
||||
| `CACHE_HOST` | `cache.host` | Хост KeyDB/Redis |
|
||||
| `CACHE_PORT` | `cache.port` | Порт KeyDB/Redis |
|
||||
| `CACHE_USERNAME` | `cache.username` | Имя пользователя KeyDB/Redis |
|
||||
| `CACHE_PASSWORD` | `cache.password` | Пароль KeyDB/Redis |
|
||||
| `CACHE_DB` | `cache.db` | Номер БД KeyDB/Redis |
|
||||
| `CACHE_TTL` | `cache.ttl` | TTL записей в кеше (сек) |
|
||||
| `WEB_PORT` | `server.port` | Порт веб-сервера |
|
||||
| `WEB_HOST` | `server.host` | Хост для привязки |
|
||||
| `PAGE_SIZE` | `server.page_size` | Размер страницы (0 — без пагинации) |
|
||||
| `REPO_URL` | `server.repo_url` | Ссылка на репозиторий |
|
||||
|
||||
### Маршруты
|
||||
|
||||
| Метод | Путь | Описание |
|
||||
| ----- | ----------------------- | ---------------------------------------- |
|
||||
| GET | `/` | Главная страница со списком плейлистов |
|
||||
| GET | `/page/{N}` | Страница N списка плейлистов |
|
||||
| GET | `/{code}` | Редирект на прямую ссылку плейлиста |
|
||||
| GET | `/{code}.m3u[8]` | Редирект на прямую ссылку плейлиста |
|
||||
| GET | `/{code}/details` | Страница с описанием плейлиста |
|
||||
| GET | `/api/playlists/{code}` | JSON: информация о плейлисте |
|
||||
| GET | `/api/version` | JSON: версии компонентов |
|
||||
| GET | `/api/health` | JSON: состояние сервиса |
|
||||
| GET | `/api/stats` | JSON: статистика по плейлистам и каналам |
|
||||
|
||||
### Связь с проверкой
|
||||
|
||||
Веб-сервер отображает данные из кеша KeyDB/Redis, который заполняется командой `check`.
|
||||
Если кеш недоступен, все плейлисты отображаются со статусом `unknown`.
|
||||
|
||||
Для обновления данных в фоне запустите `serve` с флагом `--check`:
|
||||
|
||||
```
|
||||
./iptvc serve -i playlists.ini -p 8080 --check
|
||||
```
|
||||
|
||||
Результаты проверок появляются на веб-страницах немедленно после проверки каждого
|
||||
плейлиста, не дожидаясь завершения полного цикла.
|
||||
|
||||
Можно запускать `serve` и `check` отдельными процессами:
|
||||
|
||||
```
|
||||
./iptvc check --repeat 0 --every 60 &
|
||||
./iptvc serve
|
||||
```
|
||||
|
||||
Или используйте docker-окружение проекта.
|
||||
|
||||
## Результаты проверки
|
||||
|
||||
Программа логирует процесс и результаты своей работы.
|
||||
|
||||
+46
-3
@@ -26,6 +26,42 @@ type Arguments struct {
|
||||
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 +70,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)
|
||||
}
|
||||
}
|
||||
|
||||
+118
-85
@@ -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,106 @@ 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: задержка после проверки каждого плейлиста
|
||||
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)
|
||||
// all-cooldown: задержка после проверки всех плейлистов
|
||||
allCd := time.Duration(pc.AllCooldown.Value()) * time.Millisecond
|
||||
if allCd > 0 {
|
||||
log.Printf("All playlists checked, cooldown %dms...\n", allCd.Milliseconds())
|
||||
time.Sleep(allCd)
|
||||
}
|
||||
|
||||
return onlineCount, offlineCount
|
||||
@@ -183,7 +233,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 +250,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=%dms byte-range=%d max-routines=%d\n", timeoutMs, byteRange, maxRoutines)
|
||||
|
||||
startTime := time.Now()
|
||||
for _, tvChannel := range pls.Channels {
|
||||
wg.Add(1)
|
||||
@@ -227,8 +291,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 +304,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 +327,12 @@ func CheckChannels(pls playlist.Playlist) playlist.Playlist {
|
||||
}
|
||||
|
||||
chOnline <- tvChannel
|
||||
return
|
||||
|
||||
// cooldown: задержка после проверки каждого канала
|
||||
cd := time.Duration(cc.Cooldown.Value()) * time.Millisecond
|
||||
if cd > 0 {
|
||||
time.Sleep(cd)
|
||||
}
|
||||
}(tvChannel)
|
||||
}
|
||||
|
||||
@@ -323,42 +392,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
|
||||
|
||||
+517
-37
@@ -7,71 +7,551 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"github.com/joho/godotenv"
|
||||
"fmt"
|
||||
"log"
|
||||
"math/rand"
|
||||
"os"
|
||||
"strconv"
|
||||
|
||||
"github.com/joho/godotenv"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// 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"`
|
||||
}
|
||||
|
||||
// AppConfig описывает общие настройки приложения
|
||||
type AppConfig struct {
|
||||
Timezone string `yaml:"timezone"`
|
||||
Debug bool `yaml:"debug"`
|
||||
LogLevel string `yaml:"log_level"`
|
||||
Playlists string `yaml:"playlists"`
|
||||
Tags string `yaml:"tags"`
|
||||
}
|
||||
|
||||
// ServerConfig описывает конфигурацию веб-сервера
|
||||
type ServerConfig struct {
|
||||
Port uint `yaml:"port"`
|
||||
Host string `yaml:"host"`
|
||||
}
|
||||
|
||||
// Link описывает ссылку в навигации или подвале сайта.
|
||||
// Если заданы Children, рендерится как выпадающее меню.
|
||||
type Link struct {
|
||||
Title string `yaml:"title"`
|
||||
Url string `yaml:"url"`
|
||||
Icon string `yaml:"icon"`
|
||||
Children []Link `yaml:"children"`
|
||||
}
|
||||
|
||||
// HeaderConfig описывает настройки шапки сайта
|
||||
type HeaderConfig struct {
|
||||
Title string `yaml:"title"`
|
||||
Navigation []Link `yaml:"navigation"`
|
||||
}
|
||||
|
||||
// SiteConfig описывает настройки сайта
|
||||
type SiteConfig struct {
|
||||
BaseUrl string `yaml:"base-url"`
|
||||
RepoUrl string `yaml:"repo-url"`
|
||||
PageSize uint `yaml:"page-size"`
|
||||
Favicon string `yaml:"favicon"`
|
||||
Header HeaderConfig `yaml:"header"`
|
||||
FooterLinks []Link `yaml:"footer-links"`
|
||||
}
|
||||
|
||||
// CacheConfig описывает конфигурацию подключения к keydb
|
||||
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"`
|
||||
}
|
||||
|
||||
// CheckPlaylistsConfig — параметры проверки плейлистов
|
||||
type CheckPlaylistsConfig struct {
|
||||
UserAgent UserAgents `yaml:"user-agent"`
|
||||
Timeout int `yaml:"timeout"` // ms
|
||||
AllCooldown IntRange `yaml:"all-cooldown"` // ms
|
||||
OneCooldown IntRange `yaml:"one-cooldown"` // ms
|
||||
MaxRoutines int `yaml:"max-routines"` // count
|
||||
PerRoutine int `yaml:"per-routine"` // count
|
||||
}
|
||||
|
||||
// CheckChannelsConfig — параметры проверки каналов
|
||||
type CheckChannelsConfig struct {
|
||||
UserAgent UserAgents `yaml:"user-agent"`
|
||||
Timeout int `yaml:"timeout"` // ms
|
||||
ByteRange int `yaml:"byte-range"` // bytes
|
||||
Cooldown IntRange `yaml:"cooldown"` // ms
|
||||
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.
|
||||
// Поддерживает скаляр (int) или массив из двух int [min, max].
|
||||
func (r *IntRange) UnmarshalYAML(value *yaml.Node) error {
|
||||
if value.Kind == yaml.ScalarNode {
|
||||
var v int
|
||||
if err := value.Decode(&v); err != nil {
|
||||
return err
|
||||
}
|
||||
r.Min = v
|
||||
r.Max = v
|
||||
return nil
|
||||
}
|
||||
if value.Kind == yaml.SequenceNode {
|
||||
var arr []int
|
||||
if err := value.Decode(&arr); err != nil {
|
||||
return err
|
||||
}
|
||||
if len(arr) >= 2 {
|
||||
r.Min = arr[0]
|
||||
r.Max = arr[1]
|
||||
} else if len(arr) == 1 {
|
||||
r.Min = arr[0]
|
||||
r.Max = arr[0]
|
||||
}
|
||||
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: 8080,
|
||||
Host: "",
|
||||
},
|
||||
Site: SiteConfig{
|
||||
BaseUrl: "http://localhost:8080",
|
||||
PageSize: 0,
|
||||
RepoUrl: "https://git.axenov.dev/IPTV",
|
||||
Header: HeaderConfig{
|
||||
Title: "IPTV Checker",
|
||||
Navigation: []Link{
|
||||
{Title: "Аптайм", Url: "https://status.m3u.su", Icon: "pulse-outline"},
|
||||
{Title: "Документация", Url: "/docs", Icon: "document-text-outline"},
|
||||
{Title: "Исходники", Url: "https://git.axenov.dev/IPTV", Icon: "code-slash-outline"},
|
||||
{Title: "Telegram", Icon: "paper-plane-outline", Children: []Link{
|
||||
{Title: "Канал @iptv_aggregator", Url: "https://t.me/iptv_aggregator", Icon: "megaphone-outline"},
|
||||
{Title: "Чат @iptv_aggregator_chat", Url: "https://t.me/iptv_aggregator_chat", Icon: "chatbubbles-outline"},
|
||||
{Title: "Бот @iptv_aggregator_bot", Url: "https://t.me/iptv_aggregator_bot", Icon: "chatbox-ellipses-outline"},
|
||||
}},
|
||||
},
|
||||
},
|
||||
FooterLinks: []Link{
|
||||
{Title: "Исходники", Url: "https://git.axenov.dev/IPTV", Icon: "code-slash-outline"},
|
||||
{Title: "axenov.dev", Url: "https://axenov.dev", Icon: "person-outline"},
|
||||
{Title: "Канал", Url: "https://t.me/iptv_aggregator", Icon: "megaphone-outline"},
|
||||
{Title: "Чат", Url: "https://t.me/iptv_aggregator_chat", Icon: "chatbubbles-outline"},
|
||||
{Title: "Бот", Url: "https://t.me/iptv_aggregator_bot", Icon: "chatbox-ellipses-outline"},
|
||||
},
|
||||
},
|
||||
Check: CheckConfig{
|
||||
StartOnServe: false,
|
||||
Playlists: CheckPlaylistsConfig{
|
||||
UserAgent: UserAgents{"Mozilla/5.0 WINK/1.31.1 (AndroidTV/9) HlsWinkPlayer"},
|
||||
Timeout: 10000,
|
||||
AllCooldown: IntRange{Min: 0, Max: 0},
|
||||
OneCooldown: IntRange{Min: 0, Max: 0},
|
||||
MaxRoutines: 5,
|
||||
PerRoutine: 1,
|
||||
},
|
||||
Channels: CheckChannelsConfig{
|
||||
UserAgent: UserAgents{"Mozilla/5.0 WINK/1.31.1 (AndroidTV/9) HlsWinkPlayer"},
|
||||
Timeout: 10000,
|
||||
ByteRange: 512,
|
||||
Cooldown: IntRange{Min: 0, Max: 0},
|
||||
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: 1800,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// 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("WEB_PORT"); ok && v != "" {
|
||||
cfg.Server.Port = parseUint(v, cfg.Server.Port)
|
||||
}
|
||||
if v, ok := os.LookupEnv("WEB_HOST"); ok {
|
||||
cfg.Server.Host = v
|
||||
}
|
||||
|
||||
// site
|
||||
if v, ok := os.LookupEnv("APP_URL"); ok && v != "" {
|
||||
cfg.Site.BaseUrl = v
|
||||
}
|
||||
if v, ok := os.LookupEnv("PAGE_SIZE"); ok && v != "" {
|
||||
cfg.Site.PageSize = parseUint(v, cfg.Site.PageSize)
|
||||
}
|
||||
if v, ok := os.LookupEnv("REPO_URL"); ok && v != "" {
|
||||
cfg.Site.RepoUrl = v
|
||||
}
|
||||
if v, ok := os.LookupEnv("SITE_FAVICON"); ok {
|
||||
cfg.Site.Favicon = v
|
||||
}
|
||||
if v, ok := os.LookupEnv("APP_TITLE"); ok && v != "" {
|
||||
cfg.Site.Header.Title = v
|
||||
}
|
||||
|
||||
// check
|
||||
if v, ok := os.LookupEnv("CHECK_START_ON_SERVE"); ok {
|
||||
cfg.Check.StartOnServe = parseBool(v, cfg.Check.StartOnServe)
|
||||
}
|
||||
|
||||
// check.playlists
|
||||
pp := &cfg.Check.Playlists
|
||||
if v, ok := os.LookupEnv("CHECK_PLAYLISTS_TIMEOUT"); ok && v != "" {
|
||||
pp.Timeout = parseInt(v, pp.Timeout)
|
||||
}
|
||||
applyEnvIntRange(&pp.AllCooldown, "CHECK_PLAYLISTS_ALL_COOLDOWN", "CHECK_PLAYLISTS_ALL_COOLDOWN_MIN", "CHECK_PLAYLISTS_ALL_COOLDOWN_MAX")
|
||||
applyEnvIntRange(&pp.OneCooldown, "CHECK_PLAYLISTS_ONE_COOLDOWN", "CHECK_PLAYLISTS_ONE_COOLDOWN_MIN", "CHECK_PLAYLISTS_ONE_COOLDOWN_MAX")
|
||||
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 = parseInt(v, 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", "CHECK_CHANNELS_COOLDOWN_MIN", "CHECK_CHANNELS_COOLDOWN_MAX")
|
||||
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
|
||||
}
|
||||
|
||||
// applyEnvIntRange читает env-переменные для IntRange.
|
||||
// Если VAR_MIN и VAR_MAX обе заданы — используется диапазон.
|
||||
// Если только VAR задана — используется скаляр (Min == Max).
|
||||
func applyEnvIntRange(r *IntRange, scalarName, minName, maxName string) {
|
||||
minVal, minOk := os.LookupEnv(minName)
|
||||
maxVal, maxOk := os.LookupEnv(maxName)
|
||||
if minOk && maxOk {
|
||||
r.Min = parseInt(minVal, r.Min)
|
||||
r.Max = parseInt(maxVal, r.Max)
|
||||
return
|
||||
}
|
||||
if v, ok := os.LookupEnv(scalarName); ok {
|
||||
n := parseInt(v, r.Min)
|
||||
r.Min = n
|
||||
r.Max = n
|
||||
}
|
||||
}
|
||||
|
||||
// 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 {
|
||||
log.Println("Config: server.port is 0, using default 8080")
|
||||
cfg.Server.Port = 8080
|
||||
}
|
||||
if cfg.Server.Port > 65535 {
|
||||
log.Println("Config: server.port > 65535, using default 8080")
|
||||
cfg.Server.Port = 8080
|
||||
}
|
||||
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.Println("Config: cache.ttl is 0, using default 1800")
|
||||
cfg.Cache.Ttl = 1800
|
||||
}
|
||||
}
|
||||
|
||||
// check.playlists
|
||||
pp := &cfg.Check.Playlists
|
||||
if pp.Timeout <= 0 {
|
||||
log.Println("Config: check.playlists.timeout <= 0, using default 10000")
|
||||
pp.Timeout = 10000
|
||||
}
|
||||
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.Println("Config: check.channels.timeout <= 0, using default 10000")
|
||||
cc.Timeout = 10000
|
||||
}
|
||||
validateIntRange(&cc.Cooldown, "check.channels.cooldown", 0, 3600000)
|
||||
if cc.MaxRoutines < 1 {
|
||||
log.Println("Config: check.channels.max-routines < 1, using default 50")
|
||||
cc.MaxRoutines = 50
|
||||
}
|
||||
if cc.PerRoutine < 1 {
|
||||
log.Println("Config: check.channels.per-routine < 1, using default 10")
|
||||
cc.PerRoutine = 10
|
||||
}
|
||||
if cc.ByteRange <= 0 {
|
||||
log.Println("Config: check.channels.byte-range <= 0, using default 512")
|
||||
cc.ByteRange = 512
|
||||
}
|
||||
if len(cc.UserAgent) == 0 {
|
||||
log.Println("Config: check.channels.user-agent is empty, using default")
|
||||
cc.UserAgent = UserAgents{"Mozilla/5.0 WINK/1.31.1 (AndroidTV/9) HlsWinkPlayer"}
|
||||
}
|
||||
}
|
||||
|
||||
// validateIntRange проверяет IntRange: Min <= Max, оба в пределах [minVal, maxVal].
|
||||
// Некорректные значения исправляются с логированием предупреждения.
|
||||
func validateIntRange(r *IntRange, name string, minVal, maxVal int) {
|
||||
if r.Min > r.Max {
|
||||
log.Printf("Config: %s min=%d > max=%d, swapping", name, r.Min, r.Max)
|
||||
r.Min, r.Max = r.Max, r.Min
|
||||
}
|
||||
if r.Min < minVal {
|
||||
log.Printf("Config: %s min=%d < %d, clamped", name, r.Min, minVal)
|
||||
r.Min = minVal
|
||||
}
|
||||
if r.Max > maxVal {
|
||||
log.Printf("Config: %s max=%d > %d, clamped", name, r.Max, maxVal)
|
||||
r.Max = maxVal
|
||||
}
|
||||
if r.Min > r.Max {
|
||||
r.Min = minVal
|
||||
r.Max = minVal
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,548 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// --- IntRange ---
|
||||
|
||||
func TestIntRangeValue_Scalar(t *testing.T) {
|
||||
r := IntRange{Min: 5, Max: 5}
|
||||
if v := r.Value(); v != 5 {
|
||||
t.Errorf("expected 5, got %d", v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntRangeValue_Range(t *testing.T) {
|
||||
r := IntRange{Min: 10, Max: 20}
|
||||
for i := 0; i < 100; i++ {
|
||||
v := r.Value()
|
||||
if v < 10 || v > 20 {
|
||||
t.Errorf("value %d out of [10, 20]", v)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntRangeUnmarshalYAML_Scalar(t *testing.T) {
|
||||
yamlData := []byte("timeout: 5000\n")
|
||||
type wrapper struct {
|
||||
Timeout int `yaml:"timeout"`
|
||||
}
|
||||
var w wrapper
|
||||
if err := yaml.Unmarshal(yamlData, &w); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if w.Timeout != 5000 {
|
||||
t.Errorf("expected 5000, got %d", w.Timeout)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntRangeUnmarshalYAML_FromFullConfig(t *testing.T) {
|
||||
yamlData := []byte(`
|
||||
check:
|
||||
playlists:
|
||||
all-cooldown: 100
|
||||
one-cooldown: [50, 200]
|
||||
channels:
|
||||
cooldown: [10, 20]
|
||||
`)
|
||||
cfg := defaults()
|
||||
if err := yaml.Unmarshal(yamlData, cfg); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cfg.Check.Playlists.AllCooldown.Min != 100 || cfg.Check.Playlists.AllCooldown.Max != 100 {
|
||||
t.Errorf("scalar all-cooldown: expected Min=Max=100, got Min=%d Max=%d",
|
||||
cfg.Check.Playlists.AllCooldown.Min, cfg.Check.Playlists.AllCooldown.Max)
|
||||
}
|
||||
if cfg.Check.Playlists.OneCooldown.Min != 50 || cfg.Check.Playlists.OneCooldown.Max != 200 {
|
||||
t.Errorf("range one-cooldown: expected [50,200], got [%d,%d]",
|
||||
cfg.Check.Playlists.OneCooldown.Min, cfg.Check.Playlists.OneCooldown.Max)
|
||||
}
|
||||
if cfg.Check.Channels.Cooldown.Min != 10 || cfg.Check.Channels.Cooldown.Max != 20 {
|
||||
t.Errorf("range cooldown: expected [10,20], 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
|
||||
}{
|
||||
{"8080", 0, 8080},
|
||||
{"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", "500")
|
||||
r := IntRange{Min: 0, Max: 0}
|
||||
applyEnvIntRange(&r, "TEST_COOLDOWN", "TEST_COOLDOWN_MIN", "TEST_COOLDOWN_MAX")
|
||||
if r.Min != 500 || r.Max != 500 {
|
||||
t.Errorf("expected Min=Max=500, got Min=%d Max=%d", r.Min, r.Max)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyEnvIntRange_Range(t *testing.T) {
|
||||
t.Setenv("TEST_COOLDOWN_MIN", "100")
|
||||
t.Setenv("TEST_COOLDOWN_MAX", "200")
|
||||
r := IntRange{Min: 0, Max: 0}
|
||||
applyEnvIntRange(&r, "TEST_COOLDOWN", "TEST_COOLDOWN_MIN", "TEST_COOLDOWN_MAX")
|
||||
if r.Min != 100 || r.Max != 200 {
|
||||
t.Errorf("expected [100,200], got [%d,%d]", r.Min, r.Max)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyEnvIntRange_OnlyMin(t *testing.T) {
|
||||
t.Setenv("TEST_COOLDOWN_MIN", "100")
|
||||
r := IntRange{Min: 5, Max: 5}
|
||||
applyEnvIntRange(&r, "TEST_COOLDOWN", "TEST_COOLDOWN_MIN", "TEST_COOLDOWN_MAX")
|
||||
// only one of _MIN/_MAX set → ignored
|
||||
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", "TEST_COOLDOWN_MIN", "TEST_COOLDOWN_MAX")
|
||||
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 != 8080 {
|
||||
t.Errorf("expected 8080, got %d", cfg.Server.Port)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidate_ServerPortTooHigh(t *testing.T) {
|
||||
cfg := defaults()
|
||||
cfg.Server.Port = 99999
|
||||
validate(cfg)
|
||||
if cfg.Server.Port != 8080 {
|
||||
t.Errorf("expected 8080, 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 != 1800 {
|
||||
t.Errorf("expected 1800, 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 != 8080 {
|
||||
t.Errorf("expected 8080, got %d", cfg.Server.Port)
|
||||
}
|
||||
if cfg.Check.Playlists.Timeout != 10000 {
|
||||
t.Errorf("expected 10000, got %d", cfg.Check.Playlists.Timeout)
|
||||
}
|
||||
if cfg.Cache.Port != 6379 {
|
||||
t.Errorf("expected 6379, got %d", cfg.Cache.Port)
|
||||
}
|
||||
if len(cfg.Site.Header.Navigation) == 0 {
|
||||
t.Error("expected non-empty navigation")
|
||||
}
|
||||
}
|
||||
|
||||
// --- Init (integration) ---
|
||||
|
||||
func TestInit_NoConfigFile(t *testing.T) {
|
||||
cfg := Init("/nonexistent/config.yml")
|
||||
if cfg == nil {
|
||||
t.Fatal("expected non-nil config")
|
||||
}
|
||||
if cfg.Server.Port != 8080 {
|
||||
t.Errorf("expected default port 8080, 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: 5000
|
||||
all-cooldown: [100, 200]
|
||||
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, got %d", cfg.Check.Playlists.Timeout)
|
||||
}
|
||||
if cfg.Check.Playlists.AllCooldown.Min != 100 || cfg.Check.Playlists.AllCooldown.Max != 200 {
|
||||
t.Errorf("expected [100,200], 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("WEB_PORT", "9090")
|
||||
t.Setenv("APP_DEBUG", "true")
|
||||
t.Setenv("CHECK_PLAYLISTS_TIMEOUT", "7000")
|
||||
t.Setenv("CHECK_PLAYLISTS_USER_AGENT_1", "CustomUA")
|
||||
t.Setenv("CHECK_PLAYLISTS_ALL_COOLDOWN_MIN", "50")
|
||||
t.Setenv("CHECK_PLAYLISTS_ALL_COOLDOWN_MAX", "150")
|
||||
|
||||
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 from 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 != 50 || cfg.Check.Playlists.AllCooldown.Max != 150 {
|
||||
t.Errorf("expected [50,150] from 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: [100, 200]\n")
|
||||
if err := os.WriteFile(tmp, data, 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Setenv("CHECK_PLAYLISTS_ALL_COOLDOWN", "42")
|
||||
|
||||
cfg := Init(tmp)
|
||||
if cfg.Check.Playlists.AllCooldown.Min != 42 || cfg.Check.Playlists.AllCooldown.Max != 42 {
|
||||
t.Errorf("expected scalar [42,42] from env, got [%d,%d]",
|
||||
cfg.Check.Playlists.AllCooldown.Min, cfg.Check.Playlists.AllCooldown.Max)
|
||||
}
|
||||
}
|
||||
|
||||
// helper removed — using yaml.Unmarshal directly
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -131,8 +131,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()
|
||||
|
||||
@@ -0,0 +1,226 @@
|
||||
package playlist
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
+7
-4
@@ -44,16 +44,19 @@ func ArrayUnique(arr []string) []string {
|
||||
return result
|
||||
}
|
||||
|
||||
// Fetch выполняет GET запрос и возвращает результат в виде массива байт
|
||||
func Fetch(url string) ([]byte, error) {
|
||||
// Fetch выполняет GET запрос и возвращает результат в виде массива байт.
|
||||
// userAgent — значение заголовка User-Agent, timeout — таймаут запроса.
|
||||
func Fetch(url string, userAgent string, timeout time.Duration) ([]byte, error) {
|
||||
req, err := http.NewRequest("GET", url, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
req.Header.Set("User-Agent", "Mozilla/5.0 WINK/1.31.1 (AndroidTV/9) HlsWinkPlayer")
|
||||
if userAgent != "" {
|
||||
req.Header.Set("User-Agent", userAgent)
|
||||
}
|
||||
http.DefaultTransport.(*http.Transport).TLSClientConfig = &tls.Config{InsecureSkipVerify: true}
|
||||
httpClient := http.Client{Timeout: 10 * time.Second}
|
||||
httpClient := http.Client{Timeout: timeout}
|
||||
resp, err := httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestMd5str(t *testing.T) {
|
||||
cases := []struct {
|
||||
in string
|
||||
want string
|
||||
}{
|
||||
{"", "d41d8cd98f00b204e9800998ecf8427e"},
|
||||
{"hello", "5d41402abc4b2a76b9719d911017c592"},
|
||||
{"test", "098f6bcd4621d373cade4e832627b4f6"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
got := Md5str(c.in)
|
||||
if got != c.want {
|
||||
t.Errorf("Md5str(%q) = %s, want %s", c.in, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMd5str_Consistency(t *testing.T) {
|
||||
h1 := Md5str("same")
|
||||
h2 := Md5str("same")
|
||||
if h1 != h2 {
|
||||
t.Error("Md5str should be deterministic")
|
||||
}
|
||||
}
|
||||
|
||||
func TestArrayUnique(t *testing.T) {
|
||||
in := []string{"a", "b", "a", "c", "b", "d"}
|
||||
got := ArrayUnique(in)
|
||||
if len(got) != 4 {
|
||||
t.Fatalf("expected 4, got %d: %v", len(got), got)
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
for _, v := range got {
|
||||
if seen[v] {
|
||||
t.Errorf("duplicate found: %s", v)
|
||||
}
|
||||
seen[v] = true
|
||||
}
|
||||
}
|
||||
|
||||
func TestArrayUnique_Empty(t *testing.T) {
|
||||
got := ArrayUnique([]string{})
|
||||
if len(got) != 0 {
|
||||
t.Errorf("expected empty, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestArrayUnique_Single(t *testing.T) {
|
||||
got := ArrayUnique([]string{"only"})
|
||||
if len(got) != 1 || got[0] != "only" {
|
||||
t.Errorf("expected [only], got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestArrayUnique_AllSame(t *testing.T) {
|
||||
got := ArrayUnique([]string{"x", "x", "x"})
|
||||
if len(got) != 1 || got[0] != "x" {
|
||||
t.Errorf("expected [x], got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExpandPath_Relative(t *testing.T) {
|
||||
got, err := ExpandPath("foo/bar")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got == "" {
|
||||
t.Error("expected non-empty path")
|
||||
}
|
||||
// should be absolute
|
||||
if got[0] != '/' {
|
||||
t.Errorf("expected absolute path, got %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExpandPath_Tilde(t *testing.T) {
|
||||
got, err := ExpandPath("~/test")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got == "" {
|
||||
t.Error("expected non-empty path")
|
||||
}
|
||||
// should not contain ~
|
||||
for _, c := range got {
|
||||
if c == '~' {
|
||||
t.Error("path should not contain ~")
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetch_Success(t *testing.T) {
|
||||
body := "hello world"
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if ua := r.Header.Get("User-Agent"); ua != "TestUA" {
|
||||
t.Errorf("expected User-Agent 'TestUA', got '%s'", ua)
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(body))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
data, err := Fetch(srv.URL, "TestUA", 5*time.Second)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(data) != body {
|
||||
t.Errorf("expected '%s', got '%s'", body, string(data))
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetch_NonOK(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
_, err := Fetch(srv.URL, "", 5*time.Second)
|
||||
if err == nil {
|
||||
t.Error("expected error for non-200 status")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetch_EmptyUserAgent(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte("ok"))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
data, err := Fetch(srv.URL, "", 5*time.Second)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(data) != "ok" {
|
||||
t.Errorf("expected 'ok', got '%s'", string(data))
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetch_InvalidURL(t *testing.T) {
|
||||
_, err := Fetch("http://[::1]:named", "", 1*time.Second)
|
||||
if err == nil {
|
||||
t.Error("expected error for invalid URL")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,444 @@
|
||||
/*
|
||||
* Copyright (c) 2025, Антон Аксенов
|
||||
* 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"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"math"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// handleHome — главная страница со списком плейлистов
|
||||
func (s *Server) handleHome(w http.ResponseWriter, r *http.Request) {
|
||||
playlists, updatedAt, err := s.getPlaylistsView()
|
||||
errorMsg := ""
|
||||
if err != nil {
|
||||
log.Printf("Error loading playlists: %s", err)
|
||||
errorMsg = "Не удалось загрузить список плейлистов. Проверьте наличие файла playlists.ini."
|
||||
playlists = []PlaylistView{}
|
||||
updatedAt = ""
|
||||
}
|
||||
|
||||
count := len(playlists)
|
||||
pageSize := int(s.cfg.Site.PageSize)
|
||||
pageCurrent := 1
|
||||
pageCount := 1
|
||||
|
||||
// pagination
|
||||
if pageSize > 0 && count > pageSize {
|
||||
pageCount = int(math.Ceil(float64(count) / float64(pageSize)))
|
||||
if pageArg := r.PathValue("page"); pageArg != "" {
|
||||
if p, err := strconv.Atoi(pageArg); err == nil && p >= 1 && p <= pageCount {
|
||||
pageCurrent = p
|
||||
}
|
||||
}
|
||||
offset := (pageCurrent - 1) * pageSize
|
||||
if offset >= count {
|
||||
offset = 0
|
||||
pageCurrent = 1
|
||||
}
|
||||
end := offset + pageSize
|
||||
if end > count {
|
||||
end = count
|
||||
}
|
||||
playlists = playlists[offset:end]
|
||||
}
|
||||
|
||||
data := &PageData{
|
||||
Title: s.cfg.Site.Header.Title,
|
||||
BaseUrl: s.cfg.Site.BaseUrl,
|
||||
RepoUrl: s.cfg.Site.RepoUrl,
|
||||
Version: app.VERSION,
|
||||
UpdatedAt: updatedAt,
|
||||
Navigation: s.cfg.Site.Header.Navigation,
|
||||
FooterLinks: s.cfg.Site.FooterLinks,
|
||||
Playlists: playlists,
|
||||
Count: count,
|
||||
PageCount: pageCount,
|
||||
PageCurrent: pageCurrent,
|
||||
Error: errorMsg,
|
||||
}
|
||||
|
||||
s.templates.Render(w, "list", data)
|
||||
}
|
||||
|
||||
// handleDetails — страница с описанием плейлиста
|
||||
func (s *Server) handleDetails(w http.ResponseWriter, r *http.Request) {
|
||||
code := r.PathValue("code")
|
||||
if code == "" {
|
||||
s.renderNotFound(w, r, "")
|
||||
return
|
||||
}
|
||||
|
||||
view, ok := s.getPlaylistView(code)
|
||||
if !ok {
|
||||
s.renderNotFound(w, r, code)
|
||||
return
|
||||
}
|
||||
|
||||
data := &PageData{
|
||||
Title: s.cfg.Site.Header.Title,
|
||||
BaseUrl: s.cfg.Site.BaseUrl,
|
||||
RepoUrl: s.cfg.Site.RepoUrl,
|
||||
Version: app.VERSION,
|
||||
Navigation: s.cfg.Site.Header.Navigation,
|
||||
FooterLinks: s.cfg.Site.FooterLinks,
|
||||
Playlist: view,
|
||||
}
|
||||
|
||||
s.templates.Render(w, "details", data)
|
||||
}
|
||||
|
||||
// handleRoute — единый обработчик для не-API маршрутов
|
||||
// Разбирает путь и направляет на нужный обработчик:
|
||||
// /page/N → главная с пагинацией
|
||||
// /CODE/details → страница плейлиста
|
||||
// /CODE или /CODE.m3u[8] → редирект на URL плейлиста
|
||||
// остальное → 404
|
||||
func (s *Server) handleRoute(w http.ResponseWriter, r *http.Request) {
|
||||
path := r.PathValue("path")
|
||||
if path == "" {
|
||||
s.handleHome(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
segments := strings.Split(path, "/")
|
||||
|
||||
switch len(segments) {
|
||||
case 1:
|
||||
// /page/N → пагинация
|
||||
if segments[0] == "page" {
|
||||
s.renderNotFound(w, r, "")
|
||||
return
|
||||
}
|
||||
if strings.HasPrefix(segments[0], "page/") {
|
||||
// shouldn't happen with single segment, but just in case
|
||||
pageStr := strings.TrimPrefix(segments[0], "page/")
|
||||
if _, err := strconv.Atoi(pageStr); err == nil {
|
||||
r.SetPathValue("page", pageStr)
|
||||
s.handleHome(w, r)
|
||||
return
|
||||
}
|
||||
}
|
||||
// /CODE or /CODE.m3u[8] → redirect
|
||||
s.handleRedirect(w, r, segments[0])
|
||||
|
||||
case 2:
|
||||
// /page/N → пагинация
|
||||
if segments[0] == "page" {
|
||||
if _, err := strconv.Atoi(segments[1]); err == nil {
|
||||
r.SetPathValue("page", segments[1])
|
||||
s.handleHome(w, r)
|
||||
return
|
||||
}
|
||||
s.renderNotFound(w, r, "")
|
||||
return
|
||||
}
|
||||
// /CODE/details → страница плейлиста
|
||||
if segments[1] == "details" {
|
||||
r.SetPathValue("code", segments[0])
|
||||
s.handleDetails(w, r)
|
||||
return
|
||||
}
|
||||
s.renderNotFound(w, r, segments[0])
|
||||
|
||||
default:
|
||||
s.renderNotFound(w, r, "")
|
||||
}
|
||||
}
|
||||
|
||||
// handleRedirect — перенаправляет на URL плейлиста по коду
|
||||
func (s *Server) handleRedirect(w http.ResponseWriter, r *http.Request, path string) {
|
||||
// strip .m3u / .m3u8 suffix
|
||||
code := path
|
||||
code = strings.TrimSuffix(code, ".m3u8")
|
||||
code = strings.TrimSuffix(code, ".m3u")
|
||||
|
||||
// validate: alphanumeric
|
||||
if !isAlphanumeric(code) {
|
||||
s.renderNotFound(w, r, code)
|
||||
return
|
||||
}
|
||||
|
||||
// look up in ini
|
||||
ini, err := s.loadIni()
|
||||
if err != nil {
|
||||
s.renderNotFound(w, r, code)
|
||||
return
|
||||
}
|
||||
pls, exists := ini.Lists[code]
|
||||
if !exists || pls.Url == "" {
|
||||
s.renderNotFound(w, r, code)
|
||||
return
|
||||
}
|
||||
|
||||
http.Redirect(w, r, pls.Url, http.StatusFound)
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// если онлайн — не отдаем content
|
||||
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": view.Attributes,
|
||||
"groups": view.Groups,
|
||||
"channels": view.Channels,
|
||||
"tags": view.Tags,
|
||||
}
|
||||
|
||||
if view.IsKnown {
|
||||
resp["isOnline"] = view.IsOnline
|
||||
if view.IsOnline {
|
||||
delete(resp, "content")
|
||||
} else {
|
||||
resp["content"] = view.Content
|
||||
}
|
||||
}
|
||||
|
||||
s.jsonResponse(w, http.StatusOK, resp)
|
||||
}
|
||||
|
||||
// handleAPIVersion — API: версия
|
||||
func (s *Server) handleAPIVersion(w http.ResponseWriter, r *http.Request) {
|
||||
resp := map[string]interface{}{
|
||||
"iptvc": app.VERSION,
|
||||
}
|
||||
|
||||
if s.cache != nil {
|
||||
ctx := r.Context()
|
||||
info, err := s.cache.Info(ctx, "server").Result()
|
||||
if err == nil {
|
||||
resp["keydb"] = parseRedisVersion(info)
|
||||
}
|
||||
}
|
||||
|
||||
s.jsonResponse(w, http.StatusOK, resp)
|
||||
}
|
||||
|
||||
// handleAPIHealth — API: состояние
|
||||
func (s *Server) handleAPIHealth(w http.ResponseWriter, r *http.Request) {
|
||||
health := map[string]interface{}{
|
||||
"timestamp": time.Now().Unix(),
|
||||
}
|
||||
|
||||
if s.cache != nil {
|
||||
ctx := r.Context()
|
||||
health["redis"] = map[string]interface{}{
|
||||
"isConnected": s.cache.Ping(ctx).Err() == nil,
|
||||
}
|
||||
} else {
|
||||
health["redis"] = map[string]interface{}{
|
||||
"isConnected": false,
|
||||
}
|
||||
}
|
||||
|
||||
health["iniFile"] = map[string]interface{}{
|
||||
"path": s.cfg.App.Playlists,
|
||||
}
|
||||
|
||||
s.jsonResponse(w, http.StatusOK, health)
|
||||
}
|
||||
|
||||
// handleAPIStats — API: статистика
|
||||
func (s *Server) handleAPIStats(w http.ResponseWriter, r *http.Request) {
|
||||
playlists, _, err := s.getPlaylistsView()
|
||||
if err != nil {
|
||||
s.jsonError(w, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
stats := computeStats(playlists)
|
||||
s.jsonResponse(w, http.StatusOK, stats)
|
||||
}
|
||||
|
||||
// renderNotFound — отображает страницу 404
|
||||
func (s *Server) renderNotFound(w http.ResponseWriter, r *http.Request, code string) {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
data := &PageData{
|
||||
Title: s.cfg.Site.Header.Title,
|
||||
BaseUrl: s.cfg.Site.BaseUrl,
|
||||
RepoUrl: s.cfg.Site.RepoUrl,
|
||||
Version: app.VERSION,
|
||||
Navigation: s.cfg.Site.Header.Navigation,
|
||||
FooterLinks: s.cfg.Site.FooterLinks,
|
||||
Code: code,
|
||||
}
|
||||
s.templates.Render(w, "notfound", data)
|
||||
}
|
||||
|
||||
// jsonResponse отправляет JSON-ответ
|
||||
func (s *Server) jsonResponse(w http.ResponseWriter, status int, data interface{}) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
resp := map[string]interface{}{
|
||||
"timestamp": time.Now().Unix(),
|
||||
}
|
||||
if m, ok := data.(map[string]interface{}); ok {
|
||||
for k, v := range m {
|
||||
resp[k] = v
|
||||
}
|
||||
} else {
|
||||
resp["data"] = data
|
||||
}
|
||||
json.NewEncoder(w).Encode(resp)
|
||||
}
|
||||
|
||||
// jsonError отправляет JSON-ошибку
|
||||
func (s *Server) jsonError(w http.ResponseWriter, status int, err error) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"timestamp": time.Now().Unix(),
|
||||
"error": map[string]string{
|
||||
"message": err.Error(),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// isAlphanumeric проверяет, состоит ли строка только из букв и цифр
|
||||
func isAlphanumeric(s string) bool {
|
||||
for _, c := range s {
|
||||
if !((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9')) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return len(s) > 0
|
||||
}
|
||||
|
||||
// parseRedisVersion извлекает версию Redis из INFO-ответа
|
||||
func parseRedisVersion(info string) string {
|
||||
for _, line := range strings.Split(info, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if strings.HasPrefix(line, "redis_version:") {
|
||||
return strings.TrimPrefix(line, "redis_version:")
|
||||
}
|
||||
}
|
||||
return "unknown"
|
||||
}
|
||||
|
||||
// computeStats вычисляет статистику по плейлистам и каналам
|
||||
func computeStats(playlists []PlaylistView) map[string]interface{} {
|
||||
plsOnline := 0
|
||||
plsOffline := 0
|
||||
plsUnknown := 0
|
||||
plsAdult := 0
|
||||
plsCatchup := 0
|
||||
plsTvg := 0
|
||||
plsGroupped := 0
|
||||
|
||||
channelsAll := 0
|
||||
channelsOnline := 0
|
||||
channelsOffline := 0
|
||||
channelsAdult := 0
|
||||
|
||||
var latestCode string
|
||||
var latestTime int64
|
||||
|
||||
for _, pls := range playlists {
|
||||
if !pls.IsKnown {
|
||||
plsUnknown++
|
||||
} else if pls.IsOnline {
|
||||
plsOnline++
|
||||
} else {
|
||||
plsOffline++
|
||||
}
|
||||
|
||||
if containsTag(pls.Tags, "adult") {
|
||||
plsAdult++
|
||||
}
|
||||
if pls.HasCatchup {
|
||||
plsCatchup++
|
||||
}
|
||||
if pls.HasTvg {
|
||||
plsTvg++
|
||||
}
|
||||
if len(pls.Groups) > 0 {
|
||||
plsGroupped++
|
||||
}
|
||||
|
||||
if pls.CheckedAt > latestTime {
|
||||
latestTime = pls.CheckedAt
|
||||
latestCode = pls.Code
|
||||
}
|
||||
|
||||
for _, ch := range pls.Channels {
|
||||
channelsAll++
|
||||
if ch.IsOnline {
|
||||
channelsOnline++
|
||||
} else {
|
||||
channelsOffline++
|
||||
}
|
||||
if containsTag(ch.Tags, "adult") {
|
||||
channelsAdult++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
latest := map[string]interface{}{
|
||||
"code": latestCode,
|
||||
"time": latestTime,
|
||||
}
|
||||
if latestTime > 0 {
|
||||
latest["timeFmt"] = time.Unix(latestTime, 0).Format("15:04:05 02.01.2006")
|
||||
}
|
||||
|
||||
return map[string]interface{}{
|
||||
"playlists": map[string]interface{}{
|
||||
"all": len(playlists),
|
||||
"online": plsOnline,
|
||||
"offline": plsOffline,
|
||||
"unknown": plsUnknown,
|
||||
"adult": plsAdult,
|
||||
"hasCatchup": plsCatchup,
|
||||
"hasTvg": plsTvg,
|
||||
"groupped": plsGroupped,
|
||||
"latest": latest,
|
||||
},
|
||||
"channels": map[string]interface{}{
|
||||
"all": channelsAll,
|
||||
"online": channelsOnline,
|
||||
"offline": channelsOffline,
|
||||
"adult": channelsAdult,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// containsTag проверяет наличие тега в списке
|
||||
func containsTag(tags []string, tag string) bool {
|
||||
for _, t := range tags {
|
||||
if t == tag {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
/*
|
||||
* Copyright (c) 2025, Антон Аксенов
|
||||
* 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/{code}", s.handleAPIGetOne)
|
||||
mux.HandleFunc("GET /api/version", s.handleAPIVersion)
|
||||
mux.HandleFunc("GET /api/health", s.handleAPIHealth)
|
||||
mux.HandleFunc("GET /api/stats", s.handleAPIStats)
|
||||
|
||||
// All other routes handled by catch-all to avoid mux pattern conflicts
|
||||
mux.HandleFunc("GET /{$}", s.handleHome)
|
||||
mux.HandleFunc("GET /{path...}", s.handleRoute)
|
||||
|
||||
handler := s.withMiddleware(mux)
|
||||
|
||||
addr := s.cfg.Server.Host + ":" + strconv.FormatUint(uint64(s.cfg.Server.Port), 10)
|
||||
log.Printf("Starting web server on %s", addr)
|
||||
if err := http.ListenAndServe(addr, handler); err != nil {
|
||||
log.Fatalf("Server failed: %s", err)
|
||||
}
|
||||
}
|
||||
|
||||
// withMiddleware добавляет middleware: логирование, восстановление после паники
|
||||
func (s *Server) withMiddleware(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
start := time.Now()
|
||||
defer func() {
|
||||
if err := recover(); err != nil {
|
||||
log.Printf("PANIC: %v", err)
|
||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||
}
|
||||
}()
|
||||
next.ServeHTTP(w, r)
|
||||
log.Printf("%s %s %s %s", r.Method, r.URL.Path, time.Since(start), r.RemoteAddr)
|
||||
})
|
||||
}
|
||||
|
||||
// loadIni загружает ini-файл (с кешированием на 30 секунд)
|
||||
func (s *Server) loadIni() (inifile.IniFile, error) {
|
||||
// перезагружаем раз в 30 секунд
|
||||
if !s.iniLoaded.IsZero() && time.Since(s.iniLoaded) < 30*time.Second && len(s.iniCache.Lists) > 0 {
|
||||
return s.iniCache, nil
|
||||
}
|
||||
|
||||
ini, err := inifile.Init(s.cfg.App.Playlists)
|
||||
if err != nil {
|
||||
return inifile.IniFile{}, err
|
||||
}
|
||||
s.iniCache = ini
|
||||
s.iniLoaded = time.Now()
|
||||
return ini, nil
|
||||
}
|
||||
|
||||
// getUpdatedAt возвращает время последнего изменения ini-файла
|
||||
func (s *Server) getUpdatedAt() string {
|
||||
path, err := utils.ExpandPath(s.cfg.App.Playlists)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return info.ModTime().Format("02.01.2006 15:04")
|
||||
}
|
||||
|
||||
// CheckOptions — параметры фоновой проверки плейлистов
|
||||
type CheckOptions struct {
|
||||
Every uint
|
||||
Repeat uint
|
||||
Random uint
|
||||
Files []string
|
||||
Urls []string
|
||||
Codes []string
|
||||
}
|
||||
|
||||
// StartBackgroundChecker запускает фоновую периодическую проверку плейлистов.
|
||||
// Метод блокирует вызывающую горутину, поэтому должен запускаться в отдельной goroutine.
|
||||
func (s *Server) StartBackgroundChecker(opts CheckOptions) {
|
||||
if opts.Every == 0 {
|
||||
opts.Every = 60
|
||||
}
|
||||
|
||||
// каждый проверенный плейлист сразу попадает в in-memory кеш
|
||||
checker.OnPlaylistChecked = func(pls playlist.Playlist) {
|
||||
s.memMu.Lock()
|
||||
s.memCache[pls.Code] = pls
|
||||
s.memMu.Unlock()
|
||||
// сбрасываем кеш ini, чтобы веб-часть подхватила свежие данные
|
||||
s.iniLoaded = time.Time{}
|
||||
}
|
||||
|
||||
log.Printf("Background checker started: every=%ds repeat=%d", opts.Every, 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... seconds=%d", opts.Every)
|
||||
time.Sleep(time.Duration(opts.Every) * time.Second)
|
||||
}
|
||||
}
|
||||
|
||||
// runCheckerOnce выполняет один цикл проверки плейлистов
|
||||
func (s *Server) runCheckerOnce(opts CheckOptions) {
|
||||
defer func() {
|
||||
if err := recover(); err != nil {
|
||||
log.Printf("Background checker panic: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
var lists []playlist.Playlist
|
||||
|
||||
if len(opts.Files) > 0 || len(opts.Urls) > 0 || len(opts.Codes) > 0 {
|
||||
// проверка конкретных файлов/URL/кодов
|
||||
lists = checker.PrepareListsToCheck(opts.Files, opts.Urls, opts.Codes)
|
||||
} else {
|
||||
// проверка всех плейлистов из ini
|
||||
ini, err := inifile.Init(s.cfg.App.Playlists)
|
||||
if err != nil {
|
||||
log.Printf("Background checker: cannot load ini: %s", err)
|
||||
return
|
||||
}
|
||||
lists = slices.Collect(maps.Values(ini.Lists))
|
||||
}
|
||||
|
||||
if len(lists) == 0 {
|
||||
log.Println("Background checker: no playlists to check")
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("Background checker: %d playlists will be checked", len(lists))
|
||||
startTime := time.Now()
|
||||
onlineCount, offlineCount := checker.CheckPlaylists(lists)
|
||||
log.Printf(
|
||||
"Background checker done! online=%d offline=%d elapsed=%.2fs",
|
||||
onlineCount,
|
||||
offlineCount,
|
||||
time.Since(startTime).Seconds(),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
/*
|
||||
* Copyright (c) 2025, Антон Аксенов
|
||||
* This file is part of iptvc project
|
||||
* MIT License: https://git.axenov.dev/IPTV/iptvc/src/branch/master/LICENSE
|
||||
*/
|
||||
|
||||
package web
|
||||
|
||||
import (
|
||||
"axenov/iptv-checker/app/config"
|
||||
"bytes"
|
||||
"embed"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"io"
|
||||
"log"
|
||||
"math"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
|
||||
//go:embed views/*.html
|
||||
var viewsFS embed.FS
|
||||
|
||||
// TemplateManager управляет загрузкой и рендерингом шаблонов
|
||||
type TemplateManager struct {
|
||||
templates map[string]*template.Template
|
||||
funcMap template.FuncMap
|
||||
}
|
||||
|
||||
// NewTemplateManager создаёт новый менеджер шаблонов
|
||||
func NewTemplateManager(cfg *config.Config) (*TemplateManager, error) {
|
||||
funcMap := template.FuncMap{
|
||||
"baseUrl": func(path string) string {
|
||||
base := cfg.Site.BaseUrl
|
||||
if path == "" {
|
||||
return base
|
||||
}
|
||||
return base + "/" + path
|
||||
},
|
||||
"formatDate": formatDate,
|
||||
"formatTimeAgo": formatTimeAgo,
|
||||
"statusClass": func(v PlaylistView) string {
|
||||
return statusClass(&v)
|
||||
},
|
||||
"statusBadge": func(v PlaylistView) string {
|
||||
return statusBadge(&v)
|
||||
},
|
||||
"jsonChannels": func(channels map[string]ChannelView) template.JS {
|
||||
arr := make([]ChannelView, 0, len(channels))
|
||||
for _, ch := range channels {
|
||||
arr = append(arr, ch)
|
||||
}
|
||||
b, _ := json.Marshal(arr)
|
||||
return template.JS(b)
|
||||
},
|
||||
"seq": func(start, end int) []int {
|
||||
result := make([]int, 0, end-start+1)
|
||||
for i := start; i <= end; i++ {
|
||||
result = append(result, i)
|
||||
}
|
||||
return result
|
||||
},
|
||||
"sub": func(a, b int) int {
|
||||
return a - b
|
||||
},
|
||||
"add": func(a, b int) int {
|
||||
return a + b
|
||||
},
|
||||
"channelCount": func(v PlaylistView) int {
|
||||
return len(v.Channels)
|
||||
},
|
||||
"groupCount": func(v PlaylistView) int {
|
||||
return len(v.Groups)
|
||||
},
|
||||
"hasTag": containsTag,
|
||||
}
|
||||
|
||||
tm := &TemplateManager{
|
||||
templates: make(map[string]*template.Template),
|
||||
funcMap: funcMap,
|
||||
}
|
||||
|
||||
// parse base template
|
||||
baseContent, err := viewsFS.ReadFile("views/base.html")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot read base.html: %w", err)
|
||||
}
|
||||
|
||||
pages := []string{"list", "details", "notfound"}
|
||||
for _, page := range pages {
|
||||
pageContent, err := viewsFS.ReadFile(fmt.Sprintf("views/%s.html", page))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot read %s.html: %w", page, err)
|
||||
}
|
||||
|
||||
tmpl := template.New("base").Funcs(funcMap)
|
||||
tmpl, err = tmpl.Parse(string(baseContent))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot parse base template: %w", err)
|
||||
}
|
||||
tmpl, err = tmpl.Parse(string(pageContent))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot parse %s template: %w", page, err)
|
||||
}
|
||||
|
||||
tm.templates[page] = tmpl
|
||||
}
|
||||
|
||||
return tm, nil
|
||||
}
|
||||
|
||||
// Render рендерит шаблон страницы
|
||||
func (tm *TemplateManager) Render(w io.Writer, page string, data *PageData) {
|
||||
tmpl, ok := tm.templates[page]
|
||||
if !ok {
|
||||
http.Error(w.(http.ResponseWriter), "Template not found", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
if err := tmpl.ExecuteTemplate(&buf, "base", data); err != nil {
|
||||
log.Printf("Template error (%s): %s", page, err)
|
||||
http.Error(w.(http.ResponseWriter), "Template error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Write(buf.Bytes())
|
||||
}
|
||||
|
||||
// formatTimeAgo возвращает относительное время на русском
|
||||
func formatTimeAgo(ts int64) string {
|
||||
if ts == 0 {
|
||||
return "неизвестно"
|
||||
}
|
||||
diff := time.Now().Unix() - ts
|
||||
if diff < 0 {
|
||||
diff = 0
|
||||
}
|
||||
if diff < 60 {
|
||||
return "только что"
|
||||
}
|
||||
if diff < 3600 {
|
||||
minutes := diff / 60
|
||||
return strconv.FormatInt(minutes, 10) + " " + getNounForm(int(minutes), "минуту", "минуты", "минут") + " назад"
|
||||
}
|
||||
if diff < 86400 {
|
||||
hours := diff / 3600
|
||||
return strconv.FormatInt(hours, 10) + " " + getNounForm(int(hours), "час", "часа", "часов") + " назад"
|
||||
}
|
||||
if diff < 604800 {
|
||||
days := diff / 86400
|
||||
return strconv.FormatInt(days, 10) + " " + getNounForm(int(days), "день", "дня", "дней") + " назад"
|
||||
}
|
||||
return formatDate(ts)
|
||||
}
|
||||
|
||||
// getNounForm возвращает правильную форму существительного для числительного
|
||||
func getNounForm(number int, form1, form2, form5 string) string {
|
||||
abs := int(math.Abs(float64(number)))
|
||||
lastDigit := abs % 10
|
||||
lastTwoDigits := abs % 100
|
||||
|
||||
if lastTwoDigits >= 11 && lastTwoDigits <= 14 {
|
||||
return form5
|
||||
}
|
||||
if lastDigit == 1 {
|
||||
return form1
|
||||
}
|
||||
if lastDigit >= 2 && lastDigit <= 4 {
|
||||
return form2
|
||||
}
|
||||
return form5
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestGetNounForm(t *testing.T) {
|
||||
cases := []struct {
|
||||
n int
|
||||
f1 string
|
||||
f2 string
|
||||
f5 string
|
||||
want string
|
||||
}{
|
||||
{1, "минуту", "минуты", "минут", "минуту"},
|
||||
{2, "минуту", "минуты", "минут", "минуты"},
|
||||
{5, "минуту", "минуты", "минут", "минут"},
|
||||
{11, "минуту", "минуты", "минут", "минут"},
|
||||
{21, "минуту", "минуты", "минут", "минуту"},
|
||||
{22, "минуту", "минуты", "минут", "минуты"},
|
||||
{25, "минуту", "минуты", "минут", "минут"},
|
||||
{101, "минуту", "минуты", "минут", "минуту"},
|
||||
{111, "минуту", "минуты", "минут", "минут"},
|
||||
{0, "минуту", "минуты", "минут", "минут"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
got := getNounForm(c.n, c.f1, c.f2, c.f5)
|
||||
if got != c.want {
|
||||
t.Errorf("getNounForm(%d) = %q, want %q", c.n, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatTimeAgo_Zero(t *testing.T) {
|
||||
if formatTimeAgo(0) != "неизвестно" {
|
||||
t.Errorf("expected 'неизвестно' for ts=0")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatTimeAgo_JustNow(t *testing.T) {
|
||||
now := time.Now().Unix()
|
||||
got := formatTimeAgo(now)
|
||||
if got != "только что" {
|
||||
t.Errorf("expected 'только что', got '%s'", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatTimeAgo_Minutes(t *testing.T) {
|
||||
ts := time.Now().Unix() - 120 // 2 minutes ago
|
||||
got := formatTimeAgo(ts)
|
||||
if got == "неизвестно" || got == "только что" {
|
||||
t.Errorf("expected minutes format, got '%s'", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatTimeAgo_Hours(t *testing.T) {
|
||||
ts := time.Now().Unix() - 7200 // 2 hours ago
|
||||
got := formatTimeAgo(ts)
|
||||
if got == "неизвестно" || got == "только что" {
|
||||
t.Errorf("expected hours format, got '%s'", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatTimeAgo_Days(t *testing.T) {
|
||||
ts := time.Now().Unix() - 172800 // 2 days ago
|
||||
got := formatTimeAgo(ts)
|
||||
if got == "неизвестно" || got == "только что" {
|
||||
t.Errorf("expected days format, got '%s'", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatTimeAgo_Future(t *testing.T) {
|
||||
ts := time.Now().Unix() + 100
|
||||
got := formatTimeAgo(ts)
|
||||
// future → diff clamped to 0 → "только что"
|
||||
if got != "только что" {
|
||||
t.Errorf("expected 'только что' for future, got '%s'", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,322 @@
|
||||
/*
|
||||
* Copyright (c) 2025, Антон Аксенов
|
||||
* This file is part of iptvc project
|
||||
* MIT License: https://git.axenov.dev/IPTV/iptvc/src/branch/master/LICENSE
|
||||
*/
|
||||
|
||||
package web
|
||||
|
||||
import (
|
||||
"axenov/iptv-checker/app/config"
|
||||
"axenov/iptv-checker/app/playlist"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"math"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// PlaylistView — расширенное представление плейлиста для шаблонов
|
||||
type PlaylistView struct {
|
||||
playlist.Playlist
|
||||
IsKnown bool `json:"-"` // true если есть данные в кеше
|
||||
OnlinePercent int `json:"-"`
|
||||
OfflinePercent int `json:"-"`
|
||||
HasCatchup bool `json:"-"`
|
||||
HasTvg bool `json:"-"`
|
||||
HasTokens bool `json:"-"`
|
||||
Tags []string `json:"-"`
|
||||
ViewChannels map[string]ChannelView `json:"-"`
|
||||
}
|
||||
|
||||
// ChannelView — расширенное представление канала
|
||||
type ChannelView struct {
|
||||
playlist.Channel
|
||||
HasToken bool `json:"hasToken"`
|
||||
}
|
||||
|
||||
// PageData — данные для рендеринга шаблонов
|
||||
type PageData struct {
|
||||
Title string
|
||||
BaseUrl string
|
||||
RepoUrl string
|
||||
Version string
|
||||
UpdatedAt string
|
||||
Navigation []config.Link
|
||||
FooterLinks []config.Link
|
||||
Playlists []PlaylistView
|
||||
Playlist *PlaylistView
|
||||
Count int
|
||||
PageCount int
|
||||
PageCurrent int
|
||||
Code string
|
||||
Error string
|
||||
}
|
||||
|
||||
// tokenPatterns — паттерны для определения нестабильных каналов
|
||||
var tokenPatterns = []string{
|
||||
`[?&]token=`,
|
||||
`[?&]drmreq=`,
|
||||
`[?&]u=`,
|
||||
`[?&]user=`,
|
||||
`[?&]username=`,
|
||||
`[?&]p=`,
|
||||
`[?&]pwd=`,
|
||||
`[?&]password=`,
|
||||
}
|
||||
|
||||
// enrichPlaylist подготавливает данные о плейлисте в расширенном формате,
|
||||
// аналогично PHP IniFile::initPlaylist
|
||||
func enrichPlaylist(view *PlaylistView) {
|
||||
// приколы golang: nil maps
|
||||
if view.Attributes == nil {
|
||||
view.Attributes = make(map[string]string)
|
||||
}
|
||||
if view.Groups == nil {
|
||||
view.Groups = make(map[string]playlist.Group)
|
||||
}
|
||||
if view.Channels == nil {
|
||||
view.Channels = make(map[string]playlist.Channel)
|
||||
}
|
||||
|
||||
// проценты онлайн/оффлайн
|
||||
view.OnlinePercent = 0
|
||||
view.OfflinePercent = 0
|
||||
if view.IsKnown && view.IsOnline && len(view.Channels) > 0 {
|
||||
view.OnlinePercent = int(math.Round(float64(view.OnlineCount) / float64(len(view.Channels)) * 100))
|
||||
view.OfflinePercent = int(math.Round(float64(view.OfflineCount) / float64(len(view.Channels)) * 100))
|
||||
}
|
||||
|
||||
// наличие catchup
|
||||
view.HasCatchup = strings.Contains(view.Content, "catchup")
|
||||
|
||||
// наличие TVG
|
||||
view.HasTvg = view.Attributes["url-tvg"] != "" || view.Attributes["x-tvg-url"] != ""
|
||||
|
||||
// наличие токенов в плейлисте
|
||||
view.HasTokens = hasTokensString(view.Url + " " + view.Content)
|
||||
|
||||
// сбор тегов и токенов каналов
|
||||
view.ViewChannels = make(map[string]ChannelView, len(view.Channels))
|
||||
tagSet := make(map[string]bool)
|
||||
for id, ch := range view.Channels {
|
||||
cv := ChannelView{Channel: ch}
|
||||
cv.HasToken = hasTokensString(ch.URL)
|
||||
view.ViewChannels[id] = cv
|
||||
for _, tag := range ch.Tags {
|
||||
tagSet[tag] = true
|
||||
}
|
||||
}
|
||||
view.Tags = make([]string, 0, len(tagSet))
|
||||
for tag := range tagSet {
|
||||
view.Tags = append(view.Tags, tag)
|
||||
}
|
||||
sort.Strings(view.Tags)
|
||||
}
|
||||
|
||||
// hasTokensString проверяет наличие токенов/ключей/логинов в строке
|
||||
func hasTokensString(s string) bool {
|
||||
if s == "" {
|
||||
return false
|
||||
}
|
||||
for _, pattern := range tokenPatterns {
|
||||
if matched, _ := regexp.MatchString(pattern, s); matched {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// getPlaylistFromCache возвращает плейлист из кеша Redis по коду
|
||||
func (s *Server) getPlaylistFromCache(code string) (*PlaylistView, bool) {
|
||||
if s.cache == nil {
|
||||
return nil, false
|
||||
}
|
||||
ctx := context.Background()
|
||||
result, err := s.cache.Get(ctx, code).Result()
|
||||
if err != nil {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
var pls playlist.Playlist
|
||||
if err := json.Unmarshal([]byte(result), &pls); err != nil {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
view := &PlaylistView{Playlist: pls, IsKnown: true}
|
||||
enrichPlaylist(view)
|
||||
return view, true
|
||||
}
|
||||
|
||||
// getPlaylistView возвращает представление плейлиста: из in-memory кеша, Redis или из ini
|
||||
func (s *Server) getPlaylistView(code string) (*PlaylistView, bool) {
|
||||
// сначала пробуем in-memory кеш (результаты фоновой проверки)
|
||||
s.memMu.RLock()
|
||||
if pls, ok := s.memCache[code]; ok {
|
||||
s.memMu.RUnlock()
|
||||
view := &PlaylistView{Playlist: pls, IsKnown: true}
|
||||
enrichPlaylist(view)
|
||||
return view, true
|
||||
}
|
||||
s.memMu.RUnlock()
|
||||
|
||||
// затем пробуем Redis
|
||||
if view, ok := s.getPlaylistFromCache(code); ok {
|
||||
return view, true
|
||||
}
|
||||
|
||||
// если нет в кеше — берём метаданные из ini
|
||||
ini, err := s.loadIni()
|
||||
if err != nil {
|
||||
return nil, false
|
||||
}
|
||||
iniPls, exists := ini.Lists[code]
|
||||
if !exists {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
view := &PlaylistView{
|
||||
Playlist: playlist.Playlist{
|
||||
Code: code,
|
||||
Name: iniPls.Name,
|
||||
Description: iniPls.Description,
|
||||
Url: iniPls.Url,
|
||||
Source: iniPls.Source,
|
||||
IsOnline: false,
|
||||
Attributes: make(map[string]string),
|
||||
Groups: make(map[string]playlist.Group),
|
||||
Channels: make(map[string]playlist.Channel),
|
||||
},
|
||||
IsKnown: false,
|
||||
Tags: []string{},
|
||||
ViewChannels: make(map[string]ChannelView),
|
||||
}
|
||||
return view, true
|
||||
}
|
||||
|
||||
// getPlaylistsView возвращает все плейлисты для списка (отсортированные по коду)
|
||||
func (s *Server) getPlaylistsView() ([]PlaylistView, string, error) {
|
||||
ini, err := s.loadIni()
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
|
||||
updatedAt := s.getUpdatedAt()
|
||||
result := make([]PlaylistView, 0, len(ini.Lists))
|
||||
|
||||
// собираем коды в порядке из ini-файла
|
||||
codes := make([]string, 0, len(ini.Lists))
|
||||
for _, section := range ini.File.Sections() {
|
||||
if section.Name() == "DEFAULT" {
|
||||
continue
|
||||
}
|
||||
if _, ok := ini.Lists[section.Name()]; ok {
|
||||
codes = append(codes, section.Name())
|
||||
}
|
||||
}
|
||||
|
||||
// сначала проверяем in-memory кеш (результаты фоновой проверки)
|
||||
s.memMu.RLock()
|
||||
memHasData := len(s.memCache) > 0
|
||||
s.memMu.RUnlock()
|
||||
|
||||
if memHasData {
|
||||
for _, code := range codes {
|
||||
iniPls := ini.Lists[code]
|
||||
s.memMu.RLock()
|
||||
pls, ok := s.memCache[code]
|
||||
s.memMu.RUnlock()
|
||||
if ok {
|
||||
view := PlaylistView{Playlist: pls, IsKnown: true}
|
||||
enrichPlaylist(&view)
|
||||
result = append(result, view)
|
||||
} else {
|
||||
result = append(result, makeUnknownView(code, iniPls))
|
||||
}
|
||||
}
|
||||
return result, updatedAt, nil
|
||||
}
|
||||
|
||||
// если in-memory пуст — пробуем Redis
|
||||
if s.cache != nil {
|
||||
ctx := context.Background()
|
||||
cached, err := s.cache.MGet(ctx, codes...).Result()
|
||||
if err == nil {
|
||||
for i, code := range codes {
|
||||
iniPls := ini.Lists[code]
|
||||
if i < len(cached) {
|
||||
strVal, ok := cached[i].(string)
|
||||
if ok && strVal != "" {
|
||||
var pls playlist.Playlist
|
||||
if json.Unmarshal([]byte(strVal), &pls) == nil {
|
||||
view := PlaylistView{Playlist: pls, IsKnown: true}
|
||||
enrichPlaylist(&view)
|
||||
result = append(result, view)
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
result = append(result, makeUnknownView(code, iniPls))
|
||||
}
|
||||
return result, updatedAt, nil
|
||||
}
|
||||
}
|
||||
|
||||
// без кеша — все плейлисты unknown
|
||||
for _, code := range codes {
|
||||
result = append(result, makeUnknownView(code, ini.Lists[code]))
|
||||
}
|
||||
return result, updatedAt, nil
|
||||
}
|
||||
|
||||
// makeUnknownView создаёт представление непроверенного плейлиста
|
||||
func makeUnknownView(code string, iniPls playlist.Playlist) PlaylistView {
|
||||
return PlaylistView{
|
||||
Playlist: playlist.Playlist{
|
||||
Code: code,
|
||||
Name: iniPls.Name,
|
||||
Description: iniPls.Description,
|
||||
Url: iniPls.Url,
|
||||
Source: iniPls.Source,
|
||||
IsOnline: false,
|
||||
Attributes: make(map[string]string),
|
||||
Groups: make(map[string]playlist.Group),
|
||||
Channels: make(map[string]playlist.Channel),
|
||||
},
|
||||
IsKnown: false,
|
||||
Tags: []string{},
|
||||
ViewChannels: make(map[string]ChannelView),
|
||||
}
|
||||
}
|
||||
|
||||
// statusClass возвращает CSS-класс для статуса плейлиста
|
||||
func statusClass(view *PlaylistView) string {
|
||||
if !view.IsKnown {
|
||||
return "secondary"
|
||||
}
|
||||
if view.IsOnline {
|
||||
return "success"
|
||||
}
|
||||
return "danger"
|
||||
}
|
||||
|
||||
// statusBadge возвращает текст бейджа статуса
|
||||
func statusBadge(view *PlaylistView) string {
|
||||
if !view.IsKnown {
|
||||
return "unknown"
|
||||
}
|
||||
if view.IsOnline {
|
||||
return "online"
|
||||
}
|
||||
return "offline"
|
||||
}
|
||||
|
||||
// formatDate форматирует unix timestamp в строку
|
||||
func formatDate(ts int64) string {
|
||||
if ts == 0 {
|
||||
return ""
|
||||
}
|
||||
return time.Unix(ts, 0).Format("02.01.2006 15:04:05")
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
{{define "base"}}
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru" class="h-100">
|
||||
<head>
|
||||
<title>{{block "title" .}}{{.Title}}{{end}}</title>
|
||||
<meta charset="utf-8">
|
||||
<meta name="description" content="{{block "metadescription" .}}{{end}}">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="keywords" content="{{block "metakeywords" .}}{{end}}">
|
||||
<meta http-equiv="x-ua-compatible" content="ie=edge">
|
||||
<style>.cursor-pointer{cursor:pointer}.cursor-help{cursor:help}</style>
|
||||
<script async type="module" src="https://unpkg.com/ionicons@7.1.0/dist/ionicons/ionicons.esm.js"></script>
|
||||
<script async nomodule src="https://unpkg.com/ionicons@7.1.0/dist/ionicons/ionicons.js"></script>
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.2.3/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
{{block "head" .}}{{end}}
|
||||
</head>
|
||||
<body class="d-flex flex-column h-100 bg-dark text-light">
|
||||
<header class="sticky-top bg-dark border-bottom border-secondary">
|
||||
<nav class="navbar navbar-expand-lg navbar-dark container px-2">
|
||||
<a class="navbar-brand d-flex align-items-center gap-2" href="/" title="На главную">
|
||||
<span>{{.Title}}</span>
|
||||
</a>
|
||||
<button class="navbar-toggler"
|
||||
type="button"
|
||||
data-bs-toggle="collapse"
|
||||
data-bs-target="#navbarNav"
|
||||
aria-controls="navbarNav"
|
||||
>
|
||||
<span class="navbar-toggler-icon"></span>
|
||||
</button>
|
||||
<div class="collapse navbar-collapse" id="navbarNav">
|
||||
<ul class="navbar-nav ms-auto">
|
||||
{{range .Navigation}}
|
||||
{{if .Children}}
|
||||
<li class="nav-item dropdown">
|
||||
<a class="nav-link dropdown-toggle d-flex align-items-center"
|
||||
href="#"
|
||||
role="button"
|
||||
data-bs-toggle="dropdown"
|
||||
aria-expanded="false"
|
||||
>
|
||||
{{if .Icon}}<ion-icon name="{{.Icon}}" class="me-1"></ion-icon>{{end}}{{.Title}}
|
||||
</a>
|
||||
<ul class="dropdown-menu dropdown-menu-dark">
|
||||
{{range .Children}}
|
||||
<li>
|
||||
<a class="dropdown-item d-flex align-items-center gap-2" target="_blank" href="{{.Url}}">
|
||||
{{if .Icon}}<ion-icon name="{{.Icon}}"></ion-icon>{{end}}{{.Title}}
|
||||
</a>
|
||||
</li>
|
||||
{{end}}
|
||||
</ul>
|
||||
</li>
|
||||
{{else}}
|
||||
<li class="nav-item">
|
||||
<a class="nav-link d-flex align-items-center" target="_blank" href="{{.Url}}">
|
||||
{{if .Icon}}<ion-icon name="{{.Icon}}" class="me-1"></ion-icon>{{end}}{{.Title}}
|
||||
</a>
|
||||
</li>
|
||||
{{end}}
|
||||
{{end}}
|
||||
</ul>
|
||||
</div>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<main class="flex-grow-1 container py-4">
|
||||
{{block "header" .}}{{end}}
|
||||
<div class="content-wrapper">
|
||||
{{block "content" .}}{{end}}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<footer class="bg-dark border-top border-secondary py-4">
|
||||
<div class="container text-center">
|
||||
<div class="d-flex flex-wrap justify-content-center gap-3 mb-3">
|
||||
{{range .FooterLinks}}
|
||||
<a target="_blank" href="{{.Url}}" class="text-light text-decoration-none d-flex align-items-center gap-1">
|
||||
{{if .Icon}}<ion-icon name="{{.Icon}}"></ion-icon>{{end}} {{.Title}}
|
||||
</a>
|
||||
{{end}}
|
||||
</div>
|
||||
<div>
|
||||
<a class="small text-secondary d-inline-flex align-items-center gap-1"
|
||||
href="{{.RepoUrl}}/releases/tag/v{{.Version}}"
|
||||
target="_blank"
|
||||
>
|
||||
<ion-icon name="pricetag-outline"></ion-icon>iptvc v{{.Version}}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<div class="toast-container position-fixed bottom-0 end-0 p-3">
|
||||
<div class="toast align-items-center text-bg-success border-0" role="alert" id="clipboardToast">
|
||||
<div class="d-flex">
|
||||
<div class="toast-body" id="clipboardToastBody"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.2.3/dist/js/bootstrap.bundle.min.js"></script>
|
||||
{{block "footer" .}}{{end}}
|
||||
|
||||
<script>
|
||||
function showToast(message) {
|
||||
const toastEl = document.getElementById('clipboardToast');
|
||||
const toastBodyEl = document.getElementById('clipboardToastBody');
|
||||
toastBodyEl.innerHTML = message;
|
||||
const toast = new bootstrap.Toast(toastEl, {delay: 5000});
|
||||
toast.show();
|
||||
}
|
||||
|
||||
function copyPlaylistUrl(code) {
|
||||
const url = '{{.BaseUrl}}/' + code;
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
navigator.clipboard
|
||||
.writeText(url)
|
||||
.then(() => showToast(`Ссылка на плейлист '${code}' скопирована в буфер обмена`))
|
||||
.catch(err => console.error('Failed to copy:', err));
|
||||
} else {
|
||||
try {
|
||||
const textArea = document.createElement("textarea");
|
||||
textArea.value = url;
|
||||
textArea.style.position = "fixed";
|
||||
document.body.appendChild(textArea);
|
||||
textArea.focus();
|
||||
textArea.select();
|
||||
const successful = document.execCommand('copy');
|
||||
document.body.removeChild(textArea);
|
||||
if (successful) {
|
||||
showToast(`Ссылка на плейлист '${code}' скопирована в буфер обмена`);
|
||||
} else {
|
||||
showToast('Ошибка при копировании ссылки', true);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Fallback copy failed:', err);
|
||||
showToast('Ошибка при копировании ссылки', true);
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
@@ -0,0 +1,493 @@
|
||||
{{define "title"}}[{{.Playlist.Code}}] {{.Playlist.Name}} - {{.Title}}{{end}}
|
||||
|
||||
{{define "metadescription"}}Смотреть бесплатный самообновляемый плейлист {{.Playlist.Name}}, проверить статус, {{.Playlist.Description}}{{end}}
|
||||
|
||||
{{define "head"}}
|
||||
<style>
|
||||
img.tvg-logo{max-width:80px;max-height:80px;padding:2px;border-radius:5px}
|
||||
tr.chrow td{padding:3px}
|
||||
td.chindex{width:1%}
|
||||
td.chlogo{width:100px}
|
||||
div.chlist-table{max-height:550px}
|
||||
textarea.m3u-raw{font-size:.7rem}
|
||||
</style>
|
||||
<script>
|
||||
const DEFAULT_LOGO = 'data:image/svg+xml,' + encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><rect width="100" height="100" fill="#333"/><text x="50" y="55" text-anchor="middle" fill="#999" font-size="14">no logo</text></svg>')
|
||||
|
||||
function setDefaultLogo(imgtag) {
|
||||
imgtag.onerror = null
|
||||
imgtag.src = DEFAULT_LOGO
|
||||
}
|
||||
</script>
|
||||
{{end}}
|
||||
|
||||
{{define "header"}}
|
||||
<h2>О плейлисте: {{.Playlist.Name}}</h2>
|
||||
{{end}}
|
||||
|
||||
{{define "content"}}
|
||||
{{$pls := .Playlist}}
|
||||
<div class="row">
|
||||
<div class="col-lg-7">
|
||||
<ul class="nav nav-tabs">
|
||||
<li class="nav-item small">
|
||||
<a class="nav-link active"
|
||||
type="button"
|
||||
href="#tab-data"
|
||||
data-bs-toggle="tab"
|
||||
data-bs-target="#tab-data"
|
||||
>
|
||||
<ion-icon name="radio-outline"></ion-icon> Основные данные
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item small">
|
||||
<a class="nav-link"
|
||||
type="button"
|
||||
href="#tab-raw"
|
||||
data-bs-toggle="tab"
|
||||
data-bs-target="#tab-raw"
|
||||
>
|
||||
<ion-icon name="document-text-outline"></ion-icon> Исходный текст
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item small">
|
||||
<a class="nav-link"
|
||||
type="button"
|
||||
href="#tab-abuse"
|
||||
data-bs-toggle="tab"
|
||||
data-bs-target="#tab-abuse"
|
||||
>
|
||||
<ion-icon name="wallet-outline"></ion-icon> Правообладателям
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
<div class="tab-content">
|
||||
<div class="tab-pane fade show active" id="tab-data" tabindex="0">
|
||||
<table class="table table-dark table-hover small mb-lg-5">
|
||||
<tbody>
|
||||
<tr>
|
||||
<th class="w-25" scope="row">Код</th>
|
||||
<th class="text-break">
|
||||
<span class="pe-3 font-monospace">{{$pls.Code}}</span>
|
||||
{{if not $pls.IsKnown}}
|
||||
<span class="cursor-help badge small text-dark bg-secondary"
|
||||
title="Не проверялся"
|
||||
>unknown</span>
|
||||
{{else if $pls.IsOnline}}
|
||||
<span class="cursor-help badge small text-dark bg-success"
|
||||
title="Вероятно, работает"
|
||||
>online</span>
|
||||
{{else}}
|
||||
<span class="cursor-help badge small text-dark bg-danger"
|
||||
title="Вероятно, не работает"
|
||||
>offline</span>
|
||||
{{end}}
|
||||
</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">Описание</th>
|
||||
<td class="text-break"><p class="mb-0">{{$pls.Description}}</p></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">Короткая ссылка</th>
|
||||
<td>
|
||||
<span onclick="copyPlaylistUrl('{{$pls.Code}}')"
|
||||
class="cursor-pointer"
|
||||
title="Нажми на ссылку, чтобы скопировать её в буфер обмена"
|
||||
>
|
||||
<b class="cursor-pointer font-monospace text-break">{{baseUrl $pls.Code}}</b>
|
||||
<ion-icon name="copy-outline"></ion-icon>
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">Источник</th>
|
||||
<td class="text-break">{{$pls.Source}}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">Наполнение</th>
|
||||
<td class="text-break">
|
||||
{{if and $pls.IsKnown $pls.IsOnline}}
|
||||
{{if $pls.HasTokens}}
|
||||
<span class="cursor-help badge bg-info text-dark">
|
||||
<ion-icon name="paw"></ion-icon>
|
||||
</span> могут быть нестабильные каналы<br>
|
||||
{{end}}
|
||||
|
||||
{{if hasTag $pls.Tags "adult"}}
|
||||
<span class="cursor-help badge small bg-warning text-dark">18+</span> есть каналы для взрослых<br>
|
||||
{{end}}
|
||||
|
||||
<ion-icon name="folder-open-outline"></ion-icon> группы: {{groupCount $pls}}<br>
|
||||
<ion-icon name="videocam-outline"></ion-icon> каналы:
|
||||
<span class="cursor-help text-success" title="Возможно, рабочие каналы">
|
||||
{{$pls.OnlineCount}} ({{$pls.OnlinePercent}}%)
|
||||
</span>
|
||||
+
|
||||
<span class="cursor-help text-danger" title="Возможно, НЕрабочие каналы">
|
||||
{{$pls.OfflineCount}} ({{$pls.OfflinePercent}}%)
|
||||
</span>
|
||||
= {{channelCount $pls}}
|
||||
{{end}}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">Возможности</th>
|
||||
<td class="text-break">
|
||||
{{if and $pls.IsKnown $pls.IsOnline}}
|
||||
<ion-icon name="newspaper-outline"></ion-icon> Программа передач: {{if $pls.HasTvg}}есть{{else}}нет{{end}}<br>
|
||||
<ion-icon name="play-back"></ion-icon> Перемотка (архив): {{if $pls.HasCatchup}}есть{{else}}нет{{end}}
|
||||
{{end}}
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="text-secondary">
|
||||
<th scope="row">M3U</th>
|
||||
<td class="text-break">{{$pls.Url}}</td>
|
||||
</tr>
|
||||
<tr class="text-secondary">
|
||||
<th class="w-25" scope="row">Проверка плейлиста</th>
|
||||
<td class="text-break">
|
||||
<span title="Фактическая метка времени окончания проверки плейлиста">
|
||||
{{formatDate $pls.CheckedAt}}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
{{if and $pls.IsKnown (not $pls.IsOnline)}}
|
||||
<tr class="text-secondary">
|
||||
<th class="w-25" scope="row">Ошибка проверки</th>
|
||||
<td class="text-break">{{$pls.Content}}</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{{if $pls.Attributes}}
|
||||
<h4>Дополнительные атрибуты</h4>
|
||||
<table class="table table-dark table-hover small font-monospace">
|
||||
<tbody>
|
||||
{{range $attr, $val := $pls.Attributes}}
|
||||
<tr>
|
||||
<th class="w-25" scope="row">{{$attr}}</th>
|
||||
<td class="text-break">{{$val}}</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
{{end}}
|
||||
</div>
|
||||
<div class="tab-pane fade" id="tab-raw" tabindex="1">
|
||||
<button class="btn btn-sm btn-success my-3"
|
||||
id="saveM3UBtn"
|
||||
onclick="savePlaylist()"
|
||||
>
|
||||
<ion-icon name="download-outline"></ion-icon> {{$pls.Code}}.m3u8
|
||||
</button>
|
||||
<textarea class="form-control bg-dark text-light font-monospace mb-3 mb-md-0 m3u-raw"
|
||||
rows="40"
|
||||
id="m3u-raw"
|
||||
readonly
|
||||
>{{$pls.Content}}</textarea>
|
||||
</div>
|
||||
<div class="tab-pane fade" id="tab-abuse" tabindex="2">
|
||||
<p class="my-3">
|
||||
Данные, представленные на данной странице, получены автоматически из открыто доступных в
|
||||
интернете IPTV-плейлистов, опубликованных третьими лицами.
|
||||
При наличии технической возможности, источник плейлиста может быть указан на вкладке "Основные
|
||||
данные".
|
||||
</p>
|
||||
<p class="my-3">
|
||||
Сервис {{baseUrl ""}} не размещает и не транслирует медиаконтент, не создаёт, не призывает
|
||||
использовать и распространять плейлисты третьих лиц, а также не оказывает услуг по ретрансляции
|
||||
телепрограмм.
|
||||
</p>
|
||||
<p class="my-3">
|
||||
Информация о телеканалах (наименования, логотипы, технический статус и другие сведения)
|
||||
формируется исключительно путём обработки содержимого самого плейлиста.
|
||||
Вся информация носит технический и ознакомительный характер, и её достоверность не гарантируется.
|
||||
</p>
|
||||
<p class="my-3">
|
||||
Все права на торговые марки и графические изображения принадлежат их законным владельцам.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-lg-5">
|
||||
<h4>Список каналов: <span id="chcount">{{channelCount $pls}}</span></h4>
|
||||
{{if gt (channelCount $pls) 0}}
|
||||
{{if gt (groupCount $pls) 1}}
|
||||
<div class="row my-3">
|
||||
<div class="col-12">
|
||||
{{if gt (channelCount $pls) 100}}
|
||||
<div class="alert alert-warning small" role="alert" id="chListLoading">
|
||||
<div class="spinner-border text-success spinner-border-sm" role="status"></div>
|
||||
Загрузка...
|
||||
</div>
|
||||
{{end}}
|
||||
<div class="input-group">
|
||||
<select id="groupSelector"
|
||||
class="form-select form-select-sm border-secondary bg-dark text-light"
|
||||
onchange="updateFilter()"
|
||||
>
|
||||
<option selected value="all">Все группы</option>
|
||||
{{range $pls.Groups}}
|
||||
<option value="{{.Id}}">{{.Name}}</option>
|
||||
{{end}}
|
||||
</select>
|
||||
|
||||
<button type="button"
|
||||
onclick="resetGroup()"
|
||||
class="btn btn-sm btn-outline-secondary"
|
||||
title="Сбросить группу"
|
||||
>
|
||||
<ion-icon name="close-outline"></ion-icon>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
<div class="row my-3">
|
||||
<div class="col-12">
|
||||
<div class="input-group">
|
||||
<input type="text"
|
||||
id="search-field"
|
||||
class="cursor-help form-control form-control-sm border-secondary bg-dark text-light fuzzy-search"
|
||||
placeholder="Поиск каналов..."
|
||||
title="Начни вводить название"
|
||||
/>
|
||||
|
||||
<input type="radio"
|
||||
class="btn-check"
|
||||
name="chFilter"
|
||||
id="chfAll"
|
||||
autocomplete="off"
|
||||
onclick="updateFilter()"
|
||||
checked
|
||||
>
|
||||
<label class="btn btn-sm btn-outline-secondary"
|
||||
for="chfAll"
|
||||
title="Выбрать все каналы"
|
||||
>
|
||||
<ion-icon name="radio-button-on-outline"></ion-icon>
|
||||
</label>
|
||||
|
||||
<input type="radio"
|
||||
class="btn-check"
|
||||
name="chFilter"
|
||||
id="chfOnline"
|
||||
autocomplete="off"
|
||||
onclick="updateFilter()"
|
||||
>
|
||||
<label class="btn btn-sm btn-outline-success"
|
||||
for="chfOnline"
|
||||
title="Выбрать только онлайн каналы"
|
||||
>
|
||||
<ion-icon name="radio-button-on-outline"></ion-icon>{{$pls.OnlineCount}}
|
||||
</label>
|
||||
|
||||
<input type="radio"
|
||||
class="btn-check"
|
||||
name="chFilter"
|
||||
id="chfOffline"
|
||||
autocomplete="off"
|
||||
onclick="updateFilter()"
|
||||
>
|
||||
<label class="btn btn-sm btn-outline-danger"
|
||||
for="chfOffline"
|
||||
title="Выбрать только оффлайн каналы"
|
||||
>
|
||||
<ion-icon name="radio-button-on-outline"></ion-icon>{{$pls.OfflineCount}}
|
||||
</label>
|
||||
|
||||
<button type="button"
|
||||
class="btn btn-sm btn-outline-secondary"
|
||||
onclick="resetSearch()"
|
||||
title="Сбросить фильтрацию"
|
||||
>
|
||||
<ion-icon name="close-outline"></ion-icon>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="my-3">
|
||||
{{range $pls.Tags}}
|
||||
<input type="checkbox"
|
||||
class="btn-check"
|
||||
id="btn-tag-{{.}}"
|
||||
data-tag="{{.}}"
|
||||
autocomplete="off"
|
||||
onclick="updateFilter()"
|
||||
>
|
||||
<label class="badge btn btn-sm btn-outline-secondary rounded-pill"
|
||||
for="btn-tag-{{.}}"
|
||||
title="Нажми для фильтрации каналов по тегу, нажми ещё раз чтобы снять фильтр"
|
||||
>#{{.}}</label>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="chlist-table overflow-auto">
|
||||
<table id="chlist" class="table table-dark table-hover small">
|
||||
<tbody class="list">
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{{else if and $pls.IsKnown (not $pls.IsOnline)}}
|
||||
<div class="alert alert-danger small" role="alert">
|
||||
Ошибка плейлиста: {{$pls.Content}}
|
||||
</div>
|
||||
{{else if not $pls.IsKnown}}
|
||||
<div class="alert alert-warning small" role="alert" id="chListLoading">
|
||||
Список каналов сейчас неизвестен: плейлист ещё не проверялся, либо данные о последней проверке потеряли актуальность.
|
||||
<br><br>
|
||||
Вернитесь на эту страницу позже. Каналы будут известны, когда плейлист будет в статусе online.
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{define "footer"}}
|
||||
{{if and .Playlist.IsKnown .Playlist.IsOnline}}
|
||||
<script src="https://cdn.jsdelivr.net/npm/list.js@2.3.1/dist/list.min.js"></script>
|
||||
<script>
|
||||
function getChannelTemplate(channel) {
|
||||
const httpCode = channel.status ?? '(неизвестно)'
|
||||
const errText = !!channel.error
|
||||
? channel.error.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''')
|
||||
: '(нет)'
|
||||
|
||||
const logoUrl = channel.attributes['tvg-logo'] ?? DEFAULT_LOGO
|
||||
const logoHint = !!channel.attributes['tvg-logo']
|
||||
? `Логотип канала '${channel.title}'`
|
||||
: `Нет логотипа для канала '${channel.title}'`
|
||||
|
||||
const statusIconColor = channel.isOnline ? 'text-success' : 'text-danger'
|
||||
const statusIconHint = channel.isOnline
|
||||
? 'Состояние: онлайн (возможно, работает прямо сейчас)"'
|
||||
: 'Состояние: оффлайн (не работал в момент проверки или ошибка проверки)"'
|
||||
|
||||
const adultIcon = channel.tags.indexOf('adult') !== -1
|
||||
? '<span class="badge small bg-warning text-dark" title="Канал для взрослых!">18+</span>'
|
||||
: ''
|
||||
|
||||
const pawIcon = channel.hasToken
|
||||
? '<span class="cursor-help badge small bg-info text-dark" title="Может быть нестабилен"><ion-icon name="paw"></ion-icon></span>'
|
||||
: ''
|
||||
|
||||
const tvgId = !!channel.attributes['tvg-id']
|
||||
? `<div title="Идентификатор канала для телепрограммы (tvg-id)" class="cursor-help"><ion-icon name="star-outline" class="me-1"></ion-icon> ${channel.attributes['tvg-id']}</div>`
|
||||
: ``
|
||||
|
||||
const mimeType = !!channel.contentType
|
||||
? `<div title="Тип контента (mime-type)" class="cursor-help"><ion-icon name="eye-outline" class="me-1"></ion-icon> ${channel.contentType}</div>`
|
||||
: ``
|
||||
|
||||
const tags = channel.tags.length > 0
|
||||
? `<ion-icon name="pricetag-outline" class="cursor-help me-1" title="Теги"></ion-icon> `
|
||||
+ channel.tags.map((tag) => `<span class="chtag">#${tag}</span>`).join(' ')
|
||||
: ``
|
||||
|
||||
return `<tr class="chrow" title="
HTTP: ${httpCode}
Error: ${errText}">
|
||||
<td class="chlogo text-center">
|
||||
<img class="tvg-logo" alt="${logoHint}" title="${logoHint}" src="${logoUrl}" onerror="setDefaultLogo(this)" />
|
||||
</td>
|
||||
<td class="text-break">
|
||||
<ion-icon name="radio-button-on-outline" class="cursor-help me-1 ${statusIconColor}" title="${statusIconHint}"></ion-icon>
|
||||
${adultIcon}
|
||||
${pawIcon}
|
||||
${channel.title}
|
||||
<div class="text-secondary small">
|
||||
${tvgId}
|
||||
${mimeType}
|
||||
${tags}
|
||||
</div>
|
||||
</td>
|
||||
</tr>`
|
||||
}
|
||||
|
||||
const options = {
|
||||
searchColumns: ['title'],
|
||||
item: (channel) => getChannelTemplate(channel),
|
||||
};
|
||||
const values = {{jsonChannels .Playlist.ViewChannels}}
|
||||
|
||||
const list = new List('chlist', options, values)
|
||||
list.on('updated', (data) => document.getElementById('chcount').innerText = data.visibleItems.length)
|
||||
document.getElementById('search-field').addEventListener('keyup', (e) => list.search(e.target.value))
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
const alert = document.getElementById("chListLoading")
|
||||
!!alert && alert.remove()
|
||||
});
|
||||
|
||||
function savePlaylist() {
|
||||
const link = document.createElement("a");
|
||||
const content = document.getElementById("m3u-raw").value
|
||||
const file = new Blob([content], { type: 'text/plain' });
|
||||
link.href = URL.createObjectURL(file);
|
||||
link.download = "{{.Playlist.Code}}.m3u8";
|
||||
link.click();
|
||||
URL.revokeObjectURL(link.href);
|
||||
}
|
||||
|
||||
function resetGroup() {
|
||||
document.getElementById('groupSelector').value = 'all'
|
||||
updateFilter()
|
||||
}
|
||||
|
||||
function resetSearch() {
|
||||
list.search('')
|
||||
document.getElementById('search-field').value = ''
|
||||
document.getElementById('chfAll').checked = true
|
||||
document.querySelectorAll('input[id*="btn-tag-"]:checked').forEach(tag => tag.checked = false)
|
||||
updateFilter()
|
||||
}
|
||||
|
||||
function updateFilter() {
|
||||
const selectedGroupId = document.getElementById('groupSelector')?.value ?? 'all';
|
||||
const tagsElements = document.querySelectorAll('input[id*="btn-tag-"]:checked')
|
||||
const tagsSelected = []
|
||||
tagsElements.forEach(tag => tagsSelected.push(tag.attributes['data-tag'].value));
|
||||
const activeType = document.querySelector('input[name="chFilter"]:checked').id;
|
||||
switch (activeType) {
|
||||
case 'chfAll':
|
||||
list.filter(item => {
|
||||
const chTags = item.values().tags
|
||||
const isGroupValid = item.values().groupId === selectedGroupId || selectedGroupId === 'all';
|
||||
const tagsIntersection = tagsSelected.filter(tagSelected => chTags.includes(tagSelected));
|
||||
const hasValidTags = tagsIntersection.length > 0 || tagsSelected.length === 0;
|
||||
return isGroupValid && hasValidTags;
|
||||
})
|
||||
break
|
||||
|
||||
case 'chfOnline':
|
||||
list.filter(item => {
|
||||
const isOnline = item.values().isOnline
|
||||
const chTags = item.values().tags
|
||||
const isGroupValid = item.values().groupId === selectedGroupId || selectedGroupId === 'all';
|
||||
const tagsIntersection = tagsSelected.filter(tagSelected => chTags.includes(tagSelected));
|
||||
const hasValidTags = tagsIntersection.length > 0 || tagsSelected.length === 0;
|
||||
return isGroupValid && isOnline && hasValidTags
|
||||
})
|
||||
break
|
||||
|
||||
case 'chfOffline':
|
||||
list.filter(item => {
|
||||
const isOffline = !item.values().isOnline
|
||||
const chTags = item.values().tags
|
||||
const isGroupValid = item.values().groupId === selectedGroupId || selectedGroupId === 'all';
|
||||
const tagsIntersection = tagsSelected.filter(tagSelected => chTags.includes(tagSelected));
|
||||
const hasValidTags = tagsIntersection.length > 0 || tagsSelected.length === 0;
|
||||
return isGroupValid && isOffline && hasValidTags
|
||||
})
|
||||
break
|
||||
}
|
||||
}
|
||||
</script>
|
||||
{{end}}
|
||||
{{end}}
|
||||
@@ -0,0 +1,151 @@
|
||||
{{define "title"}}{{.Title}}{{end}}
|
||||
|
||||
{{define "metadescription"}}Самообновляемые бесплатные IPTV-плейлисты для домашнего просмотра по коротким ссылкам, списки каналов, проверка доступности{{end}}
|
||||
|
||||
{{define "metakeywords"}}самообновляемые,бесплатные,iptv-плейлисты,iptv,плейлисты{{end}}
|
||||
|
||||
{{define "head"}}
|
||||
<style>
|
||||
.card {transition: box-shadow .2s, transform .2s}
|
||||
.card.hover-success:hover {transform: translateY(-7px); box-shadow: rgba(var(--bs-success-rgb), 1) 0 5px 20px -5px}
|
||||
.card.hover-danger:hover {transform: translateY(-7px); box-shadow: rgba(var(--bs-danger-rgb), 1) 0 5px 20px -5px}
|
||||
.card.hover-secondary:hover {transform: translateY(-7px); box-shadow: rgba(var(--bs-secondary-rgb), 1) 0 5px 20px -5px}
|
||||
</style>
|
||||
<script>
|
||||
const DEFAULT_LOGO = 'data:image/svg+xml,' + encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><rect width="100" height="100" fill="#333"/><text x="50" y="55" text-anchor="middle" fill="#999" font-size="14">no logo</text></svg>')
|
||||
|
||||
function setDefaultLogo(imgtag) {
|
||||
imgtag.onerror = null
|
||||
imgtag.src = DEFAULT_LOGO
|
||||
}
|
||||
</script>
|
||||
{{end}}
|
||||
|
||||
{{define "header"}}
|
||||
<div class="d-flex flex-wrap justify-content-between align-items-center mb-4">
|
||||
<div class="mb-2">
|
||||
<h2 class="mb-0">Список плейлистов ({{.Count}})</h2>
|
||||
<div class="text-muted small">Изменён {{.UpdatedAt}} МСК</div>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{define "content"}}
|
||||
{{if .Error}}
|
||||
<div class="alert alert-danger" role="alert">
|
||||
<ion-icon name="alert-circle-outline" class="me-1"></ion-icon>{{.Error}}
|
||||
</div>
|
||||
{{else if eq (len .Playlists) 0}}
|
||||
<div class="alert alert-warning" role="alert">
|
||||
<ion-icon name="information-circle-outline" class="me-1"></ion-icon>Список плейлистов пуст.
|
||||
Вернитесь на эту страницу позже — возможно, плейлисты ещё не добавлены или не проверены.
|
||||
</div>
|
||||
{{end}}
|
||||
<div class="row g-4">
|
||||
{{range .Playlists}}
|
||||
{{$sc := statusClass .}}
|
||||
<div class="col-md-6 col-lg-4">
|
||||
<div class="card bg-dark text-light h-100 border border-{{$sc}} hover-{{$sc}} position-relative">
|
||||
<a href="/{{.Code}}/details" class="text-decoration-none">
|
||||
<div class="card-header d-flex align-items-center gap-2">
|
||||
<span class="font-monospace text-{{$sc}}">{{.Code}}</span>
|
||||
|
||||
{{if not .IsKnown}}
|
||||
<span class="cursor-help badge bg-{{$sc}} ms-auto"
|
||||
title="Плейлист ещё не проверялся, придётся подождать"
|
||||
>unknown</span>
|
||||
{{else if .IsOnline}}
|
||||
<span class="cursor-help badge bg-{{$sc}} ms-auto"
|
||||
title="Возможно, этот плейлист рабочий"
|
||||
>online</span>
|
||||
{{else}}
|
||||
<span class="cursor-help badge bg-{{$sc}} ms-auto"
|
||||
title="Этот плейлист нерабочий или его не удалось проверить"
|
||||
>offline</span>
|
||||
{{end}}
|
||||
|
||||
{{if and .IsKnown .IsOnline}}
|
||||
<span class="cursor-help badge border border-success"
|
||||
title="Процент рабочих каналов"
|
||||
>{{.OnlinePercent}}%</span>
|
||||
{{end}}
|
||||
|
||||
{{if hasTag .Tags "adult"}}
|
||||
<span class="cursor-help badge bg-warning text-dark"
|
||||
title="Есть каналы для взрослых!"
|
||||
>18+</span>
|
||||
{{end}}
|
||||
|
||||
{{if .HasTokens}}
|
||||
<span class="cursor-help badge bg-info text-dark"
|
||||
title="В плейлисте есть каналы, которые могут быть нестабильны"
|
||||
><ion-icon name="paw"></ion-icon></span>
|
||||
{{end}}
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<div class="card-body position-relative z-2">
|
||||
<a href="/{{.Code}}/details" class="text-decoration-none">
|
||||
<h5 class="card-title text-light">{{.Name}}</h5>
|
||||
</a>
|
||||
{{if .Description}}
|
||||
<p class="card-text small text-secondary d-none d-md-block">{{.Description}}</p>
|
||||
{{end}}
|
||||
<div class="d-flex flex-wrap gap-2 mb-1">
|
||||
{{if and .IsKnown .IsOnline}}
|
||||
<span class="badge border border-secondary">
|
||||
<ion-icon name="videocam-outline" class="me-1"></ion-icon> {{channelCount .}}<span class="d-none d-xl-inline-block"> каналов</span>
|
||||
</span>
|
||||
{{if gt (groupCount .) 0}}
|
||||
<span class="badge border border-secondary">
|
||||
<ion-icon name="folder-open-outline" class="me-1"></ion-icon> {{groupCount .}}<span class="d-none d-xl-inline-block"> групп</span>
|
||||
</span>
|
||||
{{end}}
|
||||
{{if .HasTvg}}
|
||||
<span class="badge border border-secondary">
|
||||
<ion-icon name="newspaper-outline" class="me-1"></ion-icon><span class="d-none d-xl-inline-block"> ТВ-программа</span>
|
||||
</span>
|
||||
{{end}}
|
||||
{{if .HasCatchup}}
|
||||
<span class="badge border border-secondary">
|
||||
<ion-icon name="play-back-outline" class="me-1"></ion-icon><span class="d-none d-xl-inline-block"> Архив</span>
|
||||
</span>
|
||||
{{end}}
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card-footer cursor-pointer"
|
||||
onclick="copyPlaylistUrl('{{.Code}}')"
|
||||
title="Нажми на ссылку, чтобы скопировать её в буфер обмена"
|
||||
>
|
||||
<div class="d-flex justify-content-between align-items-center small">
|
||||
<span class="font-monospace text-truncate">
|
||||
{{baseUrl .Code}}
|
||||
</span>
|
||||
<ion-icon name="copy-outline"></ion-icon>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
{{if gt .PageCount 1}}
|
||||
<nav class="mt-4">
|
||||
<ul class="pagination justify-content-center">
|
||||
{{range $page := seq 1 .PageCount}}
|
||||
{{if eq $page $.PageCurrent}}
|
||||
<li class="page-item active" aria-current="page">
|
||||
<span class="page-link">{{$page}}</span>
|
||||
</li>
|
||||
{{else}}
|
||||
<li class="page-item">
|
||||
<a class="page-link bg-dark text-light" href="/page/{{$page}}">{{$page}}</a>
|
||||
</li>
|
||||
{{end}}
|
||||
{{end}}
|
||||
</ul>
|
||||
</nav>
|
||||
{{end}}
|
||||
{{end}}
|
||||
@@ -0,0 +1,23 @@
|
||||
{{define "title"}}Плейлист не найден - {{.Title}}{{end}}
|
||||
|
||||
{{define "content"}}
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-md-8 col-lg-6 text-center">
|
||||
<div class="card bg-dark border-secondary">
|
||||
<div class="card-body">
|
||||
<ion-icon name="warning-outline" class="display-1 text-warning mb-3"></ion-icon>
|
||||
<h2 class="card-title">Плейлист <code>{{.Code}}</code> не найден</h2>
|
||||
<p class="card-text">
|
||||
Возможно, его здесь никогда не было, либо он уже был удалён.
|
||||
</p>
|
||||
<p class="text-muted small">
|
||||
Если хочешь, чтобы здесь был плейлист, предложи его к добавлению.
|
||||
</p>
|
||||
<a class="btn btn-outline-light" href="/" title="На главную">
|
||||
<ion-icon name="list-outline" class="me-1"></ion-icon>Перейти к списку плейлистов
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
@@ -0,0 +1,360 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"axenov/iptv-checker/app/playlist"
|
||||
)
|
||||
|
||||
func TestHasTokensString(t *testing.T) {
|
||||
cases := []struct {
|
||||
in string
|
||||
want bool
|
||||
}{
|
||||
{"http://example.com/stream.m3u8?token=abc", true},
|
||||
{"http://example.com/stream?user=test&pwd=secret", true},
|
||||
{"http://example.com/stream?drmreq=xyz", true},
|
||||
{"http://example.com/stream?u=foo", true},
|
||||
{"http://example.com/stream?password=123", true},
|
||||
{"http://example.com/stream.m3u8", false},
|
||||
{"", false},
|
||||
{"plain text without tokens", false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
got := hasTokensString(c.in)
|
||||
if got != c.want {
|
||||
t.Errorf("hasTokensString(%q) = %v, want %v", c.in, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestStatusClass(t *testing.T) {
|
||||
cases := []struct {
|
||||
view *PlaylistView
|
||||
want string
|
||||
}{
|
||||
{&PlaylistView{IsKnown: false}, "secondary"},
|
||||
{&PlaylistView{IsKnown: true, Playlist: playlist.Playlist{IsOnline: true}}, "success"},
|
||||
{&PlaylistView{IsKnown: true, Playlist: playlist.Playlist{IsOnline: false}}, "danger"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
got := statusClass(c.view)
|
||||
if got != c.want {
|
||||
t.Errorf("statusClass() = %s, want %s", got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestStatusBadge(t *testing.T) {
|
||||
cases := []struct {
|
||||
view *PlaylistView
|
||||
want string
|
||||
}{
|
||||
{&PlaylistView{IsKnown: false}, "unknown"},
|
||||
{&PlaylistView{IsKnown: true, Playlist: playlist.Playlist{IsOnline: true}}, "online"},
|
||||
{&PlaylistView{IsKnown: true, Playlist: playlist.Playlist{IsOnline: false}}, "offline"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
got := statusBadge(c.view)
|
||||
if got != c.want {
|
||||
t.Errorf("statusBadge() = %s, want %s", got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatDate(t *testing.T) {
|
||||
if formatDate(0) != "" {
|
||||
t.Error("expected empty string for ts=0")
|
||||
}
|
||||
result := formatDate(1700000000)
|
||||
if result == "" {
|
||||
t.Error("expected non-empty date string")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsAlphanumeric(t *testing.T) {
|
||||
cases := []struct {
|
||||
in string
|
||||
want bool
|
||||
}{
|
||||
{"ABC123", true},
|
||||
{"abc", true},
|
||||
{"123", true},
|
||||
{"", false},
|
||||
{"ABC-123", false},
|
||||
{"ABC_123", false},
|
||||
{"ABC 123", false},
|
||||
{"Привет", false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
got := isAlphanumeric(c.in)
|
||||
if got != c.want {
|
||||
t.Errorf("isAlphanumeric(%q) = %v, want %v", c.in, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseRedisVersion(t *testing.T) {
|
||||
info := "# Server\nredis_version:6.2.6\nredis_mode:standalone\n"
|
||||
got := parseRedisVersion(info)
|
||||
if got != "6.2.6" {
|
||||
t.Errorf("expected '6.2.6', got '%s'", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseRedisVersion_NotFound(t *testing.T) {
|
||||
info := "# Server\nsome_other_field:foo\n"
|
||||
got := parseRedisVersion(info)
|
||||
if got != "unknown" {
|
||||
t.Errorf("expected 'unknown', got '%s'", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestContainsTag(t *testing.T) {
|
||||
tags := []string{"hd", "adult", "russian"}
|
||||
if !containsTag(tags, "adult") {
|
||||
t.Error("expected true for 'adult'")
|
||||
}
|
||||
if containsTag(tags, "movies") {
|
||||
t.Error("expected false for 'movies'")
|
||||
}
|
||||
if containsTag([]string{}, "x") {
|
||||
t.Error("expected false for empty list")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMakeUnknownView(t *testing.T) {
|
||||
iniPls := playlist.Playlist{
|
||||
Name: "Test",
|
||||
Description: "Desc",
|
||||
Url: "http://example.com",
|
||||
Source: "public",
|
||||
}
|
||||
view := makeUnknownView("CODE", iniPls)
|
||||
if view.Code != "CODE" {
|
||||
t.Errorf("expected Code='CODE', got '%s'", view.Code)
|
||||
}
|
||||
if view.IsKnown {
|
||||
t.Error("expected IsKnown=false")
|
||||
}
|
||||
if view.Name != "Test" {
|
||||
t.Errorf("expected Name='Test', got '%s'", view.Name)
|
||||
}
|
||||
if len(view.Tags) != 0 {
|
||||
t.Errorf("expected 0 tags, got %d", len(view.Tags))
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnrichPlaylist_OnlinePercent(t *testing.T) {
|
||||
view := &PlaylistView{
|
||||
Playlist: playlist.Playlist{
|
||||
IsOnline: true,
|
||||
OnlineCount: 8,
|
||||
OfflineCount: 2,
|
||||
Channels: map[string]playlist.Channel{
|
||||
"1": {},
|
||||
"2": {},
|
||||
"3": {},
|
||||
"4": {},
|
||||
"5": {},
|
||||
"6": {},
|
||||
"7": {},
|
||||
"8": {},
|
||||
"9": {},
|
||||
"10": {},
|
||||
},
|
||||
Attributes: map[string]string{},
|
||||
Groups: map[string]playlist.Group{},
|
||||
Content: "#EXTM3U",
|
||||
},
|
||||
IsKnown: true,
|
||||
}
|
||||
enrichPlaylist(view)
|
||||
if view.OnlinePercent != 80 {
|
||||
t.Errorf("expected 80%%, got %d%%", view.OnlinePercent)
|
||||
}
|
||||
if view.OfflinePercent != 20 {
|
||||
t.Errorf("expected 20%%, got %d%%", view.OfflinePercent)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnrichPlaylist_Unknown(t *testing.T) {
|
||||
view := &PlaylistView{
|
||||
Playlist: playlist.Playlist{
|
||||
Channels: map[string]playlist.Channel{"1": {}},
|
||||
Attributes: map[string]string{},
|
||||
Groups: map[string]playlist.Group{},
|
||||
Content: "",
|
||||
},
|
||||
IsKnown: false,
|
||||
}
|
||||
enrichPlaylist(view)
|
||||
if view.OnlinePercent != 0 || view.OfflinePercent != 0 {
|
||||
t.Errorf("expected 0%% for unknown, got online=%d%% offline=%d%%",
|
||||
view.OnlinePercent, view.OfflinePercent)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnrichPlaylist_HasCatchup(t *testing.T) {
|
||||
view := &PlaylistView{
|
||||
Playlist: playlist.Playlist{
|
||||
Content: "#EXTM3U catchup=default",
|
||||
Channels: map[string]playlist.Channel{},
|
||||
Attributes: map[string]string{},
|
||||
Groups: map[string]playlist.Group{},
|
||||
},
|
||||
IsKnown: true,
|
||||
}
|
||||
enrichPlaylist(view)
|
||||
if !view.HasCatchup {
|
||||
t.Error("expected HasCatchup=true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnrichPlaylist_HasTvg(t *testing.T) {
|
||||
view := &PlaylistView{
|
||||
Playlist: playlist.Playlist{
|
||||
Attributes: map[string]string{"url-tvg": "http://example.com/epg.xml"},
|
||||
Channels: map[string]playlist.Channel{},
|
||||
Groups: map[string]playlist.Group{},
|
||||
Content: "",
|
||||
},
|
||||
IsKnown: true,
|
||||
}
|
||||
enrichPlaylist(view)
|
||||
if !view.HasTvg {
|
||||
t.Error("expected HasTvg=true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnrichPlaylist_HasTokens(t *testing.T) {
|
||||
view := &PlaylistView{
|
||||
Playlist: playlist.Playlist{
|
||||
Url: "http://example.com/list.m3u8?token=abc",
|
||||
Content: "#EXTM3U",
|
||||
Channels: map[string]playlist.Channel{},
|
||||
Attributes: map[string]string{},
|
||||
Groups: map[string]playlist.Group{},
|
||||
},
|
||||
IsKnown: true,
|
||||
}
|
||||
enrichPlaylist(view)
|
||||
if !view.HasTokens {
|
||||
t.Error("expected HasTokens=true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnrichPlaylist_TagsCollection(t *testing.T) {
|
||||
view := &PlaylistView{
|
||||
Playlist: playlist.Playlist{
|
||||
Channels: map[string]playlist.Channel{
|
||||
"1": {Tags: []string{"hd", "russian"}},
|
||||
"2": {Tags: []string{"adult", "hd"}},
|
||||
},
|
||||
Attributes: map[string]string{},
|
||||
Groups: map[string]playlist.Group{},
|
||||
Content: "",
|
||||
},
|
||||
IsKnown: true,
|
||||
}
|
||||
enrichPlaylist(view)
|
||||
if len(view.Tags) != 3 {
|
||||
t.Errorf("expected 3 unique tags, got %d: %v", len(view.Tags), view.Tags)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnrichPlaylist_ChannelHasToken(t *testing.T) {
|
||||
view := &PlaylistView{
|
||||
Playlist: playlist.Playlist{
|
||||
Channels: map[string]playlist.Channel{
|
||||
"1": {URL: "http://example.com/stream?token=abc"},
|
||||
},
|
||||
Attributes: map[string]string{},
|
||||
Groups: map[string]playlist.Group{},
|
||||
Content: "",
|
||||
},
|
||||
IsKnown: true,
|
||||
}
|
||||
enrichPlaylist(view)
|
||||
for _, cv := range view.ViewChannels {
|
||||
if !cv.HasToken {
|
||||
t.Error("expected channel HasToken=true")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestComputeStats(t *testing.T) {
|
||||
playlists := []PlaylistView{
|
||||
{
|
||||
Playlist: playlist.Playlist{
|
||||
Code: "RU",
|
||||
IsOnline: true,
|
||||
CheckedAt: 1700000000,
|
||||
Channels: map[string]playlist.Channel{"1": {IsOnline: true}, "2": {IsOnline: false}},
|
||||
Groups: map[string]playlist.Group{"g1": {}},
|
||||
Attributes: map[string]string{"url-tvg": "http://epg.xml"},
|
||||
Content: "#EXTM3U catchup=default",
|
||||
},
|
||||
IsKnown: true,
|
||||
HasCatchup: true,
|
||||
HasTvg: true,
|
||||
Tags: []string{"adult"},
|
||||
},
|
||||
{
|
||||
Playlist: playlist.Playlist{
|
||||
Code: "EN",
|
||||
IsOnline: false,
|
||||
CheckedAt: 1700000100,
|
||||
Channels: map[string]playlist.Channel{"1": {IsOnline: false}},
|
||||
},
|
||||
IsKnown: false,
|
||||
},
|
||||
}
|
||||
stats := computeStats(playlists)
|
||||
plsStats := stats["playlists"].(map[string]interface{})
|
||||
if plsStats["all"].(int) != 2 {
|
||||
t.Errorf("expected 2 playlists, got %d", plsStats["all"])
|
||||
}
|
||||
if plsStats["online"].(int) != 1 {
|
||||
t.Errorf("expected 1 online, got %d", plsStats["online"])
|
||||
}
|
||||
if plsStats["unknown"].(int) != 1 {
|
||||
t.Errorf("expected 1 unknown, got %d", plsStats["unknown"])
|
||||
}
|
||||
if plsStats["adult"].(int) != 1 {
|
||||
t.Errorf("expected 1 adult, got %d", plsStats["adult"])
|
||||
}
|
||||
if plsStats["hasCatchup"].(int) != 1 {
|
||||
t.Errorf("expected 1 catchup, got %d", plsStats["hasCatchup"])
|
||||
}
|
||||
if plsStats["hasTvg"].(int) != 1 {
|
||||
t.Errorf("expected 1 tvg, got %d", plsStats["hasTvg"])
|
||||
}
|
||||
if plsStats["groupped"].(int) != 1 {
|
||||
t.Errorf("expected 1 groupped, got %d", plsStats["groupped"])
|
||||
}
|
||||
|
||||
chStats := stats["channels"].(map[string]interface{})
|
||||
if chStats["all"].(int) != 3 {
|
||||
t.Errorf("expected 3 channels, got %d", chStats["all"])
|
||||
}
|
||||
if chStats["online"].(int) != 1 {
|
||||
t.Errorf("expected 1 online channel, got %d", chStats["online"])
|
||||
}
|
||||
if chStats["offline"].(int) != 2 {
|
||||
t.Errorf("expected 2 offline channels, got %d", chStats["offline"])
|
||||
}
|
||||
|
||||
latest := plsStats["latest"].(map[string]interface{})
|
||||
if latest["code"] != "EN" {
|
||||
t.Errorf("expected latest code 'EN', got '%v'", latest["code"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestComputeStats_Empty(t *testing.T) {
|
||||
stats := computeStats([]PlaylistView{})
|
||||
plsStats := stats["playlists"].(map[string]interface{})
|
||||
if plsStats["all"].(int) != 0 {
|
||||
t.Errorf("expected 0, got %d", plsStats["all"])
|
||||
}
|
||||
}
|
||||
+9
-17
@@ -24,10 +24,10 @@ var checkCmd = &cobra.Command{
|
||||
Short: "Check playlists",
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
app.Init()
|
||||
|
||||
files, _ := cmd.Flags().GetStringSlice("file")
|
||||
urls, _ := cmd.Flags().GetStringSlice("url")
|
||||
codes, _ := cmd.Flags().GetStringSlice("code")
|
||||
applyAppOverrides(cmd)
|
||||
applyCacheOverrides(cmd)
|
||||
applyCheckOverrides(cmd)
|
||||
app.InitCache()
|
||||
|
||||
waitSeconds := app.Args.RepeatEverySec
|
||||
if waitSeconds <= 0 {
|
||||
@@ -45,11 +45,11 @@ var checkCmd = &cobra.Command{
|
||||
}
|
||||
|
||||
var lists []playlist.Playlist
|
||||
if len(files) == 0 && len(urls) == 0 && len(codes) == 0 {
|
||||
lists = checker.PrepareListsToCheck(files, urls, codes)
|
||||
if len(app.Args.Files) == 0 && len(app.Args.Urls) == 0 && len(app.Args.Codes) == 0 {
|
||||
lists = checker.PrepareListsToCheck(app.Args.Files, app.Args.Urls, app.Args.Codes)
|
||||
} else {
|
||||
if currentIteration == 1 {
|
||||
lists = checker.PrepareListsToCheck(files, urls, codes)
|
||||
lists = checker.PrepareListsToCheck(app.Args.Files, app.Args.Urls, app.Args.Codes)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -86,15 +86,7 @@ var checkCmd = &cobra.Command{
|
||||
}
|
||||
|
||||
func init() {
|
||||
checkCmd.Flags().StringVarP(&app.Args.TagsPath, "tags", "t", "./channels.json", "path to a local tagfile")
|
||||
checkCmd.Flags().StringVarP(&app.Args.IniPath, "ini", "i", "./playlists.ini", "path to a local ini-file")
|
||||
checkCmd.Flags().UintVarP(&app.Args.RandomCount, "random", "r", 0, "take this count of random playlists to check from ini-file")
|
||||
checkCmd.Flags().UintVarP(&app.Args.RepeatCount, "repeat", "", 1, "repeat same check X times")
|
||||
checkCmd.Flags().UintVarP(&app.Args.RepeatEverySec, "every", "", 5, "wait N seconds after every check")
|
||||
checkCmd.Flags().BoolVarP(&app.Args.NeedJson, "json", "j", false, "print results in JSON format in the end")
|
||||
checkCmd.Flags().BoolVarP(&app.Args.NeedQuiet, "quiet", "q", false, "suppress logs (does not affect on -j)")
|
||||
checkCmd.Flags().StringSliceP("file", "f", []string{}, "path to a local playlist file (m3u/m3u8)")
|
||||
checkCmd.Flags().StringSliceP("url", "u", []string{}, "URL to a remote playlist (http/https)")
|
||||
checkCmd.Flags().StringSliceP("code", "c", []string{}, "code of playlist from ini-file")
|
||||
addCommonCheckFlags(checkCmd, 5, 1)
|
||||
addCheckOnlyFlags(checkCmd)
|
||||
rootCmd.AddCommand(checkCmd)
|
||||
}
|
||||
|
||||
+153
@@ -0,0 +1,153 @@
|
||||
/*
|
||||
* Copyright (c) 2025, Антон Аксенов
|
||||
* This file is part of iptvc project
|
||||
* MIT License: https://git.axenov.dev/IPTV/iptvc/src/branch/master/LICENSE
|
||||
*/
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"axenov/iptv-checker/app"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// addCommonCheckFlags добавляет общие флаги, используемые как командой check,
|
||||
// так и serve. Флаги используют zero-value defaults; переопределение config.yml
|
||||
// происходит через cmd.Flags().Changed() в applyCacheOverrides() и applyCheckOverrides().
|
||||
func addCommonCheckFlags(cmd *cobra.Command, everyDefault, repeatDefault uint) {
|
||||
// paths
|
||||
cmd.Flags().StringVarP(&app.Args.IniPath, "ini", "i", "", "path to a local ini-file (overrides config.yml)")
|
||||
cmd.Flags().StringVarP(&app.Args.TagsPath, "tags", "t", "", "path to a local tagfile (overrides config.yml)")
|
||||
|
||||
// iteration
|
||||
cmd.Flags().UintVarP(&app.Args.RandomCount, "random", "r", 0, "take this count of random playlists to check from ini-file")
|
||||
cmd.Flags().UintVarP(&app.Args.RepeatCount, "repeat", "", repeatDefault, "repeat same check X times (0 = infinite)")
|
||||
cmd.Flags().UintVarP(&app.Args.RepeatEverySec, "every", "", everyDefault, "wait N seconds after every check")
|
||||
|
||||
// check.playlists
|
||||
cmd.Flags().IntVar(&app.Args.PlTimeout, "playlists-timeout", 0, "playlist request timeout in ms (overrides config.yml)")
|
||||
cmd.Flags().IntVar(&app.Args.PlAllCooldown, "playlists-all-cooldown", 0, "cooldown after all playlists in ms (overrides config.yml)")
|
||||
cmd.Flags().IntVar(&app.Args.PlOneCooldown, "playlists-one-cooldown", 0, "cooldown after each playlist in ms (overrides config.yml)")
|
||||
cmd.Flags().IntVar(&app.Args.PlMaxRoutines, "playlists-max-routines", 0, "max concurrent playlist checks (overrides config.yml)")
|
||||
cmd.Flags().IntVar(&app.Args.PlPerRoutine, "playlists-per-routine", 0, "playlists per routine (overrides config.yml)")
|
||||
cmd.Flags().StringSliceVar(&app.Args.PlUserAgent, "playlists-user-agent", nil, "user-agent for playlist requests (overrides config.yml)")
|
||||
|
||||
// check.channels
|
||||
cmd.Flags().IntVar(&app.Args.ChTimeout, "channels-timeout", 0, "channel request timeout in ms (overrides config.yml)")
|
||||
cmd.Flags().IntVar(&app.Args.ChByteRange, "channels-byte-range", 0, "bytes to fetch from server per channel (overrides config.yml)")
|
||||
cmd.Flags().IntVar(&app.Args.ChCooldown, "channels-cooldown", 0, "cooldown after each channel in ms (overrides config.yml)")
|
||||
cmd.Flags().IntVar(&app.Args.ChMaxRoutines, "channels-max-routines", 0, "max concurrent channel checks (overrides config.yml)")
|
||||
cmd.Flags().IntVar(&app.Args.ChPerRoutine, "channels-per-routine", 0, "channels per routine (overrides config.yml)")
|
||||
cmd.Flags().StringSliceVar(&app.Args.ChUserAgent, "channels-user-agent", nil, "user-agent for channel requests (overrides config.yml)")
|
||||
|
||||
// cache
|
||||
cmd.Flags().BoolVar(&app.Args.CacheEnabled, "cache-enabled", false, "enable cache (overrides config.yml)")
|
||||
cmd.Flags().StringVar(&app.Args.CacheHost, "cache-host", "", "cache host (overrides config.yml)")
|
||||
cmd.Flags().UintVar(&app.Args.CachePort, "cache-port", 0, "cache port (overrides config.yml)")
|
||||
cmd.Flags().StringVar(&app.Args.CacheUsername, "cache-username", "", "cache username (overrides config.yml)")
|
||||
cmd.Flags().StringVar(&app.Args.CachePassword, "cache-password", "", "cache password (overrides config.yml)")
|
||||
cmd.Flags().UintVar(&app.Args.CacheDb, "cache-db", 0, "cache database number (overrides config.yml)")
|
||||
cmd.Flags().UintVar(&app.Args.CacheTtl, "cache-ttl", 0, "cache TTL in seconds (overrides config.yml)")
|
||||
}
|
||||
|
||||
// addCheckOnlyFlags добавляет флаги, доступные только в команде check.
|
||||
func addCheckOnlyFlags(cmd *cobra.Command) {
|
||||
cmd.Flags().BoolVarP(&app.Args.NeedJson, "json", "j", false, "print results in JSON format in the end")
|
||||
cmd.Flags().BoolVarP(&app.Args.NeedQuiet, "quiet", "q", false, "suppress logs (does not affect on -j)")
|
||||
cmd.Flags().StringSliceVarP(&app.Args.Files, "file", "f", []string{}, "path to a local playlist file (m3u/m3u8)")
|
||||
cmd.Flags().StringSliceVarP(&app.Args.Urls, "url", "u", []string{}, "URL to a remote playlist (http/https)")
|
||||
cmd.Flags().StringSliceVarP(&app.Args.Codes, "code", "c", []string{}, "code of playlist from ini-file")
|
||||
}
|
||||
|
||||
// applyAppOverrides применяет CLI-флаги для app.* поверх конфигурации.
|
||||
// Использует Changed() — переопределение срабатывает только при явной передаче флага.
|
||||
func applyAppOverrides(cmd *cobra.Command) {
|
||||
if cmd.Flags().Changed("debug") {
|
||||
app.Config.App.Debug = app.Args.Debug
|
||||
}
|
||||
if cmd.Flags().Changed("log-level") {
|
||||
app.Config.App.LogLevel = app.Args.LogLevel
|
||||
}
|
||||
if cmd.Flags().Changed("ini") {
|
||||
app.Config.App.Playlists = app.Args.IniPath
|
||||
}
|
||||
if cmd.Flags().Changed("tags") {
|
||||
app.Config.App.Tags = app.Args.TagsPath
|
||||
}
|
||||
}
|
||||
|
||||
// applyCacheOverrides применяет CLI-флаги для cache.* поверх конфигурации.
|
||||
// Использует Changed() — переопределение срабатывает только при явной передаче флага.
|
||||
// Вызывается между app.Init() и app.InitCache().
|
||||
func applyCacheOverrides(cmd *cobra.Command) {
|
||||
c := &app.Config.Cache
|
||||
if cmd.Flags().Changed("cache-enabled") {
|
||||
c.Enabled = app.Args.CacheEnabled
|
||||
}
|
||||
if cmd.Flags().Changed("cache-host") {
|
||||
c.Host = app.Args.CacheHost
|
||||
}
|
||||
if cmd.Flags().Changed("cache-port") {
|
||||
c.Port = app.Args.CachePort
|
||||
}
|
||||
if cmd.Flags().Changed("cache-username") {
|
||||
c.Username = app.Args.CacheUsername
|
||||
}
|
||||
if cmd.Flags().Changed("cache-password") {
|
||||
c.Password = app.Args.CachePassword
|
||||
}
|
||||
if cmd.Flags().Changed("cache-db") {
|
||||
c.Db = app.Args.CacheDb
|
||||
}
|
||||
if cmd.Flags().Changed("cache-ttl") {
|
||||
c.Ttl = app.Args.CacheTtl
|
||||
}
|
||||
}
|
||||
|
||||
// applyCheckOverrides применяет CLI-флаги для check.* поверх конфигурации.
|
||||
// Вызывается в обработчиках check и serve после app.Init().
|
||||
func applyCheckOverrides(cmd *cobra.Command) {
|
||||
pp := &app.Config.Check.Playlists
|
||||
if cmd.Flags().Changed("playlists-timeout") {
|
||||
pp.Timeout = app.Args.PlTimeout
|
||||
}
|
||||
if cmd.Flags().Changed("playlists-all-cooldown") {
|
||||
pp.AllCooldown.Min = app.Args.PlAllCooldown
|
||||
pp.AllCooldown.Max = app.Args.PlAllCooldown
|
||||
}
|
||||
if cmd.Flags().Changed("playlists-one-cooldown") {
|
||||
pp.OneCooldown.Min = app.Args.PlOneCooldown
|
||||
pp.OneCooldown.Max = app.Args.PlOneCooldown
|
||||
}
|
||||
if cmd.Flags().Changed("playlists-max-routines") {
|
||||
pp.MaxRoutines = app.Args.PlMaxRoutines
|
||||
}
|
||||
if cmd.Flags().Changed("playlists-per-routine") {
|
||||
pp.PerRoutine = app.Args.PlPerRoutine
|
||||
}
|
||||
if cmd.Flags().Changed("playlists-user-agent") {
|
||||
pp.UserAgent = app.Args.PlUserAgent
|
||||
}
|
||||
|
||||
cc := &app.Config.Check.Channels
|
||||
if cmd.Flags().Changed("channels-timeout") {
|
||||
cc.Timeout = app.Args.ChTimeout
|
||||
}
|
||||
if cmd.Flags().Changed("channels-byte-range") {
|
||||
cc.ByteRange = app.Args.ChByteRange
|
||||
}
|
||||
if cmd.Flags().Changed("channels-cooldown") {
|
||||
cc.Cooldown.Min = app.Args.ChCooldown
|
||||
cc.Cooldown.Max = app.Args.ChCooldown
|
||||
}
|
||||
if cmd.Flags().Changed("channels-max-routines") {
|
||||
cc.MaxRoutines = app.Args.ChMaxRoutines
|
||||
}
|
||||
if cmd.Flags().Changed("channels-per-routine") {
|
||||
cc.PerRoutine = app.Args.ChPerRoutine
|
||||
}
|
||||
if cmd.Flags().Changed("channels-user-agent") {
|
||||
cc.UserAgent = app.Args.ChUserAgent
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,296 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"axenov/iptv-checker/app"
|
||||
"axenov/iptv-checker/app/config"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func newCmdWithFlags() *cobra.Command {
|
||||
cmd := &cobra.Command{Use: "test"}
|
||||
// persistent flags normally set on rootCmd
|
||||
cmd.PersistentFlags().BoolVar(&app.Args.Debug, "debug", false, "")
|
||||
cmd.PersistentFlags().StringVar(&app.Args.LogLevel, "log-level", "", "")
|
||||
addCommonCheckFlags(cmd, 60, 0)
|
||||
return cmd
|
||||
}
|
||||
|
||||
func resetArgs() {
|
||||
app.Args = app.Arguments{}
|
||||
}
|
||||
|
||||
func resetConfig() {
|
||||
app.Config = &config.Config{
|
||||
App: config.AppConfig{
|
||||
Debug: false,
|
||||
LogLevel: "info",
|
||||
Playlists: "./playlists.ini",
|
||||
Tags: "./channels.json",
|
||||
},
|
||||
Check: config.CheckConfig{
|
||||
Playlists: config.CheckPlaylistsConfig{
|
||||
Timeout: 10000,
|
||||
AllCooldown: config.IntRange{Min: 0, Max: 0},
|
||||
OneCooldown: config.IntRange{Min: 0, Max: 0},
|
||||
MaxRoutines: 5,
|
||||
PerRoutine: 1,
|
||||
UserAgent: config.UserAgents{"default"},
|
||||
},
|
||||
Channels: config.CheckChannelsConfig{
|
||||
Timeout: 10000,
|
||||
ByteRange: 512,
|
||||
Cooldown: config.IntRange{Min: 0, Max: 0},
|
||||
MaxRoutines: 50,
|
||||
PerRoutine: 10,
|
||||
UserAgent: config.UserAgents{"default"},
|
||||
},
|
||||
},
|
||||
Cache: config.CacheConfig{
|
||||
Enabled: false,
|
||||
Host: "localhost",
|
||||
Port: 6379,
|
||||
Ttl: 1800,
|
||||
},
|
||||
Server: config.ServerConfig{Port: 8080},
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyAppOverrides_NothingChanged(t *testing.T) {
|
||||
resetArgs()
|
||||
resetConfig()
|
||||
cmd := newCmdWithFlags()
|
||||
cmd.ParseFlags([]string{})
|
||||
applyAppOverrides(cmd)
|
||||
if app.Config.App.Debug {
|
||||
t.Error("debug should remain false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyAppOverrides_Debug(t *testing.T) {
|
||||
resetArgs()
|
||||
resetConfig()
|
||||
cmd := newCmdWithFlags()
|
||||
cmd.ParseFlags([]string{"--debug"})
|
||||
applyAppOverrides(cmd)
|
||||
if !app.Config.App.Debug {
|
||||
t.Error("expected debug=true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyAppOverrides_LogLevel(t *testing.T) {
|
||||
resetArgs()
|
||||
resetConfig()
|
||||
cmd := newCmdWithFlags()
|
||||
cmd.ParseFlags([]string{"--log-level", "error"})
|
||||
applyAppOverrides(cmd)
|
||||
if app.Config.App.LogLevel != "error" {
|
||||
t.Errorf("expected 'error', got '%s'", app.Config.App.LogLevel)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyAppOverrides_Ini(t *testing.T) {
|
||||
resetArgs()
|
||||
resetConfig()
|
||||
cmd := newCmdWithFlags()
|
||||
cmd.ParseFlags([]string{"-i", "/custom/path.ini"})
|
||||
applyAppOverrides(cmd)
|
||||
if app.Config.App.Playlists != "/custom/path.ini" {
|
||||
t.Errorf("expected '/custom/path.ini', got '%s'", app.Config.App.Playlists)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyAppOverrides_Tags(t *testing.T) {
|
||||
resetArgs()
|
||||
resetConfig()
|
||||
cmd := newCmdWithFlags()
|
||||
cmd.ParseFlags([]string{"-t", "/custom/tags.json"})
|
||||
applyAppOverrides(cmd)
|
||||
if app.Config.App.Tags != "/custom/tags.json" {
|
||||
t.Errorf("expected '/custom/tags.json', got '%s'", app.Config.App.Tags)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyCacheOverrides_NothingChanged(t *testing.T) {
|
||||
resetArgs()
|
||||
resetConfig()
|
||||
cmd := newCmdWithFlags()
|
||||
cmd.ParseFlags([]string{})
|
||||
applyCacheOverrides(cmd)
|
||||
if app.Config.Cache.Enabled {
|
||||
t.Error("cache should remain disabled")
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyCacheOverrides_Enable(t *testing.T) {
|
||||
resetArgs()
|
||||
resetConfig()
|
||||
cmd := newCmdWithFlags()
|
||||
cmd.ParseFlags([]string{"--cache-enabled"})
|
||||
applyCacheOverrides(cmd)
|
||||
if !app.Config.Cache.Enabled {
|
||||
t.Error("expected cache enabled")
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyCacheOverrides_DisableWhenAlreadyEnabled(t *testing.T) {
|
||||
resetArgs()
|
||||
resetConfig()
|
||||
app.Config.Cache.Enabled = true
|
||||
cmd := newCmdWithFlags()
|
||||
cmd.ParseFlags([]string{"--cache-enabled=false"})
|
||||
applyCacheOverrides(cmd)
|
||||
if app.Config.Cache.Enabled {
|
||||
t.Error("expected cache disabled")
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyCacheOverrides_Host(t *testing.T) {
|
||||
resetArgs()
|
||||
resetConfig()
|
||||
cmd := newCmdWithFlags()
|
||||
cmd.ParseFlags([]string{"--cache-host", "db.local"})
|
||||
applyCacheOverrides(cmd)
|
||||
if app.Config.Cache.Host != "db.local" {
|
||||
t.Errorf("expected 'db.local', got '%s'", app.Config.Cache.Host)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyCacheOverrides_Port(t *testing.T) {
|
||||
resetArgs()
|
||||
resetConfig()
|
||||
cmd := newCmdWithFlags()
|
||||
cmd.ParseFlags([]string{"--cache-port", "6380"})
|
||||
applyCacheOverrides(cmd)
|
||||
if app.Config.Cache.Port != 6380 {
|
||||
t.Errorf("expected 6380, got %d", app.Config.Cache.Port)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyCacheOverrides_Ttl(t *testing.T) {
|
||||
resetArgs()
|
||||
resetConfig()
|
||||
cmd := newCmdWithFlags()
|
||||
cmd.ParseFlags([]string{"--cache-ttl", "3600"})
|
||||
applyCacheOverrides(cmd)
|
||||
if app.Config.Cache.Ttl != 3600 {
|
||||
t.Errorf("expected 3600, got %d", app.Config.Cache.Ttl)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyCheckOverrides_NothingChanged(t *testing.T) {
|
||||
resetArgs()
|
||||
resetConfig()
|
||||
cmd := newCmdWithFlags()
|
||||
cmd.ParseFlags([]string{})
|
||||
applyCheckOverrides(cmd)
|
||||
if app.Config.Check.Playlists.Timeout != 10000 {
|
||||
t.Errorf("expected 10000, got %d", app.Config.Check.Playlists.Timeout)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyCheckOverrides_PlaylistsTimeout(t *testing.T) {
|
||||
resetArgs()
|
||||
resetConfig()
|
||||
cmd := newCmdWithFlags()
|
||||
cmd.ParseFlags([]string{"--playlists-timeout", "5000"})
|
||||
applyCheckOverrides(cmd)
|
||||
if app.Config.Check.Playlists.Timeout != 5000 {
|
||||
t.Errorf("expected 5000, got %d", app.Config.Check.Playlists.Timeout)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyCheckOverrides_PlaylistsAllCooldown(t *testing.T) {
|
||||
resetArgs()
|
||||
resetConfig()
|
||||
cmd := newCmdWithFlags()
|
||||
cmd.ParseFlags([]string{"--playlists-all-cooldown", "300"})
|
||||
applyCheckOverrides(cmd)
|
||||
if app.Config.Check.Playlists.AllCooldown.Min != 300 || app.Config.Check.Playlists.AllCooldown.Max != 300 {
|
||||
t.Errorf("expected [300,300], got [%d,%d]",
|
||||
app.Config.Check.Playlists.AllCooldown.Min, app.Config.Check.Playlists.AllCooldown.Max)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyCheckOverrides_PlaylistsMaxRoutines(t *testing.T) {
|
||||
resetArgs()
|
||||
resetConfig()
|
||||
cmd := newCmdWithFlags()
|
||||
cmd.ParseFlags([]string{"--playlists-max-routines", "10"})
|
||||
applyCheckOverrides(cmd)
|
||||
if app.Config.Check.Playlists.MaxRoutines != 10 {
|
||||
t.Errorf("expected 10, got %d", app.Config.Check.Playlists.MaxRoutines)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyCheckOverrides_PlaylistsUserAgent(t *testing.T) {
|
||||
resetArgs()
|
||||
resetConfig()
|
||||
cmd := newCmdWithFlags()
|
||||
cmd.ParseFlags([]string{"--playlists-user-agent", "UA1,UA2"})
|
||||
applyCheckOverrides(cmd)
|
||||
if len(app.Config.Check.Playlists.UserAgent) != 2 {
|
||||
t.Fatalf("expected 2 user-agents, got %d", len(app.Config.Check.Playlists.UserAgent))
|
||||
}
|
||||
if app.Config.Check.Playlists.UserAgent[0] != "UA1" || app.Config.Check.Playlists.UserAgent[1] != "UA2" {
|
||||
t.Errorf("unexpected user-agents: %v", app.Config.Check.Playlists.UserAgent)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyCheckOverrides_ChannelsTimeout(t *testing.T) {
|
||||
resetArgs()
|
||||
resetConfig()
|
||||
cmd := newCmdWithFlags()
|
||||
cmd.ParseFlags([]string{"--channels-timeout", "8000"})
|
||||
applyCheckOverrides(cmd)
|
||||
if app.Config.Check.Channels.Timeout != 8000 {
|
||||
t.Errorf("expected 8000, got %d", app.Config.Check.Channels.Timeout)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyCheckOverrides_ChannelsByteRange(t *testing.T) {
|
||||
resetArgs()
|
||||
resetConfig()
|
||||
cmd := newCmdWithFlags()
|
||||
cmd.ParseFlags([]string{"--channels-byte-range", "1024"})
|
||||
applyCheckOverrides(cmd)
|
||||
if app.Config.Check.Channels.ByteRange != 1024 {
|
||||
t.Errorf("expected 1024, got %d", app.Config.Check.Channels.ByteRange)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyCheckOverrides_ChannelsCooldown(t *testing.T) {
|
||||
resetArgs()
|
||||
resetConfig()
|
||||
cmd := newCmdWithFlags()
|
||||
cmd.ParseFlags([]string{"--channels-cooldown", "100"})
|
||||
applyCheckOverrides(cmd)
|
||||
if app.Config.Check.Channels.Cooldown.Min != 100 || app.Config.Check.Channels.Cooldown.Max != 100 {
|
||||
t.Errorf("expected [100,100], got [%d,%d]",
|
||||
app.Config.Check.Channels.Cooldown.Min, app.Config.Check.Channels.Cooldown.Max)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyCheckOverrides_ChannelsMaxRoutines(t *testing.T) {
|
||||
resetArgs()
|
||||
resetConfig()
|
||||
cmd := newCmdWithFlags()
|
||||
cmd.ParseFlags([]string{"--channels-max-routines", "100"})
|
||||
applyCheckOverrides(cmd)
|
||||
if app.Config.Check.Channels.MaxRoutines != 100 {
|
||||
t.Errorf("expected 100, got %d", app.Config.Check.Channels.MaxRoutines)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyCheckOverrides_ChannelsUserAgent(t *testing.T) {
|
||||
resetArgs()
|
||||
resetConfig()
|
||||
cmd := newCmdWithFlags()
|
||||
cmd.ParseFlags([]string{"--channels-user-agent", "VLC"})
|
||||
applyCheckOverrides(cmd)
|
||||
if len(app.Config.Check.Channels.UserAgent) != 1 || app.Config.Check.Channels.UserAgent[0] != "VLC" {
|
||||
t.Errorf("expected [VLC], got %v", app.Config.Check.Channels.UserAgent)
|
||||
}
|
||||
}
|
||||
@@ -35,4 +35,7 @@ func Execute() {
|
||||
|
||||
func init() {
|
||||
rootCmd.PersistentFlags().BoolVarP(&app.Args.Verbose, "verbose", "v", false, "enable additional output")
|
||||
rootCmd.PersistentFlags().StringVar(&app.Args.ConfigPath, "config", "config.yml", "path to config file")
|
||||
rootCmd.PersistentFlags().BoolVar(&app.Args.Debug, "debug", false, "enable debug mode (overrides config.yml)")
|
||||
rootCmd.PersistentFlags().StringVar(&app.Args.LogLevel, "log-level", "", "log level: debug, info, warn, error (overrides config.yml)")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
/*
|
||||
* Copyright (c) 2025, Антон Аксенов
|
||||
* This file is part of iptvc project
|
||||
* MIT License: https://git.axenov.dev/IPTV/iptvc/src/branch/master/LICENSE
|
||||
*/
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"axenov/iptv-checker/app"
|
||||
"axenov/iptv-checker/app/web"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// serveCmd represents the serve command
|
||||
var serveCmd = &cobra.Command{
|
||||
Use: "serve",
|
||||
Short: "Start web interface",
|
||||
Long: `Start web interface for browsing IPTV playlists.
|
||||
Use --check to enable background playlist checking.
|
||||
Copyright (c) 2025, Антон Аксенов, MIT license.`,
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
app.Init()
|
||||
applyAppOverrides(cmd)
|
||||
applyCacheOverrides(cmd)
|
||||
applyCheckOverrides(cmd)
|
||||
app.InitCache()
|
||||
|
||||
// CLI-флаги переопределяют конфиг, только если заданы явно.
|
||||
if cmd.Flags().Changed("port") {
|
||||
app.Config.Server.Port = app.Args.ServerPort
|
||||
}
|
||||
if cmd.Flags().Changed("host") {
|
||||
app.Config.Server.Host = app.Args.ServerHost
|
||||
}
|
||||
|
||||
server := web.NewServer(app.Config, app.Cache)
|
||||
|
||||
// запускаем фоновую проверку, если передан --check или включён check.start-on-serve в конфиге
|
||||
if app.Args.NeedCheck || app.Config.Check.StartOnServe {
|
||||
opts := web.CheckOptions{
|
||||
Every: app.Args.RepeatEverySec,
|
||||
Repeat: app.Args.RepeatCount,
|
||||
Random: app.Args.RandomCount,
|
||||
}
|
||||
go server.StartBackgroundChecker(opts)
|
||||
}
|
||||
|
||||
server.Start()
|
||||
},
|
||||
}
|
||||
|
||||
func init() {
|
||||
serveCmd.Flags().UintVarP(&app.Args.ServerPort, "port", "p", 0, "port for web interface (overrides config.yml and env)")
|
||||
serveCmd.Flags().StringVar(&app.Args.ServerHost, "host", "", "host to bind web interface (overrides config.yml and env)")
|
||||
serveCmd.Flags().BoolVar(&app.Args.NeedCheck, "check", false, "enable background playlist checking")
|
||||
addCommonCheckFlags(serveCmd, 60, 0)
|
||||
rootCmd.AddCommand(serveCmd)
|
||||
}
|
||||
+72
@@ -0,0 +1,72 @@
|
||||
app:
|
||||
timezone: GMT+5
|
||||
# locale: en
|
||||
debug: false
|
||||
log_level: info
|
||||
playlists: ../playlists/playlists.ini
|
||||
tags: ../playlists/channels.json
|
||||
|
||||
server:
|
||||
host: localhost
|
||||
port: 8080
|
||||
|
||||
site:
|
||||
base-url: http://localhost:8080
|
||||
repo-url: https://git.axenov.dev/IPTV/iptvc
|
||||
page-size: 0
|
||||
favicon: # опциональный путь к иконке
|
||||
header:
|
||||
title: IPTV Checker
|
||||
navigation:
|
||||
- title: Аптайм
|
||||
url: https://status.m3u.su
|
||||
icon: pulse-outline
|
||||
- title: Помощь
|
||||
icon: help-circle-outline
|
||||
children:
|
||||
- title: Документация
|
||||
url: /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: Канал
|
||||
url: https://t.me/iptv_aggregator
|
||||
icon: megaphone-outline
|
||||
|
||||
check:
|
||||
start-on-serve: true
|
||||
playlists:
|
||||
user-agent:
|
||||
- Mozilla/5.0 WINK/1.31.1 (AndroidTV/9) HlsWinkPlayer
|
||||
timeout: 10000
|
||||
all-cooldown: 0
|
||||
one-cooldown: 0
|
||||
max-routines: 5
|
||||
per-routine: 5
|
||||
channels:
|
||||
user-agent: Mozilla/5.0 WINK/1.31.1 (AndroidTV/9) HlsWinkPlayer
|
||||
timeout: 10000
|
||||
byte-range: 512
|
||||
cooldown: 0
|
||||
max-routines: 50
|
||||
per-routine: 10
|
||||
|
||||
cache:
|
||||
enabled: false
|
||||
host: localhost
|
||||
port: 6379
|
||||
username:
|
||||
password:
|
||||
db: 1
|
||||
ttl: 1800
|
||||
@@ -7,6 +7,7 @@ require (
|
||||
github.com/redis/go-redis/v9 v9.7.3
|
||||
github.com/spf13/cobra v1.9.1
|
||||
gopkg.in/ini.v1 v1.67.0
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
)
|
||||
|
||||
require (
|
||||
|
||||
@@ -26,6 +26,7 @@ github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA=
|
||||
gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
|
||||
|
||||
Reference in New Issue
Block a user