Files
iptvc/app/config/config.go
T
2026-07-19 19:14:55 +08:00

630 lines
20 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"
)
// Константы значений по умолчанию для временных параметров.
// Внешний интерфейс (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"`
}
// 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 {
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",
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{
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("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
}
// 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
}
}