package config import ( "flag" "fmt" "os" "strconv" "strings" "time" "github.com/joho/godotenv" ) type Config struct { NCoreUsername string NCorePassword string NCoreLoginURL string NCoreHitRunURL string QBitURL string QBitUsername string QBitPassword string SkipQBit bool DBPath string DryRun bool JSONOutput bool AlertAfter time.Duration HTTPTimeout time.Duration NotificationDryRun bool NotificationType string NotificationNTFYURL string NotificationSMTPHost string NotificationSMTPPort string NotificationSMTPUsername string NotificationSMTPPassword string NotificationSMTPFrom string NotificationSMTPTo string } func Load(args []string) (Config, error) { loadDotenv(".env", "../.env") cfg := Config{ NCoreUsername: env("NCORE_USERNAME", ""), NCorePassword: env("NCORE_PASSWORD", ""), NCoreLoginURL: env("NCORE_LOGIN_URL", "https://ncore.pro/login.php"), NCoreHitRunURL: env("NCORE_HITRUN_URL", "https://ncore.pro/hitnrun.php"), QBitURL: env("QBITTORRENT_URL", ""), QBitUsername: env("QBITTORRENT_USERNAME", ""), QBitPassword: env("QBITTORRENT_PASSWORD", ""), SkipQBit: envBool("SKIP_QBIT", false), DBPath: env("APP_DB_PATH", "data/ncore-hnr.sqlite"), DryRun: envBool("DRY_RUN", false), JSONOutput: envBool("JSON_OUTPUT", false), AlertAfter: envDuration("ALERT_AFTER", 48*time.Hour), HTTPTimeout: envDuration("HTTP_TIMEOUT", 30*time.Second), NotificationDryRun: envBool("NOTIFICATION_DRY_RUN", false), NotificationType: env("NOTIFICATION_TYPE", ""), NotificationNTFYURL: env("NOTIFICATION_NTFY_URL", env("NOTIFY_URL", "")), NotificationSMTPHost: env("NOTIFICATION_SMTP_HOST", ""), NotificationSMTPPort: env("NOTIFICATION_SMTP_PORT", "587"), NotificationSMTPUsername: env("NOTIFICATION_SMTP_USERNAME", ""), NotificationSMTPPassword: env("NOTIFICATION_SMTP_PASSWORD", ""), NotificationSMTPFrom: env("NOTIFICATION_SMTP_FROM", ""), NotificationSMTPTo: env("NOTIFICATION_SMTP_TO", ""), } fs := flag.NewFlagSet("ncore-hnr", flag.ContinueOnError) fs.StringVar(&cfg.DBPath, "db", cfg.DBPath, "SQLite database path") fs.BoolVar(&cfg.DryRun, "dry-run", cfg.DryRun, "print intended qBittorrent actions without executing them") fs.BoolVar(&cfg.SkipQBit, "skip-qbit", cfg.SkipQBit, "skip qBittorrent matching/actions") fs.BoolVar(&cfg.JSONOutput, "json", cfg.JSONOutput, "print JSON summary") fs.DurationVar(&cfg.AlertAfter, "alert-after", cfg.AlertAfter, "mark active torrents manual-needed after this duration") fs.BoolVar(&cfg.NotificationDryRun, "notification-dry-run", cfg.NotificationDryRun, "print the manual-needed notification without sending it") fs.StringVar(&cfg.NotificationType, "notification-type", cfg.NotificationType, "notification sender: ntfy, smtp, or empty") fs.StringVar(&cfg.NotificationNTFYURL, "notification-ntfy-url", cfg.NotificationNTFYURL, "ntfy topic URL for manual-needed alerts") fs.StringVar(&cfg.NotificationNTFYURL, "notify-url", cfg.NotificationNTFYURL, "deprecated: use --notification-ntfy-url") fs.StringVar(&cfg.NotificationSMTPHost, "notification-smtp-host", cfg.NotificationSMTPHost, "SMTP host for manual-needed alerts") fs.StringVar(&cfg.NotificationSMTPPort, "notification-smtp-port", cfg.NotificationSMTPPort, "SMTP port for manual-needed alerts") fs.StringVar(&cfg.NotificationSMTPUsername, "notification-smtp-username", cfg.NotificationSMTPUsername, "SMTP username for manual-needed alerts") fs.StringVar(&cfg.NotificationSMTPPassword, "notification-smtp-password", cfg.NotificationSMTPPassword, "SMTP password for manual-needed alerts") fs.StringVar(&cfg.NotificationSMTPFrom, "notification-smtp-from", cfg.NotificationSMTPFrom, "SMTP From header for manual-needed alerts") fs.StringVar(&cfg.NotificationSMTPTo, "notification-smtp-to", cfg.NotificationSMTPTo, "SMTP To header for manual-needed alerts") fs.DurationVar(&cfg.HTTPTimeout, "http-timeout", cfg.HTTPTimeout, "HTTP timeout") if err := fs.Parse(args); err != nil { return cfg, err } cfg.NotificationType = normalizeNotificationType(cfg) if cfg.NCoreUsername == "" { return cfg, fmt.Errorf("missing NCORE_USERNAME") } if cfg.NCorePassword == "" { return cfg, fmt.Errorf("missing NCORE_PASSWORD") } if !cfg.SkipQBit && !cfg.DryRun { if cfg.QBitURL == "" || cfg.QBitUsername == "" || cfg.QBitPassword == "" { return cfg, fmt.Errorf("set QBITTORRENT_URL, QBITTORRENT_USERNAME and QBITTORRENT_PASSWORD, or use --dry-run/--skip-qbit") } } if !cfg.NotificationDryRun { if err := validateNotification(cfg); err != nil { return cfg, err } } else if err := validateNotificationType(cfg); err != nil { return cfg, err } return cfg, nil } func LoadReadOnly(args []string) (Config, error) { loadDotenv(".env", "../.env") cfg := Config{ DBPath: env("APP_DB_PATH", "data/ncore-hnr.sqlite"), JSONOutput: envBool("JSON_OUTPUT", false), } fs := flag.NewFlagSet("ncore-hnr", flag.ContinueOnError) fs.StringVar(&cfg.DBPath, "db", cfg.DBPath, "SQLite database path") fs.BoolVar(&cfg.JSONOutput, "json", cfg.JSONOutput, "print JSON output") if err := fs.Parse(args); err != nil { return cfg, err } return cfg, nil } func loadDotenv(paths ...string) { for _, path := range paths { _ = godotenv.Load(path) } } func env(name string, fallback string) string { value := strings.TrimSpace(os.Getenv(name)) if value == "" { return fallback } return value } func envBool(name string, fallback bool) bool { value := strings.TrimSpace(os.Getenv(name)) if value == "" { return fallback } parsed, err := strconv.ParseBool(value) if err != nil { return fallback } return parsed } func envDuration(name string, fallback time.Duration) time.Duration { value := strings.TrimSpace(os.Getenv(name)) if value == "" { return fallback } parsed, err := time.ParseDuration(value) if err != nil { return fallback } return parsed } func normalizeNotificationType(cfg Config) string { notificationType := strings.ToLower(strings.TrimSpace(cfg.NotificationType)) switch notificationType { case "none", "off", "disabled": return "" case "": if strings.TrimSpace(cfg.NotificationNTFYURL) != "" { return "ntfy" } if strings.TrimSpace(cfg.NotificationSMTPHost) != "" || strings.TrimSpace(cfg.NotificationSMTPTo) != "" { return "smtp" } } return notificationType } func validateNotification(cfg Config) error { if err := validateNotificationType(cfg); err != nil { return err } switch cfg.NotificationType { case "": return nil case "ntfy": if strings.TrimSpace(cfg.NotificationNTFYURL) == "" { return fmt.Errorf("missing NOTIFICATION_NTFY_URL for ntfy notifications") } case "smtp": if strings.TrimSpace(cfg.NotificationSMTPHost) == "" { return fmt.Errorf("missing NOTIFICATION_SMTP_HOST for smtp notifications") } if strings.TrimSpace(cfg.NotificationSMTPPort) == "" { return fmt.Errorf("missing NOTIFICATION_SMTP_PORT for smtp notifications") } if strings.TrimSpace(cfg.NotificationSMTPFrom) == "" { return fmt.Errorf("missing NOTIFICATION_SMTP_FROM for smtp notifications") } if strings.TrimSpace(cfg.NotificationSMTPTo) == "" { return fmt.Errorf("missing NOTIFICATION_SMTP_TO for smtp notifications") } if (strings.TrimSpace(cfg.NotificationSMTPUsername) == "") != (strings.TrimSpace(cfg.NotificationSMTPPassword) == "") { return fmt.Errorf("set both NOTIFICATION_SMTP_USERNAME and NOTIFICATION_SMTP_PASSWORD, or leave both empty") } default: return fmt.Errorf("unsupported NOTIFICATION_TYPE %q", cfg.NotificationType) } return nil } func validateNotificationType(cfg Config) error { switch cfg.NotificationType { case "", "ntfy", "smtp": return nil default: return fmt.Errorf("unsupported NOTIFICATION_TYPE %q", cfg.NotificationType) } }