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..4d00f3b 100644 --- a/app/app.go +++ b/app/app.go @@ -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/checker/checker.go b/app/checker/checker.go index 2070b7b..c5ef7ce 100644 --- a/app/checker/checker.go +++ b/app/checker/checker.go @@ -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..1e7e491 100644 --- a/app/config/config.go +++ b/app/config/config.go @@ -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_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/playlist/playlist.go b/app/playlist/playlist.go index 9abac7c..97b42d8 100644 --- a/app/playlist/playlist.go +++ b/app/playlist/playlist.go @@ -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() diff --git a/app/playlist/playlist_test.go b/app/playlist/playlist_test.go new file mode 100644 index 0000000..e7997ce --- /dev/null +++ b/app/playlist/playlist_test.go @@ -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") + } +} 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..02ba1c2 100644 --- a/app/utils/utils.go +++ b/app/utils/utils.go @@ -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/handlers.go b/app/web/handlers.go new file mode 100644 index 0000000..00cb0c0 --- /dev/null +++ b/app/web/handlers.go @@ -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 +} diff --git a/app/web/server.go b/app/web/server.go new file mode 100644 index 0000000..26d8aab --- /dev/null +++ b/app/web/server.go @@ -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(), + ) +} diff --git a/app/web/templates.go b/app/web/templates.go new file mode 100644 index 0000000..d0f76a5 --- /dev/null +++ b/app/web/templates.go @@ -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 +} diff --git a/app/web/templates_test.go b/app/web/templates_test.go new file mode 100644 index 0000000..33fa7d4 --- /dev/null +++ b/app/web/templates_test.go @@ -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) + } +} diff --git a/app/web/views.go b/app/web/views.go new file mode 100644 index 0000000..2ffbc3e --- /dev/null +++ b/app/web/views.go @@ -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") +} diff --git a/app/web/views/base.html b/app/web/views/base.html new file mode 100644 index 0000000..9f4185f --- /dev/null +++ b/app/web/views/base.html @@ -0,0 +1,145 @@ +{{define "base"}} + + +
+| Код | ++ {{$pls.Code}} + {{if not $pls.IsKnown}} + unknown + {{else if $pls.IsOnline}} + online + {{else}} + offline + {{end}} + | +
|---|---|
| Описание | +{{$pls.Description}} |
+
| Короткая ссылка | +
+
+ {{baseUrl $pls.Code}}
+ |
+
| Источник | +{{$pls.Source}} | +
| Наполнение | +
+ {{if and $pls.IsKnown $pls.IsOnline}}
+ {{if $pls.HasTokens}}
+
+ + {{end}} + + {{if hasTag $pls.Tags "adult"}} + 18+ есть каналы для взрослых + {{end}} + + + |
+
| Возможности | +
+ {{if and $pls.IsKnown $pls.IsOnline}}
+ + |
+
| M3U | +{{$pls.Url}} | +
| Проверка плейлиста | ++ + {{formatDate $pls.CheckedAt}} + + | +
| Ошибка проверки | +{{$pls.Content}} | +
| {{$attr}} | +{{$val}} | +
|---|
+ Данные, представленные на данной странице, получены автоматически из открыто доступных в + интернете IPTV-плейлистов, опубликованных третьими лицами. + При наличии технической возможности, источник плейлиста может быть указан на вкладке "Основные + данные". +
++ Сервис {{baseUrl ""}} не размещает и не транслирует медиаконтент, не создаёт, не призывает + использовать и распространять плейлисты третьих лиц, а также не оказывает услуг по ретрансляции + телепрограмм. +
++ Информация о телеканалах (наименования, логотипы, технический статус и другие сведения) + формируется исключительно путём обработки содержимого самого плейлиста. + Вся информация носит технический и ознакомительный характер, и её достоверность не гарантируется. +
++ Все права на торговые марки и графические изображения принадлежат их законным владельцам. +
+{{.Description}}
+ {{end}} +{{.Code}} не найден+ Возможно, его здесь никогда не было, либо он уже был удалён. +
++ Если хочешь, чтобы здесь был плейлист, предложи его к добавлению. +
+ +