mirror of
https://github.com/idanoo/autobrr
synced 2025-07-22 16:29:12 +00:00
feat(notifications): add Notifiarr support (#464)
This commit is contained in:
parent
f8ace9edbe
commit
63d4c21e54
8 changed files with 326 additions and 18 deletions
|
@ -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)
|
||||
|
|
|
@ -64,6 +64,7 @@ type NotificationType string
|
|||
|
||||
const (
|
||||
NotificationTypeDiscord NotificationType = "DISCORD"
|
||||
NotificationTypeNotifiarr NotificationType = "NOTIFIARR"
|
||||
NotificationTypeIFTTT NotificationType = "IFTTT"
|
||||
NotificationTypeJoin NotificationType = "JOIN"
|
||||
NotificationTypeMattermost NotificationType = "MATTERMOST"
|
||||
|
|
168
internal/notification/notifiarr.go
Normal file
168
internal/notification/notifiarr.go
Normal 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 ¬ifiarrSender{
|
||||
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
|
||||
}
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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 || []
|
||||
};
|
||||
|
|
|
@ -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>
|
||||
};
|
||||
|
||||
|
|
3
web/src/types/Notification.d.ts
vendored
3
web/src/types/Notification.d.ts
vendored
|
@ -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;
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue