This commit is contained in:
2026-07-13 12:28:59 +08:00
parent 6c3de4b2ef
commit f0fe5409fa
1409 changed files with 11369 additions and 413 deletions
+632 -39
View File
@@ -1,5 +1,5 @@
/*
* Copyright (c) 2025, Антон Аксенов
* Copyright (c) 2025-2026, Антон Аксенов
* This file is part of iptvc project
* MIT License: https://git.axenov.dev/IPTV/iptvc/src/branch/master/LICENSE
*/
@@ -7,71 +7,664 @@
package config
import (
"github.com/joho/godotenv"
"fmt"
"log"
"math/rand"
"os"
"strconv"
"strings"
"github.com/joho/godotenv"
"gopkg.in/yaml.v3"
)
// Константы значений по умолчанию для временных параметров.
// Внешний интерфейс (YAML, env, CLI) задаёт их в секундах,
// внутреннее представление — в миллисекундах.
const (
defaultTimeoutSeconds = 10
defaultAllCooldownSeconds = 1800 // 30 минут
defaultOneCooldownSeconds = 2
defaultChannelsCooldownSeconds = 0
defaultCacheTtlSeconds = 30
)
// Config описывает конфигурацию
type Config struct {
DebugMode bool
Cache CacheConfig
App AppConfig `yaml:"app"`
Server ServerConfig `yaml:"server"`
Site SiteConfig `yaml:"site"`
Check CheckConfig `yaml:"check"`
Cache CacheConfig `yaml:"cache"`
}
// CacheConfig описывает конфигурацию подключения к keydb
// AppConfig описывает общие настройки приложения
type AppConfig struct {
Timezone string `yaml:"timezone"`
Debug bool `yaml:"debug"`
LogLevel string `yaml:"log_level"`
Playlists string `yaml:"playlists"`
Tags string `yaml:"tags"`
}
// ServerConfig описывает конфигурацию веб-сервера
type ServerConfig struct {
Port uint `yaml:"port"`
Host string `yaml:"host"`
}
// Link описывает ссылку в навигации или подвале сайта.
// Если заданы Children, рендерится как выпадающее меню.
type Link struct {
Title string `yaml:"title"`
Url string `yaml:"url"`
Icon string `yaml:"icon"`
Children []Link `yaml:"children"`
}
// TabConfig описывает настройку одной вкладки на странице плейлиста.
type TabConfig struct {
Visible bool `yaml:"visible"`
Text string `yaml:"text"`
}
// TabsConfig описывает группу вкладок на странице плейлиста.
type TabsConfig struct {
Raw TabConfig `yaml:"raw"`
Legal TabConfig `yaml:"legal"`
}
// HeaderConfig описывает настройки шапки сайта
type HeaderConfig struct {
Title string `yaml:"title"`
Menu []Link `yaml:"menu"`
}
// FooterConfig описывает настройки подвала сайта
type FooterConfig struct {
Links []Link `yaml:"links"`
}
// SiteConfig описывает настройки сайта
type SiteConfig struct {
BaseUrl string `yaml:"base-url"`
RepoUrl string `yaml:"repo-url"`
PageSize uint `yaml:"page-size"`
Favicon string `yaml:"favicon"`
Meta MetaConfig `yaml:"meta"`
Tabs TabsConfig `yaml:"tabs"`
Header HeaderConfig `yaml:"header"`
Footer FooterConfig `yaml:"footer"`
}
// MetaConfig описывает SEO-метаданные сайта.
type MetaConfig struct {
Description string `yaml:"description"`
Keywords string `yaml:"keywords"`
}
// CacheConfig описывает конфигурацию подключения к keydb.
// Ttl задаётся в секундах.
type CacheConfig struct {
IsEnabled bool
Host string
Port uint
Username string
Password string
Db uint
Ttl uint
IsActive bool
Enabled bool `yaml:"enabled"`
Host string `yaml:"host"`
Port uint `yaml:"port"`
Username string `yaml:"username"`
Password string `yaml:"password"`
Db uint `yaml:"db"`
Ttl uint `yaml:"ttl"`
IsActive bool `yaml:"-"`
}
// Init инициализирует объект конфигурации из переменных окружения
func Init() *Config {
_ = godotenv.Load(".env")
// CheckConfig описывает параметры проверки плейлистов и каналов
type CheckConfig struct {
StartOnServe bool `yaml:"start-on-serve"`
Playlists CheckPlaylistsConfig `yaml:"playlists"`
Channels CheckChannelsConfig `yaml:"channels"`
}
// DurationSeconds — тип для временных интервалов, задаваемых в секундах,
// но хранящихся внутри в миллисекундах.
type DurationSeconds int
// UnmarshalYAML декодирует значение из секунд в миллисекунды.
func (d *DurationSeconds) UnmarshalYAML(value *yaml.Node) error {
var v float64
if err := value.Decode(&v); err != nil {
return err
}
*d = DurationSeconds(v * 1000)
return nil
}
// CheckPlaylistsConfig — параметры проверки плейлистов.
// Значения Timeout, AllCooldown, OneCooldown задаются в секундах
// в YAML/CLI/env, но хранятся в миллисекундах для использования в коде.
type CheckPlaylistsConfig struct {
UserAgent UserAgents `yaml:"user-agent"`
Timeout DurationSeconds `yaml:"timeout"` // ms internally
AllCooldown IntRange `yaml:"all-cooldown"` // ms internally
OneCooldown IntRange `yaml:"one-cooldown"` // ms internally
MaxRoutines int `yaml:"max-routines"` // count
PerRoutine int `yaml:"per-routine"` // count
}
// CheckChannelsConfig — параметры проверки каналов.
// Значения Timeout и Cooldown задаются в секундах
// в YAML/CLI/env, но хранятся в миллисекундах для использования в коде.
type CheckChannelsConfig struct {
UserAgent UserAgents `yaml:"user-agent"`
Timeout DurationSeconds `yaml:"timeout"` // ms internally
ByteRange int `yaml:"byte-range"` // bytes
Cooldown IntRange `yaml:"cooldown"` // ms internally
MaxRoutines int `yaml:"max-routines"` // count
PerRoutine int `yaml:"per-routine"` // count
}
// IntRange — значение, которое может быть скаляром или [min, max].
// Если скаляр: Min == Max. Если [min, max]: Min <= Max.
type IntRange struct {
Min int
Max int
}
// UnmarshalYAML реализует кастомный разбор IntRange из YAML.
// В YAML значение задаётся в секундах, результат сохраняется в миллисекундах.
// Поддерживает скаляр (int/float) или массив из двух значений [min, max].
func (r *IntRange) UnmarshalYAML(value *yaml.Node) error {
decodeNode := func(n *yaml.Node) (int, error) {
var f float64
if err := n.Decode(&f); err != nil {
return 0, err
}
return int(f * 1000), nil
}
if value.Kind == yaml.ScalarNode {
v, err := decodeNode(value)
if err != nil {
return err
}
r.Min = v
r.Max = v
return nil
}
if value.Kind == yaml.SequenceNode {
if len(value.Content) == 0 {
r.Min, r.Max = 0, 0
return nil
}
min, err := decodeNode(value.Content[0])
if err != nil {
return err
}
r.Min = min
if len(value.Content) >= 2 {
max, err := decodeNode(value.Content[1])
if err != nil {
return err
}
r.Max = max
} else {
r.Max = min
}
return nil
}
return nil
}
// Value возвращает значение: если Min == Max — константу, иначе случайное в [Min, Max].
func (r IntRange) Value() int {
if r.Min == r.Max {
return r.Min
}
return r.Min + rand.Intn(r.Max-r.Min+1)
}
// UserAgents — список User-Agent строк. Может быть задан как скаляр или массив.
type UserAgents []string
// UnmarshalYAML реализует кастомный разбор UserAgents из YAML.
func (u *UserAgents) UnmarshalYAML(value *yaml.Node) error {
if value.Kind == yaml.ScalarNode {
var s string
if err := value.Decode(&s); err != nil {
return err
}
*u = []string{s}
return nil
}
if value.Kind == yaml.SequenceNode {
var arr []string
if err := value.Decode(&arr); err != nil {
return err
}
*u = arr
return nil
}
return nil
}
// Pick возвращает случайный User-Agent или пустую строку если список пуст.
func (u UserAgents) Pick() string {
if len(u) == 0 {
return ""
}
if len(u) == 1 {
return u[0]
}
return u[rand.Intn(len(u))]
}
// defaults возвращает конфигурацию со значениями по умолчанию.
// Временные параметры хранятся в миллисекундах.
func defaults() *Config {
return &Config{
//DebugMode: readEnvBoolean("APP_DEBUG", false),
App: AppConfig{
Timezone: "GMT",
Debug: false,
LogLevel: "info",
Playlists: "./playlists.ini",
Tags: "./channels.json",
},
Server: ServerConfig{
Port: 8800,
Host: "",
},
Site: SiteConfig{
BaseUrl: "http://localhost:8800",
PageSize: 0,
RepoUrl: "https://git.axenov.dev/IPTV",
Meta: MetaConfig{
Description: "Самообновляемые бесплатные IPTV-плейлисты для домашнего просмотра по коротким ссылкам, списки каналов, проверка доступности",
Keywords: "самообновляемые,бесплатные,iptv-плейлисты,iptv,плейлисты",
},
Tabs: TabsConfig{
Raw: TabConfig{Visible: true},
Legal: TabConfig{Visible: true},
},
Header: HeaderConfig{
Title: "IPTV Checker",
Menu: []Link{
{Title: "Помощь", Icon: "help-circle-outline", Children: []Link{
{Title: "Документация", Url: "https://m3u.su/docs", Icon: "document-text-outline"},
{Title: "Исходники", Url: "https://git.axenov.dev/IPTV", Icon: "code-slash-outline"},
{Title: "@iptv_aggregator", Url: "https://t.me/iptv_aggregator", Icon: "paper-plane-outline"},
}},
},
},
Footer: FooterConfig{
Links: []Link{
{Title: "Исходники", Url: "https://git.axenov.dev/IPTV", Icon: "code-slash-outline"},
{Title: "axenov.dev", Url: "https://axenov.dev", Icon: "person-outline"},
{Title: "@iptv_aggregator", Url: "https://t.me/iptv_aggregator", Icon: "megaphone-outline"},
},
},
},
Check: CheckConfig{
StartOnServe: false,
Playlists: CheckPlaylistsConfig{
UserAgent: UserAgents{"Mozilla/5.0 WINK/1.31.1 (AndroidTV/9) HlsWinkPlayer"},
Timeout: defaultTimeoutSeconds * 1000,
AllCooldown: IntRange{Min: defaultAllCooldownSeconds * 1000, Max: defaultAllCooldownSeconds * 1000},
OneCooldown: IntRange{Min: defaultOneCooldownSeconds * 1000, Max: defaultOneCooldownSeconds * 1000},
MaxRoutines: 1,
PerRoutine: 1,
},
Channels: CheckChannelsConfig{
UserAgent: UserAgents{"Mozilla/5.0 WINK/1.31.1 (AndroidTV/9) HlsWinkPlayer"},
Timeout: defaultTimeoutSeconds * 1000,
ByteRange: 512,
Cooldown: IntRange{Min: defaultChannelsCooldownSeconds * 1000, Max: defaultChannelsCooldownSeconds * 1000},
MaxRoutines: 50,
PerRoutine: 10,
},
},
Cache: CacheConfig{
IsEnabled: readEnvBoolean("CACHE_ENABLED", false),
Host: readEnv("CACHE_HOST", "localhost"),
Port: readEnvInteger("CACHE_PORT", 6379),
Username: readEnv("CACHE_USERNAME", ""),
Password: readEnv("CACHE_PASSWORD", ""),
Db: readEnvInteger("CACHE_DB", 0),
Ttl: readEnvInteger("CACHE_TTL", 1800),
Enabled: false,
Host: "localhost",
Port: 6379,
Username: "",
Password: "",
Db: 0,
Ttl: defaultCacheTtlSeconds,
},
}
}
// readEnv считывает строковую переменную окружения с заданным именем или возвращает значение по умолчанию
func readEnv(key string, defaultValue string) string {
value, exists := os.LookupEnv(key)
if exists {
return value
// Init инициализирует объект конфигурации.
// Порядок приоритета (от низшего к высшему):
// 1. Значения по умолчанию
// 2. config.yml (путь можно задать через configPath)
// 3. Переменные окружения (.env загружается автоматически)
func Init(configPath string) *Config {
_ = godotenv.Load(".env")
cfg := defaults()
// загружаем config.yml
if configPath == "" {
configPath = "config.yml"
}
return defaultValue
if data, err := os.ReadFile(configPath); err == nil {
if err := yaml.Unmarshal(data, cfg); err != nil {
log.Printf("Warning: cannot parse %s: %s", configPath, err)
}
} else if !os.IsNotExist(err) {
log.Printf("Warning: cannot read %s: %s", configPath, err)
}
// переопределяем значениями из переменных окружения
applyEnv(cfg)
// валидируем и исправляем
validate(cfg)
return cfg
}
// readEnvBoolean считывает булеву переменную окружения с заданным именем или возвращает значение по умолчанию
func readEnvBoolean(name string, defaultValue bool) bool {
valStr := readEnv(name, "")
val, err := strconv.ParseBool(valStr)
// applyEnv переопределяет поля конфигурации значениями из переменных окружения.
func applyEnv(cfg *Config) {
// app
if v, ok := os.LookupEnv("APP_DEBUG"); ok {
cfg.App.Debug = parseBool(v, cfg.App.Debug)
}
if v, ok := os.LookupEnv("APP_LOG_LEVEL"); ok && v != "" {
cfg.App.LogLevel = v
}
if v, ok := os.LookupEnv("APP_TIMEZONE"); ok && v != "" {
cfg.App.Timezone = v
}
if v, ok := os.LookupEnv("APP_PLAYLISTS"); ok && v != "" {
cfg.App.Playlists = v
}
if v, ok := os.LookupEnv("APP_TAGS"); ok && v != "" {
cfg.App.Tags = v
}
// server
if v, ok := os.LookupEnv("SERVER_PORT"); ok && v != "" {
cfg.Server.Port = parseUint(v, cfg.Server.Port)
}
if v, ok := os.LookupEnv("SERVER_HOST"); ok {
cfg.Server.Host = v
}
// site
if v, ok := os.LookupEnv("SITE_BASE_URL"); ok && v != "" {
cfg.Site.BaseUrl = v
}
if v, ok := os.LookupEnv("SITE_PAGE_SIZE"); ok && v != "" {
cfg.Site.PageSize = parseUint(v, cfg.Site.PageSize)
}
if v, ok := os.LookupEnv("SITE_REPO_URL"); ok && v != "" {
cfg.Site.RepoUrl = v
}
if v, ok := os.LookupEnv("SITE_FAVICON"); ok {
cfg.Site.Favicon = v
}
if v, ok := os.LookupEnv("SITE_META_DESCRIPTION"); ok && v != "" {
cfg.Site.Meta.Description = v
}
if v, ok := os.LookupEnv("SITE_META_KEYWORDS"); ok && v != "" {
cfg.Site.Meta.Keywords = v
}
if v, ok := os.LookupEnv("SITE_TITLE"); ok && v != "" {
cfg.Site.Header.Title = v
}
// check
if v, ok := os.LookupEnv("CHECK_START_ON_SERVE"); ok {
cfg.Check.StartOnServe = parseBool(v, cfg.Check.StartOnServe)
}
// check.playlists
pp := &cfg.Check.Playlists
if v, ok := os.LookupEnv("CHECK_PLAYLISTS_TIMEOUT"); ok && v != "" {
pp.Timeout = DurationSeconds(parseSecondsToMillis(v, int(pp.Timeout)))
}
applyEnvIntRange(&pp.AllCooldown, "CHECK_PLAYLISTS_ALL_COOLDOWN")
applyEnvIntRange(&pp.OneCooldown, "CHECK_PLAYLISTS_ONE_COOLDOWN")
if v, ok := os.LookupEnv("CHECK_PLAYLISTS_MAX_ROUTINES"); ok && v != "" {
pp.MaxRoutines = parseInt(v, pp.MaxRoutines)
}
if v, ok := os.LookupEnv("CHECK_PLAYLISTS_PER_ROUTINE"); ok && v != "" {
pp.PerRoutine = parseInt(v, pp.PerRoutine)
}
if uas := applyEnvStringSlice("CHECK_PLAYLISTS_USER_AGENT"); uas != nil {
pp.UserAgent = UserAgents(uas)
}
// check.channels
cc := &cfg.Check.Channels
if v, ok := os.LookupEnv("CHECK_CHANNELS_TIMEOUT"); ok && v != "" {
cc.Timeout = DurationSeconds(parseSecondsToMillis(v, int(cc.Timeout)))
}
if v, ok := os.LookupEnv("CHECK_CHANNELS_BYTE_RANGE"); ok && v != "" {
cc.ByteRange = parseInt(v, cc.ByteRange)
}
applyEnvIntRange(&cc.Cooldown, "CHECK_CHANNELS_COOLDOWN")
if v, ok := os.LookupEnv("CHECK_CHANNELS_MAX_ROUTINES"); ok && v != "" {
cc.MaxRoutines = parseInt(v, cc.MaxRoutines)
}
if v, ok := os.LookupEnv("CHECK_CHANNELS_PER_ROUTINE"); ok && v != "" {
cc.PerRoutine = parseInt(v, cc.PerRoutine)
}
if uas := applyEnvStringSlice("CHECK_CHANNELS_USER_AGENT"); uas != nil {
cc.UserAgent = UserAgents(uas)
}
// cache
if v, ok := os.LookupEnv("CACHE_ENABLED"); ok {
cfg.Cache.Enabled = parseBool(v, cfg.Cache.Enabled)
}
if v, ok := os.LookupEnv("CACHE_HOST"); ok && v != "" {
cfg.Cache.Host = v
}
if v, ok := os.LookupEnv("CACHE_PORT"); ok && v != "" {
cfg.Cache.Port = parseUint(v, cfg.Cache.Port)
}
if v, ok := os.LookupEnv("CACHE_USERNAME"); ok {
cfg.Cache.Username = v
}
if v, ok := os.LookupEnv("CACHE_PASSWORD"); ok {
cfg.Cache.Password = v
}
if v, ok := os.LookupEnv("CACHE_DB"); ok && v != "" {
cfg.Cache.Db = parseUint(v, cfg.Cache.Db)
}
if v, ok := os.LookupEnv("CACHE_TTL"); ok && v != "" {
cfg.Cache.Ttl = parseUint(v, cfg.Cache.Ttl)
}
}
// parseBool разбирает строку в bool, при ошибке возвращает значение по умолчанию
func parseBool(s string, defaultValue bool) bool {
val, err := strconv.ParseBool(s)
if err == nil {
return val
}
return defaultValue
}
// readEnvInteger считывает целочисленную переменную окружения с заданным именем или возвращает значение по умолчанию
func readEnvInteger(name string, defaultValue uint) uint {
valueStr := readEnv(name, "")
value, err := strconv.Atoi(valueStr)
// parseUint разбирает строку в uint, при ошибке возвращает значение по умолчанию
func parseUint(s string, defaultValue uint) uint {
val, err := strconv.Atoi(s)
if err == nil {
return uint(value)
return uint(val)
}
return defaultValue
}
// parseInt разбирает строку в int, при ошибке возвращает значение по умолчанию
func parseInt(s string, defaultValue int) int {
val, err := strconv.Atoi(s)
if err == nil {
return val
}
return defaultValue
}
// parseSecondsToMillis разбирает строку с секундами и возвращает миллисекунды.
// При ошибке возвращает значение по умолчанию.
func parseSecondsToMillis(s string, defaultValue int) int {
val, err := strconv.ParseFloat(s, 64)
if err == nil && val >= 0 {
return int(val * 1000)
}
return defaultValue
}
// applyEnvIntRange читает одно env-значение (в секундах) для IntRange,
// умножая результат на 1000, так как внутри значения хранятся в миллисекундах.
// Одно число задаёт фиксированное значение, два числа через запятую — диапазон.
func applyEnvIntRange(r *IntRange, name string) {
value, ok := os.LookupEnv(name)
if !ok || strings.TrimSpace(value) == "" {
return
}
parse := func(s string) (int, bool) {
s = strings.TrimSpace(s)
v, err := strconv.ParseFloat(s, 64)
if err != nil || v < 0 {
return 0, false
}
return int(v * 1000), true
}
parts := strings.Split(value, ",")
if len(parts) == 1 {
n, ok := parse(parts[0])
if !ok {
return
}
r.Min = n
r.Max = n
return
}
if len(parts) != 2 {
return
}
min, minOk := parse(parts[0])
max, maxOk := parse(parts[1])
if !minOk || !maxOk {
return
}
r.Min = min
r.Max = max
}
// applyEnvStringSlice читает индексированные env-переменные PREFIX_1, PREFIX_2, ...
// Возвращает nil, если ни одна не задана.
func applyEnvStringSlice(prefix string) []string {
var result []string
for i := 1; ; i++ {
key := fmt.Sprintf("%s_%d", prefix, i)
v, ok := os.LookupEnv(key)
if !ok {
break
}
if v != "" {
result = append(result, v)
}
}
return result
}
// validate проверяет значения конфигурации и исправляет некорректные,
// логируя предупреждения для каждого исправления.
func validate(cfg *Config) {
// server
if cfg.Server.Port == 0 || cfg.Server.Port > 65535 {
log.Println("Config: server.port is not in the 0..65535 range, using default 8800")
cfg.Server.Port = 8800
}
if cfg.Site.BaseUrl == "" {
log.Println("Config: site.base-url is empty, using default")
cfg.Site.BaseUrl = fmt.Sprintf("http://localhost:%d", cfg.Server.Port)
}
// cache
if cfg.Cache.Enabled {
if cfg.Cache.Host == "" {
log.Println("Config: cache.host is empty, using default 'localhost'")
cfg.Cache.Host = "localhost"
}
if cfg.Cache.Port == 0 {
log.Println("Config: cache.port is 0, using default 6379")
cfg.Cache.Port = 6379
}
if cfg.Cache.Ttl == 0 {
log.Printf("Config: cache.ttl is 0, using default %d s", defaultCacheTtlSeconds)
cfg.Cache.Ttl = defaultCacheTtlSeconds
}
}
// check.playlists
pp := &cfg.Check.Playlists
if pp.Timeout <= 0 {
log.Printf("Config: check.playlists.timeout <= 0, using default %d s", defaultTimeoutSeconds)
pp.Timeout = defaultTimeoutSeconds * 1000
}
validateIntRange(&pp.AllCooldown, "check.playlists.all-cooldown", 0, 3600000)
validateIntRange(&pp.OneCooldown, "check.playlists.one-cooldown", 0, 3600000)
if pp.MaxRoutines < 1 {
log.Println("Config: check.playlists.max-routines < 1, using default 5")
pp.MaxRoutines = 5
}
if pp.PerRoutine < 1 {
log.Println("Config: check.playlists.per-routine < 1, using default 1")
pp.PerRoutine = 1
}
if len(pp.UserAgent) == 0 {
log.Println("Config: check.playlists.user-agent is empty, using default")
pp.UserAgent = UserAgents{"Mozilla/5.0 WINK/1.31.1 (AndroidTV/9) HlsWinkPlayer"}
}
// check.channels
cc := &cfg.Check.Channels
if cc.Timeout <= 0 {
log.Printf("Config: check.channels.timeout <= 0, using default %d s", defaultTimeoutSeconds)
cc.Timeout = defaultTimeoutSeconds * 1000
}
validateIntRange(&cc.Cooldown, "check.channels.cooldown", 0, 3600000)
if cc.MaxRoutines < 1 {
log.Println("Config: check.channels.max-routines < 1, using default 50")
cc.MaxRoutines = 50
}
if cc.PerRoutine < 1 {
log.Println("Config: check.channels.per-routine < 1, using default 10")
cc.PerRoutine = 10
}
if cc.ByteRange <= 0 {
log.Println("Config: check.channels.byte-range <= 0, using default 512")
cc.ByteRange = 512
}
if len(cc.UserAgent) == 0 {
log.Println("Config: check.channels.user-agent is empty, using default")
cc.UserAgent = UserAgents{"Mozilla/5.0 WINK/1.31.1 (AndroidTV/9) HlsWinkPlayer"}
}
}
// validateIntRange проверяет IntRange: Min <= Max, оба в пределах [minVal, maxVal].
// Некорректные значения исправляются с логированием предупреждения.
func validateIntRange(r *IntRange, name string, minVal, maxVal int) {
if r.Min > r.Max {
log.Printf("Config: %s min=%d > max=%d, swapping", name, r.Min, r.Max)
r.Min, r.Max = r.Max, r.Min
}
if r.Min < minVal {
log.Printf("Config: %s min=%d < %d, clamped", name, r.Min, minVal)
r.Min = minVal
}
if r.Max > maxVal {
log.Printf("Config: %s max=%d > %d, clamped", name, r.Max, maxVal)
r.Max = maxVal
}
if r.Min > r.Max {
r.Min = minVal
r.Max = minVal
}
}
+545
View File
@@ -0,0 +1,545 @@
package config
import (
"os"
"testing"
"gopkg.in/yaml.v3"
)
// --- IntRange ---
func TestIntRangeValue_Scalar(t *testing.T) {
r := IntRange{Min: 5, Max: 5}
if v := r.Value(); v != 5 {
t.Errorf("expected 5, got %d", v)
}
}
func TestIntRangeValue_Range(t *testing.T) {
r := IntRange{Min: 10, Max: 20}
for i := 0; i < 100; i++ {
v := r.Value()
if v < 10 || v > 20 {
t.Errorf("value %d out of [10, 20]", v)
}
}
}
func TestIntRangeUnmarshalYAML_Scalar(t *testing.T) {
yamlData := []byte("timeout: 5000\n")
type wrapper struct {
Timeout int `yaml:"timeout"`
}
var w wrapper
if err := yaml.Unmarshal(yamlData, &w); err != nil {
t.Fatal(err)
}
if w.Timeout != 5000 {
t.Errorf("expected 5000, got %d", w.Timeout)
}
}
func TestIntRangeUnmarshalYAML_FromFullConfig(t *testing.T) {
yamlData := []byte(`
check:
playlists:
all-cooldown: 1
one-cooldown: [2, 3]
channels:
cooldown: [0, 1]
`)
cfg := defaults()
if err := yaml.Unmarshal(yamlData, cfg); err != nil {
t.Fatal(err)
}
if cfg.Check.Playlists.AllCooldown.Min != 1000 || cfg.Check.Playlists.AllCooldown.Max != 1000 {
t.Errorf("scalar all-cooldown: expected Min=Max=1000 ms from 1 s, got Min=%d Max=%d",
cfg.Check.Playlists.AllCooldown.Min, cfg.Check.Playlists.AllCooldown.Max)
}
if cfg.Check.Playlists.OneCooldown.Min != 2000 || cfg.Check.Playlists.OneCooldown.Max != 3000 {
t.Errorf("range one-cooldown: expected [2000,3000] ms from [2,3] s, got [%d,%d]",
cfg.Check.Playlists.OneCooldown.Min, cfg.Check.Playlists.OneCooldown.Max)
}
if cfg.Check.Channels.Cooldown.Min != 0 || cfg.Check.Channels.Cooldown.Max != 1000 {
t.Errorf("range cooldown: expected [0,1000] ms from [0,1] s, got [%d,%d]",
cfg.Check.Channels.Cooldown.Min, cfg.Check.Channels.Cooldown.Max)
}
}
// --- UserAgents ---
func TestUserAgentsPick_Empty(t *testing.T) {
ua := UserAgents{}
if ua.Pick() != "" {
t.Error("expected empty string for empty UserAgents")
}
}
func TestUserAgentsPick_Single(t *testing.T) {
ua := UserAgents{"Mozilla"}
if ua.Pick() != "Mozilla" {
t.Errorf("expected 'Mozilla', got '%s'", ua.Pick())
}
}
func TestUserAgentsPick_Multiple(t *testing.T) {
ua := UserAgents{"A", "B", "C"}
for i := 0; i < 100; i++ {
v := ua.Pick()
found := false
for _, s := range ua {
if v == s {
found = true
break
}
}
if !found {
t.Errorf("Pick returned '%s' which is not in list", v)
}
}
}
func TestUserAgentsUnmarshalYAML_Scalar(t *testing.T) {
yamlData := []byte(`
check:
channels:
user-agent: Mozilla/5.0
`)
cfg := defaults()
if err := yaml.Unmarshal(yamlData, cfg); err != nil {
t.Fatal(err)
}
if len(cfg.Check.Channels.UserAgent) != 1 || cfg.Check.Channels.UserAgent[0] != "Mozilla/5.0" {
t.Errorf("expected single 'Mozilla/5.0', got %v", cfg.Check.Channels.UserAgent)
}
}
func TestUserAgentsUnmarshalYAML_Array(t *testing.T) {
yamlData := []byte(`
check:
channels:
user-agent:
- Mozilla/5.0
- curl/8.0
`)
cfg := defaults()
if err := yaml.Unmarshal(yamlData, cfg); err != nil {
t.Fatal(err)
}
if len(cfg.Check.Channels.UserAgent) != 2 {
t.Errorf("expected 2 user-agents, got %d", len(cfg.Check.Channels.UserAgent))
}
}
// --- parseBool / parseUint / parseInt ---
func TestParseBool(t *testing.T) {
cases := []struct {
in string
def bool
want bool
}{
{"true", false, true},
{"1", false, true},
{"false", true, false},
{"0", true, false},
{"yes", false, false},
{"", true, true},
}
for _, c := range cases {
got := parseBool(c.in, c.def)
if got != c.want {
t.Errorf("parseBool(%q, %v) = %v, want %v", c.in, c.def, got, c.want)
}
}
}
func TestParseUint(t *testing.T) {
cases := []struct {
in string
def uint
want uint
}{
{"8800", 0, 8800},
{"0", 999, 0},
{"abc", 999, 999},
{"", 999, 999},
}
for _, c := range cases {
got := parseUint(c.in, c.def)
if got != c.want {
t.Errorf("parseUint(%q, %d) = %d, want %d", c.in, c.def, got, c.want)
}
}
}
func TestParseInt(t *testing.T) {
cases := []struct {
in string
def int
want int
}{
{"100", 0, 100},
{"-5", 0, -5},
{"abc", 999, 999},
{"", 999, 999},
}
for _, c := range cases {
got := parseInt(c.in, c.def)
if got != c.want {
t.Errorf("parseInt(%q, %d) = %d, want %d", c.in, c.def, got, c.want)
}
}
}
// --- applyEnvIntRange ---
func TestApplyEnvIntRange_Scalar(t *testing.T) {
t.Setenv("TEST_COOLDOWN", "1.5")
r := IntRange{Min: 0, Max: 0}
applyEnvIntRange(&r, "TEST_COOLDOWN")
if r.Min != 1500 || r.Max != 1500 {
t.Errorf("expected Min=Max=1500, got Min=%d Max=%d", r.Min, r.Max)
}
}
func TestApplyEnvIntRange_Range(t *testing.T) {
t.Setenv("TEST_COOLDOWN", "1,2")
r := IntRange{Min: 0, Max: 0}
applyEnvIntRange(&r, "TEST_COOLDOWN")
if r.Min != 1000 || r.Max != 2000 {
t.Errorf("expected [1000,2000], got [%d,%d]", r.Min, r.Max)
}
}
func TestApplyEnvIntRange_Invalid(t *testing.T) {
t.Setenv("TEST_COOLDOWN", "1,invalid")
r := IntRange{Min: 5, Max: 5}
applyEnvIntRange(&r, "TEST_COOLDOWN")
if r.Min != 5 || r.Max != 5 {
t.Errorf("expected unchanged [5,5], got [%d,%d]", r.Min, r.Max)
}
}
func TestApplyEnvIntRange_None(t *testing.T) {
r := IntRange{Min: 7, Max: 7}
applyEnvIntRange(&r, "TEST_COOLDOWN")
if r.Min != 7 || r.Max != 7 {
t.Errorf("expected unchanged [7,7], got [%d,%d]", r.Min, r.Max)
}
}
// --- applyEnvStringSlice ---
func TestApplyEnvStringSlice_Multiple(t *testing.T) {
t.Setenv("MY_PREFIX_1", "foo")
t.Setenv("MY_PREFIX_2", "bar")
t.Setenv("MY_PREFIX_3", "baz")
result := applyEnvStringSlice("MY_PREFIX")
if len(result) != 3 {
t.Fatalf("expected 3 items, got %d", len(result))
}
if result[0] != "foo" || result[1] != "bar" || result[2] != "baz" {
t.Errorf("unexpected result: %v", result)
}
}
func TestApplyEnvStringSlice_EmptyValue(t *testing.T) {
t.Setenv("MY_PREFIX_1", "foo")
t.Setenv("MY_PREFIX_2", "")
t.Setenv("MY_PREFIX_3", "baz")
result := applyEnvStringSlice("MY_PREFIX")
if len(result) != 2 {
t.Fatalf("expected 2 items (empty skipped), got %d: %v", len(result), result)
}
if result[0] != "foo" || result[1] != "baz" {
t.Errorf("unexpected result: %v", result)
}
}
func TestApplyEnvStringSlice_None(t *testing.T) {
result := applyEnvStringSlice("NONEXISTENT_PREFIX")
if result != nil {
t.Errorf("expected nil, got %v", result)
}
}
// --- validate ---
func TestValidate_ServerPort(t *testing.T) {
cfg := defaults()
cfg.Server.Port = 0
validate(cfg)
if cfg.Server.Port != 8800 {
t.Errorf("expected 8800, got %d", cfg.Server.Port)
}
}
func TestValidate_ServerPortTooHigh(t *testing.T) {
cfg := defaults()
cfg.Server.Port = 99999
validate(cfg)
if cfg.Server.Port != 8800 {
t.Errorf("expected 8800, got %d", cfg.Server.Port)
}
}
func TestValidate_BaseUrlEmpty(t *testing.T) {
cfg := defaults()
cfg.Site.BaseUrl = ""
cfg.Server.Port = 3000
validate(cfg)
if cfg.Site.BaseUrl != "http://localhost:3000" {
t.Errorf("expected http://localhost:3000, got %s", cfg.Site.BaseUrl)
}
}
func TestValidate_CacheEnabled(t *testing.T) {
cfg := defaults()
cfg.Cache.Enabled = true
cfg.Cache.Host = ""
cfg.Cache.Port = 0
cfg.Cache.Ttl = 0
validate(cfg)
if cfg.Cache.Host != "localhost" {
t.Errorf("expected localhost, got %s", cfg.Cache.Host)
}
if cfg.Cache.Port != 6379 {
t.Errorf("expected 6379, got %d", cfg.Cache.Port)
}
if cfg.Cache.Ttl != 30 {
t.Errorf("expected 30, got %d", cfg.Cache.Ttl)
}
}
func TestValidate_PlaylistsTimeout(t *testing.T) {
cfg := defaults()
cfg.Check.Playlists.Timeout = 0
validate(cfg)
if cfg.Check.Playlists.Timeout != 10000 {
t.Errorf("expected 10000, got %d", cfg.Check.Playlists.Timeout)
}
}
func TestValidate_ChannelsTimeout(t *testing.T) {
cfg := defaults()
cfg.Check.Channels.Timeout = -5
validate(cfg)
if cfg.Check.Channels.Timeout != 10000 {
t.Errorf("expected 10000, got %d", cfg.Check.Channels.Timeout)
}
}
func TestValidate_PlaylistsMaxRoutines(t *testing.T) {
cfg := defaults()
cfg.Check.Playlists.MaxRoutines = 0
validate(cfg)
if cfg.Check.Playlists.MaxRoutines != 5 {
t.Errorf("expected 5, got %d", cfg.Check.Playlists.MaxRoutines)
}
}
func TestValidate_ChannelsMaxRoutines(t *testing.T) {
cfg := defaults()
cfg.Check.Channels.MaxRoutines = 0
validate(cfg)
if cfg.Check.Channels.MaxRoutines != 50 {
t.Errorf("expected 50, got %d", cfg.Check.Channels.MaxRoutines)
}
}
func TestValidate_ChannelsByteRange(t *testing.T) {
cfg := defaults()
cfg.Check.Channels.ByteRange = 0
validate(cfg)
if cfg.Check.Channels.ByteRange != 512 {
t.Errorf("expected 512, got %d", cfg.Check.Channels.ByteRange)
}
}
func TestValidate_UserAgentEmpty(t *testing.T) {
cfg := defaults()
cfg.Check.Playlists.UserAgent = nil
cfg.Check.Channels.UserAgent = nil
validate(cfg)
if len(cfg.Check.Playlists.UserAgent) == 0 {
t.Error("expected non-empty playlists user-agent")
}
if len(cfg.Check.Channels.UserAgent) == 0 {
t.Error("expected non-empty channels user-agent")
}
}
// --- validateIntRange ---
func TestValidateIntRange_Swap(t *testing.T) {
r := IntRange{Min: 100, Max: 10}
validateIntRange(&r, "test", 0, 1000)
if r.Min != 10 || r.Max != 100 {
t.Errorf("expected [10,100] after swap, got [%d,%d]", r.Min, r.Max)
}
}
func TestValidateIntRange_ClampMin(t *testing.T) {
r := IntRange{Min: -5, Max: 10}
validateIntRange(&r, "test", 0, 1000)
if r.Min != 0 {
t.Errorf("expected Min=0 after clamp, got %d", r.Min)
}
}
func TestValidateIntRange_ClampMax(t *testing.T) {
r := IntRange{Min: 0, Max: 999999}
validateIntRange(&r, "test", 0, 1000)
if r.Max != 1000 {
t.Errorf("expected Max=1000 after clamp, got %d", r.Max)
}
}
func TestValidateIntRange_SwapThenClamp(t *testing.T) {
r := IntRange{Min: 2000, Max: -10}
validateIntRange(&r, "test", 0, 1000)
// swap → [10, 2000], clamp → [0, 1000]
if r.Min != 0 || r.Max != 1000 {
t.Errorf("expected [0,1000], got [%d,%d]", r.Min, r.Max)
}
}
// --- defaults ---
func TestDefaults(t *testing.T) {
cfg := defaults()
if cfg.App.Timezone != "GMT" {
t.Errorf("expected GMT, got %s", cfg.App.Timezone)
}
if cfg.Server.Port != 8800 {
t.Errorf("expected 8800, got %d", cfg.Server.Port)
}
if cfg.Check.Playlists.Timeout != 10000 {
t.Errorf("expected 10000 ms, got %d", cfg.Check.Playlists.Timeout)
}
if cfg.Check.Channels.Timeout != 10000 {
t.Errorf("expected 10000 ms, got %d", cfg.Check.Channels.Timeout)
}
if cfg.Cache.Port != 6379 {
t.Errorf("expected 6379, got %d", cfg.Cache.Port)
}
}
// --- Init (integration) ---
func TestInit_NoConfigFile(t *testing.T) {
cfg := Init("/nonexistent/config.yml")
if cfg == nil {
t.Fatal("expected non-nil config")
}
if cfg.Server.Port != 8800 {
t.Errorf("expected default port 8800, got %d", cfg.Server.Port)
}
}
func TestInit_WithConfigFile(t *testing.T) {
tmp := t.TempDir() + "/config.yml"
data := []byte(`
app:
timezone: UTC+3
debug: true
server:
port: 3000
host: 0.0.0.0
check:
playlists:
timeout: 5
all-cooldown: [1, 2]
channels:
byte-range: 1024
cache:
enabled: true
host: db.local
port: 6380
`)
if err := os.WriteFile(tmp, data, 0644); err != nil {
t.Fatal(err)
}
cfg := Init(tmp)
if cfg.App.Timezone != "UTC+3" {
t.Errorf("expected UTC+3, got %s", cfg.App.Timezone)
}
if !cfg.App.Debug {
t.Error("expected debug=true")
}
if cfg.Server.Port != 3000 {
t.Errorf("expected 3000, got %d", cfg.Server.Port)
}
if cfg.Server.Host != "0.0.0.0" {
t.Errorf("expected 0.0.0.0, got %s", cfg.Server.Host)
}
if cfg.Check.Playlists.Timeout != 5000 {
t.Errorf("expected 5000 ms from 5 s, got %d", cfg.Check.Playlists.Timeout)
}
if cfg.Check.Playlists.AllCooldown.Min != 1000 || cfg.Check.Playlists.AllCooldown.Max != 2000 {
t.Errorf("expected [1000,2000] ms from [1,2] s, got [%d,%d]",
cfg.Check.Playlists.AllCooldown.Min, cfg.Check.Playlists.AllCooldown.Max)
}
if cfg.Check.Channels.ByteRange != 1024 {
t.Errorf("expected 1024, got %d", cfg.Check.Channels.ByteRange)
}
if !cfg.Cache.Enabled {
t.Error("expected cache enabled")
}
if cfg.Cache.Host != "db.local" {
t.Errorf("expected db.local, got %s", cfg.Cache.Host)
}
if cfg.Cache.Port != 6380 {
t.Errorf("expected 6380, got %d", cfg.Cache.Port)
}
}
func TestInit_EnvOverridesConfig(t *testing.T) {
tmp := t.TempDir() + "/config.yml"
data := []byte("server:\n port: 3000\n")
if err := os.WriteFile(tmp, data, 0644); err != nil {
t.Fatal(err)
}
t.Setenv("SERVER_PORT", "9090")
t.Setenv("APP_DEBUG", "true")
t.Setenv("CHECK_PLAYLISTS_TIMEOUT", "7")
t.Setenv("CHECK_PLAYLISTS_USER_AGENT_1", "CustomUA")
t.Setenv("CHECK_PLAYLISTS_ALL_COOLDOWN", "1.5,2.5")
cfg := Init(tmp)
if cfg.Server.Port != 9090 {
t.Errorf("expected 9090 from env, got %d", cfg.Server.Port)
}
if !cfg.App.Debug {
t.Error("expected debug=true from env")
}
if cfg.Check.Playlists.Timeout != 7000 {
t.Errorf("expected 7000 ms from 7 s env, got %d", cfg.Check.Playlists.Timeout)
}
if len(cfg.Check.Playlists.UserAgent) != 1 || cfg.Check.Playlists.UserAgent[0] != "CustomUA" {
t.Errorf("expected [CustomUA], got %v", cfg.Check.Playlists.UserAgent)
}
if cfg.Check.Playlists.AllCooldown.Min != 1500 || cfg.Check.Playlists.AllCooldown.Max != 2500 {
t.Errorf("expected [1500,2500] ms from [1.5,2.5] s env, got [%d,%d]",
cfg.Check.Playlists.AllCooldown.Min, cfg.Check.Playlists.AllCooldown.Max)
}
}
func TestInit_EnvScalarCooldownOverridesRange(t *testing.T) {
tmp := t.TempDir() + "/config.yml"
data := []byte("check:\n playlists:\n all-cooldown: [1, 2]\n")
if err := os.WriteFile(tmp, data, 0644); err != nil {
t.Fatal(err)
}
t.Setenv("CHECK_PLAYLISTS_ALL_COOLDOWN", "2.5")
cfg := Init(tmp)
if cfg.Check.Playlists.AllCooldown.Min != 2500 || cfg.Check.Playlists.AllCooldown.Max != 2500 {
t.Errorf("expected scalar [2500,2500] ms from 2.5 s env, got [%d,%d]",
cfg.Check.Playlists.AllCooldown.Min, cfg.Check.Playlists.AllCooldown.Max)
}
}
// helper removed — using yaml.Unmarshal directly