package config import ( "fmt" "os" "strings" "time" "gopkg.in/yaml.v3" ) const ( DefaultConfigPath = "/etc/media-node/config.yaml" DefaultDataDir = "/var/lib/media-node" DefaultCredentialsPath = "/etc/media-node/credentials/credentials.json" DefaultListen = "0.0.0.0:8080" ) type Config struct { Node NodeConfig `yaml:"node"` Master MasterConfig `yaml:"master"` Stream StreamConfig `yaml:"stream"` Media MediaConfig `yaml:"media"` Scanner ScannerConfig `yaml:"scanner"` Security SecurityConfig `yaml:"security"` } type NodeConfig struct { ID string `yaml:"id"` Name string `yaml:"name"` } type MasterConfig struct { URL string `yaml:"url"` } type StreamConfig struct { Listen string `yaml:"listen"` PublicURL string `yaml:"public_url"` } type MediaConfig struct { Movies []string `yaml:"movies"` Series []string `yaml:"series"` } type ScannerConfig struct { FullScanInterval string `yaml:"full_scan_interval"` } type SecurityConfig struct { SessionIdleTimeout string `yaml:"session_idle_timeout"` MaxConcurrentPerIP int `yaml:"max_concurrent_per_ip"` } type Credentials struct { NodeID string `json:"nodeId"` APIKey string `json:"apiKey"` Name string `json:"name"` } func Load(path string) (*Config, error) { data, err := os.ReadFile(path) if err != nil { if os.IsNotExist(err) { cfg, envErr := FromEnv() if envErr != nil { return nil, fmt.Errorf("read config: %w (and env incomplete: %v)", err, envErr) } if saveErr := Save(path, cfg); saveErr != nil { // Still usable in-memory when the path is not writable. return applyDefaults(cfg), nil } return applyDefaults(cfg), nil } return nil, fmt.Errorf("read config: %w", err) } var cfg Config if err := yaml.Unmarshal(data, &cfg); err != nil { return nil, fmt.Errorf("parse config: %w", err) } return applyDefaults(&cfg), nil } // FromEnv builds config from MEDIA_NODE_* environment variables (Docker/Unraid). func FromEnv() (*Config, error) { name := strings.TrimSpace(os.Getenv("MEDIA_NODE_NAME")) master := strings.TrimRight(strings.TrimSpace(os.Getenv("MEDIA_NODE_MASTER_URL")), "/") publicURL := strings.TrimRight(strings.TrimSpace(os.Getenv("MEDIA_NODE_PUBLIC_URL")), "/") if name == "" || master == "" || publicURL == "" { return nil, fmt.Errorf("MEDIA_NODE_NAME, MEDIA_NODE_MASTER_URL and MEDIA_NODE_PUBLIC_URL are required") } listen := strings.TrimSpace(os.Getenv("MEDIA_NODE_LISTEN")) if listen == "" { listen = DefaultListen } return &Config{ Node: NodeConfig{Name: name}, Master: MasterConfig{URL: master}, Stream: StreamConfig{Listen: listen, PublicURL: publicURL}, Media: MediaConfig{ Movies: splitPaths(os.Getenv("MEDIA_NODE_MOVIES")), Series: splitPaths(os.Getenv("MEDIA_NODE_SERIES")), }, Scanner: ScannerConfig{FullScanInterval: "6h"}, Security: SecurityConfig{SessionIdleTimeout: "20m", MaxConcurrentPerIP: 8}, }, nil } func applyDefaults(cfg *Config) *Config { if cfg.Stream.Listen == "" { cfg.Stream.Listen = DefaultListen } if cfg.Scanner.FullScanInterval == "" { cfg.Scanner.FullScanInterval = "6h" } if cfg.Security.SessionIdleTimeout == "" { cfg.Security.SessionIdleTimeout = "20m" } if cfg.Security.MaxConcurrentPerIP == 0 { cfg.Security.MaxConcurrentPerIP = 8 } return cfg } func splitPaths(raw string) []string { raw = strings.TrimSpace(raw) if raw == "" { return nil } var out []string for _, part := range strings.FieldsFunc(raw, func(r rune) bool { return r == ',' || r == ';' || r == '\n' }) { p := strings.TrimSpace(part) if p != "" { out = append(out, p) } } return out } func (c *Config) FullScanDuration() time.Duration { d, err := time.ParseDuration(c.Scanner.FullScanInterval) if err != nil { return 6 * time.Hour } return d } func Save(path string, cfg *Config) error { data, err := yaml.Marshal(cfg) if err != nil { return err } return os.WriteFile(path, data, 0o600) }