/* * Copyright (c) 2025-2026, Антон Аксенов * This file is part of iptvc project * MIT License: https://git.axenov.dev/IPTV/iptvc/src/branch/master/LICENSE */ package config import ( "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 { 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"` } // 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 { 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:"-"` } // 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{ 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{ Enabled: false, Host: "localhost", Port: 6379, Username: "", Password: "", Db: 0, Ttl: defaultCacheTtlSeconds, }, } } // 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" } 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 } // 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 } // parseUint разбирает строку в uint, при ошибке возвращает значение по умолчанию func parseUint(s string, defaultValue uint) uint { val, err := strconv.Atoi(s) if err == nil { 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 } }