- 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.
149 lines
4.0 KiB
Go
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
|
|
}
|