diff --git a/.env.example b/.env.example index e90f66f..c15d11a 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/.gitignore b/.gitignore index e995921..62cb540 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ bin/ output/ +.DS_Store .env *.bak *.m3u diff --git a/README.md b/README.md index 4c6ce4b..d3127ad 100644 --- a/README.md +++ b/README.md @@ -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-окружение проекта. + ## Результаты проверки Программа логирует процесс и результаты своей работы. diff --git a/app/app.go b/app/app.go index 0c1c8f7..e807345 100644 --- a/app/app.go +++ b/app/app.go @@ -1,5 +1,5 @@ /* - * Copyright (c) 2025, Антон Аксенов + * Copyright (c) 2025-2026, Антон Аксенов * This file is part of iptvc project * MIT License: https://git.axenov.dev/IPTV/iptvc/src/branch/master/LICENSE */ @@ -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) } } diff --git a/app/cache/cache.go b/app/cache/cache.go index 4b38f48..a4d6874 100644 --- a/app/cache/cache.go +++ b/app/cache/cache.go @@ -1,5 +1,5 @@ /* - * Copyright (c) 2025, Антон Аксенов + * Copyright (c) 2025-2026, Антон Аксенов * This file is part of iptvc project * MIT License: https://git.axenov.dev/IPTV/iptvc/src/branch/master/LICENSE */ diff --git a/app/checker/checker.go b/app/checker/checker.go index 2070b7b..d7b045b 100644 --- a/app/checker/checker.go +++ b/app/checker/checker.go @@ -1,5 +1,5 @@ /* - * Copyright (c) 2025, Антон Аксенов + * Copyright (c) 2025-2026, Антон Аксенов * This file is part of iptvc project * MIT License: https://git.axenov.dev/IPTV/iptvc/src/branch/master/LICENSE */ @@ -15,13 +15,12 @@ import ( "context" "crypto/tls" "encoding/json" + "fmt" "io" "log" "maps" - "math" "math/rand" "net/http" - "os" "slices" "strings" "sync" @@ -29,8 +28,9 @@ import ( ) var ( - tagBlocks []tagfile.TagBlock - ctx = context.Background() + tagBlocks []tagfile.TagBlock + ctx = context.Background() + OnPlaylistChecked func(playlist.Playlist) // вызывается после проверки каждого плейлиста ) // PrepareListsToCheck готовит список плейлистов для проверки @@ -57,7 +57,7 @@ func PrepareListsToCheck(files []string, urls []string, codes []string) []playli } if len(lists) == 0 || len(codes) > 0 { - ini, err := inifile.Init(app.Args.IniPath) + ini, err := inifile.Init(app.Config.App.Playlists) if err != nil { log.Printf("Warning: %s, all --code flags will be ignored\n", err) return lists @@ -109,56 +109,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 diff --git a/app/config/config.go b/app/config/config.go index 9712a0a..88a8081 100644 --- a/app/config/config.go +++ b/app/config/config.go @@ -1,5 +1,5 @@ /* - * Copyright (c) 2025, Антон Аксенов + * Copyright (c) 2025-2026, Антон Аксенов * This file is part of iptvc project * MIT License: https://git.axenov.dev/IPTV/iptvc/src/branch/master/LICENSE */ @@ -7,71 +7,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 + } +} diff --git a/app/config/config_test.go b/app/config/config_test.go new file mode 100644 index 0000000..8480f19 --- /dev/null +++ b/app/config/config_test.go @@ -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 diff --git a/app/inifile/inifile.go b/app/inifile/inifile.go index 7ce2dd0..ed64b21 100644 --- a/app/inifile/inifile.go +++ b/app/inifile/inifile.go @@ -1,5 +1,5 @@ /* - * Copyright (c) 2025, Антон Аксенов + * Copyright (c) 2025-2026, Антон Аксенов * This file is part of iptvc project * MIT License: https://git.axenov.dev/IPTV/iptvc/src/branch/master/LICENSE */ @@ -9,10 +9,11 @@ package inifile import ( "axenov/iptv-checker/app/playlist" "axenov/iptv-checker/app/utils" - "gopkg.in/ini.v1" "log" "os" "strings" + + "gopkg.in/ini.v1" ) // IniFile описывает ini-файл c плейлистами diff --git a/app/inifile/inifile_test.go b/app/inifile/inifile_test.go new file mode 100644 index 0000000..83e7df2 --- /dev/null +++ b/app/inifile/inifile_test.go @@ -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) + } +} diff --git a/app/logger/logger.go b/app/logger/logger.go index f4cba04..53d7690 100644 --- a/app/logger/logger.go +++ b/app/logger/logger.go @@ -1,5 +1,5 @@ /* - * Copyright (c) 2025, Антон Аксенов + * Copyright (c) 2025-2026, Антон Аксенов * This file is part of iptvc project * MIT License: https://git.axenov.dev/IPTV/iptvc/src/branch/master/LICENSE */ diff --git a/app/playlist/playlist.go b/app/playlist/playlist.go index 9abac7c..e4cb814 100644 --- a/app/playlist/playlist.go +++ b/app/playlist/playlist.go @@ -1,5 +1,5 @@ /* - * Copyright (c) 2025, Антон Аксенов + * Copyright (c) 2025-2026, Антон Аксенов * This file is part of iptvc project * MIT License: https://git.axenov.dev/IPTV/iptvc/src/branch/master/LICENSE */ @@ -54,9 +54,6 @@ type Playlist struct { CheckedAt int64 `json:"checkedAt"` // Время проверки в формате UNIX timestamp } -// tmpChannel хранит временные данные о канале, который обрабатывается в Parse -var tmpChannel = Channel{} - // MakeFromFile создаёт экземпляр плейлиста из файла func MakeFromFile(filepath string) (Playlist, error) { expandedPath, err := utils.ExpandPath(filepath) @@ -131,8 +128,10 @@ func parseTitle(line string) string { } // Download загружает плейлист по URL-адресу -func (pls *Playlist) Download() error { - content, err := utils.Fetch(pls.Url) +// Download скачивает плейлист по URL. +// userAgent и timeout передаются в HTTP-клиент. +func (pls *Playlist) Download(userAgent string, timeout time.Duration) error { + content, err := utils.Fetch(pls.Url, userAgent, timeout) if err != nil { pls.Content = err.Error() pls.CheckedAt = time.Now().Unix() @@ -159,6 +158,7 @@ func (pls *Playlist) ReadFromFs() error { // Parse разбирает плейлист func (pls *Playlist) Parse() Playlist { isChannel := false + var ch Channel pls.Attributes = make(map[string]string) pls.Channels = make(map[string]Channel) pls.Groups = make(map[string]Group) @@ -180,20 +180,20 @@ func (pls *Playlist) Parse() Playlist { if strings.HasPrefix(line, "#EXTINF") { isChannel = true - tmpChannel.Attributes = parseAttributes(line) - tmpChannel.Title = parseTitle(line) + ch = Channel{Attributes: parseAttributes(line)} + ch.Title = parseTitle(line) - if tmpChannel.Title == "" { - if tvgid, ok := tmpChannel.Attributes["tvg-id"]; ok { - tmpChannel.Title = "(канал без названия, tvg-id=" + tvgid + ")" + if ch.Title == "" { + if tvgid, ok := ch.Attributes["tvg-id"]; ok { + ch.Title = "(канал без названия, tvg-id=" + tvgid + ")" } else { - tmpChannel.Title = "(канал без названия, tvg-id неизвестен)" + ch.Title = "(канал без названия, tvg-id неизвестен)" } } - if groupName, ok := tmpChannel.Attributes["group-title"]; ok { + if groupName, ok := ch.Attributes["group-title"]; ok { id := utils.Md5str(groupName) - tmpChannel.GroupId = id + ch.GroupId = id pls.Groups[id] = Group{ Id: id, Name: groupName, @@ -207,7 +207,7 @@ func (pls *Playlist) Parse() Playlist { parts := strings.Split(line, ":") groupName := strings.Trim(parts[1], " ") id := utils.Md5str(groupName) - tmpChannel.GroupId = id + ch.GroupId = id pls.Groups[id] = Group{ Id: id, Name: groupName, @@ -217,13 +217,12 @@ func (pls *Playlist) Parse() Playlist { } if isChannel && strings.HasPrefix(line, "http") { - tmpChannel.URL = strings.Trim(line, " ") - tmpChannel.Id = utils.Md5str(tmpChannel.URL) - if tmpChannel.Id != "" { - pls.Channels[tmpChannel.Id] = tmpChannel + ch.URL = strings.Trim(line, " ") + ch.Id = utils.Md5str(ch.URL) + if ch.Id != "" { + pls.Channels[ch.Id] = ch isChannel = false - tmpChannel = Channel{} - tmpChannel.Attributes = make(map[string]string) + ch = Channel{} } } } diff --git a/app/playlist/playlist_test.go b/app/playlist/playlist_test.go new file mode 100644 index 0000000..bf3911a --- /dev/null +++ b/app/playlist/playlist_test.go @@ -0,0 +1,266 @@ +package playlist + +import ( + "fmt" + "os" + "path/filepath" + "sync" + "testing" +) + +func TestParseAttributes(t *testing.T) { + line := `#EXTINF:-1 tvg-id="123" tvg-name="Channel" group-title="News",Channel Name` + attrs := parseAttributes(line) + if attrs["tvg-id"] != "123" { + t.Errorf("expected tvg-id=123, got %s", attrs["tvg-id"]) + } + if attrs["tvg-name"] != "Channel" { + t.Errorf("expected tvg-name=Channel, got %s", attrs["tvg-name"]) + } + if attrs["group-title"] != "News" { + t.Errorf("expected group-title=News, got %s", attrs["group-title"]) + } +} + +func TestParseAttributes_Empty(t *testing.T) { + attrs := parseAttributes("#EXTINF:-1,Plain Channel") + if len(attrs) != 0 { + t.Errorf("expected 0 attrs, got %d", len(attrs)) + } +} + +func TestParseTitle_WithComma(t *testing.T) { + cases := []struct { + in string + want string + }{ + {`#EXTINF:-1 tvg-id="x",My Channel`, "My Channel"}, + {`#EXTINF:-1,Hello, World`, "Hello, World"}, + {`#EXTINF:-1 tvg-name="Test",Test Channel`, "Test Channel"}, + } + for _, c := range cases { + got := parseTitle(c.in) + if got != c.want { + t.Errorf("parseTitle(%q) = %q, want %q", c.in, got, c.want) + } + } +} + +func TestParseTitle_NoComma(t *testing.T) { + got := parseTitle(`#EXTINF:-1 tvg-name="Foo"`) + if got == "" { + t.Error("expected non-empty title") + } +} + +func TestParse_SimplePlaylist(t *testing.T) { + content := `#EXTM3U +#EXTINF:-1 tvg-id="ch1" tvg-name="Channel 1" group-title="News",Channel 1 +http://example.com/ch1.m3u8 +#EXTINF:-1 tvg-id="ch2" tvg-name="Channel 2" group-title="Movies",Channel 2 +http://example.com/ch2.m3u8 +` + pls := &Playlist{Content: content} + pls.Parse() + + if len(pls.Channels) != 2 { + t.Fatalf("expected 2 channels, got %d", len(pls.Channels)) + } + if len(pls.Groups) != 2 { + t.Fatalf("expected 2 groups, got %d", len(pls.Groups)) + } +} + +func TestParse_WithBOM(t *testing.T) { + content := "\xef\xbb\xbf#EXTM3U\n#EXTINF:-1,Test\nhttp://example.com/test.m3u8\n" + pls := &Playlist{Content: content} + pls.Parse() + if len(pls.Channels) != 1 { + t.Fatalf("expected 1 channel (BOM removed), got %d", len(pls.Channels)) + } +} + +func TestParse_WithWindowsLineEndings(t *testing.T) { + content := "#EXTM3U\r\n#EXTINF:-1,Test\r\nhttp://example.com/test.m3u8\r\n" + pls := &Playlist{Content: content} + pls.Parse() + if len(pls.Channels) != 1 { + t.Fatalf("expected 1 channel (CRLF), got %d", len(pls.Channels)) + } +} + +func TestParse_EmptyContent(t *testing.T) { + pls := &Playlist{Content: ""} + pls.Parse() + if len(pls.Channels) != 0 { + t.Errorf("expected 0 channels, got %d", len(pls.Channels)) + } +} + +func TestParse_Extgrp(t *testing.T) { + content := `#EXTM3U +#EXTINF:-1,Channel A +#EXTGRP:Sports +http://example.com/a.m3u8 +` + pls := &Playlist{Content: content} + pls.Parse() + if len(pls.Groups) != 1 { + t.Fatalf("expected 1 group, got %d", len(pls.Groups)) + } + for _, ch := range pls.Channels { + if ch.GroupId == "" { + t.Error("expected non-empty group ID") + } + } +} + +func TestParse_ExtinfAttributes(t *testing.T) { + content := `#EXTM3U +#EXTINF:-1 tvg-id="abc" tvg-logo="logo.png" group-title="HD",HD Channel +http://example.com/hd.m3u8 +` + pls := &Playlist{Content: content} + pls.Parse() + for _, ch := range pls.Channels { + if ch.Attributes["tvg-id"] != "abc" { + t.Errorf("expected tvg-id=abc, got %s", ch.Attributes["tvg-id"]) + } + if ch.Attributes["tvg-logo"] != "logo.png" { + t.Errorf("expected tvg-logo=logo.png, got %s", ch.Attributes["tvg-logo"]) + } + if ch.Title != "HD Channel" { + t.Errorf("expected title 'HD Channel', got '%s'", ch.Title) + } + } +} + +func TestParse_ChannelWithoutTitle(t *testing.T) { + content := `#EXTM3U +#EXTINF:-1 tvg-id="notitle" +http://example.com/notitle.m3u8 +` + pls := &Playlist{Content: content} + pls.Parse() + for _, ch := range pls.Channels { + if ch.Title == "" { + t.Error("expected non-empty title for channel without name") + } + } +} + +func TestParse_Extm3uAttributes(t *testing.T) { + content := `#EXTM3U url-tvg="http://example.com/epg.xml" +#EXTINF:-1,Test +http://example.com/test.m3u8 +` + pls := &Playlist{Content: content} + pls.Parse() + if pls.Attributes["url-tvg"] != "http://example.com/epg.xml" { + t.Errorf("expected url-tvg attr, got %s", pls.Attributes["url-tvg"]) + } +} + +func TestMakeFromUrl(t *testing.T) { + pls, err := MakeFromUrl("http://example.com/list.m3u8") + if err != nil { + t.Fatal(err) + } + if pls.Url != "http://example.com/list.m3u8" { + t.Errorf("expected URL, got %s", pls.Url) + } + if pls.Source != "-u" { + t.Errorf("expected source '-u', got '%s'", pls.Source) + } + if !pls.IsOnline { + t.Error("expected IsOnline=true") + } +} + +func TestMakeFromFile_NotFound(t *testing.T) { + _, err := MakeFromFile("/nonexistent/file.m3u") + if err == nil { + t.Error("expected error for non-existent file") + } +} + +func TestMakeFromFile_Success(t *testing.T) { + tmp := t.TempDir() + path := filepath.Join(tmp, "test.m3u") + content := "#EXTM3U\n#EXTINF:-1,Test\nhttp://example.com/test.m3u8\n" + if err := os.WriteFile(path, []byte(content), 0644); err != nil { + t.Fatal(err) + } + pls, err := MakeFromFile(path) + if err != nil { + t.Fatal(err) + } + if pls.Source != "-f" { + t.Errorf("expected source '-f', got '%s'", pls.Source) + } + if pls.Content != content { + t.Error("content mismatch") + } +} + +func TestReadFromFs(t *testing.T) { + tmp := t.TempDir() + path := filepath.Join(tmp, "test.m3u") + content := "#EXTM3U\n" + if err := os.WriteFile(path, []byte(content), 0644); err != nil { + t.Fatal(err) + } + pls := &Playlist{Url: path} + if err := pls.ReadFromFs(); err != nil { + t.Fatal(err) + } + if pls.Content != content { + t.Errorf("expected '%s', got '%s'", content, pls.Content) + } +} + +func TestReadFromFs_NotFound(t *testing.T) { + pls := &Playlist{Url: "/nonexistent/file.m3u"} + err := pls.ReadFromFs() + if err == nil { + t.Error("expected error for non-existent file") + } +} + +// TestParseConcurrent проверяет, что парсинг нескольких плейлистов +// в параллельных горутинах не приводит к потере каналов из-за data race. +// До фикса tmpChannel была пакетной переменной, и каналы терялись. +func TestParseConcurrent(t *testing.T) { + const goroutines = 20 + const channelsPerPlaylist = 50 + + // генерируем плейлист с channelsPerPlaylist каналами + content := "#EXTM3U\n" + for i := 0; i < channelsPerPlaylist; i++ { + content += fmt.Sprintf("#EXTINF:-1 tvg-id=\"ch%d\",Channel %d\n", i, i) + content += fmt.Sprintf("http://example.com/stream%d.m3u8\n", i) + } + + var wg sync.WaitGroup + wg.Add(goroutines) + lost := make([]int, goroutines) + + for i := 0; i < goroutines; i++ { + go func(idx int) { + defer wg.Done() + pls := &Playlist{Content: content} + pls.Parse() + lost[idx] = channelsPerPlaylist - len(pls.Channels) + }(i) + } + wg.Wait() + + totalLost := 0 + for _, l := range lost { + totalLost += l + } + if totalLost > 0 { + t.Errorf("data race detected: %d channels lost across %d goroutines (per-goroutine losses: %v)", + totalLost, goroutines, lost) + } +} diff --git a/app/tagfile/tagfile.go b/app/tagfile/tagfile.go index d7c2c3a..6bdc1bc 100644 --- a/app/tagfile/tagfile.go +++ b/app/tagfile/tagfile.go @@ -1,5 +1,5 @@ /* - * Copyright (c) 2025, Антон Аксенов + * Copyright (c) 2025-2026, Антон Аксенов * This file is part of iptvc project * MIT License: https://git.axenov.dev/IPTV/iptvc/src/branch/master/LICENSE */ diff --git a/app/tagfile/tagfile_test.go b/app/tagfile/tagfile_test.go new file mode 100644 index 0000000..edb6740 --- /dev/null +++ b/app/tagfile/tagfile_test.go @@ -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) + } +} diff --git a/app/utils/utils.go b/app/utils/utils.go index a36529f..5bee6d4 100644 --- a/app/utils/utils.go +++ b/app/utils/utils.go @@ -1,5 +1,5 @@ /* - * Copyright (c) 2025, Антон Аксенов + * Copyright (c) 2025-2026, Антон Аксенов * This file is part of iptvc project * MIT License: https://git.axenov.dev/IPTV/iptvc/src/branch/master/LICENSE */ @@ -44,16 +44,19 @@ func ArrayUnique(arr []string) []string { return result } -// Fetch выполняет GET запрос и возвращает результат в виде массива байт -func Fetch(url string) ([]byte, error) { +// Fetch выполняет GET запрос и возвращает результат в виде массива байт. +// userAgent — значение заголовка User-Agent, timeout — таймаут запроса. +func Fetch(url string, userAgent string, timeout time.Duration) ([]byte, error) { req, err := http.NewRequest("GET", url, nil) if err != nil { return nil, err } - req.Header.Set("User-Agent", "Mozilla/5.0 WINK/1.31.1 (AndroidTV/9) HlsWinkPlayer") + if userAgent != "" { + req.Header.Set("User-Agent", userAgent) + } http.DefaultTransport.(*http.Transport).TLSClientConfig = &tls.Config{InsecureSkipVerify: true} - httpClient := http.Client{Timeout: 10 * time.Second} + httpClient := http.Client{Timeout: timeout} resp, err := httpClient.Do(req) if err != nil { return nil, err diff --git a/app/utils/utils_test.go b/app/utils/utils_test.go new file mode 100644 index 0000000..3f5b22d --- /dev/null +++ b/app/utils/utils_test.go @@ -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") + } +} diff --git a/app/web/details_test.go b/app/web/details_test.go new file mode 100644 index 0000000..96a04e5 --- /dev/null +++ b/app/web/details_test.go @@ -0,0 +1,253 @@ +package web + +import ( + "axenov/iptv-checker/app/config" + "axenov/iptv-checker/app/playlist" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +// makeTestServer создаёт HTTP-сервер с тестовыми плейлистами в memCache. +// Возвращает сервер и обёртку для добавления плейлистов в memCache. +func makeTestServer(t *testing.T) (*httptest.Server, *Server) { + t.Helper() + cfg := config.Init("") + cfg.App.Playlists = "testdata/playlists.ini" + + srv := NewServer(cfg, nil) + + mux := http.NewServeMux() + // API routes + mux.HandleFunc("GET /api/playlists/{code}", srv.handleAPIGetOne) + mux.HandleFunc("GET /api/version", srv.handleAPIVersion) + mux.HandleFunc("GET /api/health", srv.handleAPIHealth) + mux.HandleFunc("GET /api/stats", srv.handleAPIStats) + mux.HandleFunc("GET /api/openapi.json", srv.handleOpenAPISpec) + mux.HandleFunc("GET /api/docs", func(w http.ResponseWriter, r *http.Request) { + http.Redirect(w, r, "/api/docs/", http.StatusMovedPermanently) + }) + mux.Handle("GET /api/docs/", http.StripPrefix("/api/docs/", swaggerFileServer)) + // page routes + mux.HandleFunc("GET /{$}", srv.handleHome) + mux.HandleFunc("GET /{path...}", srv.handleRoute) + ts := httptest.NewServer(mux) + t.Cleanup(ts.Close) + return ts, srv +} + +// putPlaylist кладёт плейлист в in-memory кеш сервера +func (s *Server) putPlaylist(pls playlist.Playlist) { + s.memMu.Lock() + defer s.memMu.Unlock() + if s.memCache == nil { + s.memCache = make(map[string]playlist.Playlist) + } + s.memCache[pls.Code] = pls +} + +// makeOnlinePlaylist создаёт тестовый плейлист с онлайн/оффлайн каналами +func makeOnlinePlaylist() playlist.Playlist { + return playlist.Playlist{ + Code: "test1", + Name: "Test Playlist 1", + Description: "A test playlist", + Url: "http://example.com/test1.m3u8", + Source: "test source", + IsOnline: true, + CheckedAt: 1700000000, + Attributes: map[string]string{"url-tvg": "http://example.com/epg.xml"}, + Groups: map[string]playlist.Group{ + "group1": {Id: "group1", Name: "Group One"}, + "group2": {Id: "group2", Name: "Group Two"}, + }, + Channels: map[string]playlist.Channel{ + "id1": { + Id: "id1", + Title: "Channel One", + URL: "http://example.com/stream1.m3u8", + GroupId: "group1", + Attributes: map[string]string{"tvg-id": "ch1", "tvg-logo": "http://example.com/logo1.png"}, + Status: 200, + IsOnline: true, + Tags: []string{"hd"}, + }, + "id2": { + Id: "id2", + Title: "Channel Two", + URL: "http://example.com/stream2.m3u8", + GroupId: "group2", + Attributes: map[string]string{"tvg-id": "ch2", "tvg-logo": "http://example.com/logo2.png"}, + Status: 404, + IsOnline: false, + Tags: []string{"untagged"}, + }, + "id3": { + Id: "id3", + Title: "Channel Three", + URL: "http://example.com/stream3.m3u8", + GroupId: "group1", + Attributes: map[string]string{"tvg-id": "ch3", "tvg-logo": "http://example.com/logo3.png"}, + Status: 200, + IsOnline: true, + Tags: []string{"hd", "news"}, + }, + }, + OnlineCount: 2, + OfflineCount: 1, + } +} + +// fetchBody читает тело HTTP-ответа целиком и возвращает его как строку +func fetchBody(t *testing.T, resp *http.Response) string { + t.Helper() + defer resp.Body.Close() + b, err := io.ReadAll(resp.Body) + if err != nil { + t.Fatalf("read body: %s", err) + } + return string(b) +} + +// TestDetailsPageRendersChannelsTable проверяет, что на странице деталей +// онлайн-плейлиста присутствует таблица с каналами и данными для list.js +func TestDetailsPageRendersChannelsTable(t *testing.T) { + ts, srv := makeTestServer(t) + srv.putPlaylist(makeOnlinePlaylist()) + + // Главная страница должна быть доступна + resp, err := http.Get(ts.URL + "/") + if err != nil { + t.Fatalf("GET /: %s", err) + } + if resp.StatusCode != http.StatusOK { + t.Errorf("GET /: expected 200, got %d", resp.StatusCode) + } + resp.Body.Close() + + // Страница деталей — основной сценарий + resp, err = http.Get(ts.URL + "/test1/details") + if err != nil { + t.Fatalf("GET /test1/details: %s", err) + } + if resp.StatusCode != http.StatusOK { + t.Fatalf("GET /test1/details: expected 200, got %d", resp.StatusCode) + } + body := fetchBody(t, resp) + + checks := []struct { + name string + want bool + }{ + // Заголовок и базовая структура + {"заголовок 'Список каналов'", strings.Contains(body, "Список каналов")}, + {"id таблицы 'chlist'", strings.Contains(body, `id="chlist"`)}, + {"класс 'list' у tbody", strings.Contains(body, `class="list"`)}, + {"chcount = 3", strings.Contains(body, `3`)}, + + // Данные каналов в JSON + {"values JSON", strings.Contains(body, "const values =")}, + {"Channel One в JSON", strings.Contains(body, "Channel One")}, + {"Channel Two в JSON", strings.Contains(body, "Channel Two")}, + {"Channel Three в JSON", strings.Contains(body, "Channel Three")}, + + // Группы (отображаются когда > 1) + {"id группы group1", strings.Contains(body, `value="group1"`)}, + {"id группы group2", strings.Contains(body, `value="group2"`)}, + + // Теги + {"тег #hd", strings.Contains(body, "btn-tag-hd")}, + {"тег #news", strings.Contains(body, "btn-tag-news")}, + {"тег #untagged", strings.Contains(body, "btn-tag-untagged")}, + + // list.js + {"list.js подключён", strings.Contains(body, "list.js@2.3.1")}, + {"getChannelTemplate функция", strings.Contains(body, "function getChannelTemplate")}, + {"опции list.js", strings.Contains(body, "const options =")}, + {"new List('chlist')", strings.Contains(body, "new List('chlist'")}, + {"getChannelTemplate возвращает