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.
# 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

View File

@@ -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

View File

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

View File

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

View File

@@ -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

View File

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

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_USERNAME: "admin"
QBITTORRENT_PASSWORD: "your-qbit-password"
NOTIFICATION_DRY_RUN: "false"
NOTIFICATION_TYPE: ""
NOTIFICATION_NTFY_URL: ""
NOTIFICATION_SMTP_HOST: ""