From d8e8cfb6aae91b0879f384c389ba742115489cea Mon Sep 17 00:00:00 2001 From: nuxen Date: Thu, 22 Aug 2024 15:19:09 +0200 Subject: [PATCH 1/7] feat(notifications): filter notifications --- internal/config/config.go | 6 ++++- internal/domain/config.go | 3 ++- internal/domain/http.go | 39 ++++++++++++++++++++++++++++++++ internal/domain/notification.go | 6 +++++ internal/notification/discord.go | 35 ++++++++++++++++++++++++---- 5 files changed, 82 insertions(+), 7 deletions(-) diff --git a/internal/config/config.go b/internal/config/config.go index 9175313..1aef29a 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -314,7 +314,8 @@ func (c *AppConfig) defaults() { }, APIToken: "", Notifications: domain.Notifications{ - Discord: "", + NotificationLevel: []string{"MATCH", "ERROR"}, + Discord: "", // Notifiarr: "", // Shoutrrr: "", }, @@ -421,6 +422,9 @@ func (c *AppConfig) DynamicReload(log logger.Logger) { parseTorrentFile := viper.GetBool("parseTorrentFile") c.Config.ParseTorrentFile = parseTorrentFile + notificationLevel := viper.GetStringSlice("notificationLevel") + c.Config.Notifications.NotificationLevel = notificationLevel + log.Debug().Msg("config file reloaded!") c.m.Unlock() diff --git a/internal/domain/config.go b/internal/domain/config.go index 738f110..75988ef 100644 --- a/internal/domain/config.go +++ b/internal/domain/config.go @@ -18,7 +18,8 @@ type FuzzyMatching struct { } type Notifications struct { - Discord string `yaml:"discord"` + NotificationLevel []string `yaml:"notificationLevel"` + Discord string `yaml:"discord"` // Notifiarr string `yaml:"notifiarr"` // Shoutrrr string `yaml:"shoutrrr"` } diff --git a/internal/domain/http.go b/internal/domain/http.go index bcb15c8..9aa1a39 100644 --- a/internal/domain/http.go +++ b/internal/domain/http.go @@ -30,3 +30,42 @@ const ( StatusGetEpisodesError = 464 StatusEpisodeCountError = 450 ) + +func GetInfoStatusCodes() []int { + return []int{ + StatusNoMatches, + StatusResolutionMismatch, + StatusSourceMismatch, + StatusRlsGrpMismatch, + StatusCutMismatch, + StatusEditionMismatch, + StatusRepackStatusMismatch, + StatusHdrMismatch, + StatusStreamingServiceMismatch, + StatusAlreadyInClient, + StatusNotASeasonPack, + StatusBelowThreshold, + } +} + +func GetMatchStatusCodes() []int { + return []int{ + StatusSuccessfulMatch, + } +} + +func GetErrorStatusCodes() []int { + return []int{ + StatusFailedHardlink, + StatusClientNotFound, + StatusGetClientError, + StatusDecodingError, + StatusAnnounceNameError, + StatusGetTorrentsError, + StatusTorrentBytesError, + StatusDecodeTorrentBytesError, + StatusParseTorrentInfoError, + StatusGetEpisodesError, + StatusEpisodeCountError, + } +} diff --git a/internal/domain/notification.go b/internal/domain/notification.go index 9216ad1..24bf8e6 100644 --- a/internal/domain/notification.go +++ b/internal/domain/notification.go @@ -8,6 +8,12 @@ type Sender interface { Send(statusCode int, payload NotificationPayload) error } +const ( + NotificationLevelInfo = "INFO" + NotificationLevelError = "ERROR" + NotificationLevelMatch = "MATCH" +) + type NotificationPayload struct { Subject string Message string diff --git a/internal/notification/discord.go b/internal/notification/discord.go index 8206dd1..131cd6b 100644 --- a/internal/notification/discord.go +++ b/internal/notification/discord.go @@ -10,6 +10,7 @@ import ( "fmt" "io" "net/http" + "slices" "strings" "time" @@ -68,6 +69,12 @@ func NewDiscordSender(log logger.Logger, config *config.AppConfig) domain.Sender func (s *discordSender) Send(statusCode int, payload domain.NotificationPayload) error { if !s.isEnabled() { + s.log.Warn().Msg("no webhook defined, skipping notification") + return nil + } + + if !s.shouldSend(statusCode) { + s.log.Warn().Msg("no notification wanted for this status code, skipping notification") return nil } @@ -114,20 +121,38 @@ func (s *discordSender) Send(statusCode int, payload domain.NotificationPayload) } func (s *discordSender) isEnabled() bool { - if s.cfg.Config.Notifications.Discord == "" { - s.log.Warn().Msg("no webhook defined, skipping notification") + return s.cfg.Config.Notifications.Discord != "" +} + +func (s *discordSender) shouldSend(statusCode int) bool { + var statusCodes []int + + if len(s.cfg.Config.Notifications.NotificationLevel) == 0 { return false } - return true + for _, level := range s.cfg.Config.Notifications.NotificationLevel { + if level == domain.NotificationLevelMatch { + statusCodes = append(statusCodes, domain.GetMatchStatusCodes()...) + } + if level == domain.NotificationLevelInfo { + statusCodes = append(statusCodes, domain.GetInfoStatusCodes()...) + } + if level == domain.NotificationLevelError { + statusCodes = append(statusCodes, domain.GetErrorStatusCodes()...) + } + } + fmt.Println(s.cfg.Config.Notifications.NotificationLevel, statusCodes) + + return slices.Contains(statusCodes, statusCode) } func (s *discordSender) buildEmbed(statusCode int, payload domain.NotificationPayload) DiscordEmbeds { color := LIGHT_BLUE - if (statusCode >= 200) && (statusCode < 250) { // not matching + if slices.Contains(domain.GetInfoStatusCodes(), statusCode) { // not matching color = GRAY - } else if (statusCode >= 400) && (statusCode < 500) { // error processing + } else if slices.Contains(domain.GetErrorStatusCodes(), statusCode) { // error processing color = RED } else { // success color = GREEN From 41ba77b1b7ecc390db7ac7df87dc7d811c104a16 Mon Sep 17 00:00:00 2001 From: nuxen Date: Thu, 22 Aug 2024 15:55:24 +0200 Subject: [PATCH 2/7] fix(notifications): default slice not overwriting correctly --- config.yaml | 7 +++++++ internal/config/config.go | 9 ++++++++- schemas/config-schema.json | 9 +++++++++ 3 files changed, 24 insertions(+), 1 deletion(-) diff --git a/config.yaml b/config.yaml index 02e0ddb..eaab47e 100644 --- a/config.yaml +++ b/config.yaml @@ -147,6 +147,13 @@ fuzzyMatching: # You can decide which notifications you want to receive # notifications: + # Notification Level + # Decides what notifications you want to receive + # + # Options: "MATCH", "INFO", "ERROR" + # + notificationLevel: [ "MATCH", "ERROR" ] + # Discord # Uses the given Discord webhook to send notifications for various events # diff --git a/internal/config/config.go b/internal/config/config.go index 1aef29a..ad47d82 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -174,6 +174,13 @@ fuzzyMatching: # You can decide which notifications you want to receive # notifications: + # Notification Level + # Decides what notifications you want to receive + # + # Options: "MATCH", "INFO", "ERROR" + # + notificationLevel: [ "MATCH", "ERROR" ] + # Discord # Uses the given Discord webhook to send notifications for various events # @@ -314,7 +321,7 @@ func (c *AppConfig) defaults() { }, APIToken: "", Notifications: domain.Notifications{ - NotificationLevel: []string{"MATCH", "ERROR"}, + NotificationLevel: []string{}, Discord: "", // Notifiarr: "", // Shoutrrr: "", diff --git a/schemas/config-schema.json b/schemas/config-schema.json index a974d3a..e534f20 100644 --- a/schemas/config-schema.json +++ b/schemas/config-schema.json @@ -110,6 +110,15 @@ "type": "object", "additionalProperties": false, "properties": { + "notificationLevel": { + "type": "array", + "items": { + "type": "string", + "enum": ["MATCH", "INFO", "ERROR"] + }, + "minItems": 1, + "uniqueItems": true + }, "discord": { "type": "string", "default": "" From d17c6093569f0576f74325047a56d452864ccbe7 Mon Sep 17 00:00:00 2001 From: nuxen Date: Fri, 23 Aug 2024 14:14:19 +0200 Subject: [PATCH 3/7] chore(logging): move notification skip logging to debug level --- internal/notification/discord.go | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/internal/notification/discord.go b/internal/notification/discord.go index 131cd6b..65e6b13 100644 --- a/internal/notification/discord.go +++ b/internal/notification/discord.go @@ -69,12 +69,12 @@ func NewDiscordSender(log logger.Logger, config *config.AppConfig) domain.Sender func (s *discordSender) Send(statusCode int, payload domain.NotificationPayload) error { if !s.isEnabled() { - s.log.Warn().Msg("no webhook defined, skipping notification") + s.log.Debug().Msg("no webhook defined, skipping notification") return nil } if !s.shouldSend(statusCode) { - s.log.Warn().Msg("no notification wanted for this status code, skipping notification") + s.log.Debug().Msg("no notification wanted for this status code, skipping notification") return nil } @@ -94,7 +94,7 @@ func (s *discordSender) Send(statusCode int, payload domain.NotificationPayload) } req.Header.Set("Content-Type", "application/json") - //req.Header.Set("User-Agent", "seasonpackarr") + // req.Header.Set("User-Agent", "seasonpackarr") res, err := s.httpClient.Do(req) if err != nil { @@ -121,7 +121,7 @@ func (s *discordSender) Send(statusCode int, payload domain.NotificationPayload) } func (s *discordSender) isEnabled() bool { - return s.cfg.Config.Notifications.Discord != "" + return len(s.cfg.Config.Notifications.Discord) != 0 } func (s *discordSender) shouldSend(statusCode int) bool { From f921df5eb29d85d88862c69d2c092945740c63cd Mon Sep 17 00:00:00 2001 From: nuxen Date: Sat, 24 Aug 2024 21:25:46 +0200 Subject: [PATCH 4/7] refactor: receivers and var names (#131) --- internal/http/health.go | 6 +- internal/http/processor.go | 114 ++++++++++++++++++------------------ internal/http/webhook.go | 6 +- internal/release/release.go | 10 ++-- internal/utils/tvmaze.go | 1 + 5 files changed, 69 insertions(+), 68 deletions(-) diff --git a/internal/http/health.go b/internal/http/health.go index 71e9fca..1ede2ee 100644 --- a/internal/http/health.go +++ b/internal/http/health.go @@ -16,16 +16,16 @@ func newHealthHandler() *healthHandler { return &healthHandler{} } -func (h healthHandler) Routes(r chi.Router) { +func (h *healthHandler) Routes(r chi.Router) { r.Get("/liveness", h.handleLiveness) r.Get("/readiness", h.handleReadiness) } -func (h healthHandler) handleLiveness(w http.ResponseWriter, r *http.Request) { +func (h *healthHandler) handleLiveness(w http.ResponseWriter, r *http.Request) { writeHealthy(w, r) } -func (h healthHandler) handleReadiness(w http.ResponseWriter, r *http.Request) { +func (h *healthHandler) handleReadiness(w http.ResponseWriter, r *http.Request) { writeHealthy(w, r) } diff --git a/internal/http/processor.go b/internal/http/processor.go index b3452c1..4f03698 100644 --- a/internal/http/processor.go +++ b/internal/http/processor.go @@ -39,11 +39,11 @@ type request struct { ClientName string } -type entryTime struct { - e map[string][]domain.Entry - d map[string]rls.Release - t time.Time - err error +type torrentRlsEntries struct { + entriesMap map[string][]domain.Entry + rlsMap map[string]rls.Release + lastUpdated time.Time + err error sync.Mutex } @@ -68,80 +68,80 @@ func newProcessor(log logger.Logger, config *config.AppConfig, notification doma } func (p *processor) getClient(client *domain.Client) error { - s := qbittorrent.Config{ + clientCfg := qbittorrent.Config{ Host: fmt.Sprintf("http://%s:%d", client.Host, client.Port), Username: client.Username, Password: client.Password, } - c, ok := clientMap.Load(s) + c, ok := clientMap.Load(clientCfg) if !ok { - c = qbittorrent.NewClient(s) + c = qbittorrent.NewClient(clientCfg) if err := c.(*qbittorrent.Client).Login(); err != nil { return errors.Wrap(err, "failed to login to qbittorrent") } - clientMap.Store(s, c) + clientMap.Store(clientCfg, c) } p.req.Client = c.(*qbittorrent.Client) return nil } -func (p *processor) getAllTorrents(client *domain.Client) entryTime { - set := qbittorrent.Config{ +func (p *processor) getAllTorrents(client *domain.Client) torrentRlsEntries { + clientCfg := qbittorrent.Config{ Host: fmt.Sprintf("http://%s:%d", client.Host, client.Port), Username: client.Username, Password: client.Password, } - f := func() *entryTime { - te, ok := torrentMap.Load(set) + f := func() *torrentRlsEntries { + tre, ok := torrentMap.Load(clientCfg) if ok { - return te.(*entryTime) + return tre.(*torrentRlsEntries) } - res := &entryTime{d: make(map[string]rls.Release)} - torrentMap.Store(set, res) - return res + entries := &torrentRlsEntries{rlsMap: make(map[string]rls.Release)} + torrentMap.Store(clientCfg, entries) + return entries } - res := f() + entries := f() cur := time.Now() - if res.t.After(cur) { - return *res + if entries.lastUpdated.After(cur) { + return *entries } - res.Lock() - defer res.Unlock() + entries.Lock() + defer entries.Unlock() - res = f() - if res.t.After(cur) { - return *res + entries = f() + if entries.lastUpdated.After(cur) { + return *entries } ts, err := p.req.Client.GetTorrents(qbittorrent.TorrentFilterOptions{}) if err != nil { - return entryTime{err: err} + return torrentRlsEntries{err: err} } - nt := time.Now() - res = &entryTime{e: make(map[string][]domain.Entry), t: nt.Add(nt.Sub(cur)), d: res.d} + after := time.Now() + entries = &torrentRlsEntries{entriesMap: make(map[string][]domain.Entry), lastUpdated: after.Add(after.Sub(cur)), rlsMap: entries.rlsMap} for _, t := range ts { - r, ok := res.d[t.Name] + r, ok := entries.rlsMap[t.Name] if !ok { r = rls.ParseString(t.Name) - res.d[t.Name] = r + entries.rlsMap[t.Name] = r } - s := utils.GetFormattedTitle(r) - res.e[s] = append(res.e[s], domain.Entry{T: t, R: r}) + fmtTitle := utils.GetFormattedTitle(r) + entries.entriesMap[fmtTitle] = append(entries.entriesMap[fmtTitle], domain.Entry{T: t, R: r}) } - torrentMap.Store(set, res) - return *res + torrentMap.Store(clientCfg, entries) + return *entries } func (p *processor) getFiles(hash string) (*qbittorrent.TorrentFiles, error) { @@ -217,13 +217,13 @@ func (p *processor) processSeasonPack() (int, error) { return domain.StatusGetClientError, err } - mp := p.getAllTorrents(client) - if mp.err != nil { - return domain.StatusGetTorrentsError, mp.err + tre := p.getAllTorrents(client) + if tre.err != nil { + return domain.StatusGetTorrentsError, tre.err } - requestRls := domain.Entry{R: rls.ParseString(p.req.Name)} - v, ok := mp.e[utils.GetFormattedTitle(requestRls.R)] + requestEntry := domain.Entry{R: rls.ParseString(p.req.Name)} + matchingEntries, ok := tre.entriesMap[utils.GetFormattedTitle(requestEntry.R)] if !ok { return domain.StatusNoMatches, fmt.Errorf("no matching releases in client") } @@ -231,8 +231,8 @@ func (p *processor) processSeasonPack() (int, error) { announcedPackName := utils.FormatSeasonPackTitle(p.req.Name) p.log.Debug().Msgf("formatted season pack name: %s", announcedPackName) - for _, child := range v { - if release.CheckCandidates(&requestRls, &child, p.cfg.Config.FuzzyMatching) == domain.StatusAlreadyInClient { + for _, entry := range matchingEntries { + if release.CheckCandidates(&requestEntry, &entry, p.cfg.Config.FuzzyMatching) == domain.StatusAlreadyInClient { return domain.StatusAlreadyInClient, fmt.Errorf("release already in client") } } @@ -240,53 +240,53 @@ func (p *processor) processSeasonPack() (int, error) { var matchedEps []int var respCodes []int - for _, child := range v { - switch res := release.CheckCandidates(&requestRls, &child, p.cfg.Config.FuzzyMatching); res { + for _, entry := range matchingEntries { + switch res := release.CheckCandidates(&requestEntry, &entry, p.cfg.Config.FuzzyMatching); res { case domain.StatusResolutionMismatch: p.log.Info().Msgf("resolution did not match: request(%s => %s), client(%s => %s)", - requestRls.R.String(), requestRls.R.Resolution, child.R.String(), child.R.Resolution) + requestEntry.R.String(), requestEntry.R.Resolution, entry.R.String(), entry.R.Resolution) respCodes = append(respCodes, res) continue case domain.StatusSourceMismatch: p.log.Info().Msgf("source did not match: request(%s => %s), client(%s => %s)", - requestRls.R.String(), requestRls.R.Source, child.R.String(), child.R.Source) + requestEntry.R.String(), requestEntry.R.Source, entry.R.String(), entry.R.Source) respCodes = append(respCodes, res) continue case domain.StatusRlsGrpMismatch: p.log.Info().Msgf("release group did not match: request(%s => %s), client(%s => %s)", - requestRls.R.String(), requestRls.R.Group, child.R.String(), child.R.Group) + requestEntry.R.String(), requestEntry.R.Group, entry.R.String(), entry.R.Group) respCodes = append(respCodes, res) continue case domain.StatusCutMismatch: p.log.Info().Msgf("cut did not match: request(%s => %s), client(%s => %s)", - requestRls.R.String(), requestRls.R.Cut, child.R.String(), child.R.Cut) + requestEntry.R.String(), requestEntry.R.Cut, entry.R.String(), entry.R.Cut) respCodes = append(respCodes, res) continue case domain.StatusEditionMismatch: p.log.Info().Msgf("edition did not match: request(%s => %s), client(%s => %s)", - requestRls.R.String(), requestRls.R.Edition, child.R.String(), child.R.Edition) + requestEntry.R.String(), requestEntry.R.Edition, entry.R.String(), entry.R.Edition) respCodes = append(respCodes, res) continue case domain.StatusRepackStatusMismatch: p.log.Info().Msgf("repack status did not match: request(%s => %s), client(%s => %s)", - requestRls.R.String(), requestRls.R.Other, child.R.String(), child.R.Other) + requestEntry.R.String(), requestEntry.R.Other, entry.R.String(), entry.R.Other) respCodes = append(respCodes, res) continue case domain.StatusHdrMismatch: p.log.Info().Msgf("hdr metadata did not match: request(%s => %s), client(%s => %s)", - requestRls.R.String(), requestRls.R.HDR, child.R.String(), child.R.HDR) + requestEntry.R.String(), requestEntry.R.HDR, entry.R.String(), entry.R.HDR) respCodes = append(respCodes, res) continue case domain.StatusStreamingServiceMismatch: p.log.Info().Msgf("streaming service did not match: request(%s => %s), client(%s => %s)", - requestRls.R.String(), requestRls.R.Collection, child.R.String(), child.R.Collection) + requestEntry.R.String(), requestEntry.R.Collection, entry.R.String(), entry.R.Collection) respCodes = append(respCodes, res) continue @@ -297,9 +297,9 @@ func (p *processor) processSeasonPack() (int, error) { return domain.StatusNotASeasonPack, fmt.Errorf("release is not a season pack") case domain.StatusSuccessfulMatch: - torrentFiles, err := p.getFiles(child.T.Hash) + torrentFiles, err := p.getFiles(entry.T.Hash) if err != nil { - p.log.Error().Err(err).Msgf("error getting files: %s", child.T.Name) + p.log.Error().Err(err).Msgf("error getting files: %s", entry.T.Name) continue } @@ -315,12 +315,12 @@ func (p *processor) processSeasonPack() (int, error) { break } if len(fileName) == 0 || size == 0 { - p.log.Error().Err(err).Msgf("error getting filename or size: %s", child.T.Name) + p.log.Error().Err(err).Msgf("error getting filename or size: %s", entry.T.Name) continue } - epRls := rls.ParseString(child.T.Name) - epPathClient := filepath.Join(child.T.SavePath, fileName) + epRls := rls.ParseString(entry.T.Name) + epPathClient := filepath.Join(entry.T.SavePath, fileName) announcedEpPath := filepath.Join(client.PreImportPath, announcedPackName, filepath.Base(fileName)) matchedEps = append(matchedEps, epRls.Episode) @@ -341,7 +341,7 @@ func (p *processor) processSeasonPack() (int, error) { newMatches := append(oldMatches.([]matchPaths), currentMatch...) matchesMap.Store(p.req.Name, newMatches) p.log.Debug().Msgf("matched torrent from client: name(%s), size(%d), hash(%s)", - child.T.Name, size, child.T.Hash) + entry.T.Name, size, entry.T.Hash) respCodes = append(respCodes, res) continue } diff --git a/internal/http/webhook.go b/internal/http/webhook.go index 909c8ad..e90d28e 100644 --- a/internal/http/webhook.go +++ b/internal/http/webhook.go @@ -28,17 +28,17 @@ func newWebhookHandler(log logger.Logger, cfg *config.AppConfig, notification do } } -func (h webhookHandler) Routes(r chi.Router) { +func (h *webhookHandler) Routes(r chi.Router) { r.Post("/pack", h.pack) r.Post("/parse", h.parse) } -func (h webhookHandler) pack(w http.ResponseWriter, r *http.Request) { +func (h *webhookHandler) pack(w http.ResponseWriter, r *http.Request) { newProcessor(h.log, h.cfg, h.noti).ProcessSeasonPackHandler(w, r) render.Status(r, http.StatusOK) } -func (h webhookHandler) parse(w http.ResponseWriter, r *http.Request) { +func (h *webhookHandler) parse(w http.ResponseWriter, r *http.Request) { newProcessor(h.log, h.cfg, h.noti).ParseTorrentHandler(w, r) render.Status(r, http.StatusOK) } diff --git a/internal/release/release.go b/internal/release/release.go index e147c14..ac8abdd 100644 --- a/internal/release/release.go +++ b/internal/release/release.go @@ -10,14 +10,14 @@ import ( "github.com/moistari/rls" ) -func CheckCandidates(requestrls, child *domain.Entry, fuzzyMatching domain.FuzzyMatching) int { - rlsRelease := requestrls.R - rlsInClient := child.R +func CheckCandidates(requestEntry, clientEntry *domain.Entry, fuzzyMatching domain.FuzzyMatching) int { + requestRls := requestEntry.R + clientRls := clientEntry.R // check if season pack and no extension - if rlsRelease.Type.Is(rls.Series) && rlsRelease.Ext == "" { + if requestRls.Type.Is(rls.Series) && requestRls.Ext == "" { // compare releases - return compareReleases(rlsInClient, rlsRelease, fuzzyMatching) + return compareReleases(clientRls, requestRls, fuzzyMatching) } // not a season pack return 211 diff --git a/internal/utils/tvmaze.go b/internal/utils/tvmaze.go index 85f0beb..eb20ce1 100644 --- a/internal/utils/tvmaze.go +++ b/internal/utils/tvmaze.go @@ -5,6 +5,7 @@ package utils import ( "fmt" + "github.com/mrobinsn/go-tvmaze/tvmaze" ) From 9b9bb2dc2c7f1530f5d0f75062c82e73bcd8c8c9 Mon Sep 17 00:00:00 2001 From: nuxen Date: Sun, 25 Aug 2024 18:00:11 +0200 Subject: [PATCH 5/7] perf(utils): optimize slice functions (#132) * perf(utils): optimize slice functions * test(utils): added tests for SimplifyHDRSlice --- internal/release/release.go | 8 +-- internal/utils/slices.go | 43 ++++++------ internal/utils/slices_test.go | 126 ++++++++++++++++++++++++++++++++-- 3 files changed, 149 insertions(+), 28 deletions(-) diff --git a/internal/release/release.go b/internal/release/release.go index ac8abdd..cca5d51 100644 --- a/internal/release/release.go +++ b/internal/release/release.go @@ -36,17 +36,17 @@ func compareReleases(r1, r2 rls.Release, fuzzyMatching domain.FuzzyMatching) int return 203 } - if !utils.CompareStringSlices(r1.Cut, r2.Cut) { + if !utils.EqualElements(r1.Cut, r2.Cut) { return 204 } - if !utils.CompareStringSlices(r1.Edition, r2.Edition) { + if !utils.EqualElements(r1.Edition, r2.Edition) { return 205 } // skip comparing repack status when skipRepackCompare is enabled if !fuzzyMatching.SkipRepackCompare { - if !utils.CompareStringSlices(r1.Other, r2.Other) { + if !utils.EqualElements(r1.Other, r2.Other) { return 206 } } @@ -57,7 +57,7 @@ func compareReleases(r1, r2 rls.Release, fuzzyMatching domain.FuzzyMatching) int r2.HDR = utils.SimplifyHDRSlice(r2.HDR) } - if !utils.CompareStringSlices(r1.HDR, r2.HDR) { + if !utils.EqualElements(r1.HDR, r2.HDR) { return 207 } diff --git a/internal/utils/slices.go b/internal/utils/slices.go index 3d792f8..5e90f86 100644 --- a/internal/utils/slices.go +++ b/internal/utils/slices.go @@ -4,43 +4,46 @@ package utils import ( - "slices" "strings" ) func DedupeSlice[T comparable](s []T) []T { - inResult := make(map[T]bool) - var result []T - for _, str := range s { - if _, ok := inResult[str]; !ok { - inResult[str] = true - result = append(result, str) - } + resultSet := make(map[T]struct{}) + for _, i := range s { + resultSet[i] = struct{}{} + } + + result := make([]T, 0, len(resultSet)) + for str := range resultSet { + result = append(result, str) } + return result } -func CompareStringSlices(x, y []string) bool { +func EqualElements[T comparable](x, y []T) bool { if len(x) != len(y) { return false } - sortedX := slices.Clone(x) - sortedY := slices.Clone(y) + freqMap := make(map[T]int) + for _, i := range x { + freqMap[i]++ + } - slices.Sort(sortedX) - slices.Sort(sortedY) + for _, i := range y { + if freqMap[i] == 0 { + return false + } + freqMap[i]-- + } - return slices.Equal(sortedX, sortedY) + return true } func SimplifyHDRSlice(hdrSlice []string) []string { - if len(hdrSlice) == 0 { - return hdrSlice - } - - for i, v := range hdrSlice { - if strings.Contains(v, "HDR") { + for i := range hdrSlice { + if strings.Contains(hdrSlice[i], "HDR") { hdrSlice[i] = "HDR" } } diff --git a/internal/utils/slices_test.go b/internal/utils/slices_test.go index 3ed50a6..c24650d 100644 --- a/internal/utils/slices_test.go +++ b/internal/utils/slices_test.go @@ -33,7 +33,7 @@ func Test_DedupeSlice(t *testing.T) { { name: "string_slice_empty", slice: []string{}, - want: []string(nil), + want: []string{}, }, { name: "int_slice_some_duplicates", @@ -53,19 +53,137 @@ func Test_DedupeSlice(t *testing.T) { { name: "int_slice_empty", slice: []int{}, - want: []int(nil), + want: []int{}, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { switch v := tt.slice.(type) { case []string: - assert.Equalf(t, tt.want, DedupeSlice(v), "Dedupe(%v)", v) + assert.ElementsMatchf(t, tt.want, DedupeSlice(v), "Dedupe(%v)", v) case []int: - assert.Equalf(t, tt.want, DedupeSlice(v), "Dedupe(%v)", v) + assert.ElementsMatchf(t, tt.want, DedupeSlice(v), "Dedupe(%v)", v) default: t.Errorf("Unsupported slice type in test case: %v", tt.name) } }) } } + +func Test_EqualElements(t *testing.T) { + tests := []struct { + name string + x interface{} + y interface{} + want bool + }{ + { + name: "string_slice_identical_elements", + x: []string{"a", "b", "c"}, + y: []string{"a", "b", "c"}, + want: true, + }, + { + name: "string_slice_different_order", + x: []string{"a", "b", "c"}, + y: []string{"c", "b", "a"}, + want: true, + }, + { + name: "string_slice_different_elements", + x: []string{"a", "b", "c"}, + y: []string{"a", "b", "d"}, + want: false, + }, + { + name: "string_slice_different_lengths", + x: []string{"a", "b", "c"}, + y: []string{"a", "b"}, + want: false, + }, + { + name: "int_slice_identical_elements", + x: []int{1, 2, 3}, + y: []int{1, 2, 3}, + want: true, + }, + { + name: "int_slice_different_order", + x: []int{1, 2, 3}, + y: []int{3, 2, 1}, + want: true, + }, + { + name: "int_slice_different_elements", + x: []int{1, 2, 3}, + y: []int{1, 2, 4}, + want: false, + }, + { + name: "int_slice_different_lengths", + x: []int{1, 2, 3}, + y: []int{1, 2}, + want: false, + }, + { + name: "empty_slices", + x: []int{}, + y: []int{}, + want: true, + }, + { + name: "one_empty_slice", + x: []int{}, + y: []int{1}, + want: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + switch v1 := tt.x.(type) { + case []string: + v2 := tt.y.([]string) + assert.Equalf(t, tt.want, EqualElements(v1, v2), "EqualElements(%v, %v)", v1, v2) + case []int: + v2 := tt.y.([]int) + assert.Equalf(t, tt.want, EqualElements(v1, v2), "EqualElements(%v, %v)", v1, v2) + default: + t.Errorf("Unsupported slice type in test case: %v", tt.name) + } + }) + } +} + +func Test_SimplifyHDRSlice(t *testing.T) { + tests := []struct { + name string + input []string + want []string + }{ + { + name: "contains_HDR", + input: []string{"HDR10", "HDR10+", "HDR"}, + want: []string{"HDR", "HDR", "HDR"}, + }, + { + name: "no_HDR", + input: []string{"SDR", "DV"}, + want: []string{"SDR", "DV"}, + }, + { + name: "empty_slice", + input: []string{}, + want: []string{}, + }, + { + name: "mixed_HDR_and_others", + input: []string{"HDR10", "DV", "SDR", "HDR10+"}, + want: []string{"HDR", "DV", "SDR", "HDR"}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equalf(t, tt.want, SimplifyHDRSlice(tt.input), "SimplifyHDRSlice(%v)", tt.input) + }) + } +} From 3877a696da390c376d381d00c4debe0f752e1417 Mon Sep 17 00:00:00 2001 From: nuxen Date: Sun, 1 Sep 2024 00:05:52 +0200 Subject: [PATCH 6/7] refactor(notifications): map notification level to status codes --- config.yaml | 10 +++++- internal/config/config.go | 14 ++++++-- internal/domain/http.go | 22 +++++-------- internal/domain/notification.go | 1 + internal/http/processor.go | 8 ++--- internal/notification/discord.go | 55 ++++++++++++++++---------------- 6 files changed, 61 insertions(+), 49 deletions(-) diff --git a/config.yaml b/config.yaml index eaab47e..9c6bc86 100644 --- a/config.yaml +++ b/config.yaml @@ -150,9 +150,17 @@ notifications: # Notification Level # Decides what notifications you want to receive # + # Default: [ "MATCH" ] + # # Options: "MATCH", "INFO", "ERROR" # - notificationLevel: [ "MATCH", "ERROR" ] + # Examples: + # [ "MATCH", "INFO", "ERROR" ] would send everything + # [ "MATCH", "INFO" ] would send all matches and rejection infos + # [ "MATCH", "ERROR" ] would send all matches and errors + # [ "ERROR" ] would only send all errors + # + notificationLevel: [ "MATCH" ] # Discord # Uses the given Discord webhook to send notifications for various events diff --git a/internal/config/config.go b/internal/config/config.go index ad47d82..e910716 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -177,9 +177,17 @@ notifications: # Notification Level # Decides what notifications you want to receive # + # Default: [ "MATCH" ] + # # Options: "MATCH", "INFO", "ERROR" # - notificationLevel: [ "MATCH", "ERROR" ] + # Examples: + # [ "MATCH", "INFO", "ERROR" ] would send everything + # [ "MATCH", "INFO" ] would send all matches and rejection infos + # [ "MATCH", "ERROR" ] would send all matches and errors + # [ "ERROR" ] would only send all errors + # + notificationLevel: [ "MATCH" ] # Discord # Uses the given Discord webhook to send notifications for various events @@ -321,7 +329,7 @@ func (c *AppConfig) defaults() { }, APIToken: "", Notifications: domain.Notifications{ - NotificationLevel: []string{}, + NotificationLevel: []string{"MATCH"}, Discord: "", // Notifiarr: "", // Shoutrrr: "", @@ -429,7 +437,7 @@ func (c *AppConfig) DynamicReload(log logger.Logger) { parseTorrentFile := viper.GetBool("parseTorrentFile") c.Config.ParseTorrentFile = parseTorrentFile - notificationLevel := viper.GetStringSlice("notificationLevel") + notificationLevel := viper.GetStringSlice("notifications.notificationLevel") c.Config.Notifications.NotificationLevel = notificationLevel log.Debug().Msg("config file reloaded!") diff --git a/internal/domain/http.go b/internal/domain/http.go index 9aa1a39..a725b7a 100644 --- a/internal/domain/http.go +++ b/internal/domain/http.go @@ -31,8 +31,11 @@ const ( StatusEpisodeCountError = 450 ) -func GetInfoStatusCodes() []int { - return []int{ +var StatusMap = map[string][]int{ + NotificationLevelMatch: { + StatusSuccessfulMatch, + }, + NotificationLevelInfo: { StatusNoMatches, StatusResolutionMismatch, StatusSourceMismatch, @@ -45,17 +48,8 @@ func GetInfoStatusCodes() []int { StatusAlreadyInClient, StatusNotASeasonPack, StatusBelowThreshold, - } -} - -func GetMatchStatusCodes() []int { - return []int{ - StatusSuccessfulMatch, - } -} - -func GetErrorStatusCodes() []int { - return []int{ + }, + NotificationLevelError: { StatusFailedHardlink, StatusClientNotFound, StatusGetClientError, @@ -67,5 +61,5 @@ func GetErrorStatusCodes() []int { StatusParseTorrentInfoError, StatusGetEpisodesError, StatusEpisodeCountError, - } + }, } diff --git a/internal/domain/notification.go b/internal/domain/notification.go index 24bf8e6..e92cdfc 100644 --- a/internal/domain/notification.go +++ b/internal/domain/notification.go @@ -5,6 +5,7 @@ package domain type Sender interface { + Name() string Send(statusCode int, payload NotificationPayload) error } diff --git a/internal/http/processor.go b/internal/http/processor.go index 4f03698..5a7d8ef 100644 --- a/internal/http/processor.go +++ b/internal/http/processor.go @@ -176,7 +176,7 @@ func (p *processor) ProcessSeasonPackHandler(w netHTTP.ResponseWriter, r *netHTT Action: "Pack", Error: err, }); sendErr != nil { - p.log.Error().Err(sendErr).Msg("error sending notification") + p.log.Error().Err(sendErr).Msgf("could not send %s notification for %d", p.noti.Name(), code) } p.log.Error().Err(err).Msgf("error processing season pack: %d", code) @@ -189,7 +189,7 @@ func (p *processor) ProcessSeasonPackHandler(w netHTTP.ResponseWriter, r *netHTT Client: p.req.ClientName, Action: "Pack", }); sendErr != nil { - p.log.Error().Err(sendErr).Msg("error sending notification") + p.log.Error().Err(sendErr).Msgf("could not send %s notification for %d", p.noti.Name(), code) } p.log.Info().Msg("successfully matched season pack to episodes in client") @@ -413,7 +413,7 @@ func (p *processor) ParseTorrentHandler(w netHTTP.ResponseWriter, r *netHTTP.Req Action: "Parse", Error: err, }); sendErr != nil { - p.log.Error().Err(sendErr).Msg("error sending notification") + p.log.Error().Err(sendErr).Msgf("could not send %s notification for %d", p.noti.Name(), code) } p.log.Error().Err(err).Msgf("error parsing torrent: %d", code) @@ -426,7 +426,7 @@ func (p *processor) ParseTorrentHandler(w netHTTP.ResponseWriter, r *netHTTP.Req Client: p.req.ClientName, Action: "Parse", }); sendErr != nil { - p.log.Error().Err(sendErr).Msg("error sending notification") + p.log.Error().Err(sendErr).Msgf("could not send %s notification for %d", p.noti.Name(), code) } p.log.Info().Msg("successfully parsed torrent and hardlinked episodes") diff --git a/internal/notification/discord.go b/internal/notification/discord.go index 65e6b13..e929166 100644 --- a/internal/notification/discord.go +++ b/internal/notification/discord.go @@ -5,6 +5,7 @@ package notification import ( + "bufio" "bytes" "encoding/json" "fmt" @@ -67,6 +68,10 @@ func NewDiscordSender(log logger.Logger, config *config.AppConfig) domain.Sender } } +func (s *discordSender) Name() string { + return "discord" +} + func (s *discordSender) Send(statusCode int, payload domain.NotificationPayload) error { if !s.isEnabled() { s.log.Debug().Msg("no webhook defined, skipping notification") @@ -74,7 +79,7 @@ func (s *discordSender) Send(statusCode int, payload domain.NotificationPayload) } if !s.shouldSend(statusCode) { - s.log.Debug().Msg("no notification wanted for this status code, skipping notification") + s.log.Debug().Msg("no notification wanted for this status, skipping notification") return nil } @@ -85,12 +90,12 @@ func (s *discordSender) Send(statusCode int, payload domain.NotificationPayload) jsonData, err := json.Marshal(m) if err != nil { - return errors.Wrap(err, "discord client could not marshal data: %+v", m) + return errors.Wrap(err, "could not marshal json request for status: %v payload: %v", statusCode, payload) } req, err := http.NewRequest(http.MethodPost, s.cfg.Config.Notifications.Discord, bytes.NewBuffer(jsonData)) if err != nil { - return errors.Wrap(err, "discord client could not create request") + return errors.Wrap(err, "could not create request for status: %v payload: %v", statusCode, payload) } req.Header.Set("Content-Type", "application/json") @@ -98,21 +103,21 @@ func (s *discordSender) Send(statusCode int, payload domain.NotificationPayload) res, err := s.httpClient.Do(req) if err != nil { - return errors.Wrap(err, "discord client could not make request: %+v", req) + return errors.Wrap(err, "client request error for status: %v payload: %v", statusCode, payload) } defer res.Body.Close() - body, err := io.ReadAll(res.Body) - if err != nil { - return errors.Wrap(err, "discord client could not read data") - } - - s.log.Trace().Msgf("discord status: %v response: %v", res.StatusCode, string(body)) + s.log.Trace().Msgf("discord response status: %d", res.StatusCode) // discord responds with 204, Notifiarr with 204 so lets take all 200 as ok - if res.StatusCode >= 300 { - return errors.New("bad discord client status: %v body: %v", res.StatusCode, string(body)) + if res.StatusCode != http.StatusOK && res.StatusCode != http.StatusNoContent { + body, err := io.ReadAll(bufio.NewReader(res.Body)) + if err != nil { + return errors.Wrap(err, "could not read body for status: %v payload: %v", statusCode, payload) + } + + return errors.New("unexpected status: %v body: %v", res.StatusCode, string(body)) } s.log.Debug().Msg("notification successfully sent to discord") @@ -125,34 +130,30 @@ func (s *discordSender) isEnabled() bool { } func (s *discordSender) shouldSend(statusCode int) bool { - var statusCodes []int - if len(s.cfg.Config.Notifications.NotificationLevel) == 0 { return false } + statusCodes := make(map[int]struct{}) + for _, level := range s.cfg.Config.Notifications.NotificationLevel { - if level == domain.NotificationLevelMatch { - statusCodes = append(statusCodes, domain.GetMatchStatusCodes()...) - } - if level == domain.NotificationLevelInfo { - statusCodes = append(statusCodes, domain.GetInfoStatusCodes()...) - } - if level == domain.NotificationLevelError { - statusCodes = append(statusCodes, domain.GetErrorStatusCodes()...) + if codes, ok := domain.StatusMap[level]; ok { + for _, code := range codes { + statusCodes[code] = struct{}{} + } } } - fmt.Println(s.cfg.Config.Notifications.NotificationLevel, statusCodes) - return slices.Contains(statusCodes, statusCode) + _, shouldSend := statusCodes[statusCode] + return shouldSend } func (s *discordSender) buildEmbed(statusCode int, payload domain.NotificationPayload) DiscordEmbeds { color := LIGHT_BLUE - if slices.Contains(domain.GetInfoStatusCodes(), statusCode) { // not matching + if slices.Contains(domain.StatusMap[domain.NotificationLevelInfo], statusCode) { // not matching color = GRAY - } else if slices.Contains(domain.GetErrorStatusCodes(), statusCode) { // error processing + } else if slices.Contains(domain.StatusMap[domain.NotificationLevelError], statusCode) { // error processing color = RED } else { // success color = GREEN @@ -189,7 +190,7 @@ func (s *discordSender) buildEmbed(statusCode int, payload domain.NotificationPa if payload.Error != nil { // actual error? - if statusCode >= 400 { + if slices.Contains(domain.StatusMap[domain.NotificationLevelError], statusCode) { f := DiscordEmbedsFields{ Name: "Error", Value: fmt.Sprintf("```%s```", payload.Error.Error()), From 2d4d00c7a931e1d731a52096076302a7509abca7 Mon Sep 17 00:00:00 2001 From: nuxen Date: Wed, 4 Sep 2024 17:08:58 +0200 Subject: [PATCH 7/7] fix(config): workaround for viper not overwriting slice --- config.yaml | 4 ++-- internal/config/config.go | 11 ++++++++--- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/config.yaml b/config.yaml index 9c6bc86..2266388 100644 --- a/config.yaml +++ b/config.yaml @@ -150,7 +150,7 @@ notifications: # Notification Level # Decides what notifications you want to receive # - # Default: [ "MATCH" ] + # Default: [ "MATCH", "ERROR" ] # # Options: "MATCH", "INFO", "ERROR" # @@ -160,7 +160,7 @@ notifications: # [ "MATCH", "ERROR" ] would send all matches and errors # [ "ERROR" ] would only send all errors # - notificationLevel: [ "MATCH" ] + notificationLevel: [ "MATCH", "ERROR" ] # Discord # Uses the given Discord webhook to send notifications for various events diff --git a/internal/config/config.go b/internal/config/config.go index e910716..9366a6b 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -177,7 +177,7 @@ notifications: # Notification Level # Decides what notifications you want to receive # - # Default: [ "MATCH" ] + # Default: [ "MATCH", "ERROR" ] # # Options: "MATCH", "INFO", "ERROR" # @@ -187,7 +187,7 @@ notifications: # [ "MATCH", "ERROR" ] would send all matches and errors # [ "ERROR" ] would only send all errors # - notificationLevel: [ "MATCH" ] + notificationLevel: [ "MATCH", "ERROR" ] # Discord # Uses the given Discord webhook to send notifications for various events @@ -329,7 +329,7 @@ func (c *AppConfig) defaults() { }, APIToken: "", Notifications: domain.Notifications{ - NotificationLevel: []string{"MATCH"}, + NotificationLevel: []string{"MATCH", "ERROR"}, Discord: "", // Notifiarr: "", // Shoutrrr: "", @@ -415,6 +415,11 @@ func (c *AppConfig) load(configPath string) { if err := viper.Unmarshal(c.Config); err != nil { log.Fatalf("Could not unmarshal config file: %v: err %q", viper.ConfigFileUsed(), err) } + + // workaround for notificationLevel default slice not being overwritten properly by viper + if levels := viper.GetStringSlice("notifications.notificationLevel"); len(levels) != 0 { + c.Config.Notifications.NotificationLevel = levels + } } func (c *AppConfig) DynamicReload(log logger.Logger) {