package config import ( "fmt" "os" "path/filepath" "strings" "time" "gopkg.in/yaml.v3" ) // ExpandPath resolves a path that may start with ~ to an absolute path. func ExpandPath(p string) string { if strings.HasPrefix(p, "~") { home, err := os.UserHomeDir() if err != nil { return p // fall back to literal on error } return filepath.Join(home, strings.TrimPrefix(p, "~")) } return p } // expandPath resolves a path that may start with ~ to an absolute path. func expandPath(p string) string { return ExpandPath(p) } // Feed represents a single podcast feed to monitor. type Feed struct { URL string `yaml:"url"` OutputDir string `yaml:"output_dir"` PollInterval *time.Duration `yaml:"poll_interval,omitempty"` // nil means use global default StartDate string `yaml:"start_date,omitempty"` // optional, format "2006-01-02" } // PublishedAt returns the parsed start date for this feed, or zero time if not set. func (f Feed) PublishedAt() time.Time { if f.StartDate == "" { return time.Time{} } t, err := time.Parse("2006-01-02", f.StartDate) if err != nil { return time.Time{} } return t } // Settings holds global defaults for the daemon. Zero values are replaced by ApplyDefaults. type Settings struct { PollInterval time.Duration `yaml:"poll_interval"` MaxRetries int `yaml:"max_retries"` BackoffBase time.Duration `yaml:"backoff_base"` DownloadPoolSize int `yaml:"download_pool_size"` YTDlpPath string `yaml:"yt_dlp_path,omitempty"` // optional; defaults to "yt-dlp" on PATH } // Config is the top-level parsed configuration. type Config struct { Settings Settings `yaml:"settings,omitempty"` Feeds []Feed `yaml:"feeds"` } // ApplyDefaults fills in zero-valued settings with sensible defaults and expands ~ paths. func (c *Config) ApplyDefaults() { if c.Settings.PollInterval == 0 { c.Settings.PollInterval = 6 * time.Hour } if c.Settings.MaxRetries == 0 { c.Settings.MaxRetries = 5 } if c.Settings.BackoffBase == 0 { c.Settings.BackoffBase = 5 * time.Minute } if c.Settings.DownloadPoolSize == 0 { c.Settings.DownloadPoolSize = 4 } for i := range c.Feeds { c.Feeds[i].OutputDir = expandPath(c.Feeds[i].OutputDir) } } // PollIntervalFor returns the effective poll interval for a feed, falling back to global default. func (c *Config) PollIntervalFor(f Feed) time.Duration { if f.PollInterval != nil && *f.PollInterval > 0 { return *f.PollInterval } return c.Settings.PollInterval } // Validate checks that the config is usable. func (c *Config) Validate() error { if len(c.Feeds) == 0 { return fmt.Errorf("no feeds defined") } for i, f := range c.Feeds { if f.URL == "" { return fmt.Errorf("feed[%d]: url is required", i) } if f.OutputDir == "" { return fmt.Errorf("feed[%d] (%s): output_dir is required", i, f.URL) } if c.Settings.PollInterval <= 0 { return fmt.Errorf("settings.poll_interval must be > 0") } if c.Settings.MaxRetries == 0 { return fmt.Errorf("settings.max_retries must be > 0") } if c.Settings.BackoffBase <= 0 { return fmt.Errorf("settings.backoff_base must be > 0") } if c.Settings.DownloadPoolSize == 0 { return fmt.Errorf("settings.download_pool_size must be > 0") } // Validate start_date format if provided. if f.StartDate != "" { if _, err := time.Parse("2006-01-02", f.StartDate); err != nil { return fmt.Errorf("feed[%d] (%s): invalid start_date %q: %w", i, f.URL, f.StartDate, err) } } } return nil } // Load reads and parses a YAML config from the given path. func Load(path string) (*Config, error) { path = expandPath(path) data, err := os.ReadFile(path) if err != nil { return nil, fmt.Errorf("read config %s: %w", path, err) } var cfg Config if err := yaml.Unmarshal(data, &cfg); err != nil { return nil, fmt.Errorf("parse config: %w", err) } cfg.ApplyDefaults() if err := cfg.Validate(); err != nil { return nil, fmt.Errorf("validate config: %w", err) } return &cfg, nil }