Files
iptvc/app/config/config.go
T
2026-07-16 12:08:06 +08:00

572 lines
17 KiB
Go

/*
* 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"
)
// 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"`
}
// 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 {
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"`
}
// 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{
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: "Аптайм", 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{
Enabled: false,
Host: "localhost",
Port: 6379,
Username: "",
Password: "",
Db: 0,
Ttl: 1800,
},
}
}
// 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("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")
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 = 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")
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
}
// applyEnvIntRange читает одно env-значение для IntRange.
// Одно число задаёт фиксированное значение, два числа через запятую — диапазон.
func applyEnvIntRange(r *IntRange, name string) {
value, ok := os.LookupEnv(name)
if !ok || strings.TrimSpace(value) == "" {
return
}
parts := strings.Split(value, ",")
if len(parts) == 1 {
n, err := strconv.Atoi(strings.TrimSpace(parts[0]))
if err != nil {
return
}
r.Min = n
r.Max = n
return
}
if len(parts) != 2 {
return
}
min, minErr := strconv.Atoi(strings.TrimSpace(parts[0]))
max, maxErr := strconv.Atoi(strings.TrimSpace(parts[1]))
if minErr != nil || maxErr != nil {
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 {
log.Println("Config: server.port is 0, using default 8800")
cfg.Server.Port = 8800
}
if cfg.Server.Port > 65535 {
log.Println("Config: server.port > 65535, 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.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
}
}