rssd/config/config.go
Greg Pomerantz bc26e73a90 Add YouTube feed support via yt-dlp, MIME-based extensions, robust state
- ytdlp: thin wrapper around yt-dlp CLI for channel/playlist discovery
  (--flat-playlist) and per-video metadata (--print-json); defaults to
  player_client=web_embedded to avoid SABR-only format restrictions.
- downloader: YouTube path extracts audio via yt-dlp -x, probes duration
  with ffprobe and rejects clips far shorter than the expected length.
- main: YouTube channel/playlist feeds, incremental discovery with
  pre-start-date boundary for newest-first channels, two-phase
  record-then-enqueue so discovered jobs survive crashes.
- poller: propagate enclosure MIME type; computeDestPath appends the
  matching file extension.
- state: jobs carry expected duration; AddJobWithStatus for skipped items.
- cmd/probe: dry-run discovery/date-resolution diagnostics.
2026-09-23 09:06:36 -04:00

149 lines
4.0 KiB
Go

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
}