diff --git a/.env.example b/.env.example index 4964d0a..7fed88b 100644 --- a/.env.example +++ b/.env.example @@ -10,6 +10,7 @@ ALERT_AFTER='48h' # Optional: leave NOTIFICATION_TYPE empty to disable alerts. # Use 'ntfy' or 'smtp'. If NOTIFICATION_NTFY_URL is set, ntfy is selected automatically. +NOTIFICATION_DRY_RUN='false' NOTIFICATION_TYPE='' # ntfy.sh example: https://ntfy.sh/your-secret-topic diff --git a/README.md b/README.md index 1c4ffe8..60ac033 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,7 @@ DRY_RUN='false' ALERT_AFTER='48h' # Optional notifications: set NOTIFICATION_TYPE to 'ntfy' or 'smtp'. +NOTIFICATION_DRY_RUN='false' NOTIFICATION_TYPE='' NOTIFICATION_NTFY_URL='' @@ -33,6 +34,8 @@ NOTIFICATION_SMTP_TO='' `DRY_RUN=false` is the default and will call qBittorrent `setForceStart` and `reannounce`. Use `--dry-run=true` when you only want to preview matches. +Use `--notification-dry-run=true` to print the manual-needed notification subject/body without sending it. + Set `NOTIFICATION_TYPE=ntfy` and `NOTIFICATION_NTFY_URL='https://ntfy.sh/your-secret-topic'` to send manual-needed alerts through ntfy. For compatibility, old `NOTIFY_URL` values still work as the ntfy URL. Set `NOTIFICATION_TYPE=smtp` to send manual-needed alerts by email. For Gmail, use: @@ -58,6 +61,12 @@ Preview without qBittorrent actions: go run ./cmd/ncore-hnr --dry-run=true ``` +Preview the notification body without sending it: + +```bash +go run ./cmd/ncore-hnr --dry-run=true --alert-after=0s --notification-dry-run=true +``` + JSON output: ```bash diff --git a/internal/app/app.go b/internal/app/app.go index a2b3473..d6be6c6 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -122,6 +122,11 @@ func Run(cfg config.Config) (model.RunSummary, error) { return summary, err } + if cfg.NotificationDryRun { + printNotificationPreview(cfg.NotificationType, summary.Results) + return summary, nil + } + notifier := newNotifier(cfg) if notifier != nil { if err := notifier.SendManualNeeded(summary.Results); err != nil { @@ -300,3 +305,17 @@ func newNotifier(cfg config.Config) notify.Sender { return nil } } + +func printNotificationPreview(notificationType string, results []model.ActionResult) { + subject, body, ok := notify.ManualNeededMessage(results) + if !ok { + fmt.Fprintln(os.Stderr, "notification dry-run: no manual-needed torrents, nothing would be sent") + return + } + + if notificationType == "" { + notificationType = "unconfigured" + } + fmt.Fprintf(os.Stderr, "notification dry-run: type=%s\n", notificationType) + fmt.Fprintf(os.Stderr, "Subject: %s\n\n%s", subject, body) +} diff --git a/internal/config/config.go b/internal/config/config.go index 7609ae3..568044d 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -28,6 +28,7 @@ type Config struct { AlertAfter time.Duration HTTPTimeout time.Duration + NotificationDryRun bool NotificationType string NotificationNTFYURL string NotificationSMTPHost string @@ -56,6 +57,7 @@ func Load(args []string) (Config, error) { 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", ""), @@ -72,6 +74,7 @@ func Load(args []string) (Config, error) { 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") @@ -99,7 +102,11 @@ func Load(args []string) (Config, error) { return cfg, fmt.Errorf("set QBITTORRENT_URL, QBITTORRENT_USERNAME and QBITTORRENT_PASSWORD, or use --dry-run/--skip-qbit") } } - if err := validateNotification(cfg); err != nil { + if !cfg.NotificationDryRun { + if err := validateNotification(cfg); err != nil { + return cfg, err + } + } else if err := validateNotificationType(cfg); err != nil { return cfg, err } @@ -179,6 +186,9 @@ func normalizeNotificationType(cfg Config) string { } func validateNotification(cfg Config) error { + if err := validateNotificationType(cfg); err != nil { + return err + } switch cfg.NotificationType { case "": return nil @@ -207,3 +217,12 @@ func validateNotification(cfg Config) error { } 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) + } +} diff --git a/internal/notify/notify.go b/internal/notify/notify.go index 99c416e..33582dc 100644 --- a/internal/notify/notify.go +++ b/internal/notify/notify.go @@ -104,6 +104,11 @@ func (s NotificationSMTP) SendManualNeeded(results []model.ActionResult) error { return nil } +func ManualNeededMessage(results []model.ActionResult) (string, string, bool) { + body, ok := manualNeededText(results) + return manualNeededSubject, body, ok +} + func manualNeededText(results []model.ActionResult) (string, bool) { var body strings.Builder manualCount := 0 diff --git a/internal/qbit/client.go b/internal/qbit/client.go index d58f577..431961c 100644 --- a/internal/qbit/client.go +++ b/internal/qbit/client.go @@ -7,11 +7,14 @@ import ( "net/http" "net/http/cookiejar" "net/url" + "regexp" "strings" "ncore-hnr/internal/model" ) +var releaseSeparator = regexp.MustCompile(`[._\-\[\]()+]+`) + type Client struct { baseURL string httpClient *http.Client @@ -87,7 +90,8 @@ func MatchByName(ncoreName string, torrents []model.QBitTorrent) (model.QBitTorr normalized := normalizeName(ncoreName) for _, torrent := range torrents { - if normalizeName(torrent.Name) == normalized { + torrentName := normalizeName(torrent.Name) + if torrentName == normalized || strings.HasPrefix(torrentName, normalized+" ") { return torrent, true } } @@ -112,5 +116,6 @@ func (c *Client) endpoint(path string) string { } func normalizeName(value string) string { + value = releaseSeparator.ReplaceAllString(value, " ") return strings.ToLower(strings.Join(strings.Fields(value), " ")) } diff --git a/internal/qbit/client_test.go b/internal/qbit/client_test.go new file mode 100644 index 0000000..a723de6 --- /dev/null +++ b/internal/qbit/client_test.go @@ -0,0 +1,31 @@ +package qbit + +import ( + "testing" + + "ncore-hnr/internal/model" +) + +func TestMatchByNameMatchesLongerReleaseName(t *testing.T) { + torrents := []model.QBitTorrent{ + {Name: "The.Black.Dagger.Brotherhood.S01E01.720p.WEB.h264-GROUP", Hash: "match"}, + } + + match, ok := MatchByName("The Black Dagger Brotherhood S01E01 720p", torrents) + if !ok { + t.Fatal("expected qBittorrent release name to match nCore display name") + } + if match.Hash != "match" { + t.Fatalf("expected match hash %q, got %q", "match", match.Hash) + } +} + +func TestMatchByNameDoesNotMatchDifferentEpisode(t *testing.T) { + torrents := []model.QBitTorrent{ + {Name: "The.Black.Dagger.Brotherhood.S01E02.720p.WEB.h264-GROUP", Hash: "wrong"}, + } + + if _, ok := MatchByName("The Black Dagger Brotherhood S01E01 720p", torrents); ok { + t.Fatal("expected different episode not to match") + } +} diff --git a/k8s/secret.example.yaml b/k8s/secret.example.yaml index 69c7fd2..87eecfd 100644 --- a/k8s/secret.example.yaml +++ b/k8s/secret.example.yaml @@ -9,6 +9,7 @@ stringData: QBITTORRENT_URL: "http://qbittorrent.default.svc.cluster.local:8080" QBITTORRENT_USERNAME: "admin" QBITTORRENT_PASSWORD: "your-qbit-password" + NOTIFICATION_DRY_RUN: "false" NOTIFICATION_TYPE: "" NOTIFICATION_NTFY_URL: "" NOTIFICATION_SMTP_HOST: ""