package config import ( "fmt" "log" "os" "strconv" "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,omitempty"` Series []string `yaml:"series,omitempty"` Roots []MediaRoot `yaml:"roots,omitempty"` } type MediaRoot struct { Path string `yaml:"path"` ShelfID string `yaml:"shelf_id"` Kind string `yaml:"kind"` // movie | series (episode) } // EffectiveRoots returns structured roots; falls back to legacy Movies/Series lists. func (m MediaConfig) EffectiveRoots() []MediaRoot { if len(m.Roots) > 0 { out := make([]MediaRoot, 0, len(m.Roots)) for _, r := range m.Roots { path := strings.TrimSpace(r.Path) if path == "" { continue } kind := strings.ToLower(strings.TrimSpace(r.Kind)) if kind == "series" || kind == "episode" { kind = "episode" } else { kind = "movie" } out = append(out, MediaRoot{Path: path, ShelfID: r.ShelfID, Kind: kind}) } return out } var out []MediaRoot for _, p := range m.Movies { p = strings.TrimSpace(p) if p != "" { out = append(out, MediaRoot{Path: p, Kind: "movie"}) } } for _, p := range m.Series { p = strings.TrimSpace(p) if p != "" { out = append(out, MediaRoot{Path: p, Kind: "episode"}) } } return out } type ScannerConfig struct { // FullScanInterval: duration like "6h", or "off" to disable interval scans. FullScanInterval string `yaml:"full_scan_interval"` // FullScanAt: local clock time HH:MM for nightly catch-up scan (default 03:30). FullScanAt string `yaml:"full_scan_at"` } 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) } cfg = normalizeScannerDefaults(applyEnvOverrides(cfg)) if saveErr := Save(path, cfg); saveErr != nil { return cfg, nil } return 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) } beforeInterval := cfg.Scanner.FullScanInterval beforeAt := cfg.Scanner.FullScanAt cfgPtr := normalizeScannerDefaults(applyEnvOverrides(&cfg)) // Persist migration away from legacy 6h interval when nightly schedule is the new default. if beforeInterval != cfgPtr.Scanner.FullScanInterval || beforeAt != cfgPtr.Scanner.FullScanAt { if err := Save(path, cfgPtr); err != nil { log.Printf("Could not persist scanner defaults: %v", err) } else { log.Printf( "Scanner config updated: interval=%s at=%s", cfgPtr.Scanner.FullScanInterval, cfgPtr.Scanner.FullScanAt, ) } } return cfgPtr, 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 } scanAt := strings.TrimSpace(os.Getenv("MEDIA_NODE_FULL_SCAN_AT")) scanInterval := strings.TrimSpace(os.Getenv("MEDIA_NODE_FULL_SCAN_INTERVAL")) 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: scanInterval, FullScanAt: scanAt, }, Security: SecurityConfig{SessionIdleTimeout: "20m", MaxConcurrentPerIP: 64}, }, nil } func applyEnvOverrides(cfg *Config) *Config { if v := strings.TrimSpace(os.Getenv("MEDIA_NODE_FULL_SCAN_AT")); v != "" { cfg.Scanner.FullScanAt = v } if v := strings.TrimSpace(os.Getenv("MEDIA_NODE_FULL_SCAN_INTERVAL")); v != "" { cfg.Scanner.FullScanInterval = v } return cfg } // normalizeScannerDefaults migrates legacy 6h-only configs to watch + nightly 03:30. func normalizeScannerDefaults(cfg *Config) *Config { if cfg.Stream.Listen == "" { cfg.Stream.Listen = DefaultListen } interval := strings.TrimSpace(strings.ToLower(cfg.Scanner.FullScanInterval)) at := strings.TrimSpace(cfg.Scanner.FullScanAt) // Empty or legacy default "6h" without an explicit nightly time → new policy. if interval == "" || interval == "6h" { if at == "" { cfg.Scanner.FullScanInterval = "off" cfg.Scanner.FullScanAt = "03:30" } else if interval == "6h" && at != "" { // Explicit nightly already set (e.g. via admin) — drop redundant 6h. cfg.Scanner.FullScanInterval = "off" } else { cfg.Scanner.FullScanInterval = "off" } } if strings.TrimSpace(cfg.Scanner.FullScanAt) == "" { cfg.Scanner.FullScanAt = "03:30" } if cfg.Security.SessionIdleTimeout == "" { cfg.Security.SessionIdleTimeout = "20m" } if cfg.Security.MaxConcurrentPerIP == 0 { cfg.Security.MaxConcurrentPerIP = 64 } 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 } // FullScanDuration returns 0 when interval scanning is disabled. func (c *Config) FullScanDuration() time.Duration { raw := strings.TrimSpace(strings.ToLower(c.Scanner.FullScanInterval)) if raw == "" || raw == "off" || raw == "0" || raw == "disabled" { return 0 } d, err := time.ParseDuration(c.Scanner.FullScanInterval) if err != nil { return 0 } return d } // FullScanClock returns hour and minute for the nightly scan (local timezone). func (c *Config) FullScanClock() (hour, minute int, ok bool) { raw := strings.TrimSpace(c.Scanner.FullScanAt) if raw == "" || strings.EqualFold(raw, "off") { return 0, 0, false } parts := strings.Split(raw, ":") if len(parts) != 2 { return 3, 30, true } h, err1 := strconv.Atoi(parts[0]) m, err2 := strconv.Atoi(parts[1]) if err1 != nil || err2 != nil || h < 0 || h > 23 || m < 0 || m > 59 { return 3, 30, true } return h, m, true } func Save(path string, cfg *Config) error { data, err := yaml.Marshal(cfg) if err != nil { return err } return os.WriteFile(path, data, 0o600) }