wip6
This commit is contained in:
+591
-39
@@ -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,623 @@
|
||||
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"`
|
||||
}
|
||||
|
||||
// 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.
|
||||
// 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",
|
||||
Header: HeaderConfig{
|
||||
Title: "IPTV Checker",
|
||||
Navigation: []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"},
|
||||
}},
|
||||
},
|
||||
},
|
||||
FooterLinks: []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("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 = 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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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: 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)
|
||||
}
|
||||
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 != 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
|
||||
Reference in New Issue
Block a user