Add support for notification dry run feature

- Introduced NOTIFICATION_DRY_RUN configuration option in .env.example and k8s/secret.example.yaml.
- Updated README.md to include usage instructions for the new dry run feature.
- Implemented logic in app.go to preview notifications without sending them when the dry run option is enabled.
- Enhanced config.go to load the new configuration option and validate notification types accordingly.
- Added a new function in notify.go to generate manual-needed notification messages for preview.
This commit is contained in:
Zsolt Alföldi
2026-05-07 00:35:05 +02:00
parent 469e5b0678
commit ecea084003
8 changed files with 92 additions and 2 deletions

View File

@@ -10,6 +10,7 @@ ALERT_AFTER='48h'
# Optional: leave NOTIFICATION_TYPE empty to disable alerts. # Optional: leave NOTIFICATION_TYPE empty to disable alerts.
# Use 'ntfy' or 'smtp'. If NOTIFICATION_NTFY_URL is set, ntfy is selected automatically. # Use 'ntfy' or 'smtp'. If NOTIFICATION_NTFY_URL is set, ntfy is selected automatically.
NOTIFICATION_DRY_RUN='false'
NOTIFICATION_TYPE='' NOTIFICATION_TYPE=''
# ntfy.sh example: https://ntfy.sh/your-secret-topic # ntfy.sh example: https://ntfy.sh/your-secret-topic

View File

@@ -19,6 +19,7 @@ DRY_RUN='false'
ALERT_AFTER='48h' ALERT_AFTER='48h'
# Optional notifications: set NOTIFICATION_TYPE to 'ntfy' or 'smtp'. # Optional notifications: set NOTIFICATION_TYPE to 'ntfy' or 'smtp'.
NOTIFICATION_DRY_RUN='false'
NOTIFICATION_TYPE='' NOTIFICATION_TYPE=''
NOTIFICATION_NTFY_URL='' 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. `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=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: 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 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: JSON output:
```bash ```bash

View File

@@ -122,6 +122,11 @@ func Run(cfg config.Config) (model.RunSummary, error) {
return summary, err return summary, err
} }
if cfg.NotificationDryRun {
printNotificationPreview(cfg.NotificationType, summary.Results)
return summary, nil
}
notifier := newNotifier(cfg) notifier := newNotifier(cfg)
if notifier != nil { if notifier != nil {
if err := notifier.SendManualNeeded(summary.Results); err != nil { if err := notifier.SendManualNeeded(summary.Results); err != nil {
@@ -300,3 +305,17 @@ func newNotifier(cfg config.Config) notify.Sender {
return nil 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)
}

View File

@@ -28,6 +28,7 @@ type Config struct {
AlertAfter time.Duration AlertAfter time.Duration
HTTPTimeout time.Duration HTTPTimeout time.Duration
NotificationDryRun bool
NotificationType string NotificationType string
NotificationNTFYURL string NotificationNTFYURL string
NotificationSMTPHost string NotificationSMTPHost string
@@ -56,6 +57,7 @@ func Load(args []string) (Config, error) {
AlertAfter: envDuration("ALERT_AFTER", 48*time.Hour), AlertAfter: envDuration("ALERT_AFTER", 48*time.Hour),
HTTPTimeout: envDuration("HTTP_TIMEOUT", 30*time.Second), HTTPTimeout: envDuration("HTTP_TIMEOUT", 30*time.Second),
NotificationDryRun: envBool("NOTIFICATION_DRY_RUN", false),
NotificationType: env("NOTIFICATION_TYPE", ""), NotificationType: env("NOTIFICATION_TYPE", ""),
NotificationNTFYURL: env("NOTIFICATION_NTFY_URL", env("NOTIFY_URL", "")), NotificationNTFYURL: env("NOTIFICATION_NTFY_URL", env("NOTIFY_URL", "")),
NotificationSMTPHost: env("NOTIFICATION_SMTP_HOST", ""), 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.SkipQBit, "skip-qbit", cfg.SkipQBit, "skip qBittorrent matching/actions")
fs.BoolVar(&cfg.JSONOutput, "json", cfg.JSONOutput, "print JSON summary") 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.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.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, "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.NotificationNTFYURL, "notify-url", cfg.NotificationNTFYURL, "deprecated: use --notification-ntfy-url")
@@ -99,9 +102,13 @@ func Load(args []string) (Config, error) {
return cfg, fmt.Errorf("set QBITTORRENT_URL, QBITTORRENT_USERNAME and QBITTORRENT_PASSWORD, or use --dry-run/--skip-qbit") 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 { if err := validateNotification(cfg); err != nil {
return cfg, err return cfg, err
} }
} else if err := validateNotificationType(cfg); err != nil {
return cfg, err
}
return cfg, nil return cfg, nil
} }
@@ -179,6 +186,9 @@ func normalizeNotificationType(cfg Config) string {
} }
func validateNotification(cfg Config) error { func validateNotification(cfg Config) error {
if err := validateNotificationType(cfg); err != nil {
return err
}
switch cfg.NotificationType { switch cfg.NotificationType {
case "": case "":
return nil return nil
@@ -207,3 +217,12 @@ func validateNotification(cfg Config) error {
} }
return nil 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)
}
}

View File

@@ -104,6 +104,11 @@ func (s NotificationSMTP) SendManualNeeded(results []model.ActionResult) error {
return nil 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) { func manualNeededText(results []model.ActionResult) (string, bool) {
var body strings.Builder var body strings.Builder
manualCount := 0 manualCount := 0

View File

@@ -7,11 +7,14 @@ import (
"net/http" "net/http"
"net/http/cookiejar" "net/http/cookiejar"
"net/url" "net/url"
"regexp"
"strings" "strings"
"ncore-hnr/internal/model" "ncore-hnr/internal/model"
) )
var releaseSeparator = regexp.MustCompile(`[._\-\[\]()+]+`)
type Client struct { type Client struct {
baseURL string baseURL string
httpClient *http.Client httpClient *http.Client
@@ -87,7 +90,8 @@ func MatchByName(ncoreName string, torrents []model.QBitTorrent) (model.QBitTorr
normalized := normalizeName(ncoreName) normalized := normalizeName(ncoreName)
for _, torrent := range torrents { for _, torrent := range torrents {
if normalizeName(torrent.Name) == normalized { torrentName := normalizeName(torrent.Name)
if torrentName == normalized || strings.HasPrefix(torrentName, normalized+" ") {
return torrent, true return torrent, true
} }
} }
@@ -112,5 +116,6 @@ func (c *Client) endpoint(path string) string {
} }
func normalizeName(value string) string { func normalizeName(value string) string {
value = releaseSeparator.ReplaceAllString(value, " ")
return strings.ToLower(strings.Join(strings.Fields(value), " ")) return strings.ToLower(strings.Join(strings.Fields(value), " "))
} }

View File

@@ -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")
}
}

View File

@@ -9,6 +9,7 @@ stringData:
QBITTORRENT_URL: "http://qbittorrent.default.svc.cluster.local:8080" QBITTORRENT_URL: "http://qbittorrent.default.svc.cluster.local:8080"
QBITTORRENT_USERNAME: "admin" QBITTORRENT_USERNAME: "admin"
QBITTORRENT_PASSWORD: "your-qbit-password" QBITTORRENT_PASSWORD: "your-qbit-password"
NOTIFICATION_DRY_RUN: "false"
NOTIFICATION_TYPE: "" NOTIFICATION_TYPE: ""
NOTIFICATION_NTFY_URL: "" NOTIFICATION_NTFY_URL: ""
NOTIFICATION_SMTP_HOST: "" NOTIFICATION_SMTP_HOST: ""