From 6f9b6fd25c56999771029b6d64998cc1359aac23 Mon Sep 17 00:00:00 2001 From: nuxen Date: Wed, 21 Aug 2024 17:07:04 +0200 Subject: [PATCH] feat: discord notifications (#126) * feat: basic notification support with shoutrrr * feat(notifications): change notifications to use discord * chore(notifications): remove leftover services * chore(notifications): apply suggested changes --- cmd/start.go | 6 +- config.yaml | 13 +- internal/config/config.go | 22 ++- internal/domain/config.go | 7 + internal/domain/http.go | 32 ++++ internal/domain/notification.go | 18 +++ internal/http/processor.go | 169 ++++++++++---------- internal/http/server.go | 15 +- internal/http/webhook.go | 17 +- internal/notification/discord.go | 191 +++++++++++++++++++++++ internal/notification/message_builder.go | 45 ++++++ 11 files changed, 439 insertions(+), 96 deletions(-) create mode 100644 internal/domain/http.go create mode 100644 internal/domain/notification.go create mode 100644 internal/notification/discord.go create mode 100644 internal/notification/message_builder.go diff --git a/cmd/start.go b/cmd/start.go index eccafac..03a8fdc 100644 --- a/cmd/start.go +++ b/cmd/start.go @@ -13,6 +13,7 @@ import ( "seasonpackarr/internal/config" "seasonpackarr/internal/http" "seasonpackarr/internal/logger" + "seasonpackarr/internal/notification" "seasonpackarr/pkg/errors" "github.com/spf13/cobra" @@ -36,7 +37,10 @@ var startCmd = &cobra.Command{ // init dynamic config cfg.DynamicReload(log) - srv := http.NewServer(log, cfg) + // init notification sender + noti := notification.NewDiscordSender(log, cfg) + + srv := http.NewServer(log, cfg, noti) log.Info().Msgf("Starting seasonpackarr") log.Info().Msgf("Version: %s", buildinfo.Version) diff --git a/config.yaml b/config.yaml index e8363e8..02e0ddb 100644 --- a/config.yaml +++ b/config.yaml @@ -141,4 +141,15 @@ fuzzyMatching: # # Optional # -# apiToken: "" \ No newline at end of file +# apiToken: "" + +# Notifications +# You can decide which notifications you want to receive +# +notifications: + # Discord + # Uses the given Discord webhook to send notifications for various events + # + # Optional + # + discord: "" diff --git a/internal/config/config.go b/internal/config/config.go index 9059469..9175313 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -169,6 +169,17 @@ fuzzyMatching: # Optional # # apiToken: "" + +# Notifications +# You can decide which notifications you want to receive +# +notifications: + # Discord + # Uses the given Discord webhook to send notifications for various events + # + # Optional + # + discord: "" ` func (c *AppConfig) writeConfig(configPath string, configFile string) error { @@ -302,6 +313,11 @@ func (c *AppConfig) defaults() { SimplifyHdrCompare: false, }, APIToken: "", + Notifications: domain.Notifications{ + Discord: "", + // Notifiarr: "", + // Shoutrrr: "", + }, } } @@ -482,7 +498,11 @@ func (c *AppConfig) processLines(lines []string) []string { foundLineSimplifyHdrCompare = true } if !foundLineApiToken && strings.Contains(line, "apiToken:") { - lines[i] = fmt.Sprintf("apiToken: \"%s\"", c.Config.APIToken) + if c.Config.APIToken == "" { + lines[i] = "# apiToken: \"\"" + } else { + lines[i] = fmt.Sprintf("apiToken: \"%s\"", c.Config.APIToken) + } foundLineApiToken = true } } diff --git a/internal/domain/config.go b/internal/domain/config.go index fcd572b..738f110 100644 --- a/internal/domain/config.go +++ b/internal/domain/config.go @@ -17,6 +17,12 @@ type FuzzyMatching struct { SimplifyHdrCompare bool `yaml:"simplifyHdrCompare"` } +type Notifications struct { + Discord string `yaml:"discord"` + // Notifiarr string `yaml:"notifiarr"` + // Shoutrrr string `yaml:"shoutrrr"` +} + type Config struct { Version string ConfigPath string @@ -32,4 +38,5 @@ type Config struct { ParseTorrentFile bool `yaml:"parseTorrentFile"` FuzzyMatching FuzzyMatching `yaml:"fuzzyMatching"` APIToken string `yaml:"apiToken"` + Notifications Notifications `yaml:"notifications"` } diff --git a/internal/domain/http.go b/internal/domain/http.go new file mode 100644 index 0000000..bcb15c8 --- /dev/null +++ b/internal/domain/http.go @@ -0,0 +1,32 @@ +// Copyright (c) 2023 - 2024, nuxen and the seasonpackarr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package domain + +const ( + StatusNoMatches = 200 + StatusResolutionMismatch = 201 + StatusSourceMismatch = 202 + StatusRlsGrpMismatch = 203 + StatusCutMismatch = 204 + StatusEditionMismatch = 205 + StatusRepackStatusMismatch = 206 + StatusHdrMismatch = 207 + StatusStreamingServiceMismatch = 208 + StatusAlreadyInClient = 210 + StatusNotASeasonPack = 211 + StatusBelowThreshold = 230 + StatusSuccessfulMatch = 250 + StatusSuccessfulHardlink = 250 + StatusFailedHardlink = 440 + StatusClientNotFound = 472 + StatusGetClientError = 471 + StatusDecodingError = 470 + StatusAnnounceNameError = 469 + StatusGetTorrentsError = 468 + StatusTorrentBytesError = 467 + StatusDecodeTorrentBytesError = 466 + StatusParseTorrentInfoError = 465 + StatusGetEpisodesError = 464 + StatusEpisodeCountError = 450 +) diff --git a/internal/domain/notification.go b/internal/domain/notification.go new file mode 100644 index 0000000..9216ad1 --- /dev/null +++ b/internal/domain/notification.go @@ -0,0 +1,18 @@ +// Copyright (c) 2021 - 2024, Ludvig Lundgren and the autobrr contributors. +// Code is heavily modified for use with seasonpackarr +// SPDX-License-Identifier: GPL-2.0-or-later + +package domain + +type Sender interface { + Send(statusCode int, payload NotificationPayload) error +} + +type NotificationPayload struct { + Subject string + Message string + ReleaseName string + Client string + Action string + Error error +} diff --git a/internal/http/processor.go b/internal/http/processor.go index a06883e..b3452c1 100644 --- a/internal/http/processor.go +++ b/internal/http/processor.go @@ -18,44 +18,18 @@ import ( "seasonpackarr/internal/release" "seasonpackarr/internal/torrents" "seasonpackarr/internal/utils" + "seasonpackarr/pkg/errors" "github.com/autobrr/go-qbittorrent" "github.com/moistari/rls" "github.com/rs/zerolog" ) -const ( - StatusNoMatches = 200 - StatusResolutionMismatch = 201 - StatusSourceMismatch = 202 - StatusRlsGrpMismatch = 203 - StatusCutMismatch = 204 - StatusEditionMismatch = 205 - StatusRepackStatusMismatch = 206 - StatusHdrMismatch = 207 - StatusStreamingServiceMismatch = 208 - StatusAlreadyInClient = 210 - StatusNotASeasonPack = 211 - StatusBelowThreshold = 230 - StatusSuccessfulMatch = 250 - StatusSuccessfulHardlink = 250 - StatusFailedHardlink = 440 - StatusClientNotFound = 472 - StatusGetClientError = 471 - StatusDecodingError = 470 - StatusAnnounceNameError = 469 - StatusGetTorrentsError = 468 - StatusTorrentBytesError = 467 - StatusDecodeTorrentBytesError = 466 - StatusParseTorrentInfoError = 465 - StatusGetEpisodesError = 464 - StatusEpisodeCountError = 450 -) - type processor struct { - log zerolog.Logger - cfg *config.AppConfig - req *request + log zerolog.Logger + cfg *config.AppConfig + noti domain.Sender + req *request } type request struct { @@ -85,10 +59,11 @@ var ( torrentMap sync.Map ) -func newProcessor(log logger.Logger, config *config.AppConfig) *processor { +func newProcessor(log logger.Logger, config *config.AppConfig, notification domain.Sender) *processor { return &processor{ - log: log.With().Str("module", "processor").Logger(), - cfg: config, + log: log.With().Str("module", "processor").Logger(), + cfg: config, + noti: notification, } } @@ -104,7 +79,7 @@ func (p *processor) getClient(client *domain.Client) error { c = qbittorrent.NewClient(s) if err := c.(*qbittorrent.Client).Login(); err != nil { - p.log.Fatal().Err(err).Msg("error logging into qBittorrent") + return errors.Wrap(err, "failed to login to qbittorrent") } clientMap.Store(s, c) @@ -189,17 +164,34 @@ func (p *processor) ProcessSeasonPackHandler(w netHTTP.ResponseWriter, r *netHTT if err := json.NewDecoder(r.Body).Decode(&p.req); err != nil { p.log.Error().Err(err).Msgf("error decoding request") - netHTTP.Error(w, err.Error(), StatusDecodingError) + netHTTP.Error(w, err.Error(), domain.StatusDecodingError) return } code, err := p.processSeasonPack() if err != nil { + if sendErr := p.noti.Send(code, domain.NotificationPayload{ + ReleaseName: p.req.Name, + Client: p.req.ClientName, + Action: "Pack", + Error: err, + }); sendErr != nil { + p.log.Error().Err(sendErr).Msg("error sending notification") + } + p.log.Error().Err(err).Msgf("error processing season pack: %d", code) netHTTP.Error(w, err.Error(), code) return } + if sendErr := p.noti.Send(code, domain.NotificationPayload{ + ReleaseName: p.req.Name, + Client: p.req.ClientName, + Action: "Pack", + }); sendErr != nil { + p.log.Error().Err(sendErr).Msg("error sending notification") + } + p.log.Info().Msg("successfully matched season pack to episodes in client") w.WriteHeader(code) } @@ -213,35 +205,35 @@ func (p *processor) processSeasonPack() (int, error) { client, ok := p.cfg.Config.Clients[clientName] if !ok { - return StatusClientNotFound, fmt.Errorf("client not found in config") + return domain.StatusClientNotFound, fmt.Errorf("client not found in config") } p.log.Info().Msgf("using %s client serving at %s:%d", clientName, client.Host, client.Port) if len(p.req.Name) == 0 { - return StatusAnnounceNameError, fmt.Errorf("couldn't get announce name") + return domain.StatusAnnounceNameError, fmt.Errorf("couldn't get announce name") } if err := p.getClient(client); err != nil { - return StatusGetClientError, err + return domain.StatusGetClientError, err } mp := p.getAllTorrents(client) if mp.err != nil { - return StatusGetTorrentsError, mp.err + return domain.StatusGetTorrentsError, mp.err } requestRls := domain.Entry{R: rls.ParseString(p.req.Name)} v, ok := mp.e[utils.GetFormattedTitle(requestRls.R)] if !ok { - return StatusNoMatches, fmt.Errorf("no matching releases in client") + return domain.StatusNoMatches, fmt.Errorf("no matching releases in client") } 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) == StatusAlreadyInClient { - return StatusAlreadyInClient, fmt.Errorf("release already in client") + if release.CheckCandidates(&requestRls, &child, p.cfg.Config.FuzzyMatching) == domain.StatusAlreadyInClient { + return domain.StatusAlreadyInClient, fmt.Errorf("release already in client") } } @@ -250,61 +242,61 @@ func (p *processor) processSeasonPack() (int, error) { for _, child := range v { switch res := release.CheckCandidates(&requestRls, &child, p.cfg.Config.FuzzyMatching); res { - case StatusResolutionMismatch: + 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) respCodes = append(respCodes, res) continue - case StatusSourceMismatch: + 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) respCodes = append(respCodes, res) continue - case StatusRlsGrpMismatch: + 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) respCodes = append(respCodes, res) continue - case StatusCutMismatch: + 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) respCodes = append(respCodes, res) continue - case StatusEditionMismatch: + 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) respCodes = append(respCodes, res) continue - case StatusRepackStatusMismatch: + 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) respCodes = append(respCodes, res) continue - case StatusHdrMismatch: + 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) respCodes = append(respCodes, res) continue - case StatusStreamingServiceMismatch: + 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) respCodes = append(respCodes, res) continue - case StatusAlreadyInClient: - return StatusAlreadyInClient, fmt.Errorf("release already in client") + case domain.StatusAlreadyInClient: + return domain.StatusAlreadyInClient, fmt.Errorf("release already in client") - case StatusNotASeasonPack: - return StatusNotASeasonPack, fmt.Errorf("release is not a season pack") + case domain.StatusNotASeasonPack: + return domain.StatusNotASeasonPack, fmt.Errorf("release is not a season pack") - case StatusSuccessfulMatch: + case domain.StatusSuccessfulMatch: torrentFiles, err := p.getFiles(child.T.Hash) if err != nil { p.log.Error().Err(err).Msgf("error getting files: %s", child.T.Name) @@ -356,8 +348,8 @@ func (p *processor) processSeasonPack() (int, error) { } matchesSlice, ok := matchesMap.Load(p.req.Name) - if !slices.Contains(respCodes, StatusSuccessfulMatch) || !ok { - return StatusNoMatches, fmt.Errorf("no matching releases in client") + if !slices.Contains(respCodes, domain.StatusSuccessfulMatch) || !ok { + return domain.StatusNoMatches, fmt.Errorf("no matching releases in client") } if p.cfg.Config.SmartMode { @@ -365,7 +357,7 @@ func (p *processor) processSeasonPack() (int, error) { totalEps, err := utils.GetEpisodesPerSeason(reqRls.Title, reqRls.Series) if err != nil { - return StatusEpisodeCountError, err + return domain.StatusEpisodeCountError, err } matchedEps = utils.DedupeSlice(matchedEps) @@ -375,13 +367,13 @@ func (p *processor) processSeasonPack() (int, error) { // delete match from matchesMap if threshold is not met matchesMap.Delete(p.req.Name) - return StatusBelowThreshold, fmt.Errorf("found %d/%d (%.2f%%) episodes in client, below configured smart mode threshold", + return domain.StatusBelowThreshold, fmt.Errorf("found %d/%d (%.2f%%) episodes in client, below configured smart mode threshold", len(matchedEps), totalEps, percentEps*100) } } if p.cfg.Config.ParseTorrentFile { - return StatusSuccessfulMatch, nil + return domain.StatusSuccessfulMatch, nil } matches := utils.DedupeSlice(matchesSlice.([]matchPaths)) @@ -390,18 +382,18 @@ func (p *processor) processSeasonPack() (int, error) { for _, match := range matches { if err := utils.CreateHardlink(match.clientEpPath, match.announcedEpPath); err != nil { p.log.Error().Err(err).Msgf("error creating hardlink: %s", match.clientEpPath) - hardlinkRespCodes = append(hardlinkRespCodes, StatusFailedHardlink) + hardlinkRespCodes = append(hardlinkRespCodes, domain.StatusFailedHardlink) continue } p.log.Log().Msgf("created hardlink: source(%s), target(%s)", match.clientEpPath, match.announcedEpPath) - hardlinkRespCodes = append(hardlinkRespCodes, StatusSuccessfulHardlink) + hardlinkRespCodes = append(hardlinkRespCodes, domain.StatusSuccessfulHardlink) } - if !slices.Contains(hardlinkRespCodes, StatusSuccessfulHardlink) { - return StatusFailedHardlink, fmt.Errorf("couldn't create hardlinks") + if !slices.Contains(hardlinkRespCodes, domain.StatusSuccessfulHardlink) { + return domain.StatusFailedHardlink, fmt.Errorf("couldn't create hardlinks") } - return StatusSuccessfulHardlink, nil + return domain.StatusSuccessfulHardlink, nil } func (p *processor) ParseTorrentHandler(w netHTTP.ResponseWriter, r *netHTTP.Request) { @@ -409,17 +401,34 @@ func (p *processor) ParseTorrentHandler(w netHTTP.ResponseWriter, r *netHTTP.Req if err := json.NewDecoder(r.Body).Decode(&p.req); err != nil { p.log.Error().Err(err).Msgf("error decoding request") - netHTTP.Error(w, err.Error(), StatusDecodingError) + netHTTP.Error(w, err.Error(), domain.StatusDecodingError) return } code, err := p.parseTorrent() if err != nil { + if sendErr := p.noti.Send(code, domain.NotificationPayload{ + ReleaseName: p.req.Name, + Client: p.req.ClientName, + Action: "Parse", + Error: err, + }); sendErr != nil { + p.log.Error().Err(sendErr).Msg("error sending notification") + } + p.log.Error().Err(err).Msgf("error parsing torrent: %d", code) netHTTP.Error(w, err.Error(), code) return } + if sendErr := p.noti.Send(code, domain.NotificationPayload{ + ReleaseName: p.req.Name, + Client: p.req.ClientName, + Action: "Parse", + }); sendErr != nil { + p.log.Error().Err(sendErr).Msg("error sending notification") + } + p.log.Info().Msg("successfully parsed torrent and hardlinked episodes") w.WriteHeader(code) } @@ -433,33 +442,33 @@ func (p *processor) parseTorrent() (int, error) { client, ok := p.cfg.Config.Clients[clientName] if !ok { - return StatusClientNotFound, fmt.Errorf("client not found in config") + return domain.StatusClientNotFound, fmt.Errorf("client not found in config") } if len(p.req.Name) == 0 { - return StatusAnnounceNameError, fmt.Errorf("couldn't get announce name") + return domain.StatusAnnounceNameError, fmt.Errorf("couldn't get announce name") } if len(p.req.Torrent) == 0 { - return StatusTorrentBytesError, fmt.Errorf("couldn't get torrent bytes") + return domain.StatusTorrentBytesError, fmt.Errorf("couldn't get torrent bytes") } torrentBytes, err := torrents.DecodeTorrentDataRawBytes(p.req.Torrent) if err != nil { - return StatusDecodeTorrentBytesError, err + return domain.StatusDecodeTorrentBytesError, err } p.req.Torrent = torrentBytes torrentInfo, err := torrents.ParseTorrentInfoFromTorrentBytes(p.req.Torrent) if err != nil { - return StatusParseTorrentInfoError, err + return domain.StatusParseTorrentInfoError, err } parsedPackName := torrentInfo.BestName() p.log.Debug().Msgf("parsed season pack name: %s", parsedPackName) torrentEps, err := torrents.GetEpisodesFromTorrentInfo(torrentInfo) if err != nil { - return StatusGetEpisodesError, err + return domain.StatusGetEpisodesError, err } for _, torrentEp := range torrentEps { p.log.Debug().Msgf("found episode in pack: name(%s), size(%d)", torrentEp.Path, torrentEp.Size) @@ -467,7 +476,7 @@ func (p *processor) parseTorrent() (int, error) { matchesSlice, ok := matchesMap.Load(p.req.Name) if !ok { - return StatusNoMatches, fmt.Errorf("no matching releases in client") + return domain.StatusNoMatches, fmt.Errorf("no matching releases in client") } matches := utils.DedupeSlice(matchesSlice.([]matchPaths)) @@ -496,22 +505,22 @@ func (p *processor) parseTorrent() (int, error) { if matchErr != nil { p.log.Error().Err(matchErr).Msgf("error matching episode to file in pack, skipping hardlink: %s", filepath.Base(match.clientEpPath)) - hardlinkRespCodes = append(hardlinkRespCodes, StatusFailedHardlink) + hardlinkRespCodes = append(hardlinkRespCodes, domain.StatusFailedHardlink) continue } if err = utils.CreateHardlink(match.clientEpPath, targetEpPath); err != nil { p.log.Error().Err(err).Msgf("error creating hardlink: %s", match.clientEpPath) - hardlinkRespCodes = append(hardlinkRespCodes, StatusFailedHardlink) + hardlinkRespCodes = append(hardlinkRespCodes, domain.StatusFailedHardlink) continue } p.log.Log().Msgf("created hardlink: source(%s), target(%s)", match.clientEpPath, targetEpPath) - hardlinkRespCodes = append(hardlinkRespCodes, StatusSuccessfulHardlink) + hardlinkRespCodes = append(hardlinkRespCodes, domain.StatusSuccessfulHardlink) } - if !slices.Contains(hardlinkRespCodes, StatusSuccessfulHardlink) { - return StatusFailedHardlink, fmt.Errorf("couldn't create hardlinks") + if !slices.Contains(hardlinkRespCodes, domain.StatusSuccessfulHardlink) { + return domain.StatusFailedHardlink, fmt.Errorf("couldn't create hardlinks") } - return StatusSuccessfulHardlink, nil + return domain.StatusSuccessfulHardlink, nil } diff --git a/internal/http/server.go b/internal/http/server.go index 7568679..ba9be33 100644 --- a/internal/http/server.go +++ b/internal/http/server.go @@ -12,6 +12,7 @@ import ( "time" "seasonpackarr/internal/config" + "seasonpackarr/internal/domain" "seasonpackarr/internal/logger" "github.com/go-chi/chi/v5" @@ -21,16 +22,18 @@ import ( var ErrServerClosed = http.ErrServerClosed type Server struct { - log logger.Logger - cfg *config.AppConfig + log logger.Logger + cfg *config.AppConfig + noti domain.Sender httpServer http.Server } -func NewServer(log logger.Logger, config *config.AppConfig) *Server { +func NewServer(log logger.Logger, config *config.AppConfig, notification domain.Sender) *Server { return &Server{ - log: log, - cfg: config, + log: log, + cfg: config, + noti: notification, } } @@ -86,7 +89,7 @@ func (s *Server) Handler() http.Handler { r.Group(func(r chi.Router) { r.Use(s.isAuthenticated) - r.Route("/", newWebhookHandler(s.log, s.cfg).Routes) + r.Route("/", newWebhookHandler(s.log, s.cfg, s.noti).Routes) }) }) diff --git a/internal/http/webhook.go b/internal/http/webhook.go index f20ad33..909c8ad 100644 --- a/internal/http/webhook.go +++ b/internal/http/webhook.go @@ -7,6 +7,7 @@ import ( "net/http" "seasonpackarr/internal/config" + "seasonpackarr/internal/domain" "seasonpackarr/internal/logger" "github.com/go-chi/chi/v5" @@ -14,14 +15,16 @@ import ( ) type webhookHandler struct { - log logger.Logger - cfg *config.AppConfig + log logger.Logger + cfg *config.AppConfig + noti domain.Sender } -func newWebhookHandler(log logger.Logger, cfg *config.AppConfig) *webhookHandler { +func newWebhookHandler(log logger.Logger, cfg *config.AppConfig, notification domain.Sender) *webhookHandler { return &webhookHandler{ - log: log, - cfg: cfg, + log: log, + cfg: cfg, + noti: notification, } } @@ -31,11 +34,11 @@ func (h webhookHandler) Routes(r chi.Router) { } func (h webhookHandler) pack(w http.ResponseWriter, r *http.Request) { - newProcessor(h.log, h.cfg).ProcessSeasonPackHandler(w, r) + 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) { - newProcessor(h.log, h.cfg).ParseTorrentHandler(w, r) + newProcessor(h.log, h.cfg, h.noti).ParseTorrentHandler(w, r) render.Status(r, http.StatusOK) } diff --git a/internal/notification/discord.go b/internal/notification/discord.go new file mode 100644 index 0000000..8206dd1 --- /dev/null +++ b/internal/notification/discord.go @@ -0,0 +1,191 @@ +// Copyright (c) 2021 - 2024, Ludvig Lundgren and the autobrr contributors. +// Code is heavily modified for use with seasonpackarr +// SPDX-License-Identifier: GPL-2.0-or-later + +package notification + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "time" + + "seasonpackarr/internal/config" + "seasonpackarr/internal/domain" + "seasonpackarr/internal/logger" + "seasonpackarr/pkg/errors" + + "github.com/rs/zerolog" +) + +type DiscordMessage struct { + Content interface{} `json:"content"` + Embeds []DiscordEmbeds `json:"embeds,omitempty"` +} + +type DiscordEmbeds struct { + Title string `json:"title"` + Description string `json:"description"` + Color int `json:"color"` + Fields []DiscordEmbedsFields `json:"fields,omitempty"` + Timestamp time.Time `json:"timestamp"` +} + +type DiscordEmbedsFields struct { + Name string `json:"name"` + Value string `json:"value"` + Inline bool `json:"inline,omitempty"` +} + +type EmbedColors int + +const ( + LIGHT_BLUE EmbedColors = 5814783 // 58b9ff + RED EmbedColors = 15548997 // ed4245 + GREEN EmbedColors = 5763719 // 57f287 + GRAY EmbedColors = 10070709 // 99aab5 +) + +type discordSender struct { + log zerolog.Logger + cfg *config.AppConfig + + httpClient *http.Client +} + +func NewDiscordSender(log logger.Logger, config *config.AppConfig) domain.Sender { + return &discordSender{ + log: log.With().Str("sender", "discord").Logger(), + cfg: config, + httpClient: &http.Client{ + Timeout: time.Second * 30, + }, + } +} + +func (s *discordSender) Send(statusCode int, payload domain.NotificationPayload) error { + if !s.isEnabled() { + return nil + } + + m := DiscordMessage{ + Content: nil, + Embeds: []DiscordEmbeds{s.buildEmbed(statusCode, payload)}, + } + + jsonData, err := json.Marshal(m) + if err != nil { + return errors.Wrap(err, "discord client could not marshal data: %+v", m) + } + + 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") + } + + req.Header.Set("Content-Type", "application/json") + //req.Header.Set("User-Agent", "seasonpackarr") + + res, err := s.httpClient.Do(req) + if err != nil { + return errors.Wrap(err, "discord client could not make request: %+v", req) + } + + 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)) + + // 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)) + } + + s.log.Debug().Msg("notification successfully sent to discord") + + return nil +} + +func (s *discordSender) isEnabled() bool { + if s.cfg.Config.Notifications.Discord == "" { + s.log.Warn().Msg("no webhook defined, skipping notification") + return false + } + + return true +} + +func (s *discordSender) buildEmbed(statusCode int, payload domain.NotificationPayload) DiscordEmbeds { + color := LIGHT_BLUE + + if (statusCode >= 200) && (statusCode < 250) { // not matching + color = GRAY + } else if (statusCode >= 400) && (statusCode < 500) { // error processing + color = RED + } else { // success + color = GREEN + } + + var fields []DiscordEmbedsFields + + if payload.ReleaseName != "" { + f := DiscordEmbedsFields{ + Name: "Release Name", + Value: payload.ReleaseName, + Inline: true, + } + fields = append(fields, f) + } + + if payload.Client != "" { + f := DiscordEmbedsFields{ + Name: "Client", + Value: payload.Client, + Inline: true, + } + fields = append(fields, f) + } + + if payload.Action != "" { + f := DiscordEmbedsFields{ + Name: "Action", + Value: payload.Action, + Inline: true, + } + fields = append(fields, f) + } + + if payload.Error != nil { + // actual error? + if statusCode >= 400 { + f := DiscordEmbedsFields{ + Name: "Error", + Value: fmt.Sprintf("```%s```", payload.Error.Error()), + Inline: false, + } + fields = append(fields, f) + } else { + payload.Message = payload.Error.Error() + } + } + + embed := DiscordEmbeds{ + Title: BuildTitle(statusCode), + Color: int(color), + Fields: fields, + Timestamp: time.Now(), + } + + if payload.Message != "" { + embed.Description = strings.ToUpper(string(payload.Message[0])) + payload.Message[1:] + } + + return embed +} diff --git a/internal/notification/message_builder.go b/internal/notification/message_builder.go new file mode 100644 index 0000000..8b8a618 --- /dev/null +++ b/internal/notification/message_builder.go @@ -0,0 +1,45 @@ +// Copyright (c) 2021 - 2024, Ludvig Lundgren and the autobrr contributors. +// Code is heavily modified for use with seasonpackarr +// SPDX-License-Identifier: GPL-2.0-or-later + +package notification + +import ( + "seasonpackarr/internal/domain" +) + +// BuildTitle constructs the title of the notification message. +func BuildTitle(event int) string { + titles := map[int]string{ + domain.StatusNoMatches: "No Matches", + domain.StatusResolutionMismatch: "Resolution Mismatch", + domain.StatusSourceMismatch: "Source Mismatch", + domain.StatusRlsGrpMismatch: "Release Group Mismatch", + domain.StatusCutMismatch: "Cut Mismatch", + domain.StatusEditionMismatch: "Edition Mismatch", + domain.StatusRepackStatusMismatch: "Repack Status Mismatch", + domain.StatusHdrMismatch: "HDR Mismatch", + domain.StatusStreamingServiceMismatch: "Streaming Service Mismatch", + domain.StatusAlreadyInClient: "Already In Client", + domain.StatusNotASeasonPack: "Not A Season Pack", + domain.StatusBelowThreshold: "Below Threshold", + domain.StatusSuccessfulMatch: "Success!", // same title for StatusSuccessfulHardlink + domain.StatusFailedHardlink: "Failed Hardlink", + domain.StatusClientNotFound: "Client Not Found", + domain.StatusGetClientError: "Get Client Error", + domain.StatusDecodingError: "Decoding Error", + domain.StatusAnnounceNameError: "Announce Name Error", + domain.StatusGetTorrentsError: "Get Torrents Error", + domain.StatusTorrentBytesError: "Torrent Bytes Error", + domain.StatusDecodeTorrentBytesError: "Torrent Bytes Decoding Error", + domain.StatusParseTorrentInfoError: "Torrent Parsing Error", + domain.StatusGetEpisodesError: "Get Episodes Error", + domain.StatusEpisodeCountError: "Episode Count Error", + } + + if title, ok := titles[event]; ok { + return title + } + + return "New Event" +}