This commit is contained in:
2026-07-13 12:28:59 +08:00
parent 6c3de4b2ef
commit 1cbf3b0e19
31 changed files with 5034 additions and 157 deletions
+11
View File
@@ -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
+1
View File
@@ -3,6 +3,7 @@
bin/
output/
.DS_Store
.env
*.bak
*.m3u
+154 -9
View File
@@ -5,21 +5,26 @@
Консольная программа для проверки IPTV-плейлистов в формате m3u или m3u8.
> **Веб-сайт:** [m3u.su](https://m3u.su)
> **Документация:** [m3u.su/docs](https://m3u.su/docs)
> Исходный код: [git.axenov.dev/IPTV](https://git.axenov.dev/IPTV)
> Документация: [m3u.su/docs](https://m3u.su/docs)
> Telegram-канал: [@iptv_aggregator](https://t.me/iptv_aggregator)
> Обсуждение: [@iptv_aggregator_chat](https://t.me/iptv_aggregator_chat)
> Бот: [@iptv_aggregator_bot](https://t.me/iptv_aggregator_bot)
> Исходный код: [git.axenov.dev/IPTV](https://git.axenov.dev/IPTV)
## Установка
Достаточно скачать и распаковать архив с подходящим исполняемым файлом [со страницы последнего релиза](https://git.axenov.dev/IPTV/iptvc/releases/latest):
| ОС | Скачать для `amd64` | Скачать для `arm64` |
| ------- | ------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------- |
| Linux | [linux_amd64.zip](https://git.axenov.dev/IPTV/iptvc/releases/download/latest/linux_amd64.zip) | [linux_arm64.zip](https://git.axenov.dev/IPTV/iptvc/releases/download/latest/linux_arm64.zip) |
| MacOS | [darwin_amd64.zip](https://git.axenov.dev/IPTV/iptvc/releases/download/latest/darwin_amd64.zip) | [darwin_arm64.zip](https://git.axenov.dev/IPTV/iptvc/releases/download/latest/darwin_arm64.zip) |
| Windows | [windows_amd64.zip](https://git.axenov.dev/IPTV/iptvc/releases/download/latest/windows_amd64.zip) | [windows_arm64.zip](https://git.axenov.dev/IPTV/iptvc/releases/download/latest/windows_arm64.zip) |
| ОС | Скачать для `amd64` | Скачать для `arm64` |
| ------- | ---------------------------------- | ---------------------------------- |
| Linux | [linux_amd64.zip][linux_amd64] | [linux_arm64.zip][linux_arm64] |
| MacOS | [darwin_amd64.zip][darwin_amd64] | [darwin_arm64.zip][darwin_arm64] |
| Windows | [windows_amd64.zip][windows_amd64] | [windows_arm64.zip][windows_arm64] |
[linux_amd64]: https://git.axenov.dev/IPTV/iptvc/releases/download/latest/linux_amd64.zip
[darwin_amd64]: https://git.axenov.dev/IPTV/iptvc/releases/download/latest/darwin_amd64.zip
[windows_amd64]: https://git.axenov.dev/IPTV/iptvc/releases/download/latest/windows_amd64.zip
[linux_arm64]: https://git.axenov.dev/IPTV/iptvc/releases/download/latest/linux_arm64.zip
[darwin_arm64]: https://git.axenov.dev/IPTV/iptvc/releases/download/latest/darwin_arm64.zip
[windows_arm64]: https://git.axenov.dev/IPTV/iptvc/releases/download/latest/windows_arm64.zip
## Компиляция
@@ -83,6 +88,146 @@
> [!NOTE]
> Набери `./iptvc help` для получения помощи.
## Веб-интерфейс
Программа включает встроенный веб-сервер для просмотра плейлистов и результатов их проверки.
### Запуск
```
./iptvc serve -i playlists.ini -p 8080
```
### Параметры команды `serve`
* `-p, --port` — порт для веб-сервера (переопределяет `config.yml` и `WEB_PORT`);
* `--host` — хост для привязки (переопределяет `config.yml` и `WEB_HOST`);
* `--check` — включить фоновую проверку плейлистов (по умолчанию выключена).
При указании `--check` доступны флаги проверки:
* `-i, --ini` — путь к ini-файлу (по умолчанию `./playlists.ini`);
* `-t, --tags` — путь к файлу тегов (по умолчанию `./channels.json`);
* `-r, --random` — проверить N случайных плейлистов из ini-файла;
* `--every` — интервал между проверками в секундах (по умолчанию `60`);
* `--repeat` — количество циклов проверки (по умолчанию `0` — бесконечно).
### Конфигурация
Приоритет настроек (от низшего к высшему):
1. **Значения по умолчанию** — встроены в код;
2. **`config.yml`** — YAML-файл в корне проекта (путь можно задать через флаг `--config`);
3. **Переменные окружения** — переопределяют значения из `config.yml` (если заданы);
4. **CLI-флаги** — переопределяют значения из окружения и `config.yml` (если заданы явно).
Флаги `--port` и `--host` переопределяют конфигурацию только если переданы явно.
Если флаг не указан, используется значение из переменной окружения, затем из `config.yml`,
затем значение по умолчанию.
Файл `.env` загружается автоматически, переменные из него применяются как переменные окружения.
#### `config.yml`
```yaml
app:
title: IPTV Checker
timezone: GMT
debug: false
log_level: info
server:
host: localhost
port: 8080
base_url: http://localhost:8080
page_size: 0
repo_url: https://git.axenov.dev/IPTV
check:
playlists:
user-agent:
- Mozilla/5.0 WINK/1.31.1 (AndroidTV/9) HlsWinkPlayer
timeout: [0, 10000] # ms, макс время ожидания ответа при проверке каждого плейлиста
all-cooldown: [0, 10000] # ms, задержка между проверками всех плейлистов
one-cooldown: [0, 10000] # ms, задержка между проверками каждого плейлиста
per-routine: [0, 10000] # количество одновременно проверяемых плейлистов
channels:
user-agent: Mozilla/5.0 WINK/1.31.1 (AndroidTV/9) HlsWinkPlayer
timeout: 10 # sec, макс время ожидания ответа при проверке каждого канала
byte-range: 512 # bytes, количество байт для получения от сервера
all-cooldown: 0 # ms, задержка между проверками всех каналов плейлиста
one-cooldown: 0 # ms, задержка между проверками каждого канала плейлиста
per-routine: 10 # количество одновременно проверяемых каналов
cache:
enabled: false
host: localhost
port: 6379
username:
password:
db: 1
ttl: 1800
```
Параметры `check.playlists` и `check.channels` (кроме `byte-range`) могут быть заданы как скаляр или массив `[min, max]`. При массиве каждый раз выбирается случайное значение в диапазоне. `user-agent` может быть строкой или массивом строк (случайный выбор). `byte-range` — всегда целое число.
#### Переменные окружения
| Переменная | Соответствует в `config.yml` | Описание |
| ---------------- | ---------------------------- | ----------------------------------- |
| `APP_DEBUG` | `app.debug` | Режим отладки |
| `APP_TITLE` | `app.title` | Заголовок сайта |
| `APP_TIMEZONE` | `app.timezone` | Часовой пояс |
| `APP_URL` | `server.base_url` | Базовый URL для ссылок |
| `CACHE_ENABLED` | `cache.enabled` | Включить кеш (KeyDB/Redis) |
| `CACHE_HOST` | `cache.host` | Хост KeyDB/Redis |
| `CACHE_PORT` | `cache.port` | Порт KeyDB/Redis |
| `CACHE_USERNAME` | `cache.username` | Имя пользователя KeyDB/Redis |
| `CACHE_PASSWORD` | `cache.password` | Пароль KeyDB/Redis |
| `CACHE_DB` | `cache.db` | Номер БД KeyDB/Redis |
| `CACHE_TTL` | `cache.ttl` | TTL записей в кеше (сек) |
| `WEB_PORT` | `server.port` | Порт веб-сервера |
| `WEB_HOST` | `server.host` | Хост для привязки |
| `PAGE_SIZE` | `server.page_size` | Размер страницы (0 — без пагинации) |
| `REPO_URL` | `server.repo_url` | Ссылка на репозиторий |
### Маршруты
| Метод | Путь | Описание |
| ----- | ----------------------- | ---------------------------------------- |
| GET | `/` | Главная страница со списком плейлистов |
| GET | `/page/{N}` | Страница N списка плейлистов |
| GET | `/{code}` | Редирект на прямую ссылку плейлиста |
| GET | `/{code}.m3u[8]` | Редирект на прямую ссылку плейлиста |
| GET | `/{code}/details` | Страница с описанием плейлиста |
| GET | `/api/playlists/{code}` | JSON: информация о плейлисте |
| GET | `/api/version` | JSON: версии компонентов |
| GET | `/api/health` | JSON: состояние сервиса |
| GET | `/api/stats` | JSON: статистика по плейлистам и каналам |
### Связь с проверкой
Веб-сервер отображает данные из кеша KeyDB/Redis, который заполняется командой `check`.
Если кеш недоступен, все плейлисты отображаются со статусом `unknown`.
Для обновления данных в фоне запустите `serve` с флагом `--check`:
```
./iptvc serve -i playlists.ini -p 8080 --check
```
Результаты проверок появляются на веб-страницах немедленно после проверки каждого
плейлиста, не дожидаясь завершения полного цикла.
Можно запускать `serve` и `check` отдельными процессами:
```
./iptvc check --repeat 0 --every 60 &
./iptvc serve
```
Или используйте docker-окружение проекта.
## Результаты проверки
Программа логирует процесс и результаты своей работы.
+46 -3
View File
@@ -26,6 +26,42 @@ type Arguments struct {
NeedJson bool
NeedQuiet bool
Verbose bool
ServerPort uint
ServerHost string
ConfigPath string
NeedCheck bool
Files []string
Urls []string
Codes []string
// app
Debug bool
LogLevel string
// check.playlists
PlTimeout int
PlAllCooldown int
PlOneCooldown int
PlMaxRoutines int
PlPerRoutine int
PlUserAgent []string
// check.channels
ChTimeout int
ChByteRange int
ChCooldown int
ChMaxRoutines int
ChPerRoutine int
ChUserAgent []string
// cache
CacheEnabled bool
CacheHost string
CachePort uint
CacheUsername string
CachePassword string
CacheDb uint
CacheTtl uint
}
var (
@@ -34,11 +70,18 @@ var (
Config *config.Config
)
// Init инициализирует конфигурацию и подключение к keydb
// Init загружает конфигурацию и инициализирует логгер.
// CLI-override применяются через applyAppOverrides(), applyCacheOverrides()
// и applyCheckOverrides() в обработчиках команд после Init().
func Init() {
Config = config.Init()
Config = config.Init(Args.ConfigPath)
logger.Init(Args.NeedQuiet)
if Config.Cache.IsEnabled {
}
// InitCache инициализирует подключение к KeyDB/Redis, если кеш включён.
// Вызывайте после применения всех cache-override.
func InitCache() {
if Config.Cache.Enabled {
Cache = cache.Init(&Config.Cache)
}
}
+118 -85
View File
@@ -15,13 +15,12 @@ import (
"context"
"crypto/tls"
"encoding/json"
"fmt"
"io"
"log"
"maps"
"math"
"math/rand"
"net/http"
"os"
"slices"
"strings"
"sync"
@@ -29,8 +28,9 @@ import (
)
var (
tagBlocks []tagfile.TagBlock
ctx = context.Background()
tagBlocks []tagfile.TagBlock
ctx = context.Background()
OnPlaylistChecked func(playlist.Playlist) // вызывается после проверки каждого плейлиста
)
// PrepareListsToCheck готовит список плейлистов для проверки
@@ -57,7 +57,7 @@ func PrepareListsToCheck(files []string, urls []string, codes []string) []playli
}
if len(lists) == 0 || len(codes) > 0 {
ini, err := inifile.Init(app.Args.IniPath)
ini, err := inifile.Init(app.Config.App.Playlists)
if err != nil {
log.Printf("Warning: %s, all --code flags will be ignored\n", err)
return lists
@@ -109,56 +109,106 @@ func getCachedPlaylists() map[string]playlist.Playlist {
return result
}
// CheckPlaylists проверяет плейлисты и возвращает их же с результатами проверки
// CheckPlaylists проверяет плейлисты и возвращает их же с результатами проверки.
// Параметры (user-agent, timeout, cooldown, per-routine) берутся из config.Check.Playlists.
func CheckPlaylists(lists []playlist.Playlist) (int, int) {
count := len(lists)
if count == 0 {
log.Println("There are no playlists to check")
os.Exit(0)
return 0, 0
}
log.Printf("%d playlists will be checked\n", len(lists))
step, onlineCount, offlineCount := 0, 0, 0
tagBlocks = tagfile.Init(app.Args.TagsPath)
pc := app.Config.Check.Playlists
maxRoutines := pc.MaxRoutines
if maxRoutines < 1 {
maxRoutines = 1
}
log.Printf("%d playlists will be checked (max-routines=%d)\n", count, maxRoutines)
tagBlocks = tagfile.Init(app.Config.App.Tags)
type checkResult struct {
idx int
pls playlist.Playlist
online bool
}
sem := make(chan struct{}, maxRoutines)
var wg sync.WaitGroup
results := make([]checkResult, count)
for idx := range lists {
pls := lists[idx]
step++
wg.Add(1)
go func(idx int) {
sem <- struct{}{}
defer func() {
<-sem
wg.Done()
}()
var err error
if pls.Source == "-f" {
// direct m3u path
log.Printf("[%.3d/%.3d] Playlist from filesystem\n", step, count)
log.Printf("Reading file... (%s)\n", pls.Url)
err = pls.ReadFromFs()
} else if pls.Source == "-u" {
// direct m3u url
log.Printf("[%.3d/%.3d] Playlist [%s]\n", step, count, pls.Url)
log.Printf("Fetching... (%s)\n", pls.Url)
err = pls.Download()
} else {
// from ini
log.Printf("[%.3d/%.3d] Playlist [%s]\n", step, count, pls.Code)
log.Printf("Fetching... (%s)\n", pls.Url)
err = pls.Download()
}
pls := lists[idx]
pls.CheckedAt = time.Now().Unix()
if err != nil {
log.Printf("Cannot read playlist [%s]: %s\n", pls.Url, err)
offlineCount++
userAgent := pc.UserAgent.Pick()
timeout := time.Duration(pc.Timeout) * time.Millisecond
var err error
if pls.Source == "-f" {
log.Printf("Playlist from filesystem (%s)\n", pls.Url)
err = pls.ReadFromFs()
} else {
log.Printf("Fetching playlist [%s] (%s)\n", pls.Code, pls.Url)
err = pls.Download(userAgent, timeout)
}
if err != nil {
log.Printf("Cannot read playlist [%s]: %s\n", pls.Url, err)
pls.IsOnline = false
results[idx] = checkResult{idx: idx, pls: pls, online: false}
cachePlaylist(pls)
if OnPlaylistChecked != nil {
OnPlaylistChecked(pls)
}
return
}
log.Println("Parsing content...")
pls.IsOnline = true
pls = pls.Parse()
log.Printf("Parsed, checking channels (%d)...\n", len(pls.Channels))
pls = CheckChannels(pls)
results[idx] = checkResult{idx: idx, pls: pls, online: true}
cachePlaylist(pls)
continue
if OnPlaylistChecked != nil {
OnPlaylistChecked(pls)
}
// one-cooldown: задержка после проверки каждого плейлиста
oneCd := time.Duration(pc.OneCooldown.Value()) * time.Millisecond
if oneCd > 0 {
time.Sleep(oneCd)
}
}(idx)
}
wg.Wait()
onlineCount, offlineCount := 0, 0
for _, r := range results {
lists[r.idx] = r.pls
if r.online {
onlineCount++
} else {
offlineCount++
}
}
log.Println("Parsing content...")
pls.IsOnline = true
onlineCount++
pls = pls.Parse()
log.Printf("Parsed, checking channels (%d)...\n", len(pls.Channels))
pls = CheckChannels(pls)
lists[idx] = pls
cachePlaylist(pls)
// all-cooldown: задержка после проверки всех плейлистов
allCd := time.Duration(pc.AllCooldown.Value()) * time.Millisecond
if allCd > 0 {
log.Printf("All playlists checked, cooldown %dms...\n", allCd.Milliseconds())
time.Sleep(allCd)
}
return onlineCount, offlineCount
@@ -183,7 +233,8 @@ func cachePlaylist(pls playlist.Playlist) {
log.Println("Cached sucessfully")
}
// CheckChannels проверяет каналы и возвращает их же с результатами проверки
// CheckChannels проверяет каналы и возвращает их же с результатами проверки.
// Параметры (user-agent, timeout, byte-range, cooldown, per-routine) берутся из config.Check.Channels.
func CheckChannels(pls playlist.Playlist) playlist.Playlist {
type errorData struct {
tvChannel playlist.Channel
@@ -199,14 +250,27 @@ func CheckChannels(pls playlist.Playlist) playlist.Playlist {
pls.OnlineCount = 0
pls.OfflineCount = 0
timeout, routines := calcParameters(count)
cc := app.Config.Check.Channels
timeoutMs := cc.Timeout
byteRange := cc.ByteRange
if byteRange <= 0 {
byteRange = 512
}
maxRoutines := cc.MaxRoutines
if maxRoutines < 1 {
maxRoutines = 1
}
timeout := time.Duration(timeoutMs) * time.Millisecond
httpClient := http.Client{Timeout: timeout}
chSemaphores := make(chan struct{}, routines)
chSemaphores := make(chan struct{}, maxRoutines)
chOnline := make(chan playlist.Channel, len(pls.Channels))
chOffline := make(chan playlist.Channel, len(pls.Channels))
chError := make(chan errorData, len(pls.Channels))
var wg sync.WaitGroup
log.Printf("Check channels parameters: timeout=%dms byte-range=%d max-routines=%d\n", timeoutMs, byteRange, maxRoutines)
startTime := time.Now()
for _, tvChannel := range pls.Channels {
wg.Add(1)
@@ -227,8 +291,8 @@ func CheckChannels(pls playlist.Playlist) playlist.Playlist {
return
}
req.Header.Set("User-Agent", "Mozilla/5.0 WINK/1.31.1 (AndroidTV/9) HlsWinkPlayer")
req.Header.Set("Range", "bytes=0-511") // 512 B, but sometimes servers ignore it
req.Header.Set("User-Agent", cc.UserAgent.Pick())
req.Header.Set("Range", fmt.Sprintf("bytes=0-%d", byteRange-1))
resp, err := httpClient.Do(req)
tvChannel.CheckedAt = time.Now().Unix()
if err != nil {
@@ -240,7 +304,7 @@ func CheckChannels(pls playlist.Playlist) playlist.Playlist {
tvChannel.Status = resp.StatusCode
tvChannel.IsOnline = tvChannel.Status < http.StatusBadRequest
tvChannel.ContentType = resp.Header.Get("Content-Type")
chunk := io.LimitReader(resp.Body, 512) // just for sure
chunk := io.LimitReader(resp.Body, int64(byteRange))
bodyBytes, _ := io.ReadAll(chunk)
bodyString := string(bodyBytes)
_ = resp.Body.Close()
@@ -263,7 +327,12 @@ func CheckChannels(pls playlist.Playlist) playlist.Playlist {
}
chOnline <- tvChannel
return
// cooldown: задержка после проверки каждого канала
cd := time.Duration(cc.Cooldown.Value()) * time.Millisecond
if cd > 0 {
time.Sleep(cd)
}
}(tvChannel)
}
@@ -323,42 +392,6 @@ func CheckChannels(pls playlist.Playlist) playlist.Playlist {
return pls
}
// calcParameters вычисляет оптимальное количество горутин и таймаут запроса
func calcParameters(count int) (time.Duration, int) {
routines := count
if routines > 3000 {
routines = 3000
}
if routines < 1 {
routines = 1
}
var digits = 1
x := count
for x >= 10 {
digits++
x /= 10
}
timeout := 10 - int(math.Ceil(float64(digits)*1.5))
if timeout > 10 {
timeout = 10
}
if timeout < 1 {
timeout = 1
}
duration := time.Duration(timeout) * time.Second
log.Printf(
"Check parameters calculated count=%d timeout=%.2fs routines=%d\n",
count,
duration.Seconds(),
routines,
)
return duration, routines
}
// getTagsForChannel ищет и возвращает теги для канала
func getTagsForChannel(tvChannel playlist.Channel) []string {
var foundTags []string
+517 -37
View File
@@ -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
}
}
+548
View File
@@ -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
+91
View File
@@ -0,0 +1,91 @@
package inifile
import (
"os"
"path/filepath"
"testing"
)
func TestInit_Success(t *testing.T) {
tmp := t.TempDir()
path := filepath.Join(tmp, "playlists.ini")
data := `[RU]
pls = http://example.com/ru.m3u
name = Russian Playlist
desc = Russian channels
src = public
[EN]
pls = http://example.com/en.m3u
name = English Playlist
`
if err := os.WriteFile(path, []byte(data), 0644); err != nil {
t.Fatal(err)
}
ini, err := Init(path)
if err != nil {
t.Fatal(err)
}
if len(ini.Lists) != 2 {
t.Fatalf("expected 2 playlists, got %d", len(ini.Lists))
}
ru, ok := ini.Lists["RU"]
if !ok {
t.Fatal("expected RU playlist")
}
if ru.Name != "Russian Playlist" {
t.Errorf("expected 'Russian Playlist', got '%s'", ru.Name)
}
if ru.Description != "Russian channels" {
t.Errorf("expected 'Russian channels', got '%s'", ru.Description)
}
if ru.Url != "http://example.com/ru.m3u" {
t.Errorf("expected URL, got '%s'", ru.Url)
}
if ru.Source != "public" {
t.Errorf("expected 'public', got '%s'", ru.Source)
}
}
func TestInit_NotFound(t *testing.T) {
_, err := Init("/nonexistent/playlists.ini")
if err == nil {
t.Error("expected error for non-existent file")
}
}
func TestInit_EmptyPls(t *testing.T) {
tmp := t.TempDir()
path := filepath.Join(tmp, "playlists.ini")
data := `[BAD]
name = Bad Playlist
`
os.WriteFile(path, []byte(data), 0644)
ini, err := Init(path)
if err != nil {
t.Fatal(err)
}
if len(ini.Lists) != 0 {
t.Errorf("expected 0 playlists (empty pls), got %d", len(ini.Lists))
}
}
func TestInit_DefaultName(t *testing.T) {
tmp := t.TempDir()
path := filepath.Join(tmp, "playlists.ini")
data := `[CODE1]
pls = http://example.com/list.m3u
`
os.WriteFile(path, []byte(data), 0644)
ini, err := Init(path)
if err != nil {
t.Fatal(err)
}
pls, ok := ini.Lists["CODE1"]
if !ok {
t.Fatal("expected CODE1 playlist")
}
if pls.Name != "Playlist #CODE1" {
t.Errorf("expected 'Playlist #CODE1', got '%s'", pls.Name)
}
}
+4 -2
View File
@@ -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()
+226
View File
@@ -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")
}
}
+163
View File
@@ -0,0 +1,163 @@
package tagfile
import (
"os"
"path/filepath"
"testing"
"axenov/iptv-checker/app/playlist"
)
func TestGetTags_TvgId(t *testing.T) {
block := TagBlock{
TvgId: "^ru-",
Tags: []string{"russian"},
}
ch := playlist.Channel{
Attributes: map[string]string{"tvg-id": "ru-first"},
}
tags := block.GetTags(ch)
if len(tags) != 1 || tags[0] != "russian" {
t.Errorf("expected [russian], got %v", tags)
}
}
func TestGetTags_TvgId_NoMatch(t *testing.T) {
block := TagBlock{
TvgId: "^en-",
Tags: []string{"english"},
}
ch := playlist.Channel{
Attributes: map[string]string{"tvg-id": "ru-first"},
}
tags := block.GetTags(ch)
if len(tags) != 0 {
t.Errorf("expected empty, got %v", tags)
}
}
func TestGetTags_TvgId_NoAttribute(t *testing.T) {
block := TagBlock{
TvgId: "^ru-",
Tags: []string{"russian"},
}
ch := playlist.Channel{
Attributes: map[string]string{},
}
tags := block.GetTags(ch)
if len(tags) != 0 {
t.Errorf("expected empty, got %v", tags)
}
}
func TestGetTags_TvgName(t *testing.T) {
block := TagBlock{
TvgName: "sport",
Tags: []string{"sports"},
}
ch := playlist.Channel{
Attributes: map[string]string{"tvg-name": "Sports Channel"},
}
tags := block.GetTags(ch)
if len(tags) != 1 || tags[0] != "sports" {
t.Errorf("expected [sports], got %v", tags)
}
}
func TestGetTags_Title(t *testing.T) {
block := TagBlock{
Title: "movie",
Tags: []string{"movies"},
}
ch := playlist.Channel{
Title: "Best Movie HD",
}
tags := block.GetTags(ch)
if len(tags) != 1 || tags[0] != "movies" {
t.Errorf("expected [movies], got %v", tags)
}
}
func TestGetTags_TitleCaseInsensitive(t *testing.T) {
block := TagBlock{
Title: "hd",
Tags: []string{"hd"},
}
ch := playlist.Channel{
Title: "some HD channel",
}
tags := block.GetTags(ch)
if len(tags) != 1 || tags[0] != "hd" {
t.Errorf("expected [hd], got %v", tags)
}
}
func TestGetTags_EmptyBlock(t *testing.T) {
block := TagBlock{}
ch := playlist.Channel{
Title: "Test",
Attributes: map[string]string{},
}
tags := block.GetTags(ch)
if len(tags) != 0 {
t.Errorf("expected empty, got %v", tags)
}
}
func TestGetTags_InvalidRegex(t *testing.T) {
block := TagBlock{
TvgId: "[invalid",
Tags: []string{"bad"},
}
ch := playlist.Channel{
Attributes: map[string]string{"tvg-id": "test"},
}
tags := block.GetTags(ch)
if len(tags) != 0 {
t.Errorf("expected empty for invalid regex, got %v", tags)
}
}
func TestGetTags_TvgIdEmptyValue(t *testing.T) {
block := TagBlock{
TvgId: "test",
Tags: []string{"tag"},
}
ch := playlist.Channel{
Attributes: map[string]string{"tvg-id": ""},
}
tags := block.GetTags(ch)
if len(tags) != 0 {
t.Errorf("expected empty for empty tvg-id value, got %v", tags)
}
}
func TestInit_Success(t *testing.T) {
tmp := t.TempDir()
path := filepath.Join(tmp, "channels.json")
data := `[{"tvg-id":"^ru-","tags":["russian"]},{"title":"movie","tags":["movies"]}]`
if err := os.WriteFile(path, []byte(data), 0644); err != nil {
t.Fatal(err)
}
blocks := Init(path)
if len(blocks) != 2 {
t.Fatalf("expected 2 blocks, got %d", len(blocks))
}
}
func TestInit_NotFound(t *testing.T) {
blocks := Init("/nonexistent/channels.json")
if blocks != nil {
t.Errorf("expected nil for non-existent file, got %v", blocks)
}
}
func TestInit_InvalidJSON(t *testing.T) {
tmp := t.TempDir()
path := filepath.Join(tmp, "channels.json")
os.WriteFile(path, []byte("not json"), 0644)
blocks := Init(path)
if blocks != nil {
t.Errorf("expected nil for invalid JSON, got %v", blocks)
}
}
+7 -4
View File
@@ -44,16 +44,19 @@ func ArrayUnique(arr []string) []string {
return result
}
// Fetch выполняет GET запрос и возвращает результат в виде массива байт
func Fetch(url string) ([]byte, error) {
// Fetch выполняет GET запрос и возвращает результат в виде массива байт.
// userAgent — значение заголовка User-Agent, timeout — таймаут запроса.
func Fetch(url string, userAgent string, timeout time.Duration) ([]byte, error) {
req, err := http.NewRequest("GET", url, nil)
if err != nil {
return nil, err
}
req.Header.Set("User-Agent", "Mozilla/5.0 WINK/1.31.1 (AndroidTV/9) HlsWinkPlayer")
if userAgent != "" {
req.Header.Set("User-Agent", userAgent)
}
http.DefaultTransport.(*http.Transport).TLSClientConfig = &tls.Config{InsecureSkipVerify: true}
httpClient := http.Client{Timeout: 10 * time.Second}
httpClient := http.Client{Timeout: timeout}
resp, err := httpClient.Do(req)
if err != nil {
return nil, err
+155
View File
@@ -0,0 +1,155 @@
package utils
import (
"net/http"
"net/http/httptest"
"testing"
"time"
)
func TestMd5str(t *testing.T) {
cases := []struct {
in string
want string
}{
{"", "d41d8cd98f00b204e9800998ecf8427e"},
{"hello", "5d41402abc4b2a76b9719d911017c592"},
{"test", "098f6bcd4621d373cade4e832627b4f6"},
}
for _, c := range cases {
got := Md5str(c.in)
if got != c.want {
t.Errorf("Md5str(%q) = %s, want %s", c.in, got, c.want)
}
}
}
func TestMd5str_Consistency(t *testing.T) {
h1 := Md5str("same")
h2 := Md5str("same")
if h1 != h2 {
t.Error("Md5str should be deterministic")
}
}
func TestArrayUnique(t *testing.T) {
in := []string{"a", "b", "a", "c", "b", "d"}
got := ArrayUnique(in)
if len(got) != 4 {
t.Fatalf("expected 4, got %d: %v", len(got), got)
}
seen := map[string]bool{}
for _, v := range got {
if seen[v] {
t.Errorf("duplicate found: %s", v)
}
seen[v] = true
}
}
func TestArrayUnique_Empty(t *testing.T) {
got := ArrayUnique([]string{})
if len(got) != 0 {
t.Errorf("expected empty, got %v", got)
}
}
func TestArrayUnique_Single(t *testing.T) {
got := ArrayUnique([]string{"only"})
if len(got) != 1 || got[0] != "only" {
t.Errorf("expected [only], got %v", got)
}
}
func TestArrayUnique_AllSame(t *testing.T) {
got := ArrayUnique([]string{"x", "x", "x"})
if len(got) != 1 || got[0] != "x" {
t.Errorf("expected [x], got %v", got)
}
}
func TestExpandPath_Relative(t *testing.T) {
got, err := ExpandPath("foo/bar")
if err != nil {
t.Fatal(err)
}
if got == "" {
t.Error("expected non-empty path")
}
// should be absolute
if got[0] != '/' {
t.Errorf("expected absolute path, got %s", got)
}
}
func TestExpandPath_Tilde(t *testing.T) {
got, err := ExpandPath("~/test")
if err != nil {
t.Fatal(err)
}
if got == "" {
t.Error("expected non-empty path")
}
// should not contain ~
for _, c := range got {
if c == '~' {
t.Error("path should not contain ~")
break
}
}
}
func TestFetch_Success(t *testing.T) {
body := "hello world"
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if ua := r.Header.Get("User-Agent"); ua != "TestUA" {
t.Errorf("expected User-Agent 'TestUA', got '%s'", ua)
}
w.WriteHeader(http.StatusOK)
w.Write([]byte(body))
}))
defer srv.Close()
data, err := Fetch(srv.URL, "TestUA", 5*time.Second)
if err != nil {
t.Fatal(err)
}
if string(data) != body {
t.Errorf("expected '%s', got '%s'", body, string(data))
}
}
func TestFetch_NonOK(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNotFound)
}))
defer srv.Close()
_, err := Fetch(srv.URL, "", 5*time.Second)
if err == nil {
t.Error("expected error for non-200 status")
}
}
func TestFetch_EmptyUserAgent(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte("ok"))
}))
defer srv.Close()
data, err := Fetch(srv.URL, "", 5*time.Second)
if err != nil {
t.Fatal(err)
}
if string(data) != "ok" {
t.Errorf("expected 'ok', got '%s'", string(data))
}
}
func TestFetch_InvalidURL(t *testing.T) {
_, err := Fetch("http://[::1]:named", "", 1*time.Second)
if err == nil {
t.Error("expected error for invalid URL")
}
}
+444
View File
@@ -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
}
+204
View File
@@ -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(),
)
}
+176
View File
@@ -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
}
+80
View File
@@ -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)
}
}
+322
View File
@@ -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")
}
+145
View File
@@ -0,0 +1,145 @@
{{define "base"}}
<!DOCTYPE html>
<html lang="ru" class="h-100">
<head>
<title>{{block "title" .}}{{.Title}}{{end}}</title>
<meta charset="utf-8">
<meta name="description" content="{{block "metadescription" .}}{{end}}">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="keywords" content="{{block "metakeywords" .}}{{end}}">
<meta http-equiv="x-ua-compatible" content="ie=edge">
<style>.cursor-pointer{cursor:pointer}.cursor-help{cursor:help}</style>
<script async type="module" src="https://unpkg.com/ionicons@7.1.0/dist/ionicons/ionicons.esm.js"></script>
<script async nomodule src="https://unpkg.com/ionicons@7.1.0/dist/ionicons/ionicons.js"></script>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.2.3/dist/css/bootstrap.min.css" rel="stylesheet">
{{block "head" .}}{{end}}
</head>
<body class="d-flex flex-column h-100 bg-dark text-light">
<header class="sticky-top bg-dark border-bottom border-secondary">
<nav class="navbar navbar-expand-lg navbar-dark container px-2">
<a class="navbar-brand d-flex align-items-center gap-2" href="/" title="На главную">
<span>{{.Title}}</span>
</a>
<button class="navbar-toggler"
type="button"
data-bs-toggle="collapse"
data-bs-target="#navbarNav"
aria-controls="navbarNav"
>
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarNav">
<ul class="navbar-nav ms-auto">
{{range .Navigation}}
{{if .Children}}
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle d-flex align-items-center"
href="#"
role="button"
data-bs-toggle="dropdown"
aria-expanded="false"
>
{{if .Icon}}<ion-icon name="{{.Icon}}" class="me-1"></ion-icon>{{end}}{{.Title}}
</a>
<ul class="dropdown-menu dropdown-menu-dark">
{{range .Children}}
<li>
<a class="dropdown-item d-flex align-items-center gap-2" target="_blank" href="{{.Url}}">
{{if .Icon}}<ion-icon name="{{.Icon}}"></ion-icon>{{end}}{{.Title}}
</a>
</li>
{{end}}
</ul>
</li>
{{else}}
<li class="nav-item">
<a class="nav-link d-flex align-items-center" target="_blank" href="{{.Url}}">
{{if .Icon}}<ion-icon name="{{.Icon}}" class="me-1"></ion-icon>{{end}}{{.Title}}
</a>
</li>
{{end}}
{{end}}
</ul>
</div>
</nav>
</header>
<main class="flex-grow-1 container py-4">
{{block "header" .}}{{end}}
<div class="content-wrapper">
{{block "content" .}}{{end}}
</div>
</main>
<footer class="bg-dark border-top border-secondary py-4">
<div class="container text-center">
<div class="d-flex flex-wrap justify-content-center gap-3 mb-3">
{{range .FooterLinks}}
<a target="_blank" href="{{.Url}}" class="text-light text-decoration-none d-flex align-items-center gap-1">
{{if .Icon}}<ion-icon name="{{.Icon}}"></ion-icon>{{end}}&nbsp;{{.Title}}
</a>
{{end}}
</div>
<div>
<a class="small text-secondary d-inline-flex align-items-center gap-1"
href="{{.RepoUrl}}/releases/tag/v{{.Version}}"
target="_blank"
>
<ion-icon name="pricetag-outline"></ion-icon>iptvc&nbsp;v{{.Version}}
</a>
</div>
</div>
</footer>
<div class="toast-container position-fixed bottom-0 end-0 p-3">
<div class="toast align-items-center text-bg-success border-0" role="alert" id="clipboardToast">
<div class="d-flex">
<div class="toast-body" id="clipboardToastBody"></div>
</div>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.2.3/dist/js/bootstrap.bundle.min.js"></script>
{{block "footer" .}}{{end}}
<script>
function showToast(message) {
const toastEl = document.getElementById('clipboardToast');
const toastBodyEl = document.getElementById('clipboardToastBody');
toastBodyEl.innerHTML = message;
const toast = new bootstrap.Toast(toastEl, {delay: 5000});
toast.show();
}
function copyPlaylistUrl(code) {
const url = '{{.BaseUrl}}/' + code;
if (navigator.clipboard && window.isSecureContext) {
navigator.clipboard
.writeText(url)
.then(() => showToast(`Ссылка на плейлист '${code}' скопирована в буфер обмена`))
.catch(err => console.error('Failed to copy:', err));
} else {
try {
const textArea = document.createElement("textarea");
textArea.value = url;
textArea.style.position = "fixed";
document.body.appendChild(textArea);
textArea.focus();
textArea.select();
const successful = document.execCommand('copy');
document.body.removeChild(textArea);
if (successful) {
showToast(`Ссылка на плейлист '${code}' скопирована в буфер обмена`);
} else {
showToast('Ошибка при копировании ссылки', true);
}
} catch (err) {
console.error('Fallback copy failed:', err);
showToast('Ошибка при копировании ссылки', true);
}
}
}
</script>
</body>
</html>
{{end}}
+493
View File
@@ -0,0 +1,493 @@
{{define "title"}}[{{.Playlist.Code}}] {{.Playlist.Name}} - {{.Title}}{{end}}
{{define "metadescription"}}Смотреть бесплатный самообновляемый плейлист {{.Playlist.Name}}, проверить статус, {{.Playlist.Description}}{{end}}
{{define "head"}}
<style>
img.tvg-logo{max-width:80px;max-height:80px;padding:2px;border-radius:5px}
tr.chrow td{padding:3px}
td.chindex{width:1%}
td.chlogo{width:100px}
div.chlist-table{max-height:550px}
textarea.m3u-raw{font-size:.7rem}
</style>
<script>
const DEFAULT_LOGO = 'data:image/svg+xml,' + encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><rect width="100" height="100" fill="#333"/><text x="50" y="55" text-anchor="middle" fill="#999" font-size="14">no logo</text></svg>')
function setDefaultLogo(imgtag) {
imgtag.onerror = null
imgtag.src = DEFAULT_LOGO
}
</script>
{{end}}
{{define "header"}}
<h2>О плейлисте: {{.Playlist.Name}}</h2>
{{end}}
{{define "content"}}
{{$pls := .Playlist}}
<div class="row">
<div class="col-lg-7">
<ul class="nav nav-tabs">
<li class="nav-item small">
<a class="nav-link active"
type="button"
href="#tab-data"
data-bs-toggle="tab"
data-bs-target="#tab-data"
>
<ion-icon name="radio-outline"></ion-icon>&nbsp;Основные&nbsp;данные
</a>
</li>
<li class="nav-item small">
<a class="nav-link"
type="button"
href="#tab-raw"
data-bs-toggle="tab"
data-bs-target="#tab-raw"
>
<ion-icon name="document-text-outline"></ion-icon>&nbsp;Исходный&nbsp;текст
</a>
</li>
<li class="nav-item small">
<a class="nav-link"
type="button"
href="#tab-abuse"
data-bs-toggle="tab"
data-bs-target="#tab-abuse"
>
<ion-icon name="wallet-outline"></ion-icon>&nbsp;Правообладателям
</a>
</li>
</ul>
<div class="tab-content">
<div class="tab-pane fade show active" id="tab-data" tabindex="0">
<table class="table table-dark table-hover small mb-lg-5">
<tbody>
<tr>
<th class="w-25" scope="row">Код</th>
<th class="text-break">
<span class="pe-3 font-monospace">{{$pls.Code}}</span>
{{if not $pls.IsKnown}}
<span class="cursor-help badge small text-dark bg-secondary"
title="Не проверялся"
>unknown</span>
{{else if $pls.IsOnline}}
<span class="cursor-help badge small text-dark bg-success"
title="Вероятно, работает"
>online</span>
{{else}}
<span class="cursor-help badge small text-dark bg-danger"
title="Вероятно, не работает"
>offline</span>
{{end}}
</th>
</tr>
<tr>
<th scope="row">Описание</th>
<td class="text-break"><p class="mb-0">{{$pls.Description}}</p></td>
</tr>
<tr>
<th scope="row">Короткая ссылка</th>
<td>
<span onclick="copyPlaylistUrl('{{$pls.Code}}')"
class="cursor-pointer"
title="Нажми на ссылку, чтобы скопировать её в буфер обмена"
>
<b class="cursor-pointer font-monospace text-break">{{baseUrl $pls.Code}}</b>
<ion-icon name="copy-outline"></ion-icon>
</span>
</td>
</tr>
<tr>
<th scope="row">Источник</th>
<td class="text-break">{{$pls.Source}}</td>
</tr>
<tr>
<th scope="row">Наполнение</th>
<td class="text-break">
{{if and $pls.IsKnown $pls.IsOnline}}
{{if $pls.HasTokens}}
<span class="cursor-help badge bg-info text-dark">
<ion-icon name="paw"></ion-icon>
</span>&nbsp;могут быть нестабильные каналы<br>
{{end}}
{{if hasTag $pls.Tags "adult"}}
<span class="cursor-help badge small bg-warning text-dark">18+</span>&nbsp;есть каналы для взрослых<br>
{{end}}
<ion-icon name="folder-open-outline"></ion-icon>&nbsp;группы: {{groupCount $pls}}<br>
<ion-icon name="videocam-outline"></ion-icon>&nbsp;каналы:
<span class="cursor-help text-success" title="Возможно, рабочие каналы">
{{$pls.OnlineCount}} ({{$pls.OnlinePercent}}%)
</span>
+
<span class="cursor-help text-danger" title="Возможно, НЕрабочие каналы">
{{$pls.OfflineCount}} ({{$pls.OfflinePercent}}%)
</span>
= {{channelCount $pls}}
{{end}}
</td>
</tr>
<tr>
<th scope="row">Возможности</th>
<td class="text-break">
{{if and $pls.IsKnown $pls.IsOnline}}
<ion-icon name="newspaper-outline"></ion-icon>&nbsp;Программа передач:&nbsp;{{if $pls.HasTvg}}есть{{else}}нет{{end}}<br>
<ion-icon name="play-back"></ion-icon>&nbsp;Перемотка&nbsp;(архив):&nbsp;{{if $pls.HasCatchup}}есть{{else}}нет{{end}}
{{end}}
</td>
</tr>
<tr class="text-secondary">
<th scope="row">M3U</th>
<td class="text-break">{{$pls.Url}}</td>
</tr>
<tr class="text-secondary">
<th class="w-25" scope="row">Проверка плейлиста</th>
<td class="text-break">
<span title="Фактическая метка времени окончания проверки плейлиста">
{{formatDate $pls.CheckedAt}}
</span>
</td>
</tr>
{{if and $pls.IsKnown (not $pls.IsOnline)}}
<tr class="text-secondary">
<th class="w-25" scope="row">Ошибка проверки</th>
<td class="text-break">{{$pls.Content}}</td>
</tr>
{{end}}
</tbody>
</table>
{{if $pls.Attributes}}
<h4>Дополнительные атрибуты</h4>
<table class="table table-dark table-hover small font-monospace">
<tbody>
{{range $attr, $val := $pls.Attributes}}
<tr>
<th class="w-25" scope="row">{{$attr}}</th>
<td class="text-break">{{$val}}</td>
</tr>
{{end}}
</tbody>
</table>
{{end}}
</div>
<div class="tab-pane fade" id="tab-raw" tabindex="1">
<button class="btn btn-sm btn-success my-3"
id="saveM3UBtn"
onclick="savePlaylist()"
>
<ion-icon name="download-outline"></ion-icon>&nbsp;{{$pls.Code}}.m3u8
</button>
<textarea class="form-control bg-dark text-light font-monospace mb-3 mb-md-0 m3u-raw"
rows="40"
id="m3u-raw"
readonly
>{{$pls.Content}}</textarea>
</div>
<div class="tab-pane fade" id="tab-abuse" tabindex="2">
<p class="my-3">
Данные, представленные на данной странице, получены автоматически из открыто доступных в
интернете IPTV-плейлистов, опубликованных третьими лицами.
При наличии технической возможности, источник плейлиста может быть указан на вкладке "Основные
данные".
</p>
<p class="my-3">
Сервис {{baseUrl ""}} не размещает и не транслирует медиаконтент, не создаёт, не призывает
использовать и распространять плейлисты третьих лиц, а также не оказывает услуг по ретрансляции
телепрограмм.
</p>
<p class="my-3">
Информация о телеканалах (наименования, логотипы, технический статус и другие сведения)
формируется исключительно путём обработки содержимого самого плейлиста.
Вся информация носит технический и ознакомительный характер, и её достоверность не гарантируется.
</p>
<p class="my-3">
Все права на торговые марки и графические изображения принадлежат их законным владельцам.
</p>
</div>
</div>
</div>
<div class="col-lg-5">
<h4>Список каналов:&nbsp;<span id="chcount">{{channelCount $pls}}</span></h4>
{{if gt (channelCount $pls) 0}}
{{if gt (groupCount $pls) 1}}
<div class="row my-3">
<div class="col-12">
{{if gt (channelCount $pls) 100}}
<div class="alert alert-warning small" role="alert" id="chListLoading">
<div class="spinner-border text-success spinner-border-sm" role="status"></div>
Загрузка...
</div>
{{end}}
<div class="input-group">
<select id="groupSelector"
class="form-select form-select-sm border-secondary bg-dark text-light"
onchange="updateFilter()"
>
<option selected value="all">Все группы</option>
{{range $pls.Groups}}
<option value="{{.Id}}">{{.Name}}</option>
{{end}}
</select>
<button type="button"
onclick="resetGroup()"
class="btn btn-sm btn-outline-secondary"
title="Сбросить группу"
>
<ion-icon name="close-outline"></ion-icon>
</button>
</div>
</div>
</div>
{{end}}
<div class="row my-3">
<div class="col-12">
<div class="input-group">
<input type="text"
id="search-field"
class="cursor-help form-control form-control-sm border-secondary bg-dark text-light fuzzy-search"
placeholder="Поиск каналов..."
title="Начни вводить название"
/>
<input type="radio"
class="btn-check"
name="chFilter"
id="chfAll"
autocomplete="off"
onclick="updateFilter()"
checked
>
<label class="btn btn-sm btn-outline-secondary"
for="chfAll"
title="Выбрать все каналы"
>
<ion-icon name="radio-button-on-outline"></ion-icon>
</label>
<input type="radio"
class="btn-check"
name="chFilter"
id="chfOnline"
autocomplete="off"
onclick="updateFilter()"
>
<label class="btn btn-sm btn-outline-success"
for="chfOnline"
title="Выбрать только онлайн каналы"
>
<ion-icon name="radio-button-on-outline"></ion-icon>{{$pls.OnlineCount}}
</label>
<input type="radio"
class="btn-check"
name="chFilter"
id="chfOffline"
autocomplete="off"
onclick="updateFilter()"
>
<label class="btn btn-sm btn-outline-danger"
for="chfOffline"
title="Выбрать только оффлайн каналы"
>
<ion-icon name="radio-button-on-outline"></ion-icon>{{$pls.OfflineCount}}
</label>
<button type="button"
class="btn btn-sm btn-outline-secondary"
onclick="resetSearch()"
title="Сбросить фильтрацию"
>
<ion-icon name="close-outline"></ion-icon>
</button>
</div>
<div class="my-3">
{{range $pls.Tags}}
<input type="checkbox"
class="btn-check"
id="btn-tag-{{.}}"
data-tag="{{.}}"
autocomplete="off"
onclick="updateFilter()"
>
<label class="badge btn btn-sm btn-outline-secondary rounded-pill"
for="btn-tag-{{.}}"
title="Нажми для фильтрации каналов по тегу, нажми ещё раз чтобы снять фильтр"
>#{{.}}</label>
{{end}}
</div>
</div>
</div>
<div class="chlist-table overflow-auto">
<table id="chlist" class="table table-dark table-hover small">
<tbody class="list">
</tbody>
</table>
</div>
{{else if and $pls.IsKnown (not $pls.IsOnline)}}
<div class="alert alert-danger small" role="alert">
Ошибка плейлиста: {{$pls.Content}}
</div>
{{else if not $pls.IsKnown}}
<div class="alert alert-warning small" role="alert" id="chListLoading">
Список каналов сейчас неизвестен: плейлист ещё не проверялся, либо данные о последней проверке потеряли актуальность.
<br><br>
Вернитесь на эту страницу позже. Каналы будут известны, когда плейлист будет в статусе online.
</div>
{{end}}
</div>
</div>
{{end}}
{{define "footer"}}
{{if and .Playlist.IsKnown .Playlist.IsOnline}}
<script src="https://cdn.jsdelivr.net/npm/list.js@2.3.1/dist/list.min.js"></script>
<script>
function getChannelTemplate(channel) {
const httpCode = channel.status ?? '(неизвестно)'
const errText = !!channel.error
? channel.error.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;')
: '(нет)'
const logoUrl = channel.attributes['tvg-logo'] ?? DEFAULT_LOGO
const logoHint = !!channel.attributes['tvg-logo']
? `Логотип канала '${channel.title}'`
: `Нет логотипа для канала '${channel.title}'`
const statusIconColor = channel.isOnline ? 'text-success' : 'text-danger'
const statusIconHint = channel.isOnline
? 'Состояние: онлайн (возможно, работает прямо сейчас)"'
: 'Состояние: оффлайн (не работал в момент проверки или ошибка проверки)"'
const adultIcon = channel.tags.indexOf('adult') !== -1
? '<span class="badge small bg-warning text-dark" title="Канал для взрослых!">18+</span>'
: ''
const pawIcon = channel.hasToken
? '<span class="cursor-help badge small bg-info text-dark" title="Может быть нестабилен"><ion-icon name="paw"></ion-icon></span>'
: ''
const tvgId = !!channel.attributes['tvg-id']
? `<div title="Идентификатор канала для телепрограммы (tvg-id)" class="cursor-help"><ion-icon name="star-outline" class="me-1"></ion-icon>&nbsp;${channel.attributes['tvg-id']}</div>`
: ``
const mimeType = !!channel.contentType
? `<div title="Тип контента (mime-type)" class="cursor-help"><ion-icon name="eye-outline" class="me-1"></ion-icon>&nbsp;${channel.contentType}</div>`
: ``
const tags = channel.tags.length > 0
? `<ion-icon name="pricetag-outline" class="cursor-help me-1" title="Теги"></ion-icon>&nbsp;`
+ channel.tags.map((tag) => `<span class="chtag">#${tag}</span>`).join(' ')
: ``
return `<tr class="chrow" title="&#010;HTTP: ${httpCode}&#010;Error: ${errText}">
<td class="chlogo text-center">
<img class="tvg-logo" alt="${logoHint}" title="${logoHint}" src="${logoUrl}" onerror="setDefaultLogo(this)" />
</td>
<td class="text-break">
<ion-icon name="radio-button-on-outline" class="cursor-help me-1 ${statusIconColor}" title="${statusIconHint}"></ion-icon>
${adultIcon}
${pawIcon}
${channel.title}
<div class="text-secondary small">
${tvgId}
${mimeType}
${tags}
</div>
</td>
</tr>`
}
const options = {
searchColumns: ['title'],
item: (channel) => getChannelTemplate(channel),
};
const values = {{jsonChannels .Playlist.ViewChannels}}
const list = new List('chlist', options, values)
list.on('updated', (data) => document.getElementById('chcount').innerText = data.visibleItems.length)
document.getElementById('search-field').addEventListener('keyup', (e) => list.search(e.target.value))
document.addEventListener("DOMContentLoaded", () => {
const alert = document.getElementById("chListLoading")
!!alert && alert.remove()
});
function savePlaylist() {
const link = document.createElement("a");
const content = document.getElementById("m3u-raw").value
const file = new Blob([content], { type: 'text/plain' });
link.href = URL.createObjectURL(file);
link.download = "{{.Playlist.Code}}.m3u8";
link.click();
URL.revokeObjectURL(link.href);
}
function resetGroup() {
document.getElementById('groupSelector').value = 'all'
updateFilter()
}
function resetSearch() {
list.search('')
document.getElementById('search-field').value = ''
document.getElementById('chfAll').checked = true
document.querySelectorAll('input[id*="btn-tag-"]:checked').forEach(tag => tag.checked = false)
updateFilter()
}
function updateFilter() {
const selectedGroupId = document.getElementById('groupSelector')?.value ?? 'all';
const tagsElements = document.querySelectorAll('input[id*="btn-tag-"]:checked')
const tagsSelected = []
tagsElements.forEach(tag => tagsSelected.push(tag.attributes['data-tag'].value));
const activeType = document.querySelector('input[name="chFilter"]:checked').id;
switch (activeType) {
case 'chfAll':
list.filter(item => {
const chTags = item.values().tags
const isGroupValid = item.values().groupId === selectedGroupId || selectedGroupId === 'all';
const tagsIntersection = tagsSelected.filter(tagSelected => chTags.includes(tagSelected));
const hasValidTags = tagsIntersection.length > 0 || tagsSelected.length === 0;
return isGroupValid && hasValidTags;
})
break
case 'chfOnline':
list.filter(item => {
const isOnline = item.values().isOnline
const chTags = item.values().tags
const isGroupValid = item.values().groupId === selectedGroupId || selectedGroupId === 'all';
const tagsIntersection = tagsSelected.filter(tagSelected => chTags.includes(tagSelected));
const hasValidTags = tagsIntersection.length > 0 || tagsSelected.length === 0;
return isGroupValid && isOnline && hasValidTags
})
break
case 'chfOffline':
list.filter(item => {
const isOffline = !item.values().isOnline
const chTags = item.values().tags
const isGroupValid = item.values().groupId === selectedGroupId || selectedGroupId === 'all';
const tagsIntersection = tagsSelected.filter(tagSelected => chTags.includes(tagSelected));
const hasValidTags = tagsIntersection.length > 0 || tagsSelected.length === 0;
return isGroupValid && isOffline && hasValidTags
})
break
}
}
</script>
{{end}}
{{end}}
+151
View File
@@ -0,0 +1,151 @@
{{define "title"}}{{.Title}}{{end}}
{{define "metadescription"}}Самообновляемые бесплатные IPTV-плейлисты для домашнего просмотра по коротким ссылкам, списки каналов, проверка доступности{{end}}
{{define "metakeywords"}}самообновляемые,бесплатные,iptv-плейлисты,iptv,плейлисты{{end}}
{{define "head"}}
<style>
.card {transition: box-shadow .2s, transform .2s}
.card.hover-success:hover {transform: translateY(-7px); box-shadow: rgba(var(--bs-success-rgb), 1) 0 5px 20px -5px}
.card.hover-danger:hover {transform: translateY(-7px); box-shadow: rgba(var(--bs-danger-rgb), 1) 0 5px 20px -5px}
.card.hover-secondary:hover {transform: translateY(-7px); box-shadow: rgba(var(--bs-secondary-rgb), 1) 0 5px 20px -5px}
</style>
<script>
const DEFAULT_LOGO = 'data:image/svg+xml,' + encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><rect width="100" height="100" fill="#333"/><text x="50" y="55" text-anchor="middle" fill="#999" font-size="14">no logo</text></svg>')
function setDefaultLogo(imgtag) {
imgtag.onerror = null
imgtag.src = DEFAULT_LOGO
}
</script>
{{end}}
{{define "header"}}
<div class="d-flex flex-wrap justify-content-between align-items-center mb-4">
<div class="mb-2">
<h2 class="mb-0">Список плейлистов ({{.Count}})</h2>
<div class="text-muted small">Изменён {{.UpdatedAt}} МСК</div>
</div>
</div>
{{end}}
{{define "content"}}
{{if .Error}}
<div class="alert alert-danger" role="alert">
<ion-icon name="alert-circle-outline" class="me-1"></ion-icon>{{.Error}}
</div>
{{else if eq (len .Playlists) 0}}
<div class="alert alert-warning" role="alert">
<ion-icon name="information-circle-outline" class="me-1"></ion-icon>Список плейлистов пуст.
Вернитесь на эту страницу позже — возможно, плейлисты ещё не добавлены или не проверены.
</div>
{{end}}
<div class="row g-4">
{{range .Playlists}}
{{$sc := statusClass .}}
<div class="col-md-6 col-lg-4">
<div class="card bg-dark text-light h-100 border border-{{$sc}} hover-{{$sc}} position-relative">
<a href="/{{.Code}}/details" class="text-decoration-none">
<div class="card-header d-flex align-items-center gap-2">
<span class="font-monospace text-{{$sc}}">{{.Code}}</span>
{{if not .IsKnown}}
<span class="cursor-help badge bg-{{$sc}} ms-auto"
title="Плейлист ещё не проверялся, придётся подождать"
>unknown</span>
{{else if .IsOnline}}
<span class="cursor-help badge bg-{{$sc}} ms-auto"
title="Возможно, этот плейлист рабочий"
>online</span>
{{else}}
<span class="cursor-help badge bg-{{$sc}} ms-auto"
title="Этот плейлист нерабочий или его не удалось проверить"
>offline</span>
{{end}}
{{if and .IsKnown .IsOnline}}
<span class="cursor-help badge border border-success"
title="Процент рабочих каналов"
>{{.OnlinePercent}}%</span>
{{end}}
{{if hasTag .Tags "adult"}}
<span class="cursor-help badge bg-warning text-dark"
title="Есть каналы для взрослых!"
>18+</span>
{{end}}
{{if .HasTokens}}
<span class="cursor-help badge bg-info text-dark"
title="В плейлисте есть каналы, которые могут быть нестабильны"
><ion-icon name="paw"></ion-icon></span>
{{end}}
</div>
</a>
<div class="card-body position-relative z-2">
<a href="/{{.Code}}/details" class="text-decoration-none">
<h5 class="card-title text-light">{{.Name}}</h5>
</a>
{{if .Description}}
<p class="card-text small text-secondary d-none d-md-block">{{.Description}}</p>
{{end}}
<div class="d-flex flex-wrap gap-2 mb-1">
{{if and .IsKnown .IsOnline}}
<span class="badge border border-secondary">
<ion-icon name="videocam-outline" class="me-1"></ion-icon>&nbsp;{{channelCount .}}<span class="d-none d-xl-inline-block">&nbsp;каналов</span>
</span>
{{if gt (groupCount .) 0}}
<span class="badge border border-secondary">
<ion-icon name="folder-open-outline" class="me-1"></ion-icon>&nbsp;{{groupCount .}}<span class="d-none d-xl-inline-block">&nbsp;групп</span>
</span>
{{end}}
{{if .HasTvg}}
<span class="badge border border-secondary">
<ion-icon name="newspaper-outline" class="me-1"></ion-icon><span class="d-none d-xl-inline-block">&nbsp;ТВ-программа</span>
</span>
{{end}}
{{if .HasCatchup}}
<span class="badge border border-secondary">
<ion-icon name="play-back-outline" class="me-1"></ion-icon><span class="d-none d-xl-inline-block">&nbsp;Архив</span>
</span>
{{end}}
{{end}}
</div>
</div>
<div class="card-footer cursor-pointer"
onclick="copyPlaylistUrl('{{.Code}}')"
title="Нажми на ссылку, чтобы скопировать её в буфер обмена"
>
<div class="d-flex justify-content-between align-items-center small">
<span class="font-monospace text-truncate">
{{baseUrl .Code}}
</span>
<ion-icon name="copy-outline"></ion-icon>
</div>
</div>
</div>
</div>
{{end}}
</div>
{{if gt .PageCount 1}}
<nav class="mt-4">
<ul class="pagination justify-content-center">
{{range $page := seq 1 .PageCount}}
{{if eq $page $.PageCurrent}}
<li class="page-item active" aria-current="page">
<span class="page-link">{{$page}}</span>
</li>
{{else}}
<li class="page-item">
<a class="page-link bg-dark text-light" href="/page/{{$page}}">{{$page}}</a>
</li>
{{end}}
{{end}}
</ul>
</nav>
{{end}}
{{end}}
+23
View File
@@ -0,0 +1,23 @@
{{define "title"}}Плейлист не найден - {{.Title}}{{end}}
{{define "content"}}
<div class="row justify-content-center">
<div class="col-md-8 col-lg-6 text-center">
<div class="card bg-dark border-secondary">
<div class="card-body">
<ion-icon name="warning-outline" class="display-1 text-warning mb-3"></ion-icon>
<h2 class="card-title">Плейлист <code>{{.Code}}</code> не найден</h2>
<p class="card-text">
Возможно, его здесь никогда не было, либо он уже был удалён.
</p>
<p class="text-muted small">
Если хочешь, чтобы здесь был плейлист, предложи его к добавлению.
</p>
<a class="btn btn-outline-light" href="/" title="На главную">
<ion-icon name="list-outline" class="me-1"></ion-icon>Перейти к списку плейлистов
</a>
</div>
</div>
</div>
</div>
{{end}}
+360
View File
@@ -0,0 +1,360 @@
package web
import (
"testing"
"axenov/iptv-checker/app/playlist"
)
func TestHasTokensString(t *testing.T) {
cases := []struct {
in string
want bool
}{
{"http://example.com/stream.m3u8?token=abc", true},
{"http://example.com/stream?user=test&pwd=secret", true},
{"http://example.com/stream?drmreq=xyz", true},
{"http://example.com/stream?u=foo", true},
{"http://example.com/stream?password=123", true},
{"http://example.com/stream.m3u8", false},
{"", false},
{"plain text without tokens", false},
}
for _, c := range cases {
got := hasTokensString(c.in)
if got != c.want {
t.Errorf("hasTokensString(%q) = %v, want %v", c.in, got, c.want)
}
}
}
func TestStatusClass(t *testing.T) {
cases := []struct {
view *PlaylistView
want string
}{
{&PlaylistView{IsKnown: false}, "secondary"},
{&PlaylistView{IsKnown: true, Playlist: playlist.Playlist{IsOnline: true}}, "success"},
{&PlaylistView{IsKnown: true, Playlist: playlist.Playlist{IsOnline: false}}, "danger"},
}
for _, c := range cases {
got := statusClass(c.view)
if got != c.want {
t.Errorf("statusClass() = %s, want %s", got, c.want)
}
}
}
func TestStatusBadge(t *testing.T) {
cases := []struct {
view *PlaylistView
want string
}{
{&PlaylistView{IsKnown: false}, "unknown"},
{&PlaylistView{IsKnown: true, Playlist: playlist.Playlist{IsOnline: true}}, "online"},
{&PlaylistView{IsKnown: true, Playlist: playlist.Playlist{IsOnline: false}}, "offline"},
}
for _, c := range cases {
got := statusBadge(c.view)
if got != c.want {
t.Errorf("statusBadge() = %s, want %s", got, c.want)
}
}
}
func TestFormatDate(t *testing.T) {
if formatDate(0) != "" {
t.Error("expected empty string for ts=0")
}
result := formatDate(1700000000)
if result == "" {
t.Error("expected non-empty date string")
}
}
func TestIsAlphanumeric(t *testing.T) {
cases := []struct {
in string
want bool
}{
{"ABC123", true},
{"abc", true},
{"123", true},
{"", false},
{"ABC-123", false},
{"ABC_123", false},
{"ABC 123", false},
{"Привет", false},
}
for _, c := range cases {
got := isAlphanumeric(c.in)
if got != c.want {
t.Errorf("isAlphanumeric(%q) = %v, want %v", c.in, got, c.want)
}
}
}
func TestParseRedisVersion(t *testing.T) {
info := "# Server\nredis_version:6.2.6\nredis_mode:standalone\n"
got := parseRedisVersion(info)
if got != "6.2.6" {
t.Errorf("expected '6.2.6', got '%s'", got)
}
}
func TestParseRedisVersion_NotFound(t *testing.T) {
info := "# Server\nsome_other_field:foo\n"
got := parseRedisVersion(info)
if got != "unknown" {
t.Errorf("expected 'unknown', got '%s'", got)
}
}
func TestContainsTag(t *testing.T) {
tags := []string{"hd", "adult", "russian"}
if !containsTag(tags, "adult") {
t.Error("expected true for 'adult'")
}
if containsTag(tags, "movies") {
t.Error("expected false for 'movies'")
}
if containsTag([]string{}, "x") {
t.Error("expected false for empty list")
}
}
func TestMakeUnknownView(t *testing.T) {
iniPls := playlist.Playlist{
Name: "Test",
Description: "Desc",
Url: "http://example.com",
Source: "public",
}
view := makeUnknownView("CODE", iniPls)
if view.Code != "CODE" {
t.Errorf("expected Code='CODE', got '%s'", view.Code)
}
if view.IsKnown {
t.Error("expected IsKnown=false")
}
if view.Name != "Test" {
t.Errorf("expected Name='Test', got '%s'", view.Name)
}
if len(view.Tags) != 0 {
t.Errorf("expected 0 tags, got %d", len(view.Tags))
}
}
func TestEnrichPlaylist_OnlinePercent(t *testing.T) {
view := &PlaylistView{
Playlist: playlist.Playlist{
IsOnline: true,
OnlineCount: 8,
OfflineCount: 2,
Channels: map[string]playlist.Channel{
"1": {},
"2": {},
"3": {},
"4": {},
"5": {},
"6": {},
"7": {},
"8": {},
"9": {},
"10": {},
},
Attributes: map[string]string{},
Groups: map[string]playlist.Group{},
Content: "#EXTM3U",
},
IsKnown: true,
}
enrichPlaylist(view)
if view.OnlinePercent != 80 {
t.Errorf("expected 80%%, got %d%%", view.OnlinePercent)
}
if view.OfflinePercent != 20 {
t.Errorf("expected 20%%, got %d%%", view.OfflinePercent)
}
}
func TestEnrichPlaylist_Unknown(t *testing.T) {
view := &PlaylistView{
Playlist: playlist.Playlist{
Channels: map[string]playlist.Channel{"1": {}},
Attributes: map[string]string{},
Groups: map[string]playlist.Group{},
Content: "",
},
IsKnown: false,
}
enrichPlaylist(view)
if view.OnlinePercent != 0 || view.OfflinePercent != 0 {
t.Errorf("expected 0%% for unknown, got online=%d%% offline=%d%%",
view.OnlinePercent, view.OfflinePercent)
}
}
func TestEnrichPlaylist_HasCatchup(t *testing.T) {
view := &PlaylistView{
Playlist: playlist.Playlist{
Content: "#EXTM3U catchup=default",
Channels: map[string]playlist.Channel{},
Attributes: map[string]string{},
Groups: map[string]playlist.Group{},
},
IsKnown: true,
}
enrichPlaylist(view)
if !view.HasCatchup {
t.Error("expected HasCatchup=true")
}
}
func TestEnrichPlaylist_HasTvg(t *testing.T) {
view := &PlaylistView{
Playlist: playlist.Playlist{
Attributes: map[string]string{"url-tvg": "http://example.com/epg.xml"},
Channels: map[string]playlist.Channel{},
Groups: map[string]playlist.Group{},
Content: "",
},
IsKnown: true,
}
enrichPlaylist(view)
if !view.HasTvg {
t.Error("expected HasTvg=true")
}
}
func TestEnrichPlaylist_HasTokens(t *testing.T) {
view := &PlaylistView{
Playlist: playlist.Playlist{
Url: "http://example.com/list.m3u8?token=abc",
Content: "#EXTM3U",
Channels: map[string]playlist.Channel{},
Attributes: map[string]string{},
Groups: map[string]playlist.Group{},
},
IsKnown: true,
}
enrichPlaylist(view)
if !view.HasTokens {
t.Error("expected HasTokens=true")
}
}
func TestEnrichPlaylist_TagsCollection(t *testing.T) {
view := &PlaylistView{
Playlist: playlist.Playlist{
Channels: map[string]playlist.Channel{
"1": {Tags: []string{"hd", "russian"}},
"2": {Tags: []string{"adult", "hd"}},
},
Attributes: map[string]string{},
Groups: map[string]playlist.Group{},
Content: "",
},
IsKnown: true,
}
enrichPlaylist(view)
if len(view.Tags) != 3 {
t.Errorf("expected 3 unique tags, got %d: %v", len(view.Tags), view.Tags)
}
}
func TestEnrichPlaylist_ChannelHasToken(t *testing.T) {
view := &PlaylistView{
Playlist: playlist.Playlist{
Channels: map[string]playlist.Channel{
"1": {URL: "http://example.com/stream?token=abc"},
},
Attributes: map[string]string{},
Groups: map[string]playlist.Group{},
Content: "",
},
IsKnown: true,
}
enrichPlaylist(view)
for _, cv := range view.ViewChannels {
if !cv.HasToken {
t.Error("expected channel HasToken=true")
}
}
}
func TestComputeStats(t *testing.T) {
playlists := []PlaylistView{
{
Playlist: playlist.Playlist{
Code: "RU",
IsOnline: true,
CheckedAt: 1700000000,
Channels: map[string]playlist.Channel{"1": {IsOnline: true}, "2": {IsOnline: false}},
Groups: map[string]playlist.Group{"g1": {}},
Attributes: map[string]string{"url-tvg": "http://epg.xml"},
Content: "#EXTM3U catchup=default",
},
IsKnown: true,
HasCatchup: true,
HasTvg: true,
Tags: []string{"adult"},
},
{
Playlist: playlist.Playlist{
Code: "EN",
IsOnline: false,
CheckedAt: 1700000100,
Channels: map[string]playlist.Channel{"1": {IsOnline: false}},
},
IsKnown: false,
},
}
stats := computeStats(playlists)
plsStats := stats["playlists"].(map[string]interface{})
if plsStats["all"].(int) != 2 {
t.Errorf("expected 2 playlists, got %d", plsStats["all"])
}
if plsStats["online"].(int) != 1 {
t.Errorf("expected 1 online, got %d", plsStats["online"])
}
if plsStats["unknown"].(int) != 1 {
t.Errorf("expected 1 unknown, got %d", plsStats["unknown"])
}
if plsStats["adult"].(int) != 1 {
t.Errorf("expected 1 adult, got %d", plsStats["adult"])
}
if plsStats["hasCatchup"].(int) != 1 {
t.Errorf("expected 1 catchup, got %d", plsStats["hasCatchup"])
}
if plsStats["hasTvg"].(int) != 1 {
t.Errorf("expected 1 tvg, got %d", plsStats["hasTvg"])
}
if plsStats["groupped"].(int) != 1 {
t.Errorf("expected 1 groupped, got %d", plsStats["groupped"])
}
chStats := stats["channels"].(map[string]interface{})
if chStats["all"].(int) != 3 {
t.Errorf("expected 3 channels, got %d", chStats["all"])
}
if chStats["online"].(int) != 1 {
t.Errorf("expected 1 online channel, got %d", chStats["online"])
}
if chStats["offline"].(int) != 2 {
t.Errorf("expected 2 offline channels, got %d", chStats["offline"])
}
latest := plsStats["latest"].(map[string]interface{})
if latest["code"] != "EN" {
t.Errorf("expected latest code 'EN', got '%v'", latest["code"])
}
}
func TestComputeStats_Empty(t *testing.T) {
stats := computeStats([]PlaylistView{})
plsStats := stats["playlists"].(map[string]interface{})
if plsStats["all"].(int) != 0 {
t.Errorf("expected 0, got %d", plsStats["all"])
}
}
+9 -17
View File
@@ -24,10 +24,10 @@ var checkCmd = &cobra.Command{
Short: "Check playlists",
Run: func(cmd *cobra.Command, args []string) {
app.Init()
files, _ := cmd.Flags().GetStringSlice("file")
urls, _ := cmd.Flags().GetStringSlice("url")
codes, _ := cmd.Flags().GetStringSlice("code")
applyAppOverrides(cmd)
applyCacheOverrides(cmd)
applyCheckOverrides(cmd)
app.InitCache()
waitSeconds := app.Args.RepeatEverySec
if waitSeconds <= 0 {
@@ -45,11 +45,11 @@ var checkCmd = &cobra.Command{
}
var lists []playlist.Playlist
if len(files) == 0 && len(urls) == 0 && len(codes) == 0 {
lists = checker.PrepareListsToCheck(files, urls, codes)
if len(app.Args.Files) == 0 && len(app.Args.Urls) == 0 && len(app.Args.Codes) == 0 {
lists = checker.PrepareListsToCheck(app.Args.Files, app.Args.Urls, app.Args.Codes)
} else {
if currentIteration == 1 {
lists = checker.PrepareListsToCheck(files, urls, codes)
lists = checker.PrepareListsToCheck(app.Args.Files, app.Args.Urls, app.Args.Codes)
}
}
@@ -86,15 +86,7 @@ var checkCmd = &cobra.Command{
}
func init() {
checkCmd.Flags().StringVarP(&app.Args.TagsPath, "tags", "t", "./channels.json", "path to a local tagfile")
checkCmd.Flags().StringVarP(&app.Args.IniPath, "ini", "i", "./playlists.ini", "path to a local ini-file")
checkCmd.Flags().UintVarP(&app.Args.RandomCount, "random", "r", 0, "take this count of random playlists to check from ini-file")
checkCmd.Flags().UintVarP(&app.Args.RepeatCount, "repeat", "", 1, "repeat same check X times")
checkCmd.Flags().UintVarP(&app.Args.RepeatEverySec, "every", "", 5, "wait N seconds after every check")
checkCmd.Flags().BoolVarP(&app.Args.NeedJson, "json", "j", false, "print results in JSON format in the end")
checkCmd.Flags().BoolVarP(&app.Args.NeedQuiet, "quiet", "q", false, "suppress logs (does not affect on -j)")
checkCmd.Flags().StringSliceP("file", "f", []string{}, "path to a local playlist file (m3u/m3u8)")
checkCmd.Flags().StringSliceP("url", "u", []string{}, "URL to a remote playlist (http/https)")
checkCmd.Flags().StringSliceP("code", "c", []string{}, "code of playlist from ini-file")
addCommonCheckFlags(checkCmd, 5, 1)
addCheckOnlyFlags(checkCmd)
rootCmd.AddCommand(checkCmd)
}
+153
View File
@@ -0,0 +1,153 @@
/*
* Copyright (c) 2025, Антон Аксенов
* This file is part of iptvc project
* MIT License: https://git.axenov.dev/IPTV/iptvc/src/branch/master/LICENSE
*/
package cmd
import (
"axenov/iptv-checker/app"
"github.com/spf13/cobra"
)
// addCommonCheckFlags добавляет общие флаги, используемые как командой check,
// так и serve. Флаги используют zero-value defaults; переопределение config.yml
// происходит через cmd.Flags().Changed() в applyCacheOverrides() и applyCheckOverrides().
func addCommonCheckFlags(cmd *cobra.Command, everyDefault, repeatDefault uint) {
// paths
cmd.Flags().StringVarP(&app.Args.IniPath, "ini", "i", "", "path to a local ini-file (overrides config.yml)")
cmd.Flags().StringVarP(&app.Args.TagsPath, "tags", "t", "", "path to a local tagfile (overrides config.yml)")
// iteration
cmd.Flags().UintVarP(&app.Args.RandomCount, "random", "r", 0, "take this count of random playlists to check from ini-file")
cmd.Flags().UintVarP(&app.Args.RepeatCount, "repeat", "", repeatDefault, "repeat same check X times (0 = infinite)")
cmd.Flags().UintVarP(&app.Args.RepeatEverySec, "every", "", everyDefault, "wait N seconds after every check")
// check.playlists
cmd.Flags().IntVar(&app.Args.PlTimeout, "playlists-timeout", 0, "playlist request timeout in ms (overrides config.yml)")
cmd.Flags().IntVar(&app.Args.PlAllCooldown, "playlists-all-cooldown", 0, "cooldown after all playlists in ms (overrides config.yml)")
cmd.Flags().IntVar(&app.Args.PlOneCooldown, "playlists-one-cooldown", 0, "cooldown after each playlist in ms (overrides config.yml)")
cmd.Flags().IntVar(&app.Args.PlMaxRoutines, "playlists-max-routines", 0, "max concurrent playlist checks (overrides config.yml)")
cmd.Flags().IntVar(&app.Args.PlPerRoutine, "playlists-per-routine", 0, "playlists per routine (overrides config.yml)")
cmd.Flags().StringSliceVar(&app.Args.PlUserAgent, "playlists-user-agent", nil, "user-agent for playlist requests (overrides config.yml)")
// check.channels
cmd.Flags().IntVar(&app.Args.ChTimeout, "channels-timeout", 0, "channel request timeout in ms (overrides config.yml)")
cmd.Flags().IntVar(&app.Args.ChByteRange, "channels-byte-range", 0, "bytes to fetch from server per channel (overrides config.yml)")
cmd.Flags().IntVar(&app.Args.ChCooldown, "channels-cooldown", 0, "cooldown after each channel in ms (overrides config.yml)")
cmd.Flags().IntVar(&app.Args.ChMaxRoutines, "channels-max-routines", 0, "max concurrent channel checks (overrides config.yml)")
cmd.Flags().IntVar(&app.Args.ChPerRoutine, "channels-per-routine", 0, "channels per routine (overrides config.yml)")
cmd.Flags().StringSliceVar(&app.Args.ChUserAgent, "channels-user-agent", nil, "user-agent for channel requests (overrides config.yml)")
// cache
cmd.Flags().BoolVar(&app.Args.CacheEnabled, "cache-enabled", false, "enable cache (overrides config.yml)")
cmd.Flags().StringVar(&app.Args.CacheHost, "cache-host", "", "cache host (overrides config.yml)")
cmd.Flags().UintVar(&app.Args.CachePort, "cache-port", 0, "cache port (overrides config.yml)")
cmd.Flags().StringVar(&app.Args.CacheUsername, "cache-username", "", "cache username (overrides config.yml)")
cmd.Flags().StringVar(&app.Args.CachePassword, "cache-password", "", "cache password (overrides config.yml)")
cmd.Flags().UintVar(&app.Args.CacheDb, "cache-db", 0, "cache database number (overrides config.yml)")
cmd.Flags().UintVar(&app.Args.CacheTtl, "cache-ttl", 0, "cache TTL in seconds (overrides config.yml)")
}
// addCheckOnlyFlags добавляет флаги, доступные только в команде check.
func addCheckOnlyFlags(cmd *cobra.Command) {
cmd.Flags().BoolVarP(&app.Args.NeedJson, "json", "j", false, "print results in JSON format in the end")
cmd.Flags().BoolVarP(&app.Args.NeedQuiet, "quiet", "q", false, "suppress logs (does not affect on -j)")
cmd.Flags().StringSliceVarP(&app.Args.Files, "file", "f", []string{}, "path to a local playlist file (m3u/m3u8)")
cmd.Flags().StringSliceVarP(&app.Args.Urls, "url", "u", []string{}, "URL to a remote playlist (http/https)")
cmd.Flags().StringSliceVarP(&app.Args.Codes, "code", "c", []string{}, "code of playlist from ini-file")
}
// applyAppOverrides применяет CLI-флаги для app.* поверх конфигурации.
// Использует Changed() — переопределение срабатывает только при явной передаче флага.
func applyAppOverrides(cmd *cobra.Command) {
if cmd.Flags().Changed("debug") {
app.Config.App.Debug = app.Args.Debug
}
if cmd.Flags().Changed("log-level") {
app.Config.App.LogLevel = app.Args.LogLevel
}
if cmd.Flags().Changed("ini") {
app.Config.App.Playlists = app.Args.IniPath
}
if cmd.Flags().Changed("tags") {
app.Config.App.Tags = app.Args.TagsPath
}
}
// applyCacheOverrides применяет CLI-флаги для cache.* поверх конфигурации.
// Использует Changed() — переопределение срабатывает только при явной передаче флага.
// Вызывается между app.Init() и app.InitCache().
func applyCacheOverrides(cmd *cobra.Command) {
c := &app.Config.Cache
if cmd.Flags().Changed("cache-enabled") {
c.Enabled = app.Args.CacheEnabled
}
if cmd.Flags().Changed("cache-host") {
c.Host = app.Args.CacheHost
}
if cmd.Flags().Changed("cache-port") {
c.Port = app.Args.CachePort
}
if cmd.Flags().Changed("cache-username") {
c.Username = app.Args.CacheUsername
}
if cmd.Flags().Changed("cache-password") {
c.Password = app.Args.CachePassword
}
if cmd.Flags().Changed("cache-db") {
c.Db = app.Args.CacheDb
}
if cmd.Flags().Changed("cache-ttl") {
c.Ttl = app.Args.CacheTtl
}
}
// applyCheckOverrides применяет CLI-флаги для check.* поверх конфигурации.
// Вызывается в обработчиках check и serve после app.Init().
func applyCheckOverrides(cmd *cobra.Command) {
pp := &app.Config.Check.Playlists
if cmd.Flags().Changed("playlists-timeout") {
pp.Timeout = app.Args.PlTimeout
}
if cmd.Flags().Changed("playlists-all-cooldown") {
pp.AllCooldown.Min = app.Args.PlAllCooldown
pp.AllCooldown.Max = app.Args.PlAllCooldown
}
if cmd.Flags().Changed("playlists-one-cooldown") {
pp.OneCooldown.Min = app.Args.PlOneCooldown
pp.OneCooldown.Max = app.Args.PlOneCooldown
}
if cmd.Flags().Changed("playlists-max-routines") {
pp.MaxRoutines = app.Args.PlMaxRoutines
}
if cmd.Flags().Changed("playlists-per-routine") {
pp.PerRoutine = app.Args.PlPerRoutine
}
if cmd.Flags().Changed("playlists-user-agent") {
pp.UserAgent = app.Args.PlUserAgent
}
cc := &app.Config.Check.Channels
if cmd.Flags().Changed("channels-timeout") {
cc.Timeout = app.Args.ChTimeout
}
if cmd.Flags().Changed("channels-byte-range") {
cc.ByteRange = app.Args.ChByteRange
}
if cmd.Flags().Changed("channels-cooldown") {
cc.Cooldown.Min = app.Args.ChCooldown
cc.Cooldown.Max = app.Args.ChCooldown
}
if cmd.Flags().Changed("channels-max-routines") {
cc.MaxRoutines = app.Args.ChMaxRoutines
}
if cmd.Flags().Changed("channels-per-routine") {
cc.PerRoutine = app.Args.ChPerRoutine
}
if cmd.Flags().Changed("channels-user-agent") {
cc.UserAgent = app.Args.ChUserAgent
}
}
+296
View File
@@ -0,0 +1,296 @@
package cmd
import (
"testing"
"axenov/iptv-checker/app"
"axenov/iptv-checker/app/config"
"github.com/spf13/cobra"
)
func newCmdWithFlags() *cobra.Command {
cmd := &cobra.Command{Use: "test"}
// persistent flags normally set on rootCmd
cmd.PersistentFlags().BoolVar(&app.Args.Debug, "debug", false, "")
cmd.PersistentFlags().StringVar(&app.Args.LogLevel, "log-level", "", "")
addCommonCheckFlags(cmd, 60, 0)
return cmd
}
func resetArgs() {
app.Args = app.Arguments{}
}
func resetConfig() {
app.Config = &config.Config{
App: config.AppConfig{
Debug: false,
LogLevel: "info",
Playlists: "./playlists.ini",
Tags: "./channels.json",
},
Check: config.CheckConfig{
Playlists: config.CheckPlaylistsConfig{
Timeout: 10000,
AllCooldown: config.IntRange{Min: 0, Max: 0},
OneCooldown: config.IntRange{Min: 0, Max: 0},
MaxRoutines: 5,
PerRoutine: 1,
UserAgent: config.UserAgents{"default"},
},
Channels: config.CheckChannelsConfig{
Timeout: 10000,
ByteRange: 512,
Cooldown: config.IntRange{Min: 0, Max: 0},
MaxRoutines: 50,
PerRoutine: 10,
UserAgent: config.UserAgents{"default"},
},
},
Cache: config.CacheConfig{
Enabled: false,
Host: "localhost",
Port: 6379,
Ttl: 1800,
},
Server: config.ServerConfig{Port: 8080},
}
}
func TestApplyAppOverrides_NothingChanged(t *testing.T) {
resetArgs()
resetConfig()
cmd := newCmdWithFlags()
cmd.ParseFlags([]string{})
applyAppOverrides(cmd)
if app.Config.App.Debug {
t.Error("debug should remain false")
}
}
func TestApplyAppOverrides_Debug(t *testing.T) {
resetArgs()
resetConfig()
cmd := newCmdWithFlags()
cmd.ParseFlags([]string{"--debug"})
applyAppOverrides(cmd)
if !app.Config.App.Debug {
t.Error("expected debug=true")
}
}
func TestApplyAppOverrides_LogLevel(t *testing.T) {
resetArgs()
resetConfig()
cmd := newCmdWithFlags()
cmd.ParseFlags([]string{"--log-level", "error"})
applyAppOverrides(cmd)
if app.Config.App.LogLevel != "error" {
t.Errorf("expected 'error', got '%s'", app.Config.App.LogLevel)
}
}
func TestApplyAppOverrides_Ini(t *testing.T) {
resetArgs()
resetConfig()
cmd := newCmdWithFlags()
cmd.ParseFlags([]string{"-i", "/custom/path.ini"})
applyAppOverrides(cmd)
if app.Config.App.Playlists != "/custom/path.ini" {
t.Errorf("expected '/custom/path.ini', got '%s'", app.Config.App.Playlists)
}
}
func TestApplyAppOverrides_Tags(t *testing.T) {
resetArgs()
resetConfig()
cmd := newCmdWithFlags()
cmd.ParseFlags([]string{"-t", "/custom/tags.json"})
applyAppOverrides(cmd)
if app.Config.App.Tags != "/custom/tags.json" {
t.Errorf("expected '/custom/tags.json', got '%s'", app.Config.App.Tags)
}
}
func TestApplyCacheOverrides_NothingChanged(t *testing.T) {
resetArgs()
resetConfig()
cmd := newCmdWithFlags()
cmd.ParseFlags([]string{})
applyCacheOverrides(cmd)
if app.Config.Cache.Enabled {
t.Error("cache should remain disabled")
}
}
func TestApplyCacheOverrides_Enable(t *testing.T) {
resetArgs()
resetConfig()
cmd := newCmdWithFlags()
cmd.ParseFlags([]string{"--cache-enabled"})
applyCacheOverrides(cmd)
if !app.Config.Cache.Enabled {
t.Error("expected cache enabled")
}
}
func TestApplyCacheOverrides_DisableWhenAlreadyEnabled(t *testing.T) {
resetArgs()
resetConfig()
app.Config.Cache.Enabled = true
cmd := newCmdWithFlags()
cmd.ParseFlags([]string{"--cache-enabled=false"})
applyCacheOverrides(cmd)
if app.Config.Cache.Enabled {
t.Error("expected cache disabled")
}
}
func TestApplyCacheOverrides_Host(t *testing.T) {
resetArgs()
resetConfig()
cmd := newCmdWithFlags()
cmd.ParseFlags([]string{"--cache-host", "db.local"})
applyCacheOverrides(cmd)
if app.Config.Cache.Host != "db.local" {
t.Errorf("expected 'db.local', got '%s'", app.Config.Cache.Host)
}
}
func TestApplyCacheOverrides_Port(t *testing.T) {
resetArgs()
resetConfig()
cmd := newCmdWithFlags()
cmd.ParseFlags([]string{"--cache-port", "6380"})
applyCacheOverrides(cmd)
if app.Config.Cache.Port != 6380 {
t.Errorf("expected 6380, got %d", app.Config.Cache.Port)
}
}
func TestApplyCacheOverrides_Ttl(t *testing.T) {
resetArgs()
resetConfig()
cmd := newCmdWithFlags()
cmd.ParseFlags([]string{"--cache-ttl", "3600"})
applyCacheOverrides(cmd)
if app.Config.Cache.Ttl != 3600 {
t.Errorf("expected 3600, got %d", app.Config.Cache.Ttl)
}
}
func TestApplyCheckOverrides_NothingChanged(t *testing.T) {
resetArgs()
resetConfig()
cmd := newCmdWithFlags()
cmd.ParseFlags([]string{})
applyCheckOverrides(cmd)
if app.Config.Check.Playlists.Timeout != 10000 {
t.Errorf("expected 10000, got %d", app.Config.Check.Playlists.Timeout)
}
}
func TestApplyCheckOverrides_PlaylistsTimeout(t *testing.T) {
resetArgs()
resetConfig()
cmd := newCmdWithFlags()
cmd.ParseFlags([]string{"--playlists-timeout", "5000"})
applyCheckOverrides(cmd)
if app.Config.Check.Playlists.Timeout != 5000 {
t.Errorf("expected 5000, got %d", app.Config.Check.Playlists.Timeout)
}
}
func TestApplyCheckOverrides_PlaylistsAllCooldown(t *testing.T) {
resetArgs()
resetConfig()
cmd := newCmdWithFlags()
cmd.ParseFlags([]string{"--playlists-all-cooldown", "300"})
applyCheckOverrides(cmd)
if app.Config.Check.Playlists.AllCooldown.Min != 300 || app.Config.Check.Playlists.AllCooldown.Max != 300 {
t.Errorf("expected [300,300], got [%d,%d]",
app.Config.Check.Playlists.AllCooldown.Min, app.Config.Check.Playlists.AllCooldown.Max)
}
}
func TestApplyCheckOverrides_PlaylistsMaxRoutines(t *testing.T) {
resetArgs()
resetConfig()
cmd := newCmdWithFlags()
cmd.ParseFlags([]string{"--playlists-max-routines", "10"})
applyCheckOverrides(cmd)
if app.Config.Check.Playlists.MaxRoutines != 10 {
t.Errorf("expected 10, got %d", app.Config.Check.Playlists.MaxRoutines)
}
}
func TestApplyCheckOverrides_PlaylistsUserAgent(t *testing.T) {
resetArgs()
resetConfig()
cmd := newCmdWithFlags()
cmd.ParseFlags([]string{"--playlists-user-agent", "UA1,UA2"})
applyCheckOverrides(cmd)
if len(app.Config.Check.Playlists.UserAgent) != 2 {
t.Fatalf("expected 2 user-agents, got %d", len(app.Config.Check.Playlists.UserAgent))
}
if app.Config.Check.Playlists.UserAgent[0] != "UA1" || app.Config.Check.Playlists.UserAgent[1] != "UA2" {
t.Errorf("unexpected user-agents: %v", app.Config.Check.Playlists.UserAgent)
}
}
func TestApplyCheckOverrides_ChannelsTimeout(t *testing.T) {
resetArgs()
resetConfig()
cmd := newCmdWithFlags()
cmd.ParseFlags([]string{"--channels-timeout", "8000"})
applyCheckOverrides(cmd)
if app.Config.Check.Channels.Timeout != 8000 {
t.Errorf("expected 8000, got %d", app.Config.Check.Channels.Timeout)
}
}
func TestApplyCheckOverrides_ChannelsByteRange(t *testing.T) {
resetArgs()
resetConfig()
cmd := newCmdWithFlags()
cmd.ParseFlags([]string{"--channels-byte-range", "1024"})
applyCheckOverrides(cmd)
if app.Config.Check.Channels.ByteRange != 1024 {
t.Errorf("expected 1024, got %d", app.Config.Check.Channels.ByteRange)
}
}
func TestApplyCheckOverrides_ChannelsCooldown(t *testing.T) {
resetArgs()
resetConfig()
cmd := newCmdWithFlags()
cmd.ParseFlags([]string{"--channels-cooldown", "100"})
applyCheckOverrides(cmd)
if app.Config.Check.Channels.Cooldown.Min != 100 || app.Config.Check.Channels.Cooldown.Max != 100 {
t.Errorf("expected [100,100], got [%d,%d]",
app.Config.Check.Channels.Cooldown.Min, app.Config.Check.Channels.Cooldown.Max)
}
}
func TestApplyCheckOverrides_ChannelsMaxRoutines(t *testing.T) {
resetArgs()
resetConfig()
cmd := newCmdWithFlags()
cmd.ParseFlags([]string{"--channels-max-routines", "100"})
applyCheckOverrides(cmd)
if app.Config.Check.Channels.MaxRoutines != 100 {
t.Errorf("expected 100, got %d", app.Config.Check.Channels.MaxRoutines)
}
}
func TestApplyCheckOverrides_ChannelsUserAgent(t *testing.T) {
resetArgs()
resetConfig()
cmd := newCmdWithFlags()
cmd.ParseFlags([]string{"--channels-user-agent", "VLC"})
applyCheckOverrides(cmd)
if len(app.Config.Check.Channels.UserAgent) != 1 || app.Config.Check.Channels.UserAgent[0] != "VLC" {
t.Errorf("expected [VLC], got %v", app.Config.Check.Channels.UserAgent)
}
}
+3
View File
@@ -35,4 +35,7 @@ func Execute() {
func init() {
rootCmd.PersistentFlags().BoolVarP(&app.Args.Verbose, "verbose", "v", false, "enable additional output")
rootCmd.PersistentFlags().StringVar(&app.Args.ConfigPath, "config", "config.yml", "path to config file")
rootCmd.PersistentFlags().BoolVar(&app.Args.Debug, "debug", false, "enable debug mode (overrides config.yml)")
rootCmd.PersistentFlags().StringVar(&app.Args.LogLevel, "log-level", "", "log level: debug, info, warn, error (overrides config.yml)")
}
+60
View File
@@ -0,0 +1,60 @@
/*
* Copyright (c) 2025, Антон Аксенов
* This file is part of iptvc project
* MIT License: https://git.axenov.dev/IPTV/iptvc/src/branch/master/LICENSE
*/
package cmd
import (
"axenov/iptv-checker/app"
"axenov/iptv-checker/app/web"
"github.com/spf13/cobra"
)
// serveCmd represents the serve command
var serveCmd = &cobra.Command{
Use: "serve",
Short: "Start web interface",
Long: `Start web interface for browsing IPTV playlists.
Use --check to enable background playlist checking.
Copyright (c) 2025, Антон Аксенов, MIT license.`,
Run: func(cmd *cobra.Command, args []string) {
app.Init()
applyAppOverrides(cmd)
applyCacheOverrides(cmd)
applyCheckOverrides(cmd)
app.InitCache()
// CLI-флаги переопределяют конфиг, только если заданы явно.
if cmd.Flags().Changed("port") {
app.Config.Server.Port = app.Args.ServerPort
}
if cmd.Flags().Changed("host") {
app.Config.Server.Host = app.Args.ServerHost
}
server := web.NewServer(app.Config, app.Cache)
// запускаем фоновую проверку, если передан --check или включён check.start-on-serve в конфиге
if app.Args.NeedCheck || app.Config.Check.StartOnServe {
opts := web.CheckOptions{
Every: app.Args.RepeatEverySec,
Repeat: app.Args.RepeatCount,
Random: app.Args.RandomCount,
}
go server.StartBackgroundChecker(opts)
}
server.Start()
},
}
func init() {
serveCmd.Flags().UintVarP(&app.Args.ServerPort, "port", "p", 0, "port for web interface (overrides config.yml and env)")
serveCmd.Flags().StringVar(&app.Args.ServerHost, "host", "", "host to bind web interface (overrides config.yml and env)")
serveCmd.Flags().BoolVar(&app.Args.NeedCheck, "check", false, "enable background playlist checking")
addCommonCheckFlags(serveCmd, 60, 0)
rootCmd.AddCommand(serveCmd)
}
+72
View File
@@ -0,0 +1,72 @@
app:
timezone: GMT+5
# locale: en
debug: false
log_level: info
playlists: ../playlists/playlists.ini
tags: ../playlists/channels.json
server:
host: localhost
port: 8080
site:
base-url: http://localhost:8080
repo-url: https://git.axenov.dev/IPTV/iptvc
page-size: 0
favicon: # опциональный путь к иконке
header:
title: IPTV Checker
navigation:
- title: Аптайм
url: https://status.m3u.su
icon: pulse-outline
- title: Помощь
icon: help-circle-outline
children:
- title: Документация
url: /docs
icon: document-text-outline
- title: Исходники
url: https://git.axenov.dev/IPTV
icon: code-slash-outline
- title: "@iptv_aggregator"
url: https://t.me/iptv_aggregator
icon: paper-plane-outline
footer-links:
- title: Исходники
url: https://git.axenov.dev/IPTV
icon: code-slash-outline
- title: axenov.dev
url: https://axenov.dev
icon: person-outline
- title: Канал
url: https://t.me/iptv_aggregator
icon: megaphone-outline
check:
start-on-serve: true
playlists:
user-agent:
- Mozilla/5.0 WINK/1.31.1 (AndroidTV/9) HlsWinkPlayer
timeout: 10000
all-cooldown: 0
one-cooldown: 0
max-routines: 5
per-routine: 5
channels:
user-agent: Mozilla/5.0 WINK/1.31.1 (AndroidTV/9) HlsWinkPlayer
timeout: 10000
byte-range: 512
cooldown: 0
max-routines: 50
per-routine: 10
cache:
enabled: false
host: localhost
port: 6379
username:
password:
db: 1
ttl: 1800
+1
View File
@@ -7,6 +7,7 @@ require (
github.com/redis/go-redis/v9 v9.7.3
github.com/spf13/cobra v1.9.1
gopkg.in/ini.v1 v1.67.0
gopkg.in/yaml.v3 v3.0.1
)
require (
+1
View File
@@ -26,6 +26,7 @@ github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA=
gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=