feat(notifications): add Notifiarr support (#464)

This commit is contained in:
ze0s 2022-09-19 15:44:31 +02:00 committed by GitHub
parent f8ace9edbe
commit 63d4c21e54
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
8 changed files with 326 additions and 18 deletions

View file

@ -28,7 +28,7 @@ func NewNotificationRepo(log logger.Logger, db *DB) domain.NotificationRepo {
func (r *NotificationRepo) Find(ctx context.Context, params domain.NotificationQueryParams) ([]domain.Notification, int, error) {
queryBuilder := r.db.squirrel.
Select("id", "name", "type", "enabled", "events", "webhook", "token", "channel", "created_at", "updated_at", "COUNT(*) OVER() AS total_count").
Select("id", "name", "type", "enabled", "events", "webhook", "token", "api_key", "channel", "created_at", "updated_at", "COUNT(*) OVER() AS total_count").
From("notification").
OrderBy("name")
@ -49,15 +49,15 @@ func (r *NotificationRepo) Find(ctx context.Context, params domain.NotificationQ
for rows.Next() {
var n domain.Notification
var webhook, token, channel sql.NullString
var webhook, token, apiKey, channel sql.NullString
//var token, apiKey, webhook, title, icon, host, username, password, channel, targets, devices sql.NullString
//if err := rows.Scan(&n.ID, &n.Name, &n.Type, &n.Enabled, pq.Array(&n.Events), &token, &apiKey, &webhook, &title, &icon, &host, &username, &password, &channel, &targets, &devices, &n.CreatedAt, &n.UpdatedAt); err != nil {
//var token, apiKey, webhook, title, icon, host, username, password, channel, targets, devices sql.NullString
if err := rows.Scan(&n.ID, &n.Name, &n.Type, &n.Enabled, pq.Array(&n.Events), &webhook, &token, &channel, &n.CreatedAt, &n.UpdatedAt, &totalCount); err != nil {
if err := rows.Scan(&n.ID, &n.Name, &n.Type, &n.Enabled, pq.Array(&n.Events), &webhook, &token, &apiKey, &channel, &n.CreatedAt, &n.UpdatedAt, &totalCount); err != nil {
return nil, 0, errors.Wrap(err, "error scanning row")
}
//n.APIKey = apiKey.String
n.APIKey = apiKey.String
n.Webhook = webhook.String
n.Token = token.String
n.Channel = channel.String
@ -130,6 +130,16 @@ func (r *NotificationRepo) FindByID(ctx context.Context, id int) (*domain.Notifi
"enabled",
"events",
"token",
"api_key",
"webhook",
"title",
"icon",
"host",
"username",
"password",
"channel",
"targets",
"devices",
"created_at",
"updated_at",
).
@ -172,6 +182,7 @@ func (r *NotificationRepo) FindByID(ctx context.Context, id int) (*domain.Notifi
func (r *NotificationRepo) Store(ctx context.Context, notification domain.Notification) (*domain.Notification, error) {
webhook := toNullString(notification.Webhook)
token := toNullString(notification.Token)
apiKey := toNullString(notification.APIKey)
channel := toNullString(notification.Channel)
queryBuilder := r.db.squirrel.
@ -183,6 +194,7 @@ func (r *NotificationRepo) Store(ctx context.Context, notification domain.Notifi
"events",
"webhook",
"token",
"api_key",
"channel",
).
Values(
@ -192,6 +204,7 @@ func (r *NotificationRepo) Store(ctx context.Context, notification domain.Notifi
pq.Array(notification.Events),
webhook,
token,
apiKey,
channel,
).
Suffix("RETURNING id").RunWith(r.db.handler)
@ -213,6 +226,7 @@ func (r *NotificationRepo) Store(ctx context.Context, notification domain.Notifi
func (r *NotificationRepo) Update(ctx context.Context, notification domain.Notification) (*domain.Notification, error) {
webhook := toNullString(notification.Webhook)
token := toNullString(notification.Token)
apiKey := toNullString(notification.APIKey)
channel := toNullString(notification.Channel)
queryBuilder := r.db.squirrel.
@ -223,6 +237,7 @@ func (r *NotificationRepo) Update(ctx context.Context, notification domain.Notif
Set("events", pq.Array(notification.Events)).
Set("webhook", webhook).
Set("token", token).
Set("api_key", apiKey).
Set("channel", channel).
Set("updated_at", sq.Expr("CURRENT_TIMESTAMP")).
Where("id = ?", notification.ID)

View file

@ -64,6 +64,7 @@ type NotificationType string
const (
NotificationTypeDiscord NotificationType = "DISCORD"
NotificationTypeNotifiarr NotificationType = "NOTIFIARR"
NotificationTypeIFTTT NotificationType = "IFTTT"
NotificationTypeJoin NotificationType = "JOIN"
NotificationTypeMattermost NotificationType = "MATTERMOST"

View file

@ -0,0 +1,168 @@
package notification
import (
"bytes"
"crypto/tls"
"encoding/json"
"fmt"
"io"
"net/http"
"time"
"github.com/autobrr/autobrr/internal/domain"
"github.com/autobrr/autobrr/pkg/errors"
"github.com/rs/zerolog"
)
type notifiarrMessage struct {
Event string `json:"event"`
Data notifiarrMessageData `json:"data"`
}
type notifiarrMessageData struct {
Subject string `json:"subject"`
Message string `json:"message"`
Event domain.NotificationEvent `json:"event"`
ReleaseName *string `json:"release_name,omitempty"`
Filter *string `json:"filter,omitempty"`
Indexer *string `json:"indexer,omitempty"`
InfoHash *string `json:"info_hash,omitempty"`
Size *uint64 `json:"size,omitempty"`
Status *domain.ReleasePushStatus `json:"status,omitempty"`
Action *string `json:"action,omitempty"`
ActionType *domain.ActionType `json:"action_type,omitempty"`
ActionClient *string `json:"action_client,omitempty"`
Rejections []string `json:"rejections,omitempty"`
Protocol *domain.ReleaseProtocol `json:"protocol,omitempty"` // torrent
Implementation *domain.ReleaseImplementation `json:"implementation,omitempty"` // irc, rss, api
Timestamp time.Time `json:"timestamp"`
}
type notifiarrSender struct {
log zerolog.Logger
Settings domain.Notification
}
func NewNotifiarrSender(log zerolog.Logger, settings domain.Notification) domain.NotificationSender {
return &notifiarrSender{
log: log.With().Str("sender", "notifiarr").Logger(),
Settings: settings,
}
}
func (s *notifiarrSender) Send(event domain.NotificationEvent, payload domain.NotificationPayload) error {
m := notifiarrMessage{
Event: string(event),
Data: s.buildMessage(payload),
}
jsonData, err := json.Marshal(m)
if err != nil {
s.log.Error().Err(err).Msgf("notifiarr client could not marshal data: %v", m)
return errors.Wrap(err, "could not marshal data: %+v", m)
}
url := fmt.Sprintf("https://notifiarr.com/api/v1/notification/autobrr/%v", s.Settings.APIKey)
req, err := http.NewRequest(http.MethodPost, url, bytes.NewBuffer(jsonData))
if err != nil {
s.log.Error().Err(err).Msgf("notifiarr client request error: %v", event)
return errors.Wrap(err, "could not create request")
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("User-Agent", "autobrr")
t := &http.Transport{
TLSClientConfig: &tls.Config{
InsecureSkipVerify: true,
},
}
client := http.Client{Transport: t, Timeout: 30 * time.Second}
res, err := client.Do(req)
if err != nil {
s.log.Error().Err(err).Msgf("notifiarr client request error: %v", event)
return errors.Wrap(err, "could not make request: %+v", req)
}
body, err := io.ReadAll(res.Body)
if err != nil {
s.log.Error().Err(err).Msgf("notifiarr client request error: %v", event)
return errors.Wrap(err, "could not read data")
}
defer res.Body.Close()
s.log.Trace().Msgf("notifiarr status: %v response: %v", res.StatusCode, string(body))
if res.StatusCode != http.StatusOK {
s.log.Error().Err(err).Msgf("notifiarr client request error: %v", string(body))
return errors.New("bad status: %v body: %v", res.StatusCode, string(body))
}
s.log.Debug().Msg("notification successfully sent to notifiarr")
return nil
}
func (s *notifiarrSender) CanSend(event domain.NotificationEvent) bool {
if s.isEnabled() && s.isEnabledEvent(event) {
return true
}
return false
}
func (s *notifiarrSender) isEnabled() bool {
if s.Settings.Enabled && s.Settings.Webhook != "" {
return true
}
return false
}
func (s *notifiarrSender) isEnabledEvent(event domain.NotificationEvent) bool {
for _, e := range s.Settings.Events {
if e == string(event) {
return true
}
}
return false
}
func (s *notifiarrSender) buildMessage(payload domain.NotificationPayload) notifiarrMessageData {
m := notifiarrMessageData{
Event: payload.Event,
Timestamp: payload.Timestamp,
}
if payload.Subject != "" && payload.Message != "" {
m.Subject = payload.Subject
m.Message = payload.Message
}
if payload.ReleaseName != "" {
m.ReleaseName = &payload.ReleaseName
}
if payload.Status != "" {
m.Status = &payload.Status
}
if payload.Indexer != "" {
m.Indexer = &payload.Indexer
}
if payload.Filter != "" {
m.Filter = &payload.Filter
}
if payload.Action != "" || payload.ActionClient != "" {
m.Action = &payload.Action
if payload.ActionClient != "" {
m.ActionClient = &payload.ActionClient
}
}
if len(payload.Rejections) > 0 {
m.Rejections = payload.Rejections
}
return m
}

View file

@ -2,6 +2,9 @@ package notification
import (
"context"
"time"
"golang.org/x/sync/errgroup"
"github.com/autobrr/autobrr/internal/domain"
"github.com/autobrr/autobrr/internal/logger"
@ -117,6 +120,8 @@ func (s *service) registerSenders() {
switch n.Type {
case domain.NotificationTypeDiscord:
s.senders = append(s.senders, NewDiscordSender(s.log, n))
case domain.NotificationTypeNotifiarr:
s.senders = append(s.senders, NewNotifiarrSender(s.log, n))
case domain.NotificationTypeTelegram:
s.senders = append(s.senders, NewTelegramSender(s.log, n))
}
@ -147,15 +152,106 @@ func (s *service) Send(event domain.NotificationEvent, payload domain.Notificati
func (s *service) Test(ctx context.Context, notification domain.Notification) error {
var agent domain.NotificationSender
// send test events
events := []domain.NotificationPayload{
{
Subject: "Test Notification",
Message: "autobrr goes brr!!",
Event: domain.NotificationEventTest,
Timestamp: time.Now(),
},
{
Subject: "New release!",
Message: "Best.Show.Ever.S18E21.1080p.AMZN.WEB-DL.DDP2.0.H.264-GROUP",
Event: domain.NotificationEventPushApproved,
ReleaseName: "Best.Show.Ever.S18E21.1080p.AMZN.WEB-DL.DDP2.0.H.264-GROUP",
Filter: "TV",
Indexer: "MockIndexer",
Status: domain.ReleasePushStatusApproved,
Action: "Send to qBittorrent",
ActionType: domain.ActionTypeQbittorrent,
ActionClient: "qBittorrent",
Rejections: nil,
Protocol: domain.ReleaseProtocolTorrent,
Implementation: domain.ReleaseImplementationIRC,
Timestamp: time.Now(),
},
{
Subject: "New release!",
Message: "Best.Show.Ever.S18E21.1080p.AMZN.WEB-DL.DDP2.0.H.264-GROUP",
Event: domain.NotificationEventPushRejected,
ReleaseName: "Best.Show.Ever.S18E21.1080p.AMZN.WEB-DL.DDP2.0.H.264-GROUP",
Filter: "TV",
Indexer: "MockIndexer",
Status: domain.ReleasePushStatusRejected,
Action: "Send to Sonarr",
ActionType: domain.ActionTypeSonarr,
ActionClient: "Sonarr",
Rejections: []string{"Unknown Series"},
Protocol: domain.ReleaseProtocolTorrent,
Implementation: domain.ReleaseImplementationIRC,
Timestamp: time.Now(),
},
{
Subject: "New release!",
Message: "Best.Show.Ever.S18E21.1080p.AMZN.WEB-DL.DDP2.0.H.264-GROUP",
Event: domain.NotificationEventPushError,
ReleaseName: "Best.Show.Ever.S18E21.1080p.AMZN.WEB-DL.DDP2.0.H.264-GROUP",
Filter: "TV",
Indexer: "MockIndexer",
Status: domain.ReleasePushStatusErr,
Action: "Send to Sonarr",
ActionType: domain.ActionTypeSonarr,
ActionClient: "Sonarr",
Rejections: []string{"error pushing to client"},
Protocol: domain.ReleaseProtocolTorrent,
Implementation: domain.ReleaseImplementationIRC,
Timestamp: time.Now(),
},
{
Subject: "IRC Disconnected unexpectedly",
Message: "Network: P2P-Network",
Event: domain.NotificationEventIRCDisconnected,
Timestamp: time.Now(),
},
{
Subject: "IRC Reconnected",
Message: "Network: P2P-Network",
Event: domain.NotificationEventIRCReconnected,
Timestamp: time.Now(),
},
{
Subject: "New update available!",
Message: "v1.6.0",
Event: domain.NotificationEventAppUpdateAvailable,
Timestamp: time.Now(),
},
}
switch notification.Type {
case domain.NotificationTypeDiscord:
agent = NewDiscordSender(s.log, notification)
case domain.NotificationTypeNotifiarr:
agent = NewNotifiarrSender(s.log, notification)
case domain.NotificationTypeTelegram:
agent = NewTelegramSender(s.log, notification)
}
return agent.Send(domain.NotificationEventTest, domain.NotificationPayload{
Subject: "Test Notification",
Message: "autobrr goes brr!!",
})
g, ctx := errgroup.WithContext(ctx)
for _, event := range events {
e := event
g.Go(func() error {
return agent.Send(e.Event, e)
})
time.Sleep(1 * time.Second)
}
if err := g.Wait(); err != nil {
s.log.Error().Err(err).Msgf("Something went wrong sending test notifications to %v", notification.Type)
return err
}
return nil
}

View file

@ -263,6 +263,11 @@ export interface OptionBasic {
value: string;
}
export interface OptionBasicTyped<T> {
label: string;
value: T;
}
export const PushStatusOptions: OptionBasic[] = [
{
label: "Rejected",
@ -278,11 +283,15 @@ export const PushStatusOptions: OptionBasic[] = [
}
];
export const NotificationTypeOptions: OptionBasic[] = [
export const NotificationTypeOptions: OptionBasicTyped<NotificationType>[] = [
{
label: "Discord",
value: "DISCORD"
},
{
label: "Notifiarr",
value: "NOTIFIARR"
},
{
label: "Telegram",
value: "TELEGRAM"

View file

@ -1,14 +1,10 @@
import { Dialog, Transition } from "@headlessui/react";
import { Fragment } from "react";
import { Field, Form, Formik, FormikErrors, FormikValues } from "formik";
import type { FieldProps } from "formik";
import { Field, Form, Formik, FormikErrors, FormikValues } from "formik";
import { XIcon } from "@heroicons/react/solid";
import Select, { components, ControlProps, InputProps, MenuProps, OptionProps } from "react-select";
import {
PasswordFieldWide,
SwitchGroupWide,
TextFieldWide
} from "../../components/inputs";
import { PasswordFieldWide, SwitchGroupWide, TextFieldWide } from "../../components/inputs";
import DEBUG from "../../components/debug";
import { EventOptions, NotificationTypeOptions, SelectOption } from "../../domain/constants";
import { useMutation } from "react-query";
@ -80,6 +76,25 @@ function FormFieldsDiscord() {
);
}
function FormFieldsNotifiarr() {
return (
<div className="border-t border-gray-200 dark:border-gray-700 py-4">
<div className="px-4 space-y-1">
<Dialog.Title className="text-lg font-medium text-gray-900 dark:text-white">Settings</Dialog.Title>
<p className="text-sm text-gray-500 dark:text-gray-400">
Enable the autobrr integration and optionally create a new API Key.
</p>
</div>
<PasswordFieldWide
name="api_key"
label="API Key"
help="Notifiarr API Key"
/>
</div>
);
}
function FormFieldsTelegram() {
return (
<div className="border-t border-gray-200 dark:border-gray-700 py-4">
@ -105,8 +120,9 @@ function FormFieldsTelegram() {
}
const componentMap: componentMapType = {
DISCORD: <FormFieldsDiscord/>,
TELEGRAM: <FormFieldsTelegram/>
DISCORD: <FormFieldsDiscord />,
NOTIFIARR: <FormFieldsNotifiarr />,
TELEGRAM: <FormFieldsTelegram />
};
interface NotificationAddFormValues {
@ -428,6 +444,7 @@ export function NotificationUpdateForm({ isOpen, toggle, notification }: UpdateP
name: notification.name,
webhook: notification.webhook,
token: notification.token,
api_key: notification.api_key,
channel: notification.channel,
events: notification.events || []
};

View file

@ -80,6 +80,7 @@ const TelegramIcon = () => (
const iconComponentMap: componentMapType = {
DISCORD: <span className="flex items-center px-2 py-0.5 rounded bg-gray-200 dark:bg-gray-700 text-gray-800 dark:text-gray-400"><DiscordIcon /> Discord</span>,
NOTIFIARR: <span className="flex items-center px-2 py-0.5 rounded bg-gray-200 dark:bg-gray-700 text-gray-800 dark:text-gray-400"><DiscordIcon /> Notifiarr</span>,
TELEGRAM: <span className="flex items-center px-2 py-0.5 rounded bg-gray-200 dark:bg-gray-700 text-gray-800 dark:text-gray-400"><TelegramIcon /> Telegram</span>
};

View file

@ -1,4 +1,4 @@
type NotificationType = "DISCORD" | "TELEGRAM";
type NotificationType = "DISCORD" | "NOTIFIARR" | "TELEGRAM";
type NotificationEvent = "PUSH_APPROVED" | "PUSH_REJECTED" | "PUSH_ERROR" | "IRC_DISCONNECTED" | "IRC_RECONNECTED" | "APP_UPDATE_AVAILABLE";
interface Notification {
@ -9,5 +9,6 @@ interface Notification {
events: NotificationEvent[];
webhook?: string;
token?: string;
api_key?: string;
channel?: string;
}