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:
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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), " "))
|
||||||
}
|
}
|
||||||
|
|||||||
31
internal/qbit/client_test.go
Normal file
31
internal/qbit/client_test.go
Normal 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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: ""
|
||||||
|
|||||||
Reference in New Issue
Block a user